Files
SCOPENET-MC/panel/server/src/routes/worldmap.rs
T

174 lines
7.5 KiB
Rust

//! SCOPENET Map endpoints: ingest from game servers, and what launchers and the admin panel load.
use super::servers::{get_server, GameServer};
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use crate::worldmap::{dim_slug, parse_tiles, LivePlayer, MAX_ZOOM, TILE_SIZE, TOKEN_TTL};
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
const PLAYER_INTERVAL_MS: u64 = 2000;
fn require_enabled(enabled: bool) -> AppResult<()> {
if enabled {
Ok(())
} else {
Err(AppError::forbidden("The map is turned off for this server. Switch it on under Servers in the admin panel."))
}
}
// ---------------------------------------------------------------------------
// Game server -> panel
// ---------------------------------------------------------------------------
pub async fn game_config(GameServer(server): GameServer, State(state): State<AppState>) -> Json<Value> {
Json(json!({
"enabled": server.map_enabled,
"epoch": state.worldmap.epoch(server.id),
"tile_size": TILE_SIZE,
"max_zoom": MAX_ZOOM,
"player_interval_ms": PLAYER_INTERVAL_MS,
}))
}
#[derive(Deserialize)]
pub struct DimQuery {
dim: String,
}
pub async fn game_tiles(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>, body: Bytes) -> AppResult<Json<Value>> {
require_enabled(server.map_enabled)?;
let slug = dim_slug(&q.dim).ok_or_else(|| AppError::bad_request("unknown dimension"))?;
let tiles = parse_tiles(&body).map_err(AppError::bad_request)?;
let map = state.worldmap.clone();
let id = server.id;
let stored = tokio::task::spawn_blocking(move || map.store_tiles(id, &slug, &tiles)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
Ok(Json(json!({ "stored": stored })))
}
#[derive(Deserialize)]
pub struct PlayersBody {
players: Vec<LivePlayer>,
}
pub async fn game_players(GameServer(server): GameServer, State(state): State<AppState>, Json(b): Json<PlayersBody>) -> AppResult<Json<Value>> {
require_enabled(server.map_enabled)?;
state.worldmap.set_players(server.id, b.players);
Ok(Json(json!({ "ok": true })))
}
/// Claims and pins, already drawn into shapes by the game server. Stored as is, size-limited.
pub async fn game_overlay(GameServer(server): GameServer, State(state): State<AppState>, Json(overlay): Json<Value>) -> AppResult<Json<Value>> {
require_enabled(server.map_enabled)?;
let ok = overlay.is_object() && overlay.get("claims").is_none_or(|c| c.is_array()) && overlay.get("pins").is_none_or(|p| p.is_array());
if !ok || overlay.to_string().len() > 4 * 1024 * 1024 {
return Err(AppError::bad_request("overlay must be an object with claims and pins lists (up to 4 MiB)"));
}
state.worldmap.set_overlay(server.id, overlay);
Ok(Json(json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Launcher and admin viewers
// ---------------------------------------------------------------------------
async fn info(state: &AppState, id: i64, enabled: bool) -> Value {
let summary = state.worldmap.summary(id);
let dims = summary["dimensions"].clone();
let has = dims.as_array().is_some_and(|d| !d.is_empty());
let message = if !enabled {
"The map is off for this server."
} else if !has {
"Waiting for the game server to draw its world. The first tiles appear within a minute or two of the server starting."
} else {
""
};
json!({
"enabled": enabled,
"ready": enabled && has,
"message": message,
"tile_size": TILE_SIZE,
"max_zoom": MAX_ZOOM,
"tile_base": format!("/api/map/{id}"),
"token": if enabled && has { json!(state.worldmap.issue_token(id)) } else { Value::Null },
"token_ttl_secs": TOKEN_TTL.as_secs(),
"dimensions": dims,
"players": state.worldmap.live_players(id).len(),
})
}
/// Lets a launcher decide whether to offer a Live Map button, and gives it what it needs to draw the map.
pub async fn viewer_info(_: AuthUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
let server = get_server(&state, id).await?;
Ok(Json(info(&state, id, server.map_enabled).await))
}
/// Players now, plus the claims and pins the game server last sent.
pub async fn viewer_overlay(_: AuthUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
let server = get_server(&state, id).await?;
require_enabled(server.map_enabled)?;
let overlay = state.worldmap.overlay(id);
Ok(Json(json!({
"players": state.worldmap.live_players(id),
"claims": overlay.get("claims").cloned().unwrap_or(json!([])),
"pins": overlay.get("pins").cloned().unwrap_or(json!([])),
"updated": state.worldmap.stats(id).last_overlay_at,
})))
}
pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
let server = get_server(&state, id).await?;
let mut v = info(&state, id, server.map_enabled).await;
v["stats"] = serde_json::to_value(state.worldmap.stats(id))?;
v["epoch"] = json!(state.worldmap.epoch(id));
v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?;
Ok(Json(v))
}
pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
get_server(&state, id).await?;
let map = state.worldmap.clone();
tokio::task::spawn_blocking(move || map.reset(id)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct TokenQuery {
#[serde(default)]
t: String,
}
/// `GET /api/map/{server}/{dimension}/{zoom}/{x}/{y}.png?t=<token>`. The token comes from [`viewer_info`], so only signed-in
/// players can load tiles, yet plain `<img>` tags work.
pub async fn tile(State(state): State<AppState>, Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, Query(q): Query<TokenQuery>, headers: axum::http::HeaderMap) -> Response {
let not_found = || (StatusCode::NOT_FOUND, "no tile").into_response();
if !state.worldmap.token_ok(id, &q.t) {
return (StatusCode::UNAUTHORIZED, "map link expired").into_response();
}
let (Some(slug), Some(y)) = (dim_slug(&dim), file.strip_suffix(".png").and_then(|s| s.parse::<i32>().ok())) else { return not_found() };
if z > MAX_ZOOM {
return not_found();
}
let map = state.worldmap.clone();
let found = tokio::task::spawn_blocking(move || map.read_tile(id, &slug, z, x, y)).await.ok().flatten();
let Some((png, modified)) = found else { return not_found() };
let etag = format!("\"{modified}-{}\"", png.len());
if headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(etag.as_str()) {
return (StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response();
}
let mut resp = (StatusCode::OK, png).into_response();
let h = resp.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=30"));
if let Ok(v) = HeaderValue::from_str(&etag) {
h.insert(header::ETAG, v);
}
resp
}