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