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:
commit
a1f19e86c6
74 files changed
+2108
-95
No files matched your search
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user