feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history
This commit is contained in:
parent
8d9a2ad0d6
commit
e82efb74e7
44 changed files with 2202 additions and 19 deletions
|
|
@ -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;
|
||||
|
|
|
|||
17
backend/Cargo.lock
generated
17
backend/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
backend/chat-service/Cargo.toml
Normal file
23
backend/chat-service/Cargo.toml
Normal 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"
|
||||
20
backend/chat-service/Dockerfile
Normal file
20
backend/chat-service/Dockerfile
Normal 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"]
|
||||
47
backend/chat-service/PLAN.md
Normal file
47
backend/chat-service/PLAN.md
Normal 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), метрики, горизонтальное масштабирование (состояние в памяти одного процесса).
|
||||
|
|
@ -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.
|
||||
45
backend/chat-service/src/config.rs
Normal file
45
backend/chat-service/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
27
backend/chat-service/src/lib.rs
Normal file
27
backend/chat-service/src/lib.rs
Normal 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)
|
||||
}
|
||||
13
backend/chat-service/src/main.rs
Normal file
13
backend/chat-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
77
backend/chat-service/src/presence/handlers.rs
Normal file
77
backend/chat-service/src/presence/handlers.rs
Normal 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()
|
||||
}
|
||||
20
backend/chat-service/src/presence/mod.rs
Normal file
20
backend/chat-service/src/presence/mod.rs
Normal 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
46
backend/chat-service/src/presence/payload.rs
Normal file
46
backend/chat-service/src/presence/payload.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
188
backend/chat-service/src/presence/store.rs
Normal file
188
backend/chat-service/src/presence/store.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
71
backend/chat-service/tests/presence.rs
Normal file
71
backend/chat-service/tests/presence.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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 configs-service
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ services:
|
|||
CONFIGS_PORT: 8082
|
||||
networks: [lovisual-internal]
|
||||
|
||||
chat-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: chat-service/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
CHAT_PORT: 8083
|
||||
networks: [lovisual-internal]
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -107,12 +117,15 @@ services:
|
|||
condition: service_started
|
||||
configs-service:
|
||||
condition: service_started
|
||||
chat-service:
|
||||
condition: service_started
|
||||
env_file: .env
|
||||
environment:
|
||||
GATEWAY_PORT: 8080
|
||||
ACCOUNTS_HTTP_URL: http://accounts-service:8081
|
||||
ACCOUNTS_GRPC_URL: http://accounts-service:50051
|
||||
CONFIGS_HTTP_URL: http://configs-service:8082
|
||||
CHAT_HTTP_URL: http://chat-service:8083
|
||||
# Read-only static files served under GET /downloads/* (lovisual.jar).
|
||||
DOWNLOADS_DIR: /srv/downloads
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -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 gateway
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub struct Config {
|
|||
pub accounts_http_url: String,
|
||||
pub accounts_grpc_url: String,
|
||||
pub configs_http_url: String,
|
||||
pub chat_http_url: String,
|
||||
pub site_origin: String,
|
||||
/// Directory served read-only under `GET /downloads/*` — today just
|
||||
/// `lovisual.jar`, the mod's direct download (see TODO.md «Скачивание»).
|
||||
|
|
@ -33,6 +34,7 @@ impl Config {
|
|||
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
|
||||
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
|
||||
configs_http_url: var("CONFIGS_HTTP_URL")?,
|
||||
chat_http_url: var("CHAT_HTTP_URL")?,
|
||||
site_origin: var("SITE_ORIGIN")?,
|
||||
downloads_dir: std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "downloads".into()),
|
||||
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
|
||||
|
|
@ -71,6 +73,7 @@ mod tests {
|
|||
accounts_http_url: String::new(),
|
||||
accounts_grpc_url: String::new(),
|
||||
configs_http_url: String::new(),
|
||||
chat_http_url: String::new(),
|
||||
site_origin: "http://localhost:5173".into(),
|
||||
downloads_dir: "downloads".into(),
|
||||
trust_proxy: false,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ pub struct Upstreams {
|
|||
pub client: reqwest::Client,
|
||||
pub accounts: String,
|
||||
pub configs: String,
|
||||
pub chat: String,
|
||||
pub internal_key: HeaderValue,
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ impl Upstreams {
|
|||
.build()?,
|
||||
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
|
||||
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
|
||||
chat: cfg.chat_http_url.trim_end_matches('/').to_owned(),
|
||||
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
|
||||
})
|
||||
}
|
||||
|
|
@ -67,6 +69,7 @@ pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
|
|||
let base = match target {
|
||||
Upstream::Accounts => &up.accounts,
|
||||
Upstream::Configs => &up.configs,
|
||||
Upstream::Chat => &up.chat,
|
||||
};
|
||||
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
|
||||
let url = format!("{base}{path_and_query}");
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
pub enum Upstream {
|
||||
Accounts,
|
||||
Configs,
|
||||
Chat,
|
||||
}
|
||||
|
||||
pub fn upstream_for(path: &str) -> Option<Upstream> {
|
||||
match path.trim_start_matches('/').split('/').next()? {
|
||||
"auth" | "device" | "avatars" | "media" | "me" | "users" => Some(Upstream::Accounts),
|
||||
"configs" | "showcase" => Some(Upstream::Configs),
|
||||
"presence" => Some(Upstream::Chat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +28,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
|
||||
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
|
||||
assert_eq!(upstream_for("/presence/gui"), Some(Upstream::Chat));
|
||||
assert_eq!(upstream_for("/authx"), None);
|
||||
assert_eq!(upstream_for("/"), None);
|
||||
assert_eq!(upstream_for("/health"), None);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ pub fn rules() -> Vec<Rule> {
|
|||
),
|
||||
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
|
||||
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
|
||||
// The mod syncs menu presence about 3 times a second while a menu is open.
|
||||
rule(Method::POST, "/presence/*", Quota::per_second(n(6)), Account),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub fn config(accounts: &str, configs: &str) -> Config {
|
|||
accounts_http_url: accounts.into(),
|
||||
accounts_grpc_url: String::new(),
|
||||
configs_http_url: configs.into(),
|
||||
chat_http_url: String::new(),
|
||||
site_origin: "http://localhost:5173".into(),
|
||||
downloads_dir: std::env::temp_dir()
|
||||
.join("lovisual-downloads-test")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# poking and scripts/e2e.sh. Restarts them if they are already running.
|
||||
#
|
||||
# Prerequisites (one-time):
|
||||
# cargo build -p accounts-service -p configs-service -p gateway
|
||||
# cargo build -p accounts-service -p configs-service -p chat-service -p gateway
|
||||
# a Postgres with accounts_db + configs_db, and MinIO with the
|
||||
# lovisual-avatars-test bucket (see backend/PLAN.md, Task 2).
|
||||
# Dev-only secrets below are fine locally; production reads backend/.env.
|
||||
|
|
@ -25,21 +25,22 @@ export COOKIE_SECURE="false"
|
|||
# browser-facing avatar prefix served through the gateway (mirrors prod /api/media)
|
||||
export AVATAR_PUBLIC_BASE_URL="http://127.0.0.1:8080/media"
|
||||
|
||||
pkill -f 'target/debug/(accounts-service|configs-service|gateway)' 2>/dev/null || true
|
||||
pkill -f 'target/debug/(accounts-service|configs-service|chat-service|gateway)' 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
PORT=8081 GRPC_PORT=50051 setsid nohup "$B/target/debug/accounts-service" >"$L/accounts.log" 2>&1 &
|
||||
CONFIGS_PORT=8082 setsid nohup "$B/target/debug/configs-service" >"$L/configs.log" 2>&1 &
|
||||
CHAT_PORT=8083 setsid nohup "$B/target/debug/chat-service" >"$L/chat.log" 2>&1 &
|
||||
GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL="http://127.0.0.1:8081" \
|
||||
CONFIGS_HTTP_URL="http://127.0.0.1:8082" SITE_ORIGIN="http://localhost:5173" \
|
||||
CONFIGS_HTTP_URL="http://127.0.0.1:8082" CHAT_HTTP_URL="http://127.0.0.1:8083" SITE_ORIGIN="http://localhost:5173" \
|
||||
setsid nohup "$B/target/debug/gateway" >"$L/gateway.log" 2>&1 &
|
||||
|
||||
up() { [ "$(curl -sf "$1" 2>/dev/null || true)" = "ok" ]; }
|
||||
for i in $(seq 1 60); do
|
||||
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8080/health && break
|
||||
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8083/health && up http://127.0.0.1:8080/health && break
|
||||
sleep 0.5
|
||||
done
|
||||
for u in 8081 8082 8080; do
|
||||
for u in 8081 8082 8083 8080; do
|
||||
up "http://127.0.0.1:$u/health" || { echo "port $u did not come up"; tail -5 "$L"/*.log; exit 1; }
|
||||
done
|
||||
echo "accounts+configs+gateway up"
|
||||
echo "accounts+configs+chat+gateway up"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue