diff --git a/panel/server/src/config.rs b/panel/server/src/config.rs index a7b3c4b..44df8f4 100644 --- a/panel/server/src/config.rs +++ b/panel/server/src/config.rs @@ -14,12 +14,6 @@ pub struct Config { pub max_upload_mb: usize, pub public_url: Option, pub trusted_proxies: Vec, - /// Vantage generator binary (defaults to `vantage` on PATH or /opt/vantage). - pub vantage_bin: Option, - /// Minecraft client assets directory handed to Vantage. - pub vantage_assets: Option, - /// Extra arguments appended to `vantage server`. - pub vantage_args: Vec, } fn var(name: &str) -> Option { @@ -41,9 +35,6 @@ impl Config { trusted_proxies: var("SCOPENET_TRUSTED_PROXIES") .map(|s| s.split(',').map(|v| v.trim().parse().expect("SCOPENET_TRUSTED_PROXIES must contain IP addresses")).collect()) .unwrap_or_default(), - vantage_bin: var("SCOPENET_VANTAGE_BIN").map(Into::into), - vantage_assets: var("SCOPENET_VANTAGE_ASSETS").map(Into::into), - vantage_args: var("SCOPENET_VANTAGE_ARGS").map(|s| s.split_whitespace().map(str::to_owned).collect()).unwrap_or_default(), } } diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index a61f405..2ec45dc 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -7,7 +7,7 @@ pub mod auth; pub mod config; pub mod db; pub mod error; -pub mod livemap; +pub mod worldmap; pub mod net; pub mod packs; pub mod progression; @@ -63,7 +63,7 @@ pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, yg keys: Arc::new(auth::Keys::new(&secret)), http: scopenet_core::http::client(), login_guard: Arc::new(auth::LoginGuard::default()), - livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir, cfg.vantage_bin.clone(), cfg.vantage_assets.clone(), cfg.vantage_args.clone())), + worldmap: Arc::new(worldmap::WorldMap::new(&cfg.data_dir)), cfg: Arc::new(cfg), }) } diff --git a/panel/server/src/livemap.rs b/panel/server/src/livemap.rs deleted file mode 100644 index 29fe098..0000000 --- a/panel/server/src/livemap.rs +++ /dev/null @@ -1,714 +0,0 @@ -//! Central live map. -//! -//! Game servers (the SCOPENET plugin/mod) push the chunks that changed and -//! their players' positions to the panel. The panel keeps a mirror of each -//! server's Anvil region files under `/livemap//world`, runs a -//! Vantage `server` sidecar over that mirror on demand, and proxies the -//! sidecar to authenticated launcher/admin sessions. Nobody has to run -//! Vantage next to their Minecraft server. - -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, VecDeque}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::Mutex; -use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, BufReader}; - -pub const SECTOR: usize = 4096; -const HEADER: usize = 2 * SECTOR; -/// A chunk that needs more sectors than a location entry can address lives in -/// an external `.mcc` file; the mirror skips those (they are vanishingly rare). -const MAX_SECTORS: usize = 255; -/// Sidecars idle for this long are stopped and restarted on the next view. -pub const IDLE_STOP: Duration = Duration::from_secs(15 * 60); -/// Player positions older than this are treated as "server stopped sending". -const PLAYERS_FRESH: Duration = Duration::from_secs(45); - -// --------------------------------------------------------------------------- -// Dimensions -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Dim { - Overworld, - Nether, - End, -} - -impl Dim { - pub const ALL: [Dim; 3] = [Dim::Overworld, Dim::Nether, Dim::End]; - - /// Accepts the short slug (`the_nether`) or the namespaced id. - pub fn parse(s: &str) -> Option { - match s.trim().trim_start_matches("minecraft:") { - "overworld" => Some(Dim::Overworld), - "the_nether" | "nether" => Some(Dim::Nether), - "the_end" | "end" => Some(Dim::End), - _ => None, - } - } - pub fn slug(self) -> &'static str { - match self { - Dim::Overworld => "overworld", - Dim::Nether => "the_nether", - Dim::End => "the_end", - } - } - pub fn id(self) -> String { - format!("minecraft:{}", self.slug()) - } - /// Folder inside a vanilla save. - fn folder(self) -> &'static str { - match self { - Dim::Overworld => "", - Dim::Nether => "DIM-1", - Dim::End => "DIM1", - } - } -} - -// --------------------------------------------------------------------------- -// Anvil region writer -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct Chunk { - pub x: i32, - pub z: i32, - /// Seconds since epoch, as stored in the region header. - pub timestamp: u32, - pub compression: u8, - pub data: Vec, -} - -pub fn region_path(dir: &Path, rx: i32, rz: i32) -> PathBuf { - dir.join(format!("r.{rx}.{rz}.mca")) -} - -fn slot(x: i32, z: i32) -> usize { - (x.rem_euclid(32) + z.rem_euclid(32) * 32) as usize -} - -fn sectors_for(len: usize) -> usize { - len.div_ceil(SECTOR) -} - -fn be32(b: &[u8]) -> u32 { - u32::from_be_bytes([b[0], b[1], b[2], b[3]]) -} - -/// Write `chunks` (all in one region) into `path`, creating it if needed. -/// Chunks older than what the file already holds are ignored. Returns the -/// number of chunks stored. -pub fn write_region(path: &Path, chunks: &[Chunk]) -> std::io::Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let mut file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?; - let mut len = file.metadata()?.len() as usize; - let mut header = vec![0u8; HEADER]; - if len >= HEADER { - file.read_exact(&mut header)?; - } else { - file.set_len(0)?; - file.write_all(&header)?; - len = HEADER; - } - let mut end = len.div_ceil(SECTOR); - let mut stored = 0; - for c in chunks { - let i = slot(c.x, c.z); - let old_ts = be32(&header[SECTOR + i * 4..]); - if old_ts != 0 && old_ts > c.timestamp { - continue; - } - let body = c.data.len() + 1; - let count = sectors_for(body + 4); - if count > MAX_SECTORS || c.compression & 0x80 != 0 || c.data.is_empty() { - continue; - } - let mut record = Vec::with_capacity(count * SECTOR); - record.extend_from_slice(&(body as u32).to_be_bytes()); - record.push(c.compression); - record.extend_from_slice(&c.data); - record.resize(count * SECTOR, 0); - file.seek(SeekFrom::Start((end * SECTOR) as u64))?; - file.write_all(&record)?; - let loc = ((end as u32) << 8) | count as u32; - header[i * 4..i * 4 + 4].copy_from_slice(&loc.to_be_bytes()); - header[SECTOR + i * 4..SECTOR + i * 4 + 4].copy_from_slice(&c.timestamp.to_be_bytes()); - end += count; - stored += 1; - } - file.seek(SeekFrom::Start(0))?; - file.write_all(&header)?; - file.flush()?; - drop(file); - - // Rewriting a chunk appends it, leaving its old sectors behind. Compact - // once more than half the file is dead space. - let live: usize = (0..1024).map(|i| (be32(&header[i * 4..]) & 0xff) as usize).sum(); - if stored > 0 && end > 2 * (live + 2) + 64 { - compact(path)?; - } - Ok(stored) -} - -fn compact(path: &Path) -> std::io::Result<()> { - let data = std::fs::read(path)?; - if data.len() < HEADER { - return Ok(()); - } - let mut header = vec![0u8; HEADER]; - header[SECTOR..].copy_from_slice(&data[SECTOR..HEADER]); - let mut body: Vec = Vec::new(); - let mut next = 2usize; - for i in 0..1024 { - let loc = be32(&data[i * 4..]); - let (off, count) = ((loc >> 8) as usize, (loc & 0xff) as usize); - if count == 0 || (off + count) * SECTOR > data.len() { - continue; - } - body.extend_from_slice(&data[off * SECTOR..(off + count) * SECTOR]); - header[i * 4..i * 4 + 4].copy_from_slice(&(((next as u32) << 8) | count as u32).to_be_bytes()); - next += count; - } - let tmp = path.with_extension("mca.tmp"); - let mut out = std::fs::File::create(&tmp)?; - out.write_all(&header)?; - out.write_all(&body)?; - out.flush()?; - drop(out); - std::fs::rename(tmp, path) -} - -/// The 4 KiB timestamp table of a region file, or `None` if it doesn't exist. -pub fn read_timestamps(path: &Path) -> Option> { - let mut f = std::fs::File::open(path).ok()?; - f.seek(SeekFrom::Start(SECTOR as u64)).ok()?; - let mut buf = vec![0u8; SECTOR]; - f.read_exact(&mut buf).ok()?; - Some(buf) -} - -/// Parse a batch upload: repeated `i32 x, i32 z, u32 timestamp, u8 -/// compression, u32 length, bytes` records, big-endian. -pub fn parse_chunks(mut b: &[u8]) -> Result, &'static str> { - let mut out = Vec::new(); - while !b.is_empty() { - if b.len() < 17 { - return Err("truncated chunk record"); - } - let x = be32(b) as i32; - let z = be32(&b[4..]) as i32; - let timestamp = be32(&b[8..]); - let compression = b[12]; - let len = be32(&b[13..]) as usize; - b = &b[17..]; - if len > MAX_SECTORS * SECTOR || len > b.len() { - return Err("chunk record exceeds its payload"); - } - if x.unsigned_abs() > 3_750_000 || z.unsigned_abs() > 3_750_000 { - return Err("chunk coordinates are outside the world"); - } - out.push(Chunk { x, z, timestamp, compression, data: b[..len].to_vec() }); - b = &b[len..]; - } - Ok(out) -} - -// --------------------------------------------------------------------------- -// Live players -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct LivePlayer { - pub uuid: String, - pub name: String, - #[serde(default)] - pub dimension: String, - pub x: f64, - pub y: f64, - pub z: f64, - #[serde(default)] - pub yaw: f64, - #[serde(default)] - pub pitch: f64, -} - -struct Snapshot { - at: Instant, - players: Vec, -} - -// --------------------------------------------------------------------------- -// Sidecar supervisor -// --------------------------------------------------------------------------- - -struct Sidecar { - child: tokio::process::Child, - port: u16, - token: String, - last_used: Instant, - log: std::sync::Arc>>, -} - -#[derive(Default, Clone, Serialize)] -pub struct ServerStats { - pub chunks_received: u64, - pub last_chunk_at: Option, - pub last_players_at: Option, -} - -pub struct LiveMap { - root: PathBuf, - bin: Option, - assets: Option, - extra_args: Vec, - players: Mutex>, - stats: Mutex>, - sidecars: tokio::sync::Mutex>, - last_error: Mutex>, - write_lock: tokio::sync::Mutex<()>, - players_written: Mutex>, -} - -fn find_bin(configured: Option<&Path>) -> Option { - let candidates: Vec = match configured { - Some(p) => vec![p.to_path_buf()], - None => { - let mut v: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).map(|d| d.join("vantage")).collect()).unwrap_or_default(); - v.push("/opt/vantage/vantage".into()); - v - } - }; - candidates.into_iter().find(|p| p.is_file()) -} - -impl LiveMap { - pub fn new(data_dir: &Path, bin: Option, assets: Option, extra_args: Vec) -> Self { - let assets = assets.or_else(|| { - let p = data_dir.join("livemap").join("assets"); - p.is_dir().then_some(p) - }); - Self { - root: data_dir.join("livemap"), - bin: find_bin(bin.as_deref()), - assets, - extra_args, - players: Mutex::default(), - stats: Mutex::default(), - sidecars: Default::default(), - last_error: Mutex::default(), - write_lock: Default::default(), - players_written: Mutex::default(), - } - } - - pub fn server_dir(&self, id: i64) -> PathBuf { - self.root.join(id.to_string()) - } - pub fn world_dir(&self, id: i64) -> PathBuf { - self.server_dir(id).join("world") - } - pub fn region_dir(&self, id: i64, dim: Dim) -> PathBuf { - let w = self.world_dir(id); - if dim.folder().is_empty() { - w.join("region") - } else { - w.join(dim.folder()).join("region") - } - } - fn cache_dir(&self, id: i64, dim: Dim) -> PathBuf { - self.server_dir(id).join("cache").join(dim.slug()) - } - fn players_file(&self, id: i64) -> PathBuf { - self.server_dir(id).join("players.json") - } - - pub fn generator_ready(&self) -> (bool, bool) { - (self.bin.is_some(), self.assets.as_ref().is_some_and(|a| a.is_dir())) - } - pub fn generator_path(&self) -> Option { - self.bin.as_ref().map(|p| p.display().to_string()) - } - pub fn assets_path(&self) -> Option { - self.assets.as_ref().map(|p| p.display().to_string()) - } - - // ---- ingest ---- - - /// Write a batch of chunks into the server's mirror. Returns how many - /// chunks were stored. - pub async fn ingest_chunks(&self, id: i64, dim: Dim, chunks: Vec) -> std::io::Result { - let dir = self.region_dir(id, dim); - let _guard = self.write_lock.lock().await; - let stored = tokio::task::spawn_blocking(move || { - let mut by_region: HashMap<(i32, i32), Vec> = HashMap::new(); - for c in chunks { - by_region.entry((c.x.div_euclid(32), c.z.div_euclid(32))).or_default().push(c); - } - let mut stored = 0; - for ((rx, rz), list) in by_region { - stored += write_region(®ion_path(&dir, rx, rz), &list)?; - } - Ok::<_, std::io::Error>(stored) - }) - .await - .map_err(std::io::Error::other)??; - let mut stats = self.stats.lock().unwrap(); - let s = stats.entry(id).or_default(); - s.chunks_received += stored as u64; - if stored > 0 { - s.last_chunk_at = Some(crate::db::now()); - } - Ok(stored) - } - - pub async fn ingest_level(&self, id: i64, bytes: Vec) -> std::io::Result<()> { - let dir = self.world_dir(id); - tokio::fs::create_dir_all(&dir).await?; - let tmp = dir.join("level.dat.tmp"); - tokio::fs::write(&tmp, bytes).await?; - tokio::fs::rename(tmp, dir.join("level.dat")).await - } - - /// Timestamp tables for every mirrored region of one dimension, so the - /// game server only uploads chunks that are newer. - pub fn manifest(&self, id: i64, dim: Dim) -> Vec<(i32, i32, Vec)> { - let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir(self.region_dir(id, dim)) else { return out }; - for e in rd.flatten() { - let name = e.file_name().to_string_lossy().to_string(); - let Some(rest) = name.strip_prefix("r.").and_then(|r| r.strip_suffix(".mca")) else { continue }; - let Some((a, b)) = rest.split_once('.') else { continue }; - let (Ok(rx), Ok(rz)) = (a.parse::(), b.parse::()) else { continue }; - if let Some(ts) = read_timestamps(&e.path()) { - out.push((rx, rz, ts)); - } - } - out - } - - pub fn set_players(&self, id: i64, players: Vec) { - self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players: players.clone() }); - self.stats.lock().unwrap().entry(id).or_default().last_players_at = Some(crate::db::now()); - // Vantage reads this file to decide what to pre-render; throttle it. - let mut written = self.players_written.lock().unwrap(); - if written.get(&id).is_none_or(|t| t.elapsed() > Duration::from_secs(5)) { - written.insert(id, Instant::now()); - let doc = players_doc(&players, None); - let path = self.players_file(id); - if std::fs::create_dir_all(self.server_dir(id)).is_ok() { - let _ = std::fs::write(path, doc.to_string()); - } - } - } - - /// Drop a player from every server's live roster (account deleted). - pub fn forget_player(&self, uuid: &str) { - for snap in self.players.lock().unwrap().values_mut() { - snap.players.retain(|p| !p.uuid.eq_ignore_ascii_case(uuid)); - } - } - - pub fn live_players(&self, id: i64) -> Vec { - self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default() - } - - /// `players.json` for one dimension (BlueMap-compatible, which Vantage reads). - pub fn players_json(&self, id: i64, dim: Dim) -> serde_json::Value { - players_doc(&self.live_players(id), Some(dim)) - } - - pub fn stats(&self, id: i64) -> ServerStats { - self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default() - } - - /// Disk usage and regions per dimension. - pub fn world_summary(&self, id: i64) -> serde_json::Value { - let mut dims = Vec::new(); - for dim in Dim::ALL { - let (mut regions, mut bytes) = (0u64, 0u64); - if let Ok(rd) = std::fs::read_dir(self.region_dir(id, dim)) { - for e in rd.flatten() { - if let Ok(m) = e.metadata() { - regions += 1; - bytes += m.len(); - } - } - } - dims.push(serde_json::json!({ "id": dim.id(), "slug": dim.slug(), "regions": regions, "bytes": bytes })); - } - serde_json::json!({ "level_dat": self.world_dir(id).join("level.dat").is_file(), "dimensions": dims }) - } - - pub async fn sidecars_running(&self, id: i64) -> Vec { - self.sidecars.lock().await.keys().filter(|(s, _)| *s == id).map(|(_, d)| d.slug().to_string()).collect() - } - - pub fn last_error(&self, id: i64) -> Option { - let errs = self.last_error.lock().unwrap(); - Dim::ALL.iter().find_map(|d| errs.get(&(id, *d)).cloned()) - } - - // ---- sidecars ---- - - /// Make sure a Vantage sidecar is serving this server/dimension and - /// return its loopback port and bearer token. - pub async fn ensure_sidecar(&self, id: i64, dim: Dim) -> Result<(u16, String), String> { - let bin = self.bin.clone().ok_or("The Vantage generator isn't installed on this panel. See docs/live-map.md.")?; - let assets = self - .assets - .clone() - .filter(|a| a.is_dir()) - .ok_or("Minecraft client assets for Vantage aren't configured. See docs/live-map.md.")?; - let world = self.world_dir(id); - if !world.join("level.dat").is_file() || !self.region_dir(id, dim).is_dir() { - return Err("Waiting for the game server to upload this world.".into()); - } - - let mut sidecars = self.sidecars.lock().await; - if let Some(s) = sidecars.get_mut(&(id, dim)) { - if matches!(s.child.try_wait(), Ok(None)) { - s.last_used = Instant::now(); - return Ok((s.port, s.token.clone())); - } - sidecars.remove(&(id, dim)); - } - - let port = { - let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; - l.local_addr().map_err(|e| e.to_string())?.port() - }; - let token = { - use rand::RngCore; - let mut b = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut b); - hex::encode(b) - }; - let cache = self.cache_dir(id, dim); - tokio::fs::create_dir_all(&cache).await.map_err(|e| e.to_string())?; - let players = self.players_file(id); - if !players.exists() { - let _ = std::fs::write(&players, players_doc(&[], None).to_string()); - } - let mut cmd = tokio::process::Command::new(&bin); - cmd.arg("server") - .arg(&world) - .arg("--assets") - .arg(&assets) - .arg("--out") - .arg(&cache) - .args(["--host", "127.0.0.1", "--port"]) - .arg(port.to_string()) - .arg("--players-file") - .arg(&players) - .args(["--dimension", dim.slug()]) - .args(&self.extra_args) - .env("VANTAGE_SERVER_TOKEN", &token) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = cmd.spawn().map_err(|e| format!("couldn't start the Vantage generator: {e}"))?; - let log: std::sync::Arc>> = Default::default(); - if let Some(out) = child.stdout.take() { - tail(out, log.clone()); - } - if let Some(err) = child.stderr.take() { - tail(err, log.clone()); - } - let mut sidecar = Sidecar { child, port, token: token.clone(), last_used: Instant::now(), log }; - - // Wait for it to answer health checks. - let client = reqwest::Client::new(); - let url = format!("http://127.0.0.1:{port}/v1/health"); - let deadline = Instant::now() + Duration::from_secs(30); - loop { - if let Ok(Some(status)) = sidecar.child.try_wait() { - let tail = sidecar - .log - .lock() - .unwrap() - .iter() - .rev() - .take(4) - .cloned() - .collect::>() - .into_iter() - .rev() - .collect::>() - .join(" | "); - let msg = format!("Vantage exited ({status}) {tail}"); - self.last_error.lock().unwrap().insert((id, dim), msg.clone()); - return Err(msg); - } - if client.get(&url).timeout(Duration::from_secs(2)).send().await.is_ok_and(|r| r.status().is_success()) { - break; - } - if Instant::now() > deadline { - let _ = sidecar.child.start_kill(); - let msg = "Vantage didn't become ready within 30 seconds".to_string(); - self.last_error.lock().unwrap().insert((id, dim), msg.clone()); - return Err(msg); - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - self.last_error.lock().unwrap().remove(&(id, dim)); - sidecars.insert((id, dim), sidecar); - Ok((port, token)) - } - - /// Stop sidecars nobody has looked at recently. - pub async fn reap_idle(&self) { - let mut sidecars = self.sidecars.lock().await; - let idle: Vec<_> = sidecars.iter().filter(|(_, s)| s.last_used.elapsed() > IDLE_STOP).map(|(k, _)| *k).collect(); - for key in idle { - if let Some(mut s) = sidecars.remove(&key) { - let _ = s.child.start_kill(); - tracing::info!("stopped idle live-map generator for server {} ({})", key.0, key.1.slug()); - } - } - } - - pub fn spawn_reaper(self: &std::sync::Arc) { - let me = std::sync::Arc::downgrade(self); - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(60)).await; - let Some(lm) = me.upgrade() else { break }; - lm.reap_idle().await; - } - }); - } - - /// Stop every sidecar of a server and delete its mirror and caches. - pub async fn reset(&self, id: i64) -> std::io::Result<()> { - { - let mut sidecars = self.sidecars.lock().await; - let keys: Vec<_> = sidecars.keys().filter(|(s, _)| *s == id).copied().collect(); - for k in keys { - if let Some(mut s) = sidecars.remove(&k) { - let _ = s.child.kill().await; - } - } - } - let _guard = self.write_lock.lock().await; - self.players.lock().unwrap().remove(&id); - self.stats.lock().unwrap().remove(&id); - match tokio::fs::remove_dir_all(self.server_dir(id)).await { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), - _ => Ok(()), - } - } -} - -fn tail(r: R, log: std::sync::Arc>>) { - tokio::spawn(async move { - let mut lines = BufReader::new(r).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let mut l = log.lock().unwrap(); - if l.len() >= 30 { - l.pop_front(); - } - l.push_back(line.chars().take(300).collect()); - } - }); -} - -fn players_doc(players: &[LivePlayer], dim: Option) -> serde_json::Value { - let list: Vec<_> = players - .iter() - .map(|p| { - let here = dim.is_none_or(|d| Dim::parse(&p.dimension).unwrap_or(Dim::Overworld) == d); - serde_json::json!({ - "uuid": p.uuid, - "name": p.name, - "foreign": !here, - "position": { "x": p.x, "y": p.y, "z": p.z }, - "rotation": { "pitch": p.pitch, "yaw": p.yaw, "roll": 0 }, - }) - }) - .collect(); - serde_json::json!({ "players": list }) -} - -/// Sidecar port and token for the proxy, without starting anything. -impl LiveMap { - pub async fn sidecar_log(&self, id: i64, dim: Dim) -> Vec { - self.sidecars.lock().await.get(&(id, dim)).map(|s| s.log.lock().unwrap().iter().cloned().collect()).unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn chunk(x: i32, z: i32, ts: u32, byte: u8, len: usize) -> Chunk { - Chunk { x, z, timestamp: ts, compression: 2, data: vec![byte; len] } - } - - fn read_chunk(path: &Path, x: i32, z: i32) -> Option<(u32, Vec)> { - let data = std::fs::read(path).ok()?; - let i = slot(x, z); - let loc = be32(&data[i * 4..]); - if loc == 0 { - return None; - } - let off = (loc >> 8) as usize * SECTOR; - let len = be32(&data[off..]) as usize; - Some((be32(&data[SECTOR + i * 4..]), data[off + 5..off + 4 + len].to_vec())) - } - - #[test] - fn writes_reads_and_rewrites_chunks() { - let dir = tempfile::tempdir().unwrap(); - let path = region_path(dir.path(), 0, 0); - assert_eq!(write_region(&path, &[chunk(1, 2, 10, 7, 5000), chunk(-1 + 32, 0, 10, 9, 10)]).unwrap(), 2); - assert_eq!(read_chunk(&path, 1, 2), Some((10, vec![7; 5000]))); - // Newer replaces, older is ignored. - assert_eq!(write_region(&path, &[chunk(1, 2, 20, 8, 100)]).unwrap(), 1); - assert_eq!(write_region(&path, &[chunk(1, 2, 5, 1, 100)]).unwrap(), 0); - assert_eq!(read_chunk(&path, 1, 2), Some((20, vec![8; 100]))); - assert_eq!(read_chunk(&path, 31, 0), Some((10, vec![9; 10]))); - assert_eq!(be32(&read_timestamps(&path).unwrap()[slot(1, 2) * 4..]), 20); - } - - #[test] - fn compaction_keeps_live_chunks() { - let dir = tempfile::tempdir().unwrap(); - let path = region_path(dir.path(), -1, 3); - for ts in 1..40u32 { - write_region(&path, &[chunk(-32, 96, ts, ts as u8, 20_000)]).unwrap(); - } - write_region(&path, &[chunk(-31, 96, 1, 5, 50)]).unwrap(); - assert!(std::fs::metadata(&path).unwrap().len() < 40 * 20_000 / 2); - assert_eq!(read_chunk(&path, -32, 96), Some((39, vec![39; 20_000]))); - assert_eq!(read_chunk(&path, -31, 96), Some((1, vec![5; 50]))); - } - - #[test] - fn parses_upload_batches() { - let mut b = Vec::new(); - b.extend_from_slice(&(-3i32).to_be_bytes()); - b.extend_from_slice(&4i32.to_be_bytes()); - b.extend_from_slice(&99u32.to_be_bytes()); - b.push(2); - b.extend_from_slice(&3u32.to_be_bytes()); - b.extend_from_slice(&[1, 2, 3]); - let chunks = parse_chunks(&b).unwrap(); - assert_eq!((chunks[0].x, chunks[0].z, chunks[0].timestamp, chunks[0].data.clone()), (-3, 4, 99, vec![1, 2, 3])); - assert!(parse_chunks(&b[..b.len() - 1]).is_err()); - } - - #[test] - fn dimensions_parse() { - assert_eq!(Dim::parse("minecraft:the_nether"), Some(Dim::Nether)); - assert_eq!(Dim::parse("overworld"), Some(Dim::Overworld)); - assert_eq!(Dim::parse("../etc"), None); - } -} diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs index ae6957c..77ae757 100644 --- a/panel/server/src/main.rs +++ b/panel/server/src/main.rs @@ -22,7 +22,6 @@ async fn main() -> anyhow::Result<()> { let bind = cfg.bind.clone(); let state = build_state(cfg, pool).await?; bootstrap_admin(&state).await?; - state.livemap.spawn_reaper(); scopenet_panel::routes::discord::spawn_worker(state.clone()); let listener = tokio::net::TcpListener::bind(&bind).await?; diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs index ae5081f..0215fca 100644 --- a/panel/server/src/purge.rs +++ b/panel/server/src/purge.rs @@ -157,6 +157,6 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult 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::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 { - 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, - Query(q): Query, -) -> AppResult> { - require_enabled(server.live_map_enabled)?; - let dim = dim_of(&q.dim)?; - let b64 = base64::engine::general_purpose::STANDARD; - let regions: Vec = 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, - Query(q): Query, - body: Bytes, -) -> AppResult> { - 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, body: Bytes) -> AppResult> { - 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, -} - -fn sanitize(mut players: Vec) -> Vec { - 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, - Json(b): Json, -) -> AppResult> { - 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, ws: WebSocketUpgrade) -> AppResult { - 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, - } - if let Ok(f) = serde_json::from_str::(&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 = 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, Path(id): Path) -> AppResult> { - 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, Path(id): Path) -> AppResult> { - 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, Path(id): Path) -> AppResult> { - 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, - Path((id, dim, path)): Path<(i64, String, String)>, - headers: HeaderMap, -) -> AppResult { - 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()))?) -} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index b94d1b6..25efd70 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -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 { .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 { .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 { .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)) diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index b6c6af7..9e57e2f 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -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, #[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, 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, 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> { 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, 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) -> AppResult 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) -> Json { + 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, Query(q): Query, body: Bytes) -> AppResult> { + 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, +} + +pub async fn game_players(GameServer(server): GameServer, State(state): State, Json(b): Json) -> AppResult> { + 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, Json(overlay): Json) -> AppResult> { + 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, Path(id): Path) -> AppResult> { + 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, Path(id): Path) -> AppResult> { + 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, Path(id): Path) -> AppResult> { + 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, Path(id): Path) -> AppResult> { + 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=`. The token comes from [`viewer_info`], so only signed-in +/// players can load tiles, yet plain `` tags work. +pub async fn tile(State(state): State, Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, Query(q): Query, 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::().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 +} diff --git a/panel/server/src/state.rs b/panel/server/src/state.rs index 6bbf130..150d8ec 100644 --- a/panel/server/src/state.rs +++ b/panel/server/src/state.rs @@ -13,5 +13,5 @@ pub struct AppState { pub ygg: Arc, pub http: reqwest::Client, pub login_guard: Arc, - pub livemap: Arc, + pub worldmap: Arc, } diff --git a/panel/server/src/worldmap.rs b/panel/server/src/worldmap.rs new file mode 100644 index 0000000..6477391 --- /dev/null +++ b/panel/server/src/worldmap.rs @@ -0,0 +1,391 @@ +//! The SCOPENET Map. +//! +//! Game servers draw their own world: the plugin/mod renders top-down map tiles from the region files and uploads only +//! the small PNGs that changed. The panel just stores them and serves them, together with live player positions and an +//! overlay (guild claims, pins) that the game server keeps up to date. There is nothing to install or run beside the panel. +//! +//! Layout on disk: `/map////_.png`. Zoom 0 is one pixel per block with 256-pixel +//! tiles; each higher zoom halves the resolution. + +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +pub const TILE_SIZE: u32 = 256; +pub const MAX_ZOOM: u8 = 6; +const MAX_TILE_BYTES: usize = 1024 * 1024; +const MAX_COORD: i32 = 1 << 20; +pub const TOKEN_TTL: Duration = Duration::from_secs(3600); +/// Player positions older than this mean "the server stopped sending". +const PLAYERS_FRESH: Duration = Duration::from_secs(30); +const MAX_PLAYERS: usize = 500; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LivePlayer { + pub uuid: String, + pub name: String, + pub dimension: String, + pub x: f64, + pub y: f64, + pub z: f64, + #[serde(default)] + pub yaw: f32, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Stats { + pub tiles_received: u64, + pub bytes_received: u64, + pub last_upload_at: Option, + pub last_players_at: Option, + pub last_overlay_at: Option, +} + +struct PlayersSnap { + at: Instant, + players: Vec, +} + +pub struct WorldMap { + root: PathBuf, + players: Mutex>, + tokens: Mutex>, + overlays: Mutex>, + stats: Mutex>, +} + +/// A dimension id ("minecraft:the_nether") as a safe folder name ("the_nether"). Other namespaces keep theirs ("mod__space"). +pub fn dim_slug(id: &str) -> Option { + let id = id.trim(); + let trimmed = id.strip_prefix("minecraft:").unwrap_or(id); + let slug = match trimmed { + "nether" => "the_nether".to_string(), + "end" => "the_end".to_string(), + other => other.replace(':', "__"), + }; + let ok = !slug.is_empty() && slug.len() <= 48 && slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.')) && !slug.contains(".."); + ok.then_some(slug) +} + +pub fn dim_label(slug: &str) -> String { + match slug { + "overworld" => "Overworld".into(), + "the_nether" => "The Nether".into(), + "the_end" => "The End".into(), + other => other.rsplit("__").next().unwrap_or(other).replace(['_', '-'], " "), + } +} + +/// One decoded upload record. +#[derive(Debug, PartialEq)] +pub struct TileRecord { + pub zoom: u8, + pub x: i32, + pub y: i32, + pub png: Vec, +} + +/// `[u8 zoom][i32 x][i32 y][u32 length][PNG bytes]` repeated, big endian. +pub fn parse_tiles(body: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let mut at = 0usize; + while at < body.len() { + if body.len() - at < 13 { + return Err("truncated tile header".into()); + } + let zoom = body[at]; + let x = i32::from_be_bytes(body[at + 1..at + 5].try_into().unwrap()); + let y = i32::from_be_bytes(body[at + 5..at + 9].try_into().unwrap()); + let len = u32::from_be_bytes(body[at + 9..at + 13].try_into().unwrap()) as usize; + at += 13; + if zoom > MAX_ZOOM || x.abs() > MAX_COORD || y.abs() > MAX_COORD { + return Err("tile out of range".into()); + } + if len < 8 || len > MAX_TILE_BYTES || body.len() - at < len { + return Err("bad tile length".into()); + } + let png = &body[at..at + len]; + if png[..8] != [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] { + return Err("tiles must be PNG images".into()); + } + out.push(TileRecord { zoom, x, y, png: png.to_vec() }); + at += len; + } + Ok(out) +} + +fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +impl WorldMap { + pub fn new(data_dir: &Path) -> Self { + Self { + root: data_dir.join("map"), + players: Default::default(), + tokens: Default::default(), + overlays: Default::default(), + stats: Default::default(), + } + } + + fn server_dir(&self, id: i64) -> PathBuf { + self.root.join(id.to_string()) + } + + pub fn tile_path(&self, id: i64, slug: &str, zoom: u8, x: i32, y: i32) -> PathBuf { + self.server_dir(id).join(slug).join(zoom.to_string()).join(format!("{x}_{y}.png")) + } + + // ---- tiles --------------------------------------------------------------------------- + + /// Store a batch. Each tile is written to a temporary file and renamed, so a viewer never sees half a PNG. + pub fn store_tiles(&self, id: i64, slug: &str, tiles: &[TileRecord]) -> std::io::Result { + for t in tiles { + let path = self.tile_path(id, slug, t.zoom, t.x, t.y); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("png.tmp"); + std::fs::write(&tmp, &t.png)?; + std::fs::rename(&tmp, &path)?; + } + let bytes: u64 = tiles.iter().map(|t| t.png.len() as u64).sum(); + let mut stats = self.stats.lock().unwrap(); + let s = stats.entry(id).or_default(); + s.tiles_received += tiles.len() as u64; + s.bytes_received += bytes; + s.last_upload_at = Some(now_rfc3339()); + Ok(tiles.len()) + } + + /// The PNG and its modification time (seconds), if that tile exists. + pub fn read_tile(&self, id: i64, slug: &str, zoom: u8, x: i32, y: i32) -> Option<(Vec, u64)> { + let path = self.tile_path(id, slug, zoom, x, y); + let bytes = std::fs::read(&path).ok()?; + let modified = std::fs::metadata(&path).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + Some((bytes, modified)) + } + + /// Which dimensions have tiles, and how far they reach (in zoom-0 tiles). + pub fn summary(&self, id: i64) -> Value { + let mut dims = Vec::new(); + let Ok(rd) = std::fs::read_dir(self.server_dir(id)) else { return json!({ "dimensions": dims }) }; + let mut names: Vec = rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect(); + names.sort_by_key(|n| (n != "overworld", n != "the_nether", n != "the_end", n.clone())); + for slug in names { + let (mut count, mut bytes) = (0u64, 0u64); + let (mut min_x, mut max_x, mut min_y, mut max_y) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); + if let Ok(files) = std::fs::read_dir(self.server_dir(id).join(&slug).join("0")) { + for f in files.flatten() { + let name = f.file_name().to_string_lossy().to_string(); + let Some((xs, ys)) = name.strip_suffix(".png").and_then(|n| n.split_once('_')) else { continue }; + let (Ok(x), Ok(y)) = (xs.parse::(), ys.parse::()) else { continue }; + count += 1; + bytes += f.metadata().map(|m| m.len()).unwrap_or(0); + (min_x, max_x, min_y, max_y) = (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y)); + } + } + if count == 0 { + continue; + } + dims.push(json!({ + "id": if slug.contains("__") { slug.replacen("__", ":", 1) } else { format!("minecraft:{slug}") }, + "slug": slug, "label": dim_label(&slug), "available": true, "tiles": count, "bytes": bytes, + "bounds": { "min_x": min_x, "max_x": max_x, "min_y": min_y, "max_y": max_y }, + })); + } + json!({ "dimensions": dims }) + } + + /// Delete every tile and the overlay; game servers notice the new epoch and redraw everything. + pub fn reset(&self, id: i64) -> std::io::Result<()> { + let dir = self.server_dir(id); + let epoch = self.epoch(id) + 1; + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("epoch"), epoch.to_string())?; + self.overlays.lock().unwrap().remove(&id); + *self.stats.lock().unwrap().entry(id).or_default() = Stats::default(); + Ok(()) + } + + /// Changes whenever the map was reset, telling the game server to start over. + pub fn epoch(&self, id: i64) -> u64 { + std::fs::read_to_string(self.server_dir(id).join("epoch")).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0) + } + + /// Remove everything for a deleted server. + pub fn forget_server(&self, id: i64) { + let _ = std::fs::remove_dir_all(self.server_dir(id)); + self.players.lock().unwrap().remove(&id); + self.overlays.lock().unwrap().remove(&id); + self.stats.lock().unwrap().remove(&id); + } + + pub fn stats(&self, id: i64) -> Stats { + self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default() + } + + // ---- players ------------------------------------------------------------------------- + + pub fn set_players(&self, id: i64, mut players: Vec) { + 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().filter(|c| !c.is_control()).take(48).collect(); + } + self.players.lock().unwrap().insert(id, PlayersSnap { at: Instant::now(), players }); + self.stats.lock().unwrap().entry(id).or_default().last_players_at = Some(now_rfc3339()); + } + + pub fn live_players(&self, id: i64) -> Vec { + self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default() + } + + /// Drop a player from every server's roster (account deleted). + pub fn forget_player(&self, uuid: &str) { + for snap in self.players.lock().unwrap().values_mut() { + snap.players.retain(|p| !p.uuid.eq_ignore_ascii_case(uuid)); + } + } + + // ---- overlay ------------------------------------------------------------------------- + + /// The last overlay (claims, pins) a game server sent. Kept on disk so a panel restart doesn't blank the map. + pub fn set_overlay(&self, id: i64, overlay: Value) { + let dir = self.server_dir(id); + if std::fs::create_dir_all(&dir).is_ok() { + let _ = std::fs::write(dir.join("overlay.json"), overlay.to_string()); + } + self.overlays.lock().unwrap().insert(id, overlay); + self.stats.lock().unwrap().entry(id).or_default().last_overlay_at = Some(now_rfc3339()); + } + + pub fn overlay(&self, id: i64) -> Value { + if let Some(v) = self.overlays.lock().unwrap().get(&id) { + return v.clone(); + } + let loaded = std::fs::read_to_string(self.server_dir(id).join("overlay.json")).ok().and_then(|s| serde_json::from_str::(&s).ok()).unwrap_or_else(|| json!({ "claims": [], "pins": [] })); + self.overlays.lock().unwrap().insert(id, loaded.clone()); + loaded + } + + // ---- tile tokens --------------------------------------------------------------------- + + /// A short-lived key that lets `` tags load tiles without an Authorization header. Random, in memory only. + pub fn issue_token(&self, id: i64) -> String { + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + let token = hex::encode(bytes); + let mut tokens = self.tokens.lock().unwrap(); + tokens.retain(|_, (_, at)| at.elapsed() < TOKEN_TTL); + tokens.insert(token.clone(), (id, Instant::now())); + token + } + + pub fn token_ok(&self, id: i64, token: &str) -> bool { + self.tokens.lock().unwrap().get(token).is_some_and(|(sid, at)| *sid == id && at.elapsed() < TOKEN_TTL) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PNG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + + fn record(zoom: u8, x: i32, y: i32, payload: &[u8]) -> Vec { + let mut out = vec![zoom]; + out.extend(x.to_be_bytes()); + out.extend(y.to_be_bytes()); + let mut png = PNG.to_vec(); + png.extend_from_slice(payload); + out.extend((png.len() as u32).to_be_bytes()); + out.extend(png); + out + } + + #[test] + fn dimension_ids_become_safe_folder_names() { + assert_eq!(dim_slug("minecraft:overworld").as_deref(), Some("overworld")); + assert_eq!(dim_slug("minecraft:the_nether").as_deref(), Some("the_nether")); + assert_eq!(dim_slug("nether").as_deref(), Some("the_nether")); + assert_eq!(dim_slug("mymod:caves").as_deref(), Some("mymod__caves")); + assert_eq!(dim_slug("../etc"), None); + assert_eq!(dim_slug("a/b"), None); + assert_eq!(dim_slug(""), None); + assert_eq!(dim_label("the_nether"), "The Nether"); + assert_eq!(dim_label("mymod__deep_dark"), "deep dark"); + } + + #[test] + fn tile_batches_are_parsed_and_checked() { + let mut body = record(0, -3, 7, b"abc"); + body.extend(record(2, 1, -1, b"zz")); + let tiles = parse_tiles(&body).unwrap(); + assert_eq!(tiles.len(), 2); + assert_eq!((tiles[0].zoom, tiles[0].x, tiles[0].y), (0, -3, 7)); + assert_eq!((tiles[1].zoom, tiles[1].x, tiles[1].y), (2, 1, -1)); + assert!(parse_tiles(&[]).unwrap().is_empty()); + assert!(parse_tiles(&body[..10]).is_err(), "truncated header"); + assert!(parse_tiles(&body[..20]).is_err(), "truncated body"); + assert!(parse_tiles(&record(9, 0, 0, b"x")).is_err(), "zoom too deep"); + assert!(parse_tiles(&record(0, i32::MAX, 0, b"x")).is_err(), "far outside any world"); + let mut not_png = record(0, 0, 0, b"x"); + not_png[13] = b'X'; + assert!(parse_tiles(¬_png).is_err()); + } + + #[test] + fn tiles_are_stored_summarised_and_reset() { + let dir = std::env::temp_dir().join(format!("scopenet-map-{}", rand::random::())); + let map = WorldMap::new(&dir); + let tiles = parse_tiles(&[record(0, -1, 2, b"a"), record(0, 3, 4, b"bb"), record(1, 0, 0, b"c")].concat()).unwrap(); + assert_eq!(map.store_tiles(5, "overworld", &tiles).unwrap(), 3); + map.store_tiles(5, "the_nether", &tiles[..1]).unwrap(); + assert!(map.read_tile(5, "overworld", 0, -1, 2).unwrap().0.ends_with(b"a")); + assert!(map.read_tile(5, "overworld", 0, 9, 9).is_none()); + let s = map.summary(5); + let dims = s["dimensions"].as_array().unwrap(); + assert_eq!(dims[0]["slug"], "overworld"); + assert_eq!(dims[0]["tiles"], 2, "only zoom 0 counts"); + assert_eq!(dims[0]["bounds"]["min_x"], -1); + assert_eq!(dims[0]["bounds"]["max_y"], 4); + assert_eq!(dims[0]["id"], "minecraft:overworld"); + assert_eq!(dims[1]["slug"], "the_nether"); + assert_eq!(map.stats(5).tiles_received, 4); + + assert_eq!(map.epoch(5), 0); + map.reset(5).unwrap(); + assert_eq!(map.epoch(5), 1); + assert!(map.summary(5)["dimensions"].as_array().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn tokens_are_per_server_and_players_are_cleaned() { + let map = WorldMap::new(&std::env::temp_dir()); + let t = map.issue_token(3); + assert!(map.token_ok(3, &t)); + assert!(!map.token_ok(4, &t), "a token only opens its own server"); + assert!(!map.token_ok(3, "nope")); + + let p = |name: &str, x: f64| LivePlayer { uuid: "ab-12\u{7}zz".into(), name: format!("{name}\n"), dimension: "minecraft:overworld".into(), x, y: 64.0, z: 0.0, yaw: 0.0 }; + map.set_players(3, vec![p("Alex", 1.0), p("Bad", f64::NAN), p("Far", 1e9)]); + let live = map.live_players(3); + assert_eq!(live.len(), 1); + assert_eq!(live[0].name, "Alex"); + assert_eq!(live[0].uuid, "ab-12", "only hex digits and dashes survive"); + map.forget_player("AB-12"); + assert!(map.live_players(3).is_empty()); + } +} diff --git a/panel/server/tests/common/mod.rs b/panel/server/tests/common/mod.rs index 7949600..3953e98 100644 --- a/panel/server/tests/common/mod.rs +++ b/panel/server/tests/common/mod.rs @@ -39,9 +39,6 @@ pub async fn setup_with(tweak: impl FnOnce(&mut Config, &std::path::Path)) -> Te max_upload_mb: 64, public_url: Some("https://panel.test".into()), trusted_proxies: vec!["127.0.0.1".parse().unwrap()], - vantage_bin: None, - vantage_assets: None, - vantage_args: vec![], }; tweak(&mut cfg, dir.path()); let pool = db::connect_memory().await.unwrap(); @@ -66,6 +63,24 @@ impl TestApp { self.send(req).await } + /// POST raw bytes (binary uploads). + pub async fn call_raw(&self, method: &str, uri: &str, token: Option<&str>, body: Vec) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header("content-type", "application/octet-stream"); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + self.send(req.body(Body::from(body)).unwrap()).await + } + + /// GET and keep the raw bytes (images). + pub async fn fetch(&self, uri: &str) -> (StatusCode, Vec) { + let mut req = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(axum::extract::ConnectInfo("127.0.0.1:12345".parse::().unwrap())); + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + (status, axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec()) + } + pub async fn send(&self, mut req: Request) -> (StatusCode, Value) { if req.extensions().get::>().is_none() { req.extensions_mut().insert(axum::extract::ConnectInfo("127.0.0.1:12345".parse::().unwrap())); diff --git a/panel/server/tests/livemap.rs b/panel/server/tests/livemap.rs deleted file mode 100644 index 717d00b..0000000 --- a/panel/server/tests/livemap.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Central live map: ingest from game servers, status for admins, viewer proxy. - -mod common; -use common::*; - -fn record(x: i32, z: i32, ts: u32, data: &[u8]) -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(&x.to_be_bytes()); - b.extend_from_slice(&z.to_be_bytes()); - b.extend_from_slice(&ts.to_be_bytes()); - b.push(2); - b.extend_from_slice(&(data.len() as u32).to_be_bytes()); - b.extend_from_slice(data); - b -} - -async fn post_bytes(t: &TestApp, uri: &str, token: &str, body: Vec) -> (StatusCode, Value) { - let req = Request::builder().method("POST").uri(uri).header("authorization", format!("Bearer {token}")).body(Body::from(body)).unwrap(); - t.send(req).await -} - -#[tokio::test] -async fn live_map_ingest_status_and_proxy() { - let t = setup().await; - let admin = t.login("admin", "supersecret").await; - let (_, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival"}))).await; - let id = v["server"]["id"].as_i64().unwrap(); - let token = v["token"].as_str().unwrap().to_string(); - - // Off by default: nothing is accepted. - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/chunks?dim=overworld", &token, record(0, 0, 5, b"abc")).await; - assert_eq!(s, StatusCode::FORBIDDEN); - let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; - assert_eq!(hello["live_map"]["enabled"], false); - - let (s, v) = - t.call("PUT", &format!("/api/admin/servers/{id}"), Some(&admin), Some(json!({"name": "Survival", "live_map_enabled": true}))).await; - assert_eq!(s, StatusCode::OK, "{v}"); - assert_eq!(v["live_map_enabled"], true); - let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; - assert_eq!(hello["live_map"]["enabled"], true); - - // Chunks land in the region mirror and show up in the manifest. - let mut batch = record(1, 2, 100, b"hello-chunk"); - batch.extend(record(-1, 40, 101, b"other-region")); - let (s, v) = post_bytes(&t, "/api/server/v1/livemap/chunks?dim=overworld", &token, batch).await; - assert_eq!(s, StatusCode::OK, "{v}"); - assert_eq!(v["stored"], 2); - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/chunks?dim=../x", &token, record(0, 0, 1, b"x")).await; - assert_eq!(s, StatusCode::BAD_REQUEST); - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/chunks", &token, vec![1, 2, 3]).await; - assert_eq!(s, StatusCode::BAD_REQUEST); - let (_, m) = t.call("GET", "/api/server/v1/livemap/manifest?dim=overworld", Some(&token), None).await; - assert_eq!(m["regions"].as_array().unwrap().len(), 2); - - // level.dat must be a gzip file. - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/level", &token, b"not gzip at all, sorry!".to_vec()).await; - assert_eq!(s, StatusCode::BAD_REQUEST); - let mut gz = vec![0x1f, 0x8b]; - gz.extend([0u8; 30]); - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/level", &token, gz).await; - assert_eq!(s, StatusCode::OK); - - // Players, sanitised. - let (s, _) = t - .call( - "POST", - "/api/server/v1/livemap/players", - Some(&token), - Some(json!({"players": [ - {"uuid": "b50ad385-829d-3141-a216-7e7d7539ba7f", "name": "Notch", "dimension": "minecraft:the_nether", "x": 10.5, "y": 70.0, "z": -4.0, "yaw": 90.0}, - {"uuid": "bad", "name": "Nowhere", "x": 1e99, "y": 0, "z": 0} - ]})), - ) - .await; - assert_eq!(s, StatusCode::OK); - - // Viewers need a session. - let (s, _) = t.call("GET", &format!("/api/v1/servers/{id}/livemap"), None, None).await; - assert_eq!(s, StatusCode::UNAUTHORIZED); - let (s, info) = t.call("GET", &format!("/api/v1/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(s, StatusCode::OK); - assert_eq!(info["enabled"], true); - assert_eq!(info["players"], 1); - assert_eq!(info["base"], format!("/api/livemap/{id}")); - assert!(info["dimensions"][0]["available"].as_bool().unwrap()); - // No generator is installed in the test environment. - assert_eq!(info["ready"], false); - - let (_, pj) = t.call("GET", &format!("/api/livemap/{id}/the_nether/v1/worlds/default/players.json"), Some(&admin), None).await; - assert_eq!(pj["players"][0]["name"], "Notch"); - assert_eq!(pj["players"][0]["foreign"], false); - let (_, pj) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/worlds/default/players.json"), Some(&admin), None).await; - assert_eq!(pj["players"][0]["foreign"], true); - - let (s, e) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/worlds/default/manifest.json"), Some(&admin), None).await; - assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); - assert!(e["error"].as_str().unwrap().contains("generator")); - let (s, _) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/openapi.json"), Some(&admin), None).await; - assert_eq!(s, StatusCode::NOT_FOUND); - let (s, _) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/worlds/default/manifest.json"), None, None).await; - assert_eq!(s, StatusCode::UNAUTHORIZED); - - let (s, st) = t.call("GET", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(s, StatusCode::OK); - assert_eq!(st["stats"]["chunks_received"], 2); - assert_eq!(st["generator"]["installed"], false); - - // Public list advertises the map; reset wipes it. - let (_, list) = t.call("GET", "/api/v1/servers/public", None, None).await; - assert_eq!(list["servers"][0]["live_map"], true); - let (s, _) = t.call("DELETE", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(s, StatusCode::OK); - let (_, m) = t.call("GET", "/api/server/v1/livemap/manifest?dim=overworld", Some(&token), None).await; - assert!(m["regions"].as_array().unwrap().is_empty()); -} - -/// A stand-in for `vantage server` that answers health checks and serves a -/// manifest, to exercise the supervisor and proxy end to end. -#[cfg(unix)] -#[tokio::test] -async fn proxy_starts_and_reaches_the_generator() { - if std::process::Command::new("python3").arg("--version").output().is_err() { - return; - } - use std::os::unix::fs::PermissionsExt; - let t = setup_with(|cfg, dir| { - let bin = dir.join("fake-vantage"); - std::fs::write( - &bin, - r#"#!/usr/bin/env python3 -import sys, os, http.server -a = sys.argv -port = int(a[a.index('--port') + 1]) -assert a[1] == 'server' and '--assets' in a and '--players-file' in a -token = os.environ['VANTAGE_SERVER_TOKEN'] -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path == '/v1/health': - body = b'ok' - elif self.headers.get('Authorization') != 'Bearer ' + token: - self.send_response(401); self.end_headers(); return - else: - body = b'{"fake":true,"path":"' + self.path.encode() + b'"}' - self.send_response(200); self.send_header('ETag', '"v1"'); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(body) - def log_message(self, *a): pass -http.server.ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever() -"#, - ) - .unwrap(); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - std::fs::create_dir_all(dir.join("assets")).unwrap(); - cfg.vantage_bin = Some(bin); - cfg.vantage_assets = Some(dir.join("assets")); - }) - .await; - let admin = t.login("admin", "supersecret").await; - let (_, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "S", "live_map_enabled": true}))).await; - let id = v["server"]["id"].as_i64().unwrap(); - let token = v["token"].as_str().unwrap().to_string(); - - let uri = format!("/api/livemap/{id}/overworld/v1/worlds/default/manifest.json"); - let (s, _) = t.call("GET", &uri, Some(&admin), None).await; - assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE, "no world uploaded yet"); - - post_bytes(&t, &format!("/api/server/v1/livemap/chunks?dim=overworld"), &token, record(0, 0, 5, b"abc")).await; - let mut gz = vec![0x1f, 0x8b]; - gz.extend([0u8; 30]); - post_bytes(&t, "/api/server/v1/livemap/level", &token, gz).await; - - let (_, info) = t.call("GET", &format!("/api/v1/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(info["ready"], true, "{info}"); - let (s, body) = t.call("GET", &uri, Some(&admin), None).await; - assert_eq!(s, StatusCode::OK, "{body}"); - assert_eq!(body["fake"], true); - assert_eq!(body["path"], "/v1/worlds/default/manifest.json"); - let (_, st) = t.call("GET", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(st["running"][0], "overworld"); - // Reset stops the generator and deletes the mirror. - t.call("DELETE", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - let (_, st) = t.call("GET", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert!(st["running"].as_array().unwrap().is_empty()); -} diff --git a/panel/server/tests/map.rs b/panel/server/tests/map.rs index 0b452c2..d7c996b 100644 --- a/panel/server/tests/map.rs +++ b/panel/server/tests/map.rs @@ -23,7 +23,7 @@ async fn world() -> World { } let (alex, steve) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await); let (alex_uuid, steve_uuid) = (t.uuid("Alex").await, t.uuid("Steve").await); - let (s, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": "https://map.example.com"}))).await; + let (s, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; assert_eq!(s, StatusCode::OK, "{srv}"); let sid = srv["server"]["id"].as_i64().unwrap(); let server = srv["token"].as_str().unwrap().to_string(); @@ -41,20 +41,6 @@ impl World { } } -#[tokio::test] -async fn map_address_is_validated_and_shown_to_launchers() { - let w = world().await; - let (_, list) = w.t.call("GET", "/api/v1/servers/public", None, None).await; - assert_eq!(list["servers"][0]["map_address"], "https://map.example.com"); - for bad in ["not a url", "ftp://map.example.com", "https://user:pw@map.example.com", "https://map.example.com/#x"] { - let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": bad}))).await; - assert_eq!(s, StatusCode::BAD_REQUEST, "{bad}"); - } - let (s, r) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": ""}))).await; - assert_eq!(s, StatusCode::OK, "{r}"); - assert_eq!(r["map_address"], ""); -} - #[tokio::test] async fn tpa_and_messages_are_queued_once_for_the_game_server() { let w = world().await; diff --git a/panel/server/tests/worldmap.rs b/panel/server/tests/worldmap.rs new file mode 100644 index 0000000..e3fa456 --- /dev/null +++ b/panel/server/tests/worldmap.rs @@ -0,0 +1,135 @@ +//! The SCOPENET Map: tiles in from a game server, tiles and overlay out to signed-in viewers. + +mod common; +use common::*; + +const PNG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + +fn record(zoom: u8, x: i32, y: i32, payload: &[u8]) -> Vec { + let mut out = vec![zoom]; + out.extend(x.to_be_bytes()); + out.extend(y.to_be_bytes()); + let mut png = PNG.to_vec(); + png.extend_from_slice(payload); + out.extend((png.len() as u32).to_be_bytes()); + out.extend(png); + out +} + +struct World { + t: TestApp, + admin: String, + alex: String, + server: String, + sid: i64, +} + +async fn world() -> World { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Alex", "password": "password123"}))).await; + let alex = t.login("Alex", "password123").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + World { t, admin, alex, server: srv["token"].as_str().unwrap().to_string(), sid: srv["server"]["id"].as_i64().unwrap() } +} + +impl World { + async fn upload(&self, dim: &str, body: Vec) -> (StatusCode, Value) { + self.t.call_raw("POST", &format!("/api/server/v1/map/tiles?dim={dim}"), Some(&self.server), body).await + } + async fn tile(&self, token: &str, dim: &str, z: u8, x: i32, y: i32) -> (StatusCode, Vec) { + self.t.fetch(&format!("/api/map/{}/{dim}/{z}/{x}/{y}.png?t={token}", self.sid)).await + } +} + +#[tokio::test] +async fn tiles_flow_from_the_game_server_to_signed_in_viewers() { + let w = world().await; + let (s, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!((cfg["enabled"].as_bool(), cfg["epoch"].as_u64(), cfg["tile_size"].as_u64()), (Some(true), Some(0), Some(256))); + + // Nothing drawn yet. + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!((info["ready"].as_bool(), info["token"].is_null()), (Some(false), true)); + assert!(info["message"].as_str().unwrap().contains("Waiting")); + + let body = [record(0, -1, 2, b"aa"), record(0, 0, 0, b"bb"), record(1, 0, 0, b"cc")].concat(); + let (s, r) = w.upload("minecraft:overworld", body).await; + assert_eq!((s, r["stored"].as_u64()), (StatusCode::OK, Some(3))); + assert_eq!(w.upload("minecraft:the_nether", record(0, 5, 5, b"n")).await.0, StatusCode::OK); + + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!(info["ready"], true); + let dims = info["dimensions"].as_array().unwrap(); + assert_eq!((dims[0]["slug"].as_str(), dims[1]["label"].as_str()), (Some("overworld"), Some("The Nether"))); + assert_eq!(dims[0]["bounds"]["min_y"], 0); + assert_eq!(dims[0]["bounds"]["max_x"], 0); + let token = info["token"].as_str().unwrap().to_string(); + assert_eq!(info["tile_base"], format!("/api/map/{}", w.sid)); + + // The token opens tiles for plain tags. + let (s, bytes) = w.tile(&token, "overworld", 0, -1, 2).await; + assert_eq!(s, StatusCode::OK); + assert!(bytes.ends_with(b"aa") && bytes.starts_with(&PNG)); + assert_eq!(w.tile(&token, "overworld", 0, 7, 7).await.0, StatusCode::NOT_FOUND); + assert_eq!(w.tile(&token, "overworld", 9, 0, 0).await.0, StatusCode::NOT_FOUND); + assert_eq!(w.tile("wrong", "overworld", 0, -1, 2).await.0, StatusCode::UNAUTHORIZED); + assert_eq!(w.tile(&token, "..%2Fetc", 0, 0, 0).await.0, StatusCode::NOT_FOUND); + // Tokens belong to one server. + let (_, other) = w.t.call("POST", "/api/admin/servers", Some(&w.admin), Some(json!({"name": "Other", "instance_id": "o"}))).await; + let other_id = other["server"]["id"].as_i64().unwrap(); + assert_eq!(w.t.fetch(&format!("/api/map/{other_id}/overworld/0/0/0.png?t={token}")).await.0, StatusCode::UNAUTHORIZED); + // Info needs an account. + assert_eq!(w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), None, None).await.0, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn bad_uploads_are_refused() { + let w = world().await; + assert_eq!(w.upload("minecraft:overworld", b"not tiles at all".to_vec()).await.0, StatusCode::BAD_REQUEST); + assert_eq!(w.upload("../x", record(0, 0, 0, b"a")).await.0, StatusCode::BAD_REQUEST); + assert_eq!(w.upload("minecraft:overworld", record(12, 0, 0, b"a")).await.0, StatusCode::BAD_REQUEST); + let (s, _) = w.t.call_raw("POST", "/api/server/v1/map/tiles?dim=minecraft:overworld", None, record(0, 0, 0, b"a")).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn players_and_overlay_are_served_to_viewers() { + let w = world().await; + let (s, _) = w.t.call("POST", "/api/server/v1/map/players", Some(&w.server), Some(json!({"players": [ + {"uuid": "11111111-2222-3333-4444-555555555555", "name": "Steve", "dimension": "minecraft:overworld", "x": 10.5, "y": 70.0, "z": -3.0, "yaw": 90.0}]}))).await; + assert_eq!(s, StatusCode::OK); + let overlay = json!({"claims": [{"guild_id": "g1", "name": "Iron", "tag": "IRON", "color": 123}], "pins": [{"id": "spawn-0", "kind": "spawn", "label": "Spawn"}]}); + assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(overlay)).await.0, StatusCode::OK); + assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(json!({"claims": "nope"}))).await.0, StatusCode::BAD_REQUEST); + + let (s, o) = w.t.call("GET", &format!("/api/v1/servers/{}/map/overlay", w.sid), Some(&w.alex), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(o["players"][0]["name"], "Steve"); + assert_eq!(o["claims"][0]["tag"], "IRON"); + assert_eq!(o["pins"][0]["kind"], "spawn"); + assert!(o["updated"].is_string()); + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!(info["players"], 1); +} + +#[tokio::test] +async fn the_map_can_be_switched_off_and_reset() { + let w = world().await; + w.upload("minecraft:overworld", record(0, 0, 0, b"a")).await; + let (s, r) = w.t.call("DELETE", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await; + assert_eq!(s, StatusCode::OK, "{r}"); + let (_, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await; + assert_eq!(cfg["epoch"], 1, "game servers see a new epoch and redraw"); + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!(info["ready"], false); + assert_eq!(w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.alex), None).await.0, StatusCode::FORBIDDEN, "admin only"); + + let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_enabled": false}))).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(w.upload("minecraft:overworld", record(0, 0, 0, b"a")).await.0, StatusCode::FORBIDDEN); + let (_, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await; + assert_eq!(cfg["enabled"], false); + assert_eq!(w.t.call("GET", &format!("/api/v1/servers/{}/map/overlay", w.sid), Some(&w.alex), None).await.0, StatusCode::FORBIDDEN); +}