Complete private authentication, server integrations and activity reporting

Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
This commit is contained in:
SCOPEDD committed 2026-09-28 13:31:17 -04:00
commit a1f19e86c6
74 files changed
+2108 -95

No files matched your search

+41 -11
View File
@@ -200,12 +200,22 @@ pub async fn login(
Json(req): Json<LoginCheck>,
) -> AppResult<Json<LoginVerdict>> {
let brand = store::branding(&state).await?.name;
let user = match user_by_uuid(&state, &req.uuid).await? {
Some(u) => Some(u),
// Offline-mode servers may send a UUID we don't know (e.g. a
// Floodgate player) — fall back to the name.
None => auth::find_user_by_name(&state, &req.name).await?,
};
// Names are not proof of identity. Only the UUID authenticated by the
// server's online-mode session check may select a panel account.
let user = user_by_uuid(&state, &req.uuid).await?;
if user.is_none() {
let reserved: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reserved_usernames WHERE name=?)").bind(&req.name).fetch_one(&state.db).await?;
if reserved {
return Ok(Json(LoginVerdict::deny("That player name belongs to another account.")));
}
}
if user.as_ref().is_some_and(|u| !u.username.eq_ignore_ascii_case(&req.name)) {
return Ok(Json(LoginVerdict::deny("Your player name does not match your account.")));
}
if server.require_launcher && req.ip.as_deref().is_none_or(|ip| ip.trim().is_empty()) {
return Ok(Json(LoginVerdict::deny("Your connection address could not be verified. Please reconnect through the launcher.")));
}
Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?))
}
@@ -217,7 +227,7 @@ async fn check_login(
brand: &str,
) -> AppResult<LoginVerdict> {
let Some(user) = user else {
return Ok(if server.access == "all" {
return Ok(if server.access == "all" && !server.require_launcher {
LoginVerdict { allowed: true, message: None, account: None }
} else {
LoginVerdict::deny(format!("You need a {brand} account to join this server.\nCreate one in the {brand} launcher."))
@@ -304,6 +314,8 @@ pub struct GameEvent {
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct Sync {
/// Stable across retries. Older integrations may omit it.
batch_id: Option<String>,
tps: Option<f64>,
online: Vec<OnlinePlayer>,
stats: Vec<StatDelta>,
@@ -316,6 +328,24 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
let at_now = now();
let mut tx = state.db.begin().await?;
// Record the receipt in the same transaction as the deltas. A response
// lost after commit can then be retried without counting activity twice.
let fresh = if let Some(batch) = &s.batch_id {
if uuid::Uuid::parse_str(batch).is_err() {
return Err(AppError::bad_request("batch_id must be a UUID"));
}
sqlx::query("INSERT OR IGNORE INTO server_sync_receipts (server_id, batch_id, created_at) VALUES (?, ?, ?)")
.bind(server.id)
.bind(batch)
.bind(&at_now)
.execute(&mut *tx)
.await?
.rows_affected()
> 0
} else {
true
};
let online: Vec<(String, String)> =
s.online.iter().take(MAX_ONLINE).filter_map(|p| Some((dashed(&p.uuid)?, clip(&p.name, 16)))).collect();
sqlx::query("UPDATE game_servers SET last_seen = ?, online_count = ?, tps = ? WHERE id = ?")
@@ -343,7 +373,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.await?;
}
for d in &s.stats {
for d in s.stats.iter().filter(|_| fresh) {
let Some(uuid) = dashed(&d.uuid) else { continue };
let n = |v: i64| v.clamp(0, 1_000_000);
sqlx::query(
@@ -378,7 +408,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.await?;
}
for e in s.events.iter().take(MAX_EVENTS_PER_SYNC) {
for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) {
let kind = clip(&e.kind, 24).to_ascii_lowercase();
if kind.is_empty() {
continue;
@@ -411,8 +441,8 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
let mut kick = Vec::new();
for (uuid, _) in &online {
let Some(user) = user_by_uuid(&state, uuid).await? else { continue };
if user.status != "active" {
let verdict = check_login(&state, &server, Some(&user), None, &brand).await?;
let verdict = check_login(&state, &server, Some(&user), None, &brand).await?;
if !verdict.allowed {
kick.push(json!({ "uuid": uuid, "message": verdict.message }));
}
}