Panel: SCOPENET Map storage and endpoints; Vantage proxy removed
This commit is contained in:
15 files changed
+751
-1286
No files matched your search
@@ -0,0 +1,173 @@
|
||||
//! 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
|
||||
}
|
||||
Reference in new issue
Block a user