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
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue