feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history

This commit is contained in:
loki5512344 2026-10-02 13:41:49 +02:00
parent 8d9a2ad0d6
commit e82efb74e7
Signed by: boba
GPG key ID: 253067914055423B
44 changed files with 2202 additions and 19 deletions

View file

@ -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"

View file

@ -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"]

View file

@ -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": "<uuid игрока>", "gui": "<base64>|null", "want": ["<uuid>", "..."], "publish": true }
```
Ответ: `{ "players": [ { "mc": "<uuid>", "gui": "<base64>|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), метрики, горизонтальное масштабирование (состояние в памяти одного процесса).

View file

@ -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.

View file

@ -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<Config> {
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());
}
}

View file

@ -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<Store>) {
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)
}

View file

@ -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(())
}

View file

@ -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<String>,
/// Minecraft uuids of nearby players the caller wants state for.
#[serde(default)]
pub want: Vec<Uuid>,
/// 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<String>,
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<Arc<Store>>,
id: GatewayIdentity,
Json(req): Json<SyncRequest>,
) -> 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<PlayerState> = 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()
}

View file

@ -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<Store>) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
store.purge(Instant::now());
}
});
}

View file

@ -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<String> {
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("<script>"));
assert!(!valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS + 1)));
assert!(valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS)));
}
#[test]
fn server_is_normalised() {
assert_eq!(normalize_server(" Play.Example.COM ").as_deref(), Some("play.example.com"));
assert_eq!(normalize_server(""), None);
assert_eq!(normalize_server("a\nb"), None);
assert_eq!(normalize_server(&"x".repeat(65)), None);
}
}

View file

@ -0,0 +1,188 @@
use super::payload::MAX_WANT;
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
use uuid::Uuid;
/// A published payload stops being relayed after this long without a refresh.
pub const ENTRY_TTL: Duration = Duration::from_secs(6);
/// A Minecraft uuid stays claimed by an account this long after its last publish.
pub const CLAIM_TTL: Duration = Duration::from_secs(600);
#[derive(Debug, PartialEq, Eq)]
pub enum PublishError {
/// The uuid is currently claimed by a different account.
Claimed,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Seen {
pub mc: Uuid,
pub gui: Option<String>,
pub age_ms: u64,
}
struct Entry {
gui: Option<String>,
updated: Instant,
}
struct Claim {
account: Uuid,
seen: Instant,
}
#[derive(Default)]
struct Inner {
entries: HashMap<(String, Uuid), Entry>,
claims: HashMap<Uuid, Claim>,
by_account: HashMap<Uuid, Uuid>,
}
/// In-memory presence state. A uuid is bound to one account at a time so a
/// client cannot impersonate somebody else's menu; an account owns one uuid at
/// a time (switching alts releases the previous claim).
#[derive(Default)]
pub struct Store {
inner: Mutex<Inner>,
}
impl Store {
pub fn publish(
&self,
account: Uuid,
server: &str,
mc: Uuid,
gui: Option<String>,
now: Instant,
) -> Result<(), PublishError> {
let mut inner = self.inner.lock().expect("presence lock");
if let Some(claim) = inner.claims.get(&mc)
&& claim.account != account
&& now.duration_since(claim.seen) < CLAIM_TTL
{
return Err(PublishError::Claimed);
}
if let Some(previous) = inner.by_account.insert(account, mc)
&& previous != mc
{
inner.claims.remove(&previous);
inner.entries.retain(|(_, id), _| *id != previous);
}
inner.claims.insert(mc, Claim { account, seen: now });
inner
.entries
.insert((server.to_owned(), mc), Entry { gui, updated: now });
Ok(())
}
/// Fresh entries for the requested uuids on this server (bounded).
pub fn lookup(&self, server: &str, want: &[Uuid], now: Instant) -> Vec<Seen> {
let inner = self.inner.lock().expect("presence lock");
want.iter()
.take(MAX_WANT)
.filter_map(|mc| {
let entry = inner.entries.get(&(server.to_owned(), *mc))?;
let age = now.duration_since(entry.updated);
(age < ENTRY_TTL).then(|| Seen {
mc: *mc,
gui: entry.gui.clone(),
age_ms: age.as_millis() as u64,
})
})
.collect()
}
pub fn purge(&self, now: Instant) {
let mut inner = self.inner.lock().expect("presence lock");
inner.entries.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
let expired: Vec<Uuid> = inner
.claims
.iter()
.filter(|(_, c)| now.duration_since(c.seen) >= CLAIM_TTL)
.map(|(mc, _)| *mc)
.collect();
for mc in expired {
if let Some(claim) = inner.claims.remove(&mc) {
inner.by_account.remove(&claim.account);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> (Uuid, Uuid, Uuid, Uuid) {
(Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(10), Uuid::from_u128(20))
}
#[test]
fn published_entry_is_visible_then_expires() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "srv", mc, Some("AAAA".into()), t0).unwrap();
let seen = store.lookup("srv", &[mc], t0 + Duration::from_millis(500));
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].gui.as_deref(), Some("AAAA"));
assert_eq!(seen[0].age_ms, 500);
assert!(store.lookup("srv", &[mc], t0 + ENTRY_TTL).is_empty());
}
#[test]
fn entries_are_scoped_to_the_server() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "a", mc, None, t0).unwrap();
assert!(store.lookup("b", &[mc], t0).is_empty());
assert_eq!(store.lookup("a", &[mc], t0).len(), 1);
}
#[test]
fn other_account_cannot_take_a_claimed_uuid() {
let (acc, other, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
assert_eq!(store.publish(other, "s", mc, None, t0 + Duration::from_secs(1)), Err(PublishError::Claimed));
// after the claim goes idle it can be taken over
assert!(store.publish(other, "s", mc, None, t0 + CLAIM_TTL).is_ok());
}
#[test]
fn switching_uuid_releases_the_previous_claim() {
let (acc, other, mc1, mc2) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc1, None, t0).unwrap();
store.publish(acc, "s", mc2, None, t0).unwrap();
assert!(store.lookup("s", &[mc1], t0).is_empty());
assert!(store.publish(other, "s", mc1, None, t0).is_ok());
}
#[test]
fn lookup_is_bounded() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
let mut want: Vec<Uuid> = (100..100 + MAX_WANT as u128).map(Uuid::from_u128).collect();
want.push(mc);
assert!(store.lookup("s", &want, t0).is_empty());
}
#[test]
fn purge_drops_stale_state() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
store.purge(t0 + CLAIM_TTL);
assert!(store.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL).is_ok());
}
}

View file

@ -0,0 +1,71 @@
use axum_test::TestServer;
use chat_service::config::Config;
use common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
use serde_json::{Value, json};
use uuid::Uuid;
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
fn server() -> TestServer {
let cfg = Config { port: 0, internal_key: KEY.into() };
let (app, _) = chat_service::build_app(&cfg);
TestServer::new(app)
}
async fn sync(s: &TestServer, account: u128, body: Value) -> axum_test::TestResponse {
s.post("/presence/gui")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, Uuid::from_u128(account).to_string())
.json(&body)
.await
}
#[tokio::test]
async fn requires_internal_key_and_identity() {
let s = server();
let r = s.post("/presence/gui").json(&json!({})).await;
assert_eq!(r.status_code(), 403);
let r = s
.post("/presence/gui")
.add_header(INTERNAL_KEY_HEADER, KEY)
.json(&json!({"server": "a", "mc": Uuid::nil()}))
.await;
assert_eq!(r.status_code(), 401);
}
#[tokio::test]
async fn two_players_see_each_other() {
let s = server();
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
let r = sync(&s, 1, json!({"server": "Play.X", "mc": a, "gui": "AQEAAAA=", "want": [b]})).await;
assert_eq!(r.status_code(), 200);
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
let r = sync(&s, 2, json!({"server": "play.x", "mc": b, "gui": null, "want": [a]})).await;
let body = r.json::<Value>();
let players = body["players"].as_array().unwrap();
assert_eq!(players.len(), 1);
assert_eq!(players[0]["mc"], a.to_string());
assert_eq!(players[0]["gui"], "AQEAAAA=");
}
#[tokio::test]
async fn rejects_bad_input_and_uuid_theft() {
let s = server();
let mc = Uuid::from_u128(10);
let r = sync(&s, 1, json!({"server": "x", "mc": mc, "gui": "<bad>"})).await;
assert_eq!(r.status_code(), 400);
let r = sync(&s, 1, json!({"server": "", "mc": mc})).await;
assert_eq!(r.status_code(), 400);
assert_eq!(sync(&s, 1, json!({"server": "x", "mc": mc})).await.status_code(), 200);
let r = sync(&s, 2, json!({"server": "x", "mc": mc})).await;
assert_eq!(r.status_code(), 409);
}
#[tokio::test]
async fn read_only_poll_does_not_publish() {
let s = server();
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
sync(&s, 1, json!({"server": "x", "mc": a, "publish": false, "want": [b]})).await;
let r = sync(&s, 2, json!({"server": "x", "mc": b, "want": [a]})).await;
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
}