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

@ -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
View file

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

View file

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

View file

@ -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)
```

View file

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

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);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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}");

View file

@ -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);

View file

@ -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),
]
}

View file

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

View file

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