chore(history): squash 10 commit(s) from 2026-07-31
- feat: Фаза 1 ребрендинг combatant → lovisual + восстановление config - refactor: Фаза 2 — выкинуть Runtime/JarReplacement, заменить на Lifecycle - refactor: Фаза 3.5 — удалить читерские модули, ESP под легит - refactor: Фаза 3 — разбить Renderer2D (3379→2150 фасад + 14 хелперов ≤200) - refactor: Фаза 3 — разбить BetterChatRenderer (2870→210 фасад + 21 хелпер ≤200) - refactor: Фаза 3 — разбить UnifiedSettingRenderer (2798→208 фасад + 24 хелпера ≤200) - refactor: Фаза 3 — разбить ClickGuiRenderer (1887→542 фасад + 15 хелперов ≤200) - refactor: Фаза 3 — разбить NameTags (1791→438 фасад + 11 хелперов ≤200) - refactor: Фаза 3 — разбить TargetHud (1655→340 фасад + 11 хелперов ≤200) - refactor: Фаза 3 — разбить ESP (1538→269 фасад + 11 хелперов ≤200)
This commit is contained in:
parent
8084ac50b3
commit
52d87368c9
1595 changed files with 35857 additions and 51145 deletions
2199
backend/Cargo.lock
generated
Normal file
2199
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
44
backend/Cargo.toml
Normal file
44
backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[package]
|
||||
name = "lovisual-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "LoVisual backend: auth, profiles, friends, cloud configs, IRC"
|
||||
license = "GPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "trace", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "chrono"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "11"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
dead_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
correctness = "deny"
|
||||
suspicious = "deny"
|
||||
style = "deny"
|
||||
complexity = "deny"
|
||||
perf = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
51
backend/TODO.md
Normal file
51
backend/TODO.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# LoVisual Backend (Rust + axum)
|
||||
|
||||
## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат)
|
||||
- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга
|
||||
- **Макс. 200 строк на файл** — превышение = разбить на модули
|
||||
- **Макс. 3 файла на папку** — больше = новая подпапка по смыслу
|
||||
- **Обязательное прохождение:** `cargo fmt` + `cargo clippy --all-targets` (ноль ошибок) + `cargo test`
|
||||
- **Запрещено `#[allow(dead_code)]`** — мёртвый код удаляется, не прячется
|
||||
- **Запрещено `unsafe`** (в собственной логике; lints: `unsafe_code = "forbid"`)
|
||||
- **Без пустышек** — модуль создаётся только с рабочей реализацией
|
||||
- **Каждый этап компилируется** — перед переходом к следующему
|
||||
- **Ошибки JSON-декода не игнорировать** — всегда `.context()`/`?`
|
||||
- **`user_id` из токена, не из тела запроса**
|
||||
|
||||
---
|
||||
|
||||
## Сделано
|
||||
- [x] Каркас: main.rs + server/{mod,config} + routes/{mod,health}
|
||||
- [x] `GET /health` + graceful shutdown (SIGTERM/SIGINT)
|
||||
- [x] Config из env (LOVISUAL_HOST/PORT/CORS_ORIGINS), валидация на старте
|
||||
- [x] CORS (настраиваемые origins) + tower TraceLayer
|
||||
- [x] Rust 2024 edition, актуальные версии пакетов
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1: DB (SQLite, sqlx)
|
||||
- [ ] `src/db/mod.rs` — подключение, миграции
|
||||
- [ ] `src/db/users.rs` — таблица users
|
||||
- [ ] `src/db/configs.rs` — облачные конфиги
|
||||
- [ ] `src/db/profiles.rs` — профили, playtime, friends
|
||||
|
||||
## Фаза 2: Auth
|
||||
- [ ] `src/auth/mod.rs` — argon2 + JWT (jsonwebtoken)
|
||||
- [ ] `src/auth/handlers.rs` — register/login/logout
|
||||
- [ ] `src/auth/middleware.rs` — Bearer token → 401, не panic
|
||||
|
||||
## Фаза 3: Profile API
|
||||
- [ ] `src/profile/mod.rs` — get/update/playtime/search/friends
|
||||
|
||||
## Фаза 4: Configs API
|
||||
- [ ] `src/config_api/mod.rs` — upload/download
|
||||
- [ ] 16-byte share keys (минимум)
|
||||
|
||||
## Фаза 5: IRC
|
||||
- [ ] `src/irc/mod.rs` — WebSocket хаб (axum + tokio-tungstenite)
|
||||
|
||||
## Фаза 6: Middleware
|
||||
- [ ] RateLimit — с cleanup-задачей (без утечки)
|
||||
- [ ] Request logging через tracing
|
||||
|
||||
### Проверка после каждой фазы: `cargo fmt && cargo clippy --all-targets && cargo test`
|
||||
30
backend/src/main.rs
Normal file
30
backend/src/main.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! LoVisual backend server entry point.
|
||||
|
||||
mod routes;
|
||||
mod server;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context as _;
|
||||
|
||||
use server::config::Config;
|
||||
use server::serve;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "lovisual_backend=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::from_env().context("failed to load config")?;
|
||||
let addr: SocketAddr = config.listen_addr();
|
||||
tracing::info!(%addr, "starting lovisual backend");
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("failed to build tokio runtime")?
|
||||
.block_on(serve(config))
|
||||
}
|
||||
47
backend/src/routes/health.rs
Normal file
47
backend/src/routes/health.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//! Health/liveness endpoints.
|
||||
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Health response body.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Health {
|
||||
pub status: &'static str,
|
||||
pub version: &'static str,
|
||||
}
|
||||
|
||||
/// Build the health router.
|
||||
pub fn router() -> Router {
|
||||
Router::new().route("/health", get(health))
|
||||
}
|
||||
|
||||
async fn health() -> axum::Json<Health> {
|
||||
axum::Json(Health {
|
||||
status: "ok",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
let response = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
13
backend/src/routes/mod.rs
Normal file
13
backend/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//! Route definitions.
|
||||
//!
|
||||
//! Top-level router; each domain (auth, profile, configs, irc) will add its
|
||||
//! own sub-router here.
|
||||
|
||||
pub mod health;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
/// Compose the full route tree.
|
||||
pub fn router() -> Router {
|
||||
Router::new().merge(health::router())
|
||||
}
|
||||
120
backend/src/server/config.rs
Normal file
120
backend/src/server/config.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Runtime configuration, loaded from environment variables.
|
||||
//!
|
||||
//! Parsed into validated types at the boundary; invalid values fail fast
|
||||
//! at startup instead of blowing up mid-request.
|
||||
|
||||
use std::env;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use axum::http::HeaderValue;
|
||||
use tower_http::cors::AllowOrigin;
|
||||
|
||||
/// Application configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: IpAddr,
|
||||
pub port: u16,
|
||||
pub cors_origins: AllowOrigin,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Default host for local development.
|
||||
pub const DEFAULT_HOST: &'static str = "127.0.0.1";
|
||||
/// Default port for local development.
|
||||
pub const DEFAULT_PORT: u16 = 8765;
|
||||
|
||||
/// Load configuration from the environment.
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Self::from_vars(
|
||||
env::var("LOVISUAL_HOST").ok(),
|
||||
env::var("LOVISUAL_PORT").ok(),
|
||||
env::var("LOVISUAL_CORS_ORIGINS").ok(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build config from raw option values (injectable for tests).
|
||||
fn from_vars(host: Option<String>, port: Option<String>, cors: Option<String>) -> Result<Self> {
|
||||
let host = host
|
||||
.unwrap_or_else(|| Self::DEFAULT_HOST.to_owned())
|
||||
.parse()
|
||||
.context("LOVISUAL_HOST is not a valid IP address")?;
|
||||
let port: u16 = match port {
|
||||
Some(p) => p
|
||||
.parse()
|
||||
.context("LOVISUAL_PORT is not a valid port number")?,
|
||||
None => Self::DEFAULT_PORT,
|
||||
};
|
||||
let origins = parse_origins(&cors)?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
cors_origins: origins,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolved listen address.
|
||||
pub fn listen_addr(&self) -> SocketAddr {
|
||||
SocketAddr::new(self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_origins(raw: &Option<String>) -> Result<AllowOrigin> {
|
||||
let Some(raw) = raw else {
|
||||
// No origins configured: allow same-origin requests only.
|
||||
return Ok(AllowOrigin::any());
|
||||
};
|
||||
|
||||
let origins = raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if origins.is_empty() {
|
||||
bail!("LOVISUAL_CORS_ORIGINS is set but contains no origins");
|
||||
}
|
||||
|
||||
Ok(AllowOrigin::list(
|
||||
origins
|
||||
.into_iter()
|
||||
.map(|o| o.parse::<HeaderValue>().context("invalid CORS origin"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_origins_allows_any() {
|
||||
assert!(parse_origins(&None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_list_origin_string_fails() {
|
||||
assert!(parse_origins(&Some(String::from(",,"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_port_fails() {
|
||||
let result = Config::from_vars(None, Some("not-a-port".into()), None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_host_fails() {
|
||||
let result = Config::from_vars(Some("not-an-ip".into()), None, None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_apply_when_empty() {
|
||||
let config = Config::from_vars(None, None, None).unwrap();
|
||||
assert_eq!(
|
||||
config.listen_addr(),
|
||||
SocketAddr::from(([127, 0, 0, 1], 8765))
|
||||
);
|
||||
}
|
||||
}
|
||||
57
backend/src/server/mod.rs
Normal file
57
backend/src/server/mod.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! Server assembly: router, listener, graceful shutdown.
|
||||
|
||||
use anyhow::Context as _;
|
||||
use axum::Router;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use super::routes;
|
||||
use config::Config;
|
||||
|
||||
pub mod config;
|
||||
|
||||
/// Build the application router for a given config.
|
||||
pub fn app(config: &Config) -> Router {
|
||||
Router::new()
|
||||
.merge(routes::router())
|
||||
.layer(CorsLayer::new().allow_origin(config.cors_origins.clone()))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
/// Bind and serve the application until a shutdown signal arrives.
|
||||
pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind(config.listen_addr())
|
||||
.await
|
||||
.context("failed to bind listener")?;
|
||||
|
||||
axum::serve(listener, app(&config))
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.context("server error")
|
||||
}
|
||||
|
||||
/// Resolves on SIGTERM/SIGINT.
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install ctrl-c handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue