fix: CI workflow, password reset flow, health/limits in services, session refactor, dead mixin stub cleanup

This commit is contained in:
loki5512344 2026-10-09 19:21:17 +02:00
parent 45dd592c40
commit f4e15b45c9
Signed by: boba
GPG key ID: 253067914055423B
95 changed files with 899 additions and 185 deletions

View file

@ -10,9 +10,10 @@ path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.7", features = ["trace"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }

View file

@ -1,11 +1,16 @@
pub mod config;
pub mod presence;
use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}};
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;
use tower_http::trace::TraceLayer;
/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids).
const MAX_BODY_BYTES: usize = 8 * 1024;
@ -22,6 +27,7 @@ pub fn build_app(cfg: &Config) -> (Router, Arc<Store>) {
));
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api);
.merge(api)
.layer(TraceLayer::new_for_http());
(app, store)
}

View file

@ -1,7 +1,12 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cfg = chat_service::config::Config::from_env()?;
cfg.validate()?;
let (app, state) = chat_service::build_app(&cfg);

View file

@ -63,15 +63,23 @@ pub async fn sync(
}
let now = Instant::now();
if req.publish
&& let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now)
&& 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");
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 })
.map(|s| PlayerState {
mc: s.mc,
gui: s.gui,
age: s.age_ms,
})
.collect();
Json(json!({ "players": players })).into_response()
}

View file

@ -6,7 +6,10 @@ pub mod handlers;
pub mod payload;
pub mod store;
use std::{sync::Arc, time::{Duration, Instant}};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use store::Store;
pub fn spawn_purger(store: Arc<Store>) {

View file

@ -10,7 +10,8 @@ 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'='))
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
/// Normalised server key: trimmed, lower-case, no control characters, bounded.
@ -38,7 +39,10 @@ mod tests {
#[test]
fn server_is_normalised() {
assert_eq!(normalize_server(" Play.Example.COM ").as_deref(), Some("play.example.com"));
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

@ -97,7 +97,9 @@ impl Store {
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);
inner
.entries
.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
let expired: Vec<Uuid> = inner
.claims
.iter()
@ -117,7 +119,12 @@ 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))
(
Uuid::from_u128(1),
Uuid::from_u128(2),
Uuid::from_u128(10),
Uuid::from_u128(20),
)
}
#[test]
@ -125,7 +132,9 @@ mod tests {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "srv", mc, Some("AAAA".into()), t0).unwrap();
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"));
@ -149,7 +158,10 @@ mod tests {
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));
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());
}
@ -183,6 +195,10 @@ mod tests {
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());
assert!(
store
.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL)
.is_ok()
);
}
}

View file

@ -7,7 +7,10 @@ use uuid::Uuid;
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
fn server() -> TestServer {
let cfg = Config { port: 0, internal_key: KEY.into() };
let cfg = Config {
port: 0,
internal_key: KEY.into(),
};
let (app, _) = chat_service::build_app(&cfg);
TestServer::new(app)
}
@ -37,10 +40,20 @@ async fn requires_internal_key_and_identity() {
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;
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 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);
@ -56,7 +69,12 @@ async fn rejects_bad_input_and_uuid_theft() {
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);
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);
}
@ -65,7 +83,12 @@ async fn rejects_bad_input_and_uuid_theft() {
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;
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);
}