From e82efb74e7004441fcd60c323b349ee7a551b3cc Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 13:41:49 +0200 Subject: [PATCH] feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history --- backend/.env.example | 1 + backend/Cargo.lock | 17 + backend/Cargo.toml | 2 +- backend/STRUCTURE.md | 6 +- backend/accounts-service/Dockerfile | 1 + backend/chat-service/Cargo.toml | 23 + backend/chat-service/Dockerfile | 20 + backend/chat-service/PLAN.md | 47 ++ backend/chat-service/README.md | 5 - backend/chat-service/src/config.rs | 45 + backend/chat-service/src/lib.rs | 27 + backend/chat-service/src/main.rs | 13 + backend/chat-service/src/presence/handlers.rs | 77 ++ backend/chat-service/src/presence/mod.rs | 20 + backend/chat-service/src/presence/payload.rs | 46 ++ backend/chat-service/src/presence/store.rs | 188 +++++ backend/chat-service/tests/presence.rs | 71 ++ backend/configs-service/Dockerfile | 1 + backend/docker-compose.prod.yml | 13 + backend/gateway/Dockerfile | 1 + backend/gateway/src/config.rs | 3 + backend/gateway/src/proxy/forward.rs | 3 + backend/gateway/src/proxy/routes.rs | 3 + backend/gateway/src/rate_limit/rules.rs | 2 + backend/gateway/tests/common/mod.rs | 1 + backend/scripts/dev-stack.sh | 13 +- mod/TODO.md | 21 +- mod/docs/TODO_HISTORY.md | 770 ++++++++++++++++++ .../screen/modules/ModulesMenuPresence.java | 53 ++ .../screen/modules/ModulesMenuScreen.java | 11 + .../screen/modules/model/MenuPresence.java | 10 + .../modules/visuals/presence/GuiPresence.java | 92 +++ .../presence/PresencePanelRenderer.java | 128 +++ .../visuals/presence/PresenceSampler.java | 49 ++ .../platform/presence/PresenceClient.java | 107 +++ .../platform/presence/PresenceCodec.java | 77 ++ .../platform/presence/PresenceTimeline.java | 87 ++ .../resources/assets/lovisual/lang/en_us.json | 7 +- .../resources/assets/lovisual/lang/ru_ru.json | 7 +- .../platform/presence/PresenceTest.java | 77 ++ server-plugin/.classpath | 25 + server-plugin/.project | 34 + .../org.eclipse.buildship.core.prefs | 13 + .../.settings/org.eclipse.jdt.core.prefs | 4 + 44 files changed, 2202 insertions(+), 19 deletions(-) create mode 100644 backend/chat-service/Cargo.toml create mode 100644 backend/chat-service/Dockerfile create mode 100644 backend/chat-service/PLAN.md delete mode 100644 backend/chat-service/README.md create mode 100644 backend/chat-service/src/config.rs create mode 100644 backend/chat-service/src/lib.rs create mode 100644 backend/chat-service/src/main.rs create mode 100644 backend/chat-service/src/presence/handlers.rs create mode 100644 backend/chat-service/src/presence/mod.rs create mode 100644 backend/chat-service/src/presence/payload.rs create mode 100644 backend/chat-service/src/presence/store.rs create mode 100644 backend/chat-service/tests/presence.rs create mode 100644 mod/docs/TODO_HISTORY.md create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPresence.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/MenuPresence.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/presence/GuiPresence.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/presence/PresencePanelRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/presence/PresenceSampler.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/presence/PresenceClient.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/presence/PresenceCodec.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/presence/PresenceTimeline.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/platform/presence/PresenceTest.java create mode 100644 server-plugin/.classpath create mode 100644 server-plugin/.project create mode 100644 server-plugin/.settings/org.eclipse.buildship.core.prefs create mode 100644 server-plugin/.settings/org.eclipse.jdt.core.prefs diff --git a/backend/.env.example b/backend/.env.example index 3680a1d..83818c2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,7 @@ 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 f81faa7..14ba96d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -860,6 +860,23 @@ 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 ee98210..19db6ab 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "accounts-service", + "chat-service", "common", "configs-service", "gateway", @@ -9,5 +10,4 @@ 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 2d363e8..b010c42 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/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence, - # виджеты-телеметрия (Подсистема 2). Единственный - # сервис с WebSocket, а не только REST. + chat-service/ # РЕАЛИЗОВАН ЧАСТИЧНО — chat-service/PLAN.md: сейчас только GUI presence + # (в памяти, POST /presence/gui); RPC-чат, друзья, виджеты-телеметрия + # (Подсистема 2) позже, там будет WebSocket. addons-registry/ # ПЛАН НЕ НАПИСАН — витрина аддонов, версии, # публикация/модерация (Подсистема 3) ``` diff --git a/backend/accounts-service/Dockerfile b/backend/accounts-service/Dockerfile index 5707085..bfd9abd 100644 --- a/backend/accounts-service/Dockerfile +++ b/backend/accounts-service/Dockerfile @@ -9,6 +9,7 @@ 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 new file mode 100644 index 0000000..a4eeeea --- /dev/null +++ b/backend/chat-service/Cargo.toml @@ -0,0 +1,23 @@ +[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 new file mode 100644 index 0000000..6afa1a2 --- /dev/null +++ b/backend/chat-service/Dockerfile @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 0000000..c418d53 --- /dev/null +++ b/backend/chat-service/PLAN.md @@ -0,0 +1,47 @@ +# 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 deleted file mode 100644 index ef36036..0000000 --- a/backend/chat-service/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 new file mode 100644 index 0000000..bd21e11 --- /dev/null +++ b/backend/chat-service/src/config.rs @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..c500627 --- /dev/null +++ b/backend/chat-service/src/lib.rs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..47e3ab2 --- /dev/null +++ b/backend/chat-service/src/main.rs @@ -0,0 +1,13 @@ +#[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 new file mode 100644 index 0000000..bc962ad --- /dev/null +++ b/backend/chat-service/src/presence/handlers.rs @@ -0,0 +1,77 @@ +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 new file mode 100644 index 0000000..ebc1a22 --- /dev/null +++ b/backend/chat-service/src/presence/mod.rs @@ -0,0 +1,20 @@ +//! 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 new file mode 100644 index 0000000..2054187 --- /dev/null +++ b/backend/chat-service/src/presence/payload.rs @@ -0,0 +1,46 @@ +//! 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("