Files
SCOPENET-MC/panel/server/src/routes/activity.rs
T
SCOPEDD a1f19e86c6 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.
2026-09-28 13:31:17 -04:00

107 lines
3.5 KiB
Rust

//! Metadata-only audit trail. Never persist passwords, tokens, chat or command arguments.
use crate::{
auth::{AdminUser, UserRow},
error::AppResult,
state::AppState,
};
use axum::{
extract::{Query, Request, State},
middleware::Next,
response::Response,
Json,
};
use serde::{Deserialize, Serialize};
pub async fn record(state: &AppState, user: &UserRow, source: &str, kind: &str, detail: Option<&str>) -> AppResult<()> {
sqlx::query("INSERT INTO events (username, uuid, source, kind, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&user.username)
.bind(&user.uuid)
.bind(source)
.bind(kind)
.bind(detail.map(|d| d.chars().filter(|c| !c.is_control()).take(256).collect::<String>()))
.bind(crate::db::now())
.execute(&state.db)
.await?;
Ok(())
}
pub async fn audit(State(state): State<AppState>, request: Request, next: Next) -> Response {
let path = request.uri().path().to_owned();
let mutation = matches!(request.method().as_str(), "POST" | "PUT" | "PATCH" | "DELETE")
&& (path.starts_with("/api/admin/") || path.starts_with("/api/v1/account/"));
let user = if mutation {
let claims = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|t| state.keys.verify(t));
if let Some(claims) = claims {
sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id=? AND auth_version=? AND status='active'")
.bind(claims.sub)
.bind(claims.version)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
} else {
None
}
} else {
None
};
let detail = format!("{} {path}", request.method());
let response = next.run(request).await;
if response.status().is_success() {
if let Some(user) = user {
if let Err(e) = record(&state, &user, "panel", "account_or_admin_change", Some(&detail)).await {
tracing::error!("audit write failed: {}", e.message);
}
}
}
response
}
#[derive(Deserialize, Default)]
pub struct Filter {
#[serde(default)]
source: String,
#[serde(default)]
player: String,
#[serde(default)]
offset: u32,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct Entry {
id: i64,
source: String,
server: Option<String>,
uuid: Option<String>,
name: Option<String>,
kind: String,
detail: Option<String>,
created_at: String,
}
pub async fn list(_: AdminUser, State(state): State<AppState>, Query(filter): Query<Filter>) -> AppResult<Json<Vec<Entry>>> {
let rows = sqlx::query_as(
"SELECT * FROM (
SELECT id, source, NULL AS server, uuid, username AS name, kind, detail, created_at FROM events
UNION ALL
SELECT e.id, 'server' AS source, s.name AS server, e.uuid, e.name, e.kind, e.detail, e.created_at
FROM server_events e JOIN game_servers s ON s.id=e.server_id
) WHERE (?='' OR source=?) AND (?='' OR name=? COLLATE NOCASE OR uuid=?)
ORDER BY created_at DESC, source, id DESC LIMIT 100 OFFSET ?",
)
.bind(&filter.source)
.bind(&filter.source)
.bind(&filter.player)
.bind(&filter.player)
.bind(&filter.player)
.bind(filter.offset.min(100_000))
.fetch_all(&state.db)
.await?;
Ok(Json(rows))
}