diff --git a/backend/.env.example b/backend/.env.example index 83818c28..3680a1dc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,7 +19,6 @@ GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL=http://127.0.0.1:8081 ACCOUNTS_GRPC_URL=http://127.0.0.1:50051 CONFIGS_HTTP_URL=http://127.0.0.1:8082 -CHAT_HTTP_URL=http://127.0.0.1:8083 SITE_ORIGIN=http://localhost:5173 TRUST_PROXY=false # Directory the gateway serves under GET /downloads/* (lovisual.jar lives here; diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 14ba96d9..f81faa7c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -860,23 +860,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chat-service" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "axum-test", - "common", - "dotenvy", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "chrono" version = "0.4.45" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 19db6abc..ee98210d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "accounts-service", - "chat-service", "common", "configs-service", "gateway", @@ -10,4 +9,5 @@ members = [ # Planned members, added when their own implementation plan starts # (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3): +# "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2) # "addons-registry" — addon marketplace backend (Подсистема 3) diff --git a/backend/STRUCTURE.md b/backend/STRUCTURE.md index b010c427..2d363e8c 100644 --- a/backend/STRUCTURE.md +++ b/backend/STRUCTURE.md @@ -17,9 +17,9 @@ backend/ configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов, # share-коды, витрина publish/browse/detail/copy # (Подсистема 1, часть 2) - chat-service/ # РЕАЛИЗОВАН ЧАСТИЧНО — chat-service/PLAN.md: сейчас только GUI presence - # (в памяти, POST /presence/gui); RPC-чат, друзья, виджеты-телеметрия - # (Подсистема 2) позже, там будет WebSocket. + chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence, + # виджеты-телеметрия (Подсистема 2). Единственный + # сервис с WebSocket, а не только REST. addons-registry/ # ПЛАН НЕ НАПИСАН — витрина аддонов, версии, # публикация/модерация (Подсистема 3) ``` diff --git a/backend/accounts-service/Dockerfile b/backend/accounts-service/Dockerfile index bfd9abd2..5707085c 100644 --- a/backend/accounts-service/Dockerfile +++ b/backend/accounts-service/Dockerfile @@ -9,7 +9,6 @@ COPY common ./common COPY accounts-service ./accounts-service COPY gateway ./gateway COPY configs-service ./configs-service -COPY chat-service ./chat-service RUN cargo build --release -p accounts-service FROM debian:trixie-slim diff --git a/backend/chat-service/Cargo.toml b/backend/chat-service/Cargo.toml deleted file mode 100644 index a4eeeeaa..00000000 --- a/backend/chat-service/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "chat-service" -version = "0.1.0" -edition = "2024" - -[lib] -name = "chat_service" -path = "src/lib.rs" - -[dependencies] -common = { path = "../common" } -axum = { version = "0.8", features = ["macros"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } -tracing = "0.1" -tracing-subscriber = "0.3" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -anyhow = "1" -dotenvy = "0.15" - -[dev-dependencies] -axum-test = "21" diff --git a/backend/chat-service/Dockerfile b/backend/chat-service/Dockerfile deleted file mode 100644 index 6afa1a29..00000000 --- a/backend/chat-service/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build context must be backend/ (the workspace root). -FROM rust:1-trixie AS builder -RUN apt-get update && apt-get install -y --no-install-recommends \ - protobuf-compiler pkg-config libssl-dev ca-certificates \ - && rm -rf /var/lib/apt/lists/* -WORKDIR /build -COPY Cargo.toml Cargo.lock ./ -COPY common ./common -COPY accounts-service ./accounts-service -COPY gateway ./gateway -COPY configs-service ./configs-service -COPY chat-service ./chat-service -RUN cargo build --release -p chat-service - -FROM debian:trixie-slim -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates libssl3 \ - && rm -rf /var/lib/apt/lists/* -COPY --from=builder /build/target/release/chat-service /usr/local/bin/chat-service -ENTRYPOINT ["/usr/local/bin/chat-service"] diff --git a/backend/chat-service/PLAN.md b/backend/chat-service/PLAN.md deleted file mode 100644 index c418d53f..00000000 --- a/backend/chat-service/PLAN.md +++ /dev/null @@ -1,47 +0,0 @@ -# chat-service - -Подсистема 2 платформы (RPC-чат, друзья, presence, телеметрия виджетов). Сейчас реализован только **GUI presence**; -чат и друзья позже (WebSocket). Сервис без БД: состояние в памяти, живёт секунды. - -## GUI presence (идея из evo `GuiPresence`, наш вариант) - -Зачем: LoVisual-игроки видят друг у друга над рукой мини-копию открытого меню клиента (какая категория, куда наведён курсор, -поиск), и знают, кто вообще пользуется LoVisual (значок у ника). Без видео и без чужих данных: только положение курсора и флаги. - -### Протокол -`POST /presence/gui` (через gateway, нужен device-токен `lvd_...`, gateway кладёт `x-lovisual-account-id`): - -```json -{ "server": "play.example.com", "mc": "", "gui": "|null", "want": ["", "..."], "publish": true } -``` -Ответ: `{ "players": [ { "mc": "", "gui": "|null", "age": 120 } ] }`, `age` в мс с последнего обновления. - -- Один запрос и публикует состояние вызывающего, и возвращает состояние тех, кого он видит (`want`, не больше 40). -- `publish = false` (по умолчанию true): только чтение, вызывающий сам не появляется у других. -- `gui = null` значит «меню закрыто, но LoVisual запущен» (значок у ника). Запись живёт 6 с без обновления. -- Игроки разных серверов не видят друг друга (ключ `server`, нормализуется в нижний регистр, ≤64 символов). -- Частота клиента: раз в ~350 мс при открытом меню (пачка до 8 сэмплов курсора по 100 мс), раз в 2 с при закрытом. - Лимит на gateway: 6 запросов в секунду на аккаунт. - -### Формат `gui` (36 байт максимум, base64 ≤48 символов; сервис его не разбирает, только проверяет алфавит и длину) -| байт | значение | -|---|---| -| 0 | версия = 1 | -| 1 | биты 0-3 категория (0..15), бит 4 открыта страница настроек, бит 5 поиск, бит 7 меню закрыто | -| 2 | индекс модуля под курсором в списке категории (255 = нет) | -| 3 | число сэмплов n (0..8) | -| 4+4i | сэмпл: x (0..255 по ширине меню), y (0..255 по высоте), бит 0 = зажата ЛКМ, прокрутка списка (0..255) | - -### Безопасность -- Идентичность только из заголовка gateway; прямой доступ закрыт общим секретом (как у остальных сервисов). -- Привязка `mc uuid` к аккаунту: чужой аккаунт не может публиковать за занятый uuid (409), привязка снимается через 10 мин - простоя; аккаунт владеет одним uuid, смена альта освобождает прежний. Это защита от подделки, не доказательство владения - аккаунтом Minecraft: серверной верификации uuid нет. -- Размер тела ≤8 КБ, payload только `[A-Za-z0-9+/=]`, `want` ≤40. - -### Структура -`src/presence/{payload.rs (валидация), store.rs (состояние + TTL + привязка), handlers.rs (sync)}`; тесты: модульные в `store.rs`, -`payload.rs`, интеграционные `tests/presence.rs`. Порт `CHAT_PORT` (8083), `INTERNAL_KEY` общий. В gateway маршрут `/presence/*`. - -### Не сделано -Просмотр видео (`.watch` у evo), чат и друзья (WebSocket), метрики, горизонтальное масштабирование (состояние в памяти одного процесса). diff --git a/backend/chat-service/README.md b/backend/chat-service/README.md new file mode 100644 index 00000000..ef36036b --- /dev/null +++ b/backend/chat-service/README.md @@ -0,0 +1,5 @@ +# chat-service (planned) + +RPC chat, friends, presence, telemetry widgets (Подсистема 2). WebSocket, not +just REST — see `TODO.md` (Фаза 10, "Подсистема 2") and +`backend/STRUCTURE.md`. No implementation plan yet. diff --git a/backend/chat-service/src/config.rs b/backend/chat-service/src/config.rs deleted file mode 100644 index bd21e114..00000000 --- a/backend/chat-service/src/config.rs +++ /dev/null @@ -1,45 +0,0 @@ -use anyhow::{Context, Result}; - -#[derive(Clone)] -pub struct Config { - pub port: u16, - pub internal_key: String, -} - -impl Config { - pub fn from_env() -> Result { - Ok(Config { - port: std::env::var("CHAT_PORT") - .unwrap_or_else(|_| "8083".into()) - .parse() - .context("CHAT_PORT")?, - internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?, - }) - } - - pub fn validate(&self) -> Result<()> { - if self.internal_key.len() < 32 { - anyhow::bail!("INTERNAL_KEY must be at least 32 bytes"); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn short_key_is_rejected() { - let c = Config { - port: 0, - internal_key: "short".into(), - }; - assert!(c.validate().is_err()); - let c = Config { - port: 0, - internal_key: "k".repeat(32), - }; - assert!(c.validate().is_ok()); - } -} diff --git a/backend/chat-service/src/lib.rs b/backend/chat-service/src/lib.rs deleted file mode 100644 index c500627b..00000000 --- a/backend/chat-service/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -pub mod config; -pub mod presence; - -use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}}; -use common::internal::{InternalKey, require_internal_key}; -use config::Config; -use presence::store::Store; -use std::sync::Arc; - -/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids). -const MAX_BODY_BYTES: usize = 8 * 1024; - -pub fn build_app(cfg: &Config) -> (Router, Arc) { - let store = Arc::new(Store::default()); - let api = Router::new() - .route("/presence/gui", post(presence::handlers::sync)) - .with_state(Arc::clone(&store)) - .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) - .layer(axum::middleware::from_fn_with_state( - InternalKey::new(cfg.internal_key.clone()), - require_internal_key, - )); - let app = Router::new() - .route("/health", get(|| async { "ok" })) - .merge(api); - (app, store) -} diff --git a/backend/chat-service/src/main.rs b/backend/chat-service/src/main.rs deleted file mode 100644 index 47e3ab2b..00000000 --- a/backend/chat-service/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -#[tokio::main] -async fn main() -> anyhow::Result<()> { - dotenvy::dotenv().ok(); - tracing_subscriber::fmt::init(); - let cfg = chat_service::config::Config::from_env()?; - cfg.validate()?; - let (app, state) = chat_service::build_app(&cfg); - chat_service::presence::spawn_purger(state); - let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?; - tracing::info!("chat-service listening on {}", cfg.port); - axum::serve(listener, app).await?; - Ok(()) -} diff --git a/backend/chat-service/src/presence/handlers.rs b/backend/chat-service/src/presence/handlers.rs deleted file mode 100644 index bc962add..00000000 --- a/backend/chat-service/src/presence/handlers.rs +++ /dev/null @@ -1,77 +0,0 @@ -use super::{ - payload::{MAX_WANT, normalize_server, valid_payload}, - store::{PublishError, Store}, -}; -use axum::{ - Json, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use common::internal::GatewayIdentity; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::{sync::Arc, time::Instant}; -use uuid::Uuid; - -#[derive(Deserialize)] -pub struct SyncRequest { - pub server: String, - pub mc: Uuid, - /// Base64 GUI payload, or null when this client has no menu open (still - /// marks the player as a LoVisual user for a few seconds). - #[serde(default)] - pub gui: Option, - /// Minecraft uuids of nearby players the caller wants state for. - #[serde(default)] - pub want: Vec, - /// False for a read-only poll: the caller sees others but does not appear itself. - #[serde(default = "yes")] - pub publish: bool, -} - -fn yes() -> bool { - true -} - -#[derive(Serialize)] -pub struct PlayerState { - pub mc: Uuid, - pub gui: Option, - pub age: u64, -} - -fn error(status: StatusCode, message: &str) -> Response { - (status, Json(json!({ "error": message }))).into_response() -} - -/// One round trip both publishes the caller's state and returns the state of -/// the players the caller can see (same request as the mod's poll loop). -pub async fn sync( - State(store): State>, - id: GatewayIdentity, - Json(req): Json, -) -> Response { - let Some(server) = normalize_server(&req.server) else { - return error(StatusCode::BAD_REQUEST, "invalid server"); - }; - if req.want.len() > MAX_WANT { - return error(StatusCode::BAD_REQUEST, "too many players requested"); - } - if req.gui.as_deref().is_some_and(|g| !valid_payload(g)) { - return error(StatusCode::BAD_REQUEST, "invalid gui payload"); - } - let now = Instant::now(); - if req.publish - && let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now) - { - return error(StatusCode::CONFLICT, "player uuid is bound to another account"); - } - let players: Vec = store - .lookup(&server, &req.want, now) - .into_iter() - .filter(|s| s.mc != req.mc) - .map(|s| PlayerState { mc: s.mc, gui: s.gui, age: s.age_ms }) - .collect(); - Json(json!({ "players": players })).into_response() -} diff --git a/backend/chat-service/src/presence/mod.rs b/backend/chat-service/src/presence/mod.rs deleted file mode 100644 index ebc1a222..00000000 --- a/backend/chat-service/src/presence/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! GUI presence: lets LoVisual users see which client menu nearby LoVisual -//! users have open. State is in-memory and short-lived (a few seconds), never -//! persisted; see `backend/chat-service/PLAN.md`. - -pub mod handlers; -pub mod payload; -pub mod store; - -use std::{sync::Arc, time::{Duration, Instant}}; -use store::Store; - -pub fn spawn_purger(store: Arc) { - tokio::spawn(async move { - let mut tick = tokio::time::interval(Duration::from_secs(30)); - loop { - tick.tick().await; - store.purge(Instant::now()); - } - }); -} diff --git a/backend/chat-service/src/presence/payload.rs b/backend/chat-service/src/presence/payload.rs deleted file mode 100644 index 20541872..00000000 --- a/backend/chat-service/src/presence/payload.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Validation of the opaque GUI payload and server key. The payload is a -//! base64 string produced by the mod (header + up to 8 cursor samples); the -//! service never decodes it, it only bounds what it stores and relays. - -/// Header (4 bytes) + 8 samples x 4 bytes = 36 bytes = 48 base64 characters. -pub const MAX_PAYLOAD_CHARS: usize = 48; -pub const MAX_SERVER_CHARS: usize = 64; -pub const MAX_WANT: usize = 40; - -pub fn valid_payload(s: &str) -> bool { - !s.is_empty() - && s.len() <= MAX_PAYLOAD_CHARS - && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')) -} - -/// Normalised server key: trimmed, lower-case, no control characters, bounded. -pub fn normalize_server(s: &str) -> Option { - let s = s.trim().to_ascii_lowercase(); - if s.is_empty() || s.chars().count() > MAX_SERVER_CHARS || s.chars().any(|c| c.is_control()) { - return None; - } - Some(s) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn payload_charset_and_length() { - assert!(valid_payload("AQEAAAA=")); - assert!(!valid_payload("")); - assert!(!valid_payload("has space")); - assert!(!valid_payload("