Panel: SCOPENET Map storage and endpoints; Vantage proxy removed

This commit is contained in:
Claude committed 2026-10-01 00:20:04 +00:00
1 parent c0daa0507a
commit 9102e2fc52
15 files changed
+751 -1286

No files matched your search

-296
View File
@@ -1,296 +0,0 @@
//! Live map endpoints: ingest from game servers, the authenticated Vantage
//! proxy for launchers/admin, and admin status.
use super::servers::{get_server, GameServer};
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
use crate::livemap::{parse_chunks, Dim, LivePlayer};
use crate::state::AppState;
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use base64::Engine;
use serde::Deserialize;
use serde_json::{json, Value};
pub const CHUNK_INTERVAL_SECS: u64 = 20;
pub const PLAYER_INTERVAL_MS: u64 = 1000;
const MAX_PLAYERS: usize = 500;
/// Settings handed to the game server in `/hello` and `/livemap/config`.
pub fn game_config(enabled: bool) -> Value {
json!({
"enabled": enabled,
"chunk_interval_secs": CHUNK_INTERVAL_SECS,
"player_interval_ms": PLAYER_INTERVAL_MS,
"max_batch_bytes": 8 * 1024 * 1024,
})
}
fn require_enabled(enabled: bool) -> AppResult<()> {
if enabled {
Ok(())
} else {
Err(AppError::forbidden("Live Map is turned off for this server. Enable it under Servers in the admin panel."))
}
}
fn dim_of(s: &str) -> AppResult<Dim> {
Dim::parse(s).ok_or_else(|| AppError::bad_request("unknown dimension (use overworld, the_nether or the_end)"))
}
#[derive(Deserialize)]
pub struct DimQuery {
#[serde(default = "overworld")]
dim: String,
}
fn overworld() -> String {
"overworld".into()
}
// ---------------------------------------------------------------------------
// Game server → panel
// ---------------------------------------------------------------------------
pub async fn game_config_route(GameServer(server): GameServer) -> Json<Value> {
Json(game_config(server.live_map_enabled))
}
/// Timestamp tables of the regions the panel already has, base64 per region.
pub async fn game_manifest(
GameServer(server): GameServer,
State(state): State<AppState>,
Query(q): Query<DimQuery>,
) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?;
let dim = dim_of(&q.dim)?;
let b64 = base64::engine::general_purpose::STANDARD;
let regions: Vec<Value> = state
.livemap
.manifest(server.id, dim)
.into_iter()
.map(|(x, z, ts)| json!({ "x": x, "z": z, "timestamps": b64.encode(ts) }))
.collect();
Ok(Json(json!({ "dimension": dim.slug(), "regions": regions })))
}
pub async fn game_chunks(
GameServer(server): GameServer,
State(state): State<AppState>,
Query(q): Query<DimQuery>,
body: Bytes,
) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?;
let dim = dim_of(&q.dim)?;
let chunks = parse_chunks(&body).map_err(AppError::bad_request)?;
let stored = state.livemap.ingest_chunks(server.id, dim, chunks).await?;
Ok(Json(json!({ "stored": stored })))
}
pub async fn game_level(GameServer(server): GameServer, State(state): State<AppState>, body: Bytes) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?;
// level.dat is a gzip-compressed NBT file.
if body.len() < 18 || body.len() > 2 * 1024 * 1024 || body[0] != 0x1f || body[1] != 0x8b {
return Err(AppError::bad_request("level.dat must be the gzip file from the world folder (up to 2 MiB)"));
}
state.livemap.ingest_level(server.id, body.to_vec()).await?;
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct PlayersBody {
players: Vec<LivePlayer>,
}
fn sanitize(mut players: Vec<LivePlayer>) -> Vec<LivePlayer> {
players.truncate(MAX_PLAYERS);
players.retain(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite() && p.x.abs() < 3e7 && p.z.abs() < 3e7);
for p in &mut players {
p.name = p.name.chars().filter(|c| !c.is_control()).take(32).collect();
p.uuid = p.uuid.chars().filter(|c| c.is_ascii_hexdigit() || *c == '-').take(36).collect();
p.dimension = p.dimension.chars().take(40).collect();
}
players
}
pub async fn game_players(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(b): Json<PlayersBody>,
) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?;
state.livemap.set_players(server.id, sanitize(b.players));
Ok(Json(json!({ "ok": true })))
}
/// Streaming channel for the integration: JSON text frames carry player
/// positions (`{"type":"players","players":[…]}`), binary frames carry chunk
/// batches (`[dimension byte][chunk records…]`, see `parse_chunks`).
pub async fn game_ws(GameServer(server): GameServer, State(state): State<AppState>, ws: WebSocketUpgrade) -> AppResult<Response> {
require_enabled(server.live_map_enabled)?;
Ok(ws.max_message_size(9 * 1024 * 1024).on_upgrade(move |socket| ws_loop(socket, state, server.id)))
}
async fn ws_loop(mut socket: WebSocket, state: AppState, id: i64) {
let mut last_check = std::time::Instant::now();
while let Some(Ok(msg)) = socket.recv().await {
if last_check.elapsed() > std::time::Duration::from_secs(30) {
last_check = std::time::Instant::now();
let enabled = get_server(&state, id).await.map(|s| s.live_map_enabled).unwrap_or(false);
if !enabled {
let _ = socket.send(Message::Text(json!({ "type": "disabled" }).to_string().into())).await;
break;
}
}
match msg {
Message::Text(t) => {
#[derive(Deserialize)]
struct Frame {
#[serde(default)]
players: Vec<LivePlayer>,
}
if let Ok(f) = serde_json::from_str::<Frame>(&t) {
state.livemap.set_players(id, sanitize(f.players));
}
}
Message::Binary(b) => {
let reply = match b.split_first() {
Some((d, rest)) => match Dim::ALL
.get(*d as usize)
.copied()
.ok_or("unknown dimension")
.and_then(|dim| parse_chunks(rest).map(|c| (dim, c)))
{
Ok((dim, chunks)) => match state.livemap.ingest_chunks(id, dim, chunks).await {
Ok(n) => json!({ "type": "stored", "count": n }),
Err(e) => json!({ "type": "error", "error": e.to_string() }),
},
Err(e) => json!({ "type": "error", "error": e }),
},
None => json!({ "type": "error", "error": "empty frame" }),
};
if socket.send(Message::Text(reply.to_string().into())).await.is_err() {
break;
}
}
Message::Ping(p) => {
let _ = socket.send(Message::Pong(p)).await;
}
Message::Close(_) => break,
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Launcher / admin viewers
// ---------------------------------------------------------------------------
fn label(d: Dim) -> &'static str {
match d {
Dim::Overworld => "Overworld",
Dim::Nether => "The Nether",
Dim::End => "The End",
}
}
async fn info(state: &AppState, id: i64, enabled: bool) -> Value {
let summary = state.livemap.world_summary(id);
let (generator, assets) = state.livemap.generator_ready();
let dims: Vec<Value> = summary["dimensions"]
.as_array()
.into_iter()
.flatten()
.filter_map(|d| {
let dim = Dim::parse(d["slug"].as_str()?)?;
Some(json!({ "id": dim.id(), "slug": dim.slug(), "label": label(dim), "available": d["regions"].as_u64().unwrap_or(0) > 0 }))
})
.collect();
let has_world = summary["level_dat"].as_bool().unwrap_or(false) && dims.iter().any(|d| d["available"] == true);
let message = if !enabled {
"Live Map is off for this server."
} else if !generator || !assets {
"The map generator isn't set up on the panel yet."
} else if !has_world {
"Waiting for the game server to upload its world."
} else {
""
};
json!({
"enabled": enabled,
"ready": enabled && generator && assets && has_world,
"message": message,
"base": format!("/api/livemap/{id}"),
"dimensions": dims,
"players": state.livemap.live_players(id).len(),
})
}
/// What the launcher needs to decide whether to offer the Live Map button.
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.live_map_enabled).await))
}
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.live_map_enabled).await;
let (generator, assets) = state.livemap.generator_ready();
let stats = state.livemap.stats(id);
v["generator"] = json!({ "installed": generator, "path": state.livemap.generator_path() });
v["assets"] = json!({ "configured": assets, "path": state.livemap.assets_path() });
v["world"] = state.livemap.world_summary(id);
v["stats"] = serde_json::to_value(stats)?;
v["running"] = json!(state.livemap.sidecars_running(id).await);
v["last_error"] = json!(state.livemap.last_error(id));
v["roster"] = serde_json::to_value(state.livemap.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?;
state.livemap.reset(id).await?;
Ok(Json(json!({ "ok": true })))
}
/// Authenticated reverse proxy to the per-server Vantage sidecar. Clients
/// use `…/api/livemap/{server}/{dimension}/v1/worlds/default/manifest.json`
/// and everything the manifest references.
pub async fn proxy(
_: AuthUser,
State(state): State<AppState>,
Path((id, dim, path)): Path<(i64, String, String)>,
headers: HeaderMap,
) -> AppResult<Response> {
let server = get_server(&state, id).await?;
require_enabled(server.live_map_enabled)?;
let dim = dim_of(&dim)?;
if path.split('/').any(|s| s == ".." || s.is_empty() || s.contains('\\')) || !path.starts_with("v1/worlds/default/") {
return Err(AppError::not_found("not found"));
}
if path == "v1/worlds/default/players.json" {
let mut resp = Json(state.livemap.players_json(id, dim)).into_response();
resp.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
return Ok(resp);
}
let (port, token) = state.livemap.ensure_sidecar(id, dim).await.map_err(|m| AppError::new(StatusCode::SERVICE_UNAVAILABLE, m))?;
let mut req = state.http.get(format!("http://127.0.0.1:{port}/{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(120));
if let Some(etag) = headers.get(header::IF_NONE_MATCH) {
req = req.header(header::IF_NONE_MATCH, etag);
}
let upstream = req.send().await.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("map generator unreachable: {e}")))?;
let mut out = Response::builder().status(upstream.status());
for name in [header::CONTENT_TYPE, header::ETAG, header::CACHE_CONTROL, header::LAST_MODIFIED] {
if let Some(v) = upstream.headers().get(&name) {
out = out.header(name, v);
}
}
// Tiles are immutable per etag; make sure shared caches never keep them.
Ok(out
.header(header::VARY, "Authorization")
.body(Body::from_stream(upstream.bytes_stream()))
.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?)
}
+12 -13
View File
@@ -10,7 +10,7 @@ pub mod guilds;
pub mod discord;
pub mod integrations;
pub mod landing;
pub mod livemap;
pub mod worldmap;
pub mod map;
pub mod leveling;
pub mod meta;
@@ -53,7 +53,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/avatar/{id}", get(account::avatar))
.route("/servers/public", get(servers::public_servers))
.route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard))
.route("/servers/{id}/livemap", get(livemap::viewer_info))
.route("/servers/{id}/map", get(worldmap::viewer_info))
.route("/servers/{id}/map/overlay", get(worldmap::viewer_overlay))
.route("/servers/{id}/map/actions", post(map::map_action))
.route("/guilds/invites", get(map::my_invites))
.route("/guilds/invites/{id}/accept", post(map::accept_invite))
@@ -154,7 +155,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/servers", get(servers::list).post(servers::create))
.route("/servers/{id}", get(servers::detail).put(servers::update).delete(servers::remove))
.route("/servers/{id}/token", post(servers::regenerate_token))
.route("/servers/{id}/livemap", get(livemap::admin_status).delete(livemap::admin_reset))
.route("/servers/{id}/map", get(worldmap::admin_status).delete(worldmap::admin_reset))
.route("/users/{id}/activity", get(servers::user_activity))
.route("/meta/minecraft", get(meta::minecraft))
.route("/meta/loaders/{loader}", get(meta::loaders))
@@ -225,20 +226,18 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/economy/market/buy", post(economy::server_market_buy))
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
// Live map ingest: chunk batches are larger than the other game calls.
let livemap_game = Router::new()
.route("/livemap/config", get(livemap::game_config_route))
.route("/livemap/manifest", get(livemap::game_manifest))
.route("/livemap/chunks", post(livemap::game_chunks))
.route("/livemap/level", post(livemap::game_level))
.route("/livemap/players", post(livemap::game_players))
.route("/livemap/ws", get(livemap::game_ws))
// Map ingest: tile batches are larger than the other game calls.
let map_game = Router::new()
.route("/map/config", get(worldmap::game_config))
.route("/map/tiles", post(worldmap::game_tiles))
.route("/map/players", post(worldmap::game_players))
.route("/map/overlay", post(worldmap::game_overlay))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024));
Router::new()
.route("/api/livemap/{id}/{dim}/{*path}", get(livemap::proxy))
.route("/api/map/{id}/{dim}/{z}/{x}/{file}", get(worldmap::tile))
.nest("/api/v1", launcher)
.nest("/api/server/v1", game.merge(livemap_game))
.nest("/api/server/v1", game.merge(map_game))
.nest("/api/admin", admin)
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
.layer(axum::middleware::from_fn_with_state(state.clone(), activity::audit))
+17 -48
View File
@@ -61,11 +61,9 @@ fn clip(s: &str, max: usize) -> String {
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct ServerRow {
pub instance_id: String,
pub map_url: String,
pub live_map_enabled: bool,
/// Address of this server's BlueMap (https://map.example.com). Shown in the launcher when set.
#[sqlx(default)]
pub map_address: String,
/// Whether this server draws the SCOPENET Map (stored in the old `live_map_enabled` column).
#[sqlx(rename = "live_map_enabled")]
pub map_enabled: bool,
/// Servers with the same non-empty name share player balances and guild banks.
#[sqlx(default)]
pub economy_group: String,
@@ -194,7 +192,7 @@ pub async fn hello(
"sync_interval_secs": SYNC_INTERVAL_SECS,
"access": server.access,
"require_launcher": server.require_launcher,
"live_map": super::livemap::game_config(server.live_map_enabled),
"map": json!({ "enabled": server.map_enabled }),
})))
}
@@ -866,14 +864,15 @@ pub struct ServerInput {
allowed_groups: Vec<String>,
#[serde(default)]
require_launcher: bool,
#[serde(default)]
map_url: String,
#[serde(default)]
live_map_enabled: bool,
/// Draw this server on the SCOPENET Map. On unless switched off.
#[serde(default = "yes")]
map_enabled: bool,
#[serde(default)]
economy_group: String,
#[serde(default)]
map_address: String,
}
fn yes() -> bool {
true
}
fn default_access() -> String {
@@ -895,31 +894,6 @@ impl ServerInput {
if group.chars().count() > 32 || !group.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) {
return Err(AppError::bad_request("economy groups are up to 32 letters, digits, spaces, - or _"));
}
let address = self.map_address.trim();
if !address.is_empty() {
let url = reqwest::Url::parse(address).map_err(|_| AppError::bad_request("map address must be a full web address like https://map.example.com"))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(AppError::bad_request("map address must start with http:// or https://"));
}
if url.username() != "" || url.password().is_some() || url.fragment().is_some() {
return Err(AppError::bad_request("map address can't contain credentials or a fragment"));
}
if address.len() > 300 {
return Err(AppError::bad_request("map address is too long"));
}
}
if !self.map_url.trim().is_empty() {
let url = reqwest::Url::parse(self.map_url.trim()).map_err(|_| AppError::bad_request("map URL must be an absolute URL"))?;
if url.scheme() != "https" && !(url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1"))) {
return Err(AppError::bad_request("map URL must use HTTPS (or localhost for development)"));
}
if url.username() != "" || url.password().is_some() || url.fragment().is_some() || url.query().is_some() {
return Err(AppError::bad_request("map URL cannot contain credentials, query parameters, or a fragment"));
}
if !matches!(url.path().rsplit('/').next(), Some("manifest.json" | "world.json")) {
return Err(AppError::bad_request("map URL must end with manifest.json or world.json"));
}
}
Ok(())
}
}
@@ -936,7 +910,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
input.validate()?;
let token = new_token();
let id: i64 = sqlx::query_scalar(
"INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled, economy_group, map_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
"INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, live_map_enabled, economy_group) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
)
.bind(input.name.trim())
.bind(hash_token(&token))
@@ -946,10 +920,8 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
.bind(input.require_launcher)
.bind(now())
.bind(&input.instance_id)
.bind(input.map_url.trim())
.bind(input.live_map_enabled)
.bind(input.map_enabled)
.bind(input.economy_group.trim())
.bind(input.map_address.trim())
.fetch_one(&state.db)
.await?;
let server = get_server(&state, id).await?;
@@ -964,16 +936,14 @@ pub async fn update(
) -> AppResult<Json<ServerView>> {
input.validate()?;
get_server(&state, id).await?;
sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ?, economy_group = ?, map_address = ? WHERE id = ?")
sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, live_map_enabled = ?, economy_group = ? WHERE id = ?")
.bind(input.name.trim())
.bind(&input.access)
.bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into()))
.bind(input.require_launcher)
.bind(&input.instance_id)
.bind(input.map_url.trim())
.bind(input.live_map_enabled)
.bind(input.map_enabled)
.bind(input.economy_group.trim())
.bind(input.map_address.trim())
.bind(id)
.execute(&state.db)
.await?;
@@ -1004,6 +974,7 @@ pub async fn remove(_: AdminUser, State(state): State<AppState>, Path(id): Path<
}
}
}
state.worldmap.forget_server(id);
let done = sqlx::query("DELETE FROM game_servers WHERE id = ?").bind(id).execute(&state.db).await?;
if done.rows_affected() == 0 {
return Err(AppError::not_found("server not found"));
@@ -1168,9 +1139,7 @@ pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Val
"id": s.id,
"name": s.name,
"instance_id": s.instance_id,
"map_url": s.map_url,
"map_address": s.map_address,
"live_map": s.live_map_enabled,
"map": s.map_enabled,
"online": is_online,
"players_online": if is_online { s.online_count } else { 0 },
"players_max": s.max_players,
+173
View File
@@ -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
}