diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 0adf050..ab09a10 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -22,6 +22,7 @@ import * as bridgeInstall from '../core/bridgeInstall' import * as worldTiles from '../core/worldTiles' import * as chunkAreas from '../core/chunkAreas' import { areaChunkCount } from '@shared/chunkAreas' +import { normalizeMapPage } from '@shared/mapPage' import type { AreaInput } from '@shared/chunkAreas' import * as backups from '../core/backups' import * as worlds from '../core/worlds' @@ -421,11 +422,21 @@ export function registerIpc(): void { // allowlist entry that matches an empty Origin header. apiOrigins: (Array.isArray(cfg.apiOrigins) ? cfg.apiOrigins : []) .map((o) => String(o).trim()) - .filter(Boolean) + .filter(Boolean), + // Clamped on the way IN, like every other config: a value only checked + // when it is read back is still a wrong number in the file (#146). + mapPage: normalizeMapPage(cfg.mapPage), + // Kept as sent, and kept when omitted. The settings form sends '' for a + // passphrase it is not changing - treating that as "clear it" would + // lock everybody out of the map every time an unrelated toggle moved. + mapPagePass: + typeof cfg.mapPagePass === 'string' && cfg.mapPagePass + ? cfg.mapPagePass + : c.web?.mapPagePass ?? '' } }) const w = getConfig().web - if (w?.enabled || w?.siteEnabled) startWebServer() + if (w?.enabled || w?.siteEnabled || w?.mapPage?.enabled) startWebServer() else stopWebServer() return getWebStatus() }) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 35a256b..de4a894 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -150,6 +150,7 @@ import { MIN_SCALE, PUBLIC_MAP_DEFAULTS } from '@shared/livemap' +import { normalizeMapPage, mapPagePublic, MAP_PAGE_DEFAULTS } from '@shared/mapPage' import type { LivePlayer, MapView } from '@shared/livemap' import { listJavaInstalls, _resetJavaCache } from './core/javaScan' import { checkJava, javaRequirement } from '@shared/javaCompat' @@ -5288,6 +5289,181 @@ export async function runWebSmoke(): Promise { console.log('WEB-SMOKE: chunk areas over HTTP OK (gated, tidied, hidden ones stay off the public feed)') } + // ---- the map page: a third listener with its own door (#146) ---- + { + const cfgBefore = getConfig().web + const mport = 8797 + const mbase = 'http://127.0.0.1:' + mport + const mget = (p: string, cookie?: string): Promise => + fetch(mbase + p, { headers: cookie ? { Cookie: cookie } : {} }) + const mpost = (p: string, body: unknown): Promise => + fetch(mbase + p, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + const setMap = (patch: Record, pass?: string): void => { + updateConfig((c) => { + c.web = { + ...(c.web ?? { enabled: false, port: 8799, bindLan: false, siteEnabled: false, sitePort: 8798 }), + mapPage: normalizeMapPage({ enabled: true, port: mport, serverId: id, ...patch }), + ...(pass !== undefined ? { mapPagePass: pass } : {}) + } + }) + startWebServer() + } + try { + // ---- pure rules first ---- + // Clamped on the way IN. A port of 0 means "whatever the OS gives us", + // which is a port nobody can bookmark. + if (normalizeMapPage({ port: 0 }).port !== MAP_PAGE_DEFAULTS.port) return fail('port 0 was accepted') + if (normalizeMapPage({ port: 99999 }).port !== MAP_PAGE_DEFAULTS.port) return fail('a huge port was accepted') + if (normalizeMapPage({ access: 'nonsense' }).access !== 'open') return fail('a junk access mode got through') + // A dimension becomes a folder name when its regions are read. + for (const bad of ['../etc', 'a/b', '..', 'a b', 'x'.repeat(80)]) { + if (normalizeMapPage({ fixedDim: bad }).fixedDim !== '') { + return fail('a bad pinned world was accepted: ' + JSON.stringify(bad)) + } + } + if (normalizeMapPage({ fixedDim: 'THE_END' }).fixedDim !== 'end') return fail('a good pin was rejected') + // Heads without names would be a lie: a face identifies a player + // exactly as well as their name (#116). + const lying = mapPagePublic(normalizeMapPage({ heads: true, names: false })) + if (lying.heads) return fail('heads survived with names off') + // The page is told what it may draw, not who it belongs to. + const shape = Object.keys(mapPagePublic(normalizeMapPage({}))).sort().join(',') + if (shape.includes('serverId') || shape.includes('port') || shape.includes('access')) { + return fail('the page payload carries operator config: ' + shape) + } + + // ---- open ---- + setMap({ access: 'open' }) + await sleep(250) + let mr = await mget('/') + if (mr.status !== 200) return fail('the map page did not serve: ' + mr.status) + const html = await mr.text() + if (!html.includes('mpCanvas')) return fail('the map page served something without a map in it') + if (html.includes('mapPagePass')) return fail('the passphrase reached the page') + const state = (await (await mget('/api/map/state')).json()) as { allowed: boolean } + if (!state.allowed) return fail('an open map refused a visitor') + if ((await mget('/api/map')).status !== 200) return fail('the open feed refused') + + // ---- password ---- + setMap({ access: 'password' }, 'hunter2') + await sleep(250) + const shut = (await (await mget('/api/map/state')).json()) as { allowed: boolean; access: string } + if (shut.allowed) return fail('a protected map let a visitor straight in') + if (shut.access !== 'password') return fail('the gate did not say which door it is') + // The DATA is what must refuse. A page that gates only its HTML is a + // page whose data anybody can fetch directly. + for (const p of ['/api/map', '/api/map/tiles?c=0,0', '/api/map/areas']) { + const r2 = await mget(p) + if (r2.status !== 403) return fail('protected ' + p + ' answered ' + r2.status) + } + if ((await mpost('/api/map/open', { pass: 'wrong' })).status !== 401) return fail('a wrong passphrase was accepted') + const opened = await mpost('/api/map/open', { pass: 'hunter2' }) + if (opened.status !== 200) return fail('the right passphrase was refused: ' + opened.status) + const cookie = String(opened.headers.get('set-cookie') ?? '') + if (!cookie.includes('HttpOnly')) return fail('the map cookie is readable from script') + const token = /msms_map=([a-f0-9]+)/.exec(cookie)?.[1] ?? '' + if (!token) return fail('no map cookie was set') + if (token.includes('hunter2')) return fail('the cookie carries the passphrase') + if ((await mget('/api/map', 'msms_map=' + token)).status !== 200) return fail('the cookie did not open the map') + // Changing the passphrase invalidates it — the only thing changing it + // is for. + setMap({ access: 'password' }, 'different') + await sleep(250) + if ((await mget('/api/map', 'msms_map=' + token)).status !== 403) { + return fail('an old cookie survived a passphrase change') + } + + // ---- players ---- + // + // The mode that had no test at all, and did not work. It reached for + // the public site's session, which lives in `localStorage` — per + // ORIGIN, and a different port IS a different origin, so this page + // could never read it. `players` was a door that never opened, and + // only a test that actually signs somebody in says so. + setMap({ access: 'players' }) + await sleep(250) + const shutP = (await (await mget('/api/map/state')).json()) as { allowed: boolean; access: string } + if (shutP.allowed) return fail('a players-only map let an anonymous visitor in') + if (shutP.access !== 'players') return fail('the gate named the wrong door: ' + shutP.access) + if ((await mget('/api/map')).status !== 403) return fail('a players-only feed answered anonymously') + // A player the public site already knows. `registerPlayer` is what the + // site's own sign-up calls, so this is the same account either would. + const mcName = 'MapViewer' + webPlayerAuth._testCreateAccount(mcName, 'mappass1') + if ((await mpost('/api/map/login', { mcName, password: 'wrong' })).status !== 401) { + return fail('a wrong password opened the map') + } + const signedIn = await mpost('/api/map/login', { mcName, password: 'mappass1' }) + if (signedIn.status !== 200) return fail('a real player could not sign in: ' + signedIn.status + ' ' + (await signedIn.text())) + const pc = String(signedIn.headers.get('set-cookie') ?? '') + if (!pc.includes('HttpOnly')) return fail('the player cookie is readable from script') + const ptok = /msms_map_player=([^;]+)/.exec(pc)?.[1] ?? '' + if (!ptok) return fail('signing in set no cookie') + if ((await mget('/api/map', 'msms_map_player=' + ptok)).status !== 200) { + return fail('a signed-in player still could not read the map') + } + // The site's own storage key is NOT what this page reads — asserting + // the negative, because reading it is the bug that was here. + if ((await mget('/api/map', 'msms_ptoken=' + decodeURIComponent(ptok))).status === 200) { + return fail('the map accepted the public site cookie name') + } + // The passphrase route does not exist in this mode, and vice versa: + // an unused door left open is a door. + if ((await mpost('/api/map/open', { pass: 'anything' })).status !== 404) { + return fail('the passphrase route answered in players mode') + } + setMap({ access: 'password' }, 'x') + await sleep(250) + if ((await mpost('/api/map/login', { mcName, password: 'mappass1' })).status !== 404) { + return fail('the player login answered in password mode') + } + + // A page title is operator text and lands inside a ' }) + await sleep(250) + const nasty = await (await mget('/')).text() + if (nasty.includes(' { + c.web = { ...(c.web as NonNullable), mapPage: normalizeMapPage({ enabled: false, port: mport }) } + }) + startWebServer() + await sleep(250) + let closed = false + try { + await mget('/') + } catch { + closed = true + } + if (!closed) return fail('the map port still answers with the page off') + } finally { + updateConfig((c) => { + c.web = cfgBefore + }) + startWebServer() + await sleep(250) + } + console.log('WEB-SMOKE: map page OK (own listener, gate refuses data not just html, settings honoured)') + } + console.log('WEB-SMOKE: profile visibility OK (' + checked + ' checks, omitted not hidden, per-field toggles)') // ---- the refresh budget (#117) ---- @@ -7703,9 +7879,21 @@ export async function runWebSmoke(): Promise { if (!existsSync(srcPath)) return fail('cannot read the router source at ' + srcPath) const whole = readFileSync(srcPath, 'utf-8') const from = whole.indexOf('async function handlePanel') - const to = whole.indexOf('export function startWebServer') + // The NEXT top-level function, not `startWebServer`. That marker held only + // while `handlePanel` happened to be the last thing before it; #146 put + // `handleMapPage` in between, and its routes — which belong to a different + // listener and are deliberately not part of the `/api/v1` surface — were + // then read as undocumented panel routes. + const after = whole.slice(from + 1) + const next = after.search(/\n(?:export )?(?:async )?function /) + const to = next < 0 ? whole.indexOf('export function startWebServer') : from + 1 + next if (from < 0 || to < 0 || to < from) return fail('could not isolate handlePanel in the source') const router = whole.slice(from, to) + // The isolation is load-bearing: too short and the coverage check reads a + // handful of routes and passes, which looks exactly like success. + if (!router.includes('/api/keys') || router.length < 20000) { + return fail('handlePanel was isolated to ' + router.length + ' chars — the slice is wrong') + } // `/api/…` literals, mapped onto the versioned form the table uses. for (const m of router.matchAll(/\b(?:raw)?[Pp]ath === '(\/api\/[^']*)'/g)) { diff --git a/src/main/web/mapPageHtml.ts b/src/main/web/mapPageHtml.ts new file mode 100644 index 0000000..a67b5dd --- /dev/null +++ b/src/main/web/mapPageHtml.ts @@ -0,0 +1,172 @@ +import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi' +import { mapPagePublic } from '@shared/mapPage' +import type { MapPageConfig } from '@shared/mapPage' +import { avatarUrl } from '@shared/profile' + +/** + * The map page (#146). + * + * The SAME map engine as the panel and the public site — `MAP_CSS`, `MAP_HTML`, + * `MAP_JS` from `@shared/mapUi`. Writing a fourth map here is the mistake #129 + * was about, and the only thing this page actually needs that the others do not + * is a shell: the canvas fills the window instead of sitting in a card, and the + * controls float over it rather than above it. + * + * So this file is a chrome, not a map. Everything below the CSS is the host + * contract `MAP_JS` asks for, plus a passphrase gate. + */ + +const esc = (s: string): string => + s.replace(/[&<>"']/g, (c) => + c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''' + ) + +export function getMapPageHtml(cfg: MapPageConfig): string { + const pub = mapPagePublic(cfg) + return ` + + + + + +${esc(pub.title)} + + + +${MAP_HTML.replace( + '
', + '
' +)} + +` +} diff --git a/src/main/web/server.ts b/src/main/web/server.ts index 1e92c46..a8f0317 100644 --- a/src/main/web/server.ts +++ b/src/main/web/server.ts @@ -1,5 +1,5 @@ import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http' -import { createHash } from 'node:crypto' +import { createHash, timingSafeEqual, randomBytes } from 'node:crypto' import { networkInterfaces } from 'node:os' import { createReadStream, existsSync } from 'node:fs' import { join, extname, resolve, sep } from 'node:path' @@ -23,6 +23,9 @@ import * as chunkAreas from '../core/chunkAreas' import { areaChunkCount } from '@shared/chunkAreas' import type { AreaInput } from '@shared/chunkAreas' import { normalizeMapPerf } from '@shared/tileCache' +import { normalizeMapPage, mapPageAllows } from '@shared/mapPage' +import type { MapPageConfig, MapPageViewer } from '@shared/mapPage' +import { getMapPageHtml } from './mapPageHtml' import { listJavaInstalls } from '../core/javaScan' import { installJava } from '../core/javaProvision' import { @@ -38,7 +41,7 @@ import { } from '@shared/ops' import type { PlayerInfo } from '@shared/types' import { bridgeFresh, bridgePlayers } from '@shared/bridge' -import { heatmap, livePlayers, mapBounds, normalizeDimension, redactPlayers } from '@shared/livemap' +import { heatmap, livePlayers, mapBounds, normalizeDimension, redactPlayers, PUBLIC_MAP_DEFAULTS } from '@shared/livemap' import { redactProfile } from '@shared/profile' import type { ProfileViewer } from '@shared/profile' import { @@ -202,6 +205,7 @@ import type { Scope, WebStatus, WebConfig } from '@shared/web' let server: Server | null = null let siteServer: Server | null = null +let mapServer: Server | null = null // ---- helpers ---- function sendJson(res: ServerResponse, code: number, body: unknown): void { @@ -2426,6 +2430,199 @@ export function lanUrls(port: number): string[] { return urls } +// ---- the map page (#146) ---- +// +// Its own listener, so an operator can hand out the map without handing out the +// shop or the panel — a firewall rule rather than trust. Its own handler for the +// same reason the public site has one: the answer to "may this caller see this" +// is different here, and a shared handler with a mode flag is how a surface ends +// up returning another surface's payload. + +function mapPageCfg(): MapPageConfig { + return normalizeMapPage(getConfig().web?.mapPage) +} + +/** + * An install-specific salt for the map cookie, generated once and kept in + * memory. + * + * In memory rather than on disk deliberately: restarting MSMS invalidates every + * map cookie, which is a cheap way for an operator to shut a leaked link without + * having to change the passphrase and tell everybody the new one. It never + * protects anything at rest — the passphrase itself is stored in the clear + * beside it, because a shared doorcode is something an operator has to be able + * to read back. + */ +let mapSalt = '' +function mapPageSalt(): string { + if (!mapSalt) mapSalt = randomBytes(16).toString('hex') + return mapSalt +} + +/** + * The passphrase cookie. A hash of the passphrase and the install's own secret, + * so the cookie cannot be recomputed from the passphrase alone by somebody who + * guessed it elsewhere, and every install's cookies are worthless on any other. + */ +function mapPassToken(pass: string): string { + return createHash('sha256').update(mapPageSalt()).update('.').update(pass).digest('hex').slice(0, 32) +} + +function mapViewer(req: IncomingMessage, cfg: MapPageConfig): MapPageViewer { + const cookies = String(req.headers.cookie ?? '') + const m = /(?:^|;\s*)msms_map=([a-f0-9]{32})/.exec(cookies) + const stored = getConfig().web?.mapPagePass ?? '' + return { + // Compared against the token for the CURRENT passphrase, so changing it + // logs everybody out — which is the only thing changing it is for. + passed: !!m && !!stored && m[1] === mapPassToken(stored), + player: !!playerAuth.resolvePlayerSession(bearer(req) || mapPlayerCookie(req)) + } +} + +/** + * The map page signs players in ITSELF, and holds the session in a cookie. + * + * It cannot borrow the public site's. That token lives in `localStorage` under + * `msms_ptoken`, and localStorage is per ORIGIN — a different port is a + * different origin, so the map page on 8724 cannot read what the site on 8723 + * wrote, whatever the two agree to call it. Reaching for the site's token was + * the first version of this and it made `players` a door that never opened. + * + * A cookie instead, set by this listener on its own origin. Cookies are not + * isolated by port, which is a weakness elsewhere and the mechanism here. + */ +function mapPlayerCookie(req: IncomingMessage): string { + const m = /(?:^|;\s*)msms_map_player=([^;]+)/.exec(String(req.headers.cookie ?? '')) + return m ? decodeURIComponent(m[1]) : '' +} + +async function handleMapPage(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'http://localhost') + const path = url.pathname.replace(/\/+$/, '') || '/' + const method = req.method ?? 'GET' + const cfg = mapPageCfg() + const viewer = mapViewer(req, cfg) + const ok = mapPageAllows(cfg, viewer) + + if (path === '/' && method === 'GET') { + // The shell is served whatever the gate says — it IS the gate. The feeds + // below are what actually refuse, so a visitor sees a door rather than a + // blank page, and no map data rides along with it. + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(getMapPageHtml(cfg)) + return + } + if (path === '/api/map/state' && method === 'GET') { + if (!cfg.enabled || !cfg.serverId) return sendJson(res, 404, { error: 'not-found' }) + // The access MODE is told to a visitor who is refused, because they need to + // know which door they are standing at. Nothing else about the config is. + return sendJson(res, 200, { allowed: ok, access: cfg.access }) + } + if (path === '/api/map/open' && method === 'POST') { + if (cfg.access !== 'password') return sendJson(res, 404, { error: 'not-found' }) + const b = (await readBody(req).catch(() => ({}))) as { pass?: string } + const stored = getConfig().web?.mapPagePass ?? '' + const given = String(b.pass ?? '') + // Timing-safe, and only after both are known to be the same length — + // `timingSafeEqual` throws on a mismatch, which is itself a length oracle. + const a = Buffer.from(mapPassToken(given)) + const c = Buffer.from(mapPassToken(stored)) + const good = !!stored && a.length === c.length && timingSafeEqual(a, c) + audit.record({ + source: 'public', + action: 'mappage.open', + actor: 'visitor', + ok: good, + ip: req.socket.remoteAddress ?? 'unknown', + serverId: cfg.serverId + }) + if (!good) return sendJson(res, 401, { error: 'bad-passphrase' }) + res.setHeader( + 'Set-Cookie', + // HttpOnly: the page never reads this, only sends it. SameSite=Lax so a + // link from elsewhere still opens the map, which is the whole use. + `msms_map=${mapPassToken(stored)}; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax` + ) + return sendJson(res, 200, { ok: true }) + } + + if (path === '/api/map/login' && method === 'POST') { + if (cfg.access !== 'players') return sendJson(res, 404, { error: 'not-found' }) + const b = (await readBody(req).catch(() => ({}))) as { mcName?: string; password?: string } + const r = playerAuth.login((b.mcName ?? '').trim(), b.password ?? '') + audit.record({ + source: 'public', + action: 'mappage.login', + actor: (b.mcName ?? '').trim() || 'unknown', + ok: r.ok, + ip: req.socket.remoteAddress ?? 'unknown', + serverId: cfg.serverId + }) + if (!r.ok) return sendJson(res, 401, { error: 'invalid-credentials' }) + res.setHeader( + 'Set-Cookie', + // Session-length, unlike the passphrase cookie: this one stands for a + // person, and a person's session should not outlive their browser. + `msms_map_player=${encodeURIComponent(r.token)}; Path=/; HttpOnly; SameSite=Lax` + ) + return sendJson(res, 200, { ok: true, mcName: r.mcName }) + } + + // Everything below is data. One gate, checked here, for all of it. + if (!ok) return sendJson(res, 403, { error: 'forbidden' }) + + if (path === '/api/map' && method === 'GET') { + const rt = processManager.getRuntime(cfg.serverId) + const now = Date.now() + // `players: false` means positions are not published at all — not that they + // are hidden in the client, which is the same payload with a flag on it. + const all = cfg.players && rt ? livePlayers(bridgePlayers(rt.bridge, now)) : [] + const dim = cfg.fixedDim || normalizeDimension(url.searchParams.get('dim') ?? 'overworld') + const shown = redactPlayers(all.filter((p) => p.dim === dim), { + ...PUBLIC_MAP_DEFAULTS, + serverId: cfg.serverId, + round: cfg.round, + names: cfg.names, + heads: cfg.heads && cfg.names + }) + const cell = Math.min(512, Math.max(1, Number(url.searchParams.get('cell')) || 16)) + return sendJson(res, 200, { + bridge: rt ? bridgeFresh(rt.bridge, now) : false, + dimension: dim, + dimensions: cfg.fixedDim ? [dim] : [...new Set(all.map((p) => p.dim))].sort(), + pinned: !!cfg.fixedDim, + players: shown, + // From the ROUNDED positions: bounds derived from the exact ones publish a + // tighter box than the dots inside it, and its corner is somebody's real + // coordinate to within a pixel. + bounds: mapBounds(shown.map((p) => ({ ...p, name: p.name ?? '', y: 0 }))), + round: cfg.round, + heads: cfg.heads && cfg.names, + ...(cfg.heatmap ? { heatmap: heatmap(shown, cell), cell } : {}), + loadOnPan: normalizeMapPerf(getServer(cfg.serverId)?.map).loadOnPan, + at: now + }) + } + if (path === '/api/map/tiles' && method === 'GET') { + if (!cfg.world) return sendJson(res, 404, { error: 'not-found' }) + const dim = cfg.fixedDim || normalizeDimension(url.searchParams.get('dim') ?? 'overworld') + // Structures only when the operator published them, whatever the caller + // asks for: where every dungeon is turns a map into a treasure map. + return sendJson( + res, + 200, + tilesFor(cfg.serverId, dim, parseWanted(url.searchParams.get('c')), { marks: cfg.structures }) + ) + } + if (path === '/api/map/areas' && method === 'GET') { + if (!cfg.areas) return sendJson(res, 200, { areas: [] }) + const dim = cfg.fixedDim || normalizeDimension(url.searchParams.get('dim') ?? 'overworld') + return sendJson(res, 200, { dimension: dim, areas: chunkAreas.listPublicAreas(cfg.serverId, dim) }) + } + return sendJson(res, 404, { error: 'not-found' }) +} + function webCfg(): Required { const c = getConfig().web return { @@ -2434,6 +2631,8 @@ function webCfg(): Required { bindLan: c?.bindLan ?? false, siteEnabled: c?.siteEnabled ?? false, sitePort: c?.sitePort ?? 8723, + mapPage: normalizeMapPage(c?.mapPage), + mapPagePass: c?.mapPagePass ?? '', apiOrigins: Array.isArray(c?.apiOrigins) ? c.apiOrigins : [] } } @@ -2469,6 +2668,10 @@ export function startWebServer(): WebStatus { if (server) attachWs(server, () => webCfg().apiOrigins ?? []) } if (cfg.siteEnabled) siteServer = listen(handleSite, cfg.sitePort, host, 'Website') + const mp = mapPageCfg() + // Not started without a server chosen: a listener that answers 404 to + // everything is a port open for nothing. + if (mp.enabled && mp.serverId) mapServer = listen(handleMapPage, mp.port, host, 'Map page') return getWebStatus() } @@ -2485,6 +2688,10 @@ export function stopWebServer(): void { siteServer.close() siteServer = null } + if (mapServer) { + mapServer.close() + mapServer = null + } } function urlsFor(port: number, bindLan: boolean): string[] { @@ -2495,6 +2702,7 @@ function urlsFor(port: number, bindLan: boolean): string[] { export function getWebStatus(): WebStatus { const cfg = webCfg() + const mp = mapPageCfg() return { bindLan: cfg.bindLan, apiOrigins: cfg.apiOrigins, @@ -2509,7 +2717,14 @@ export function getWebStatus(): WebStatus { running: !!siteServer && siteServer.listening, port: cfg.sitePort, urls: urlsFor(cfg.sitePort, cfg.bindLan) - } + }, + map: { + enabled: mp.enabled, + running: !!mapServer && mapServer.listening, + port: mp.port, + urls: urlsFor(mp.port, cfg.bindLan) + }, + mapPage: mp } } @@ -2519,5 +2734,5 @@ export function initWebServer(): void { playerAuth.initPlayerAuth() site.initSite() const cfg = webCfg() - if (cfg.enabled || cfg.siteEnabled) startWebServer() + if (cfg.enabled || cfg.siteEnabled || mapPageCfg().enabled) startWebServer() } diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index 17c550f..bce7969 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -886,6 +886,32 @@ export default { enable: 'Enable web panel', port: 'Panel port', panelSection: 'Admin panel', + mapPortClash: 'That port is already used by another listener — pick a different one.', + mapNeedsServer: 'Choose a server; the map page will not start without one.', + mapSection: 'Map page', + mapEnable: 'Enable the map page', + mapPort: 'Map port', + mapServer: 'Which server', + mapNoServer: 'Choose a server…', + mapTitle: 'Page title', + mapAccess: 'Who may open it', + mapAccessOpen: 'Anyone who can reach the port', + mapAccessPassword: 'Anyone with the passphrase', + mapAccessPlayers: 'Signed-in players only', + mapPass: 'Passphrase', + mapPassKeep: 'Leave blank to keep the current one', + mapPassHint: 'A shared doorcode, not a personal password — you can read it back and hand it out. Restarting MSMS signs everyone out.', + mapShows: 'What the page shows', + mapWorld: 'Terrain', + mapPlayers: 'Live positions', + mapNames: 'Player names', + mapHeads: 'Skin heads', + mapAreas: 'Chunk areas', + mapStructures: 'Structures', + mapHeat: 'Heatmap', + mapRound: 'Round positions to', + mapPin: 'Pin one world', + mapPinAny: 'Let visitors switch', siteSection: 'Public website', siteEnable: 'Enable website', sitePort: 'Website port', diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index e0c4463..f2b47d3 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -890,6 +890,32 @@ const tr: typeof en = { enable: 'Web panelini etkinleştir', port: 'Panel portu', panelSection: 'Yönetici paneli', + mapPortClash: 'Bu port başka bir dinleyici tarafından kullanılıyor — farklı bir port seçin.', + mapNeedsServer: 'Bir sunucu seçin; harita sayfası sunucusuz başlamaz.', + mapSection: 'Harita sayfası', + mapEnable: 'Harita sayfasını etkinleştir', + mapPort: 'Harita portu', + mapServer: 'Hangi sunucu', + mapNoServer: 'Bir sunucu seçin…', + mapTitle: 'Sayfa başlığı', + mapAccess: 'Kimler açabilir', + mapAccessOpen: 'Porta erişebilen herkes', + mapAccessPassword: 'Parolayı bilen herkes', + mapAccessPlayers: 'Yalnızca giriş yapmış oyuncular', + mapPass: 'Parola', + mapPassKeep: 'Mevcut parolayı korumak için boş bırakın', + mapPassHint: 'Kişisel bir şifre değil, paylaşılan bir kapı kodu — geri okuyup dağıtabilirsiniz. MSMS yeniden başlatılınca herkesin oturumu kapanır.', + mapShows: 'Sayfada neler görünsün', + mapWorld: 'Arazi', + mapPlayers: 'Canlı konumlar', + mapNames: 'Oyuncu adları', + mapHeads: 'Kafa görselleri', + mapAreas: 'Chunk alanları', + mapStructures: 'Yapılar', + mapHeat: 'Isı haritası', + mapRound: 'Konumları şuna yuvarla', + mapPin: 'Tek dünyaya sabitle', + mapPinAny: 'Ziyaretçi değiştirebilsin', siteSection: 'Herkese açık site', siteEnable: 'Web sitesini etkinleştir', sitePort: 'Site portu', diff --git a/src/renderer/src/views/WebPanelView.tsx b/src/renderer/src/views/WebPanelView.tsx index b42a9c5..85baac0 100644 --- a/src/renderer/src/views/WebPanelView.tsx +++ b/src/renderer/src/views/WebPanelView.tsx @@ -20,6 +20,8 @@ import { useStore } from '../store' import { SCOPES } from '@shared/web' import { effectiveScopes } from '@shared/rbac' import { isKeyUsable } from '@shared/apikeys' +import { MAP_PAGE_DEFAULTS } from '@shared/mapPage' +import type { MapPageConfig, MapPageAccess } from '@shared/mapPage' import { usageSamples, USAGE_NOTES } from '@shared/apiUsage' import type { RoleDef } from '@shared/rbac' import type { ApiKeyView, KeyServers } from '@shared/apikeys' @@ -37,6 +39,14 @@ export function WebPanelView(): JSX.Element { const [bindLan, setBindLan] = useState(false) const [siteEnabled, setSiteEnabled] = useState(false) const [sitePort, setSitePort] = useState(8723) + // The map page (#146). Held as the whole config rather than a field each: it + // has eleven settings, and eleven useStates is eleven chances to forget one + // in the save. + const [mapPage, setMapPage] = useState(MAP_PAGE_DEFAULTS) + // Separate, and never populated from the config. Blank means "leave it as it + // is" — a form that round-trips a doorcode through the renderer to save an + // unrelated toggle is a form that can lose it. + const [mapPass, setMapPass] = useState('') const [newUser, setNewUser] = useState('') const [newPass, setNewPass] = useState('') @@ -74,6 +84,7 @@ export function WebPanelView(): JSX.Element { setSiteEnabled(st.site.enabled) setSitePort(st.site.port) setBindLan(st.bindLan) + setMapPage(st.mapPage ?? MAP_PAGE_DEFAULTS) setOriginsText((st.apiOrigins ?? []).join('\n')) setUsers(await window.msms.listWebUsers()) setRoles(await window.msms.listRoles()) @@ -139,10 +150,16 @@ export function WebPanelView(): JSX.Element { bindLan, siteEnabled, sitePort: Number(sitePort), - apiOrigins: originsText.split('\n').map((s) => s.trim()).filter(Boolean) + apiOrigins: originsText.split('\n').map((s) => s.trim()).filter(Boolean), + mapPage, + // Blank leaves the stored one alone. The main process treats it that way + // too; sending '' from here on every save would otherwise clear the + // doorcode each time an unrelated toggle moved. + mapPagePass: mapPass }) setStatus(st) setOriginsText((st.apiOrigins ?? []).join('\n')) + setMapPass('') toast('success', 'web.saved') } @@ -265,6 +282,147 @@ export function WebPanelView(): JSX.Element {
)}
+ + {/* The map page (#146): its own listener, so the map can be handed out + without the shop or the panel going with it. */} +
+
+ {t('web.mapSection')} + + + {status?.map.running ? t('web.running') : t('web.stopped')} + +
+ +
+
+ + setMapPage({ ...mapPage, port: Number(e.target.value) })} + /> +
+
+ + +
+
+
+ + setMapPage({ ...mapPage, title: e.target.value })} + /> +
+
+ + +
+ {mapPage.access === 'password' && ( +
+ + setMapPass(e.target.value)} + /> + {/* A shared doorcode, not a personal credential: the operator has + to be able to read it back and tell people. Saying so beats + letting them assume otherwise. */} +

{t('web.mapPassHint')}

+
+ )} +
+ +
+ {([ + ['world', 'web.mapWorld'], + ['players', 'web.mapPlayers'], + ['names', 'web.mapNames'], + ['heads', 'web.mapHeads'], + ['areas', 'web.mapAreas'], + ['structures', 'web.mapStructures'], + ['heatmap', 'web.mapHeat'] + ] as const).map(([k, label]) => ( + + ))} +
+
+
+
+ + setMapPage({ ...mapPage, round: Number(e.target.value) })} + /> +
+
+ + setMapPage({ ...mapPage, fixedDim: e.target.value })} + /> +
+
+ {/* Otherwise the listener fails with EADDRINUSE in the log and the + card just says "stopped" with no reason on screen. */} + {(mapPage.port === port || mapPage.port === sitePort) && ( +

⚠ {t('web.mapPortClash')}

+ )} + {mapPage.enabled && !mapPage.serverId && ( +

{t('web.mapNeedsServer')}

+ )} + {status?.map.running && ( +
+ {status.map.urls.map((u) => ( + + ))} +
+ )} +
diff --git a/src/shared/mapPage.ts b/src/shared/mapPage.ts new file mode 100644 index 0000000..a6f776f --- /dev/null +++ b/src/shared/mapPage.ts @@ -0,0 +1,184 @@ +import { normalizeDimension } from './livemap' +import { clampRound } from './livemap' + +/** + * The map page (#146): a third listener whose whole job is one fullscreen map. + * + * A LISTENER, not a path on the public site. `WebConfig` already carries a port + * and an enabled flag per surface, and the reason to follow that shape here is + * not symmetry: a separate port is what lets an operator expose the map to + * people who must not reach the shop or the admin panel, with a firewall rule + * rather than with trust. "Yönetici kısıtlayabilmeli" has to mean something at + * the network layer, not only in a template. + * + * Everything the page is allowed to show is decided here, once, and the feed is + * built from this rather than from what the panel happens to send. + */ + +/** Who may open the page at all. */ +export type MapPageAccess = + /** Anyone who can reach the port. */ + | 'open' + /** A shared passphrase, checked once and remembered in a cookie. */ + | 'password' + /** A player account from the public site, signed in. */ + | 'players' + +export interface MapPageConfig { + enabled: boolean + port: number + /** Which server's world is published. Empty means the page is not ready. */ + serverId: string + /** Shown in the corner and as the document title. */ + title: string + access: MapPageAccess + /** + * Pinned world. Empty means the visitor may switch between the dimensions + * that exist — the map page is the one surface where browsing is the point. + */ + fixedDim: string + /** Terrain. Off means markers on a grid, which is not a map (#135). */ + world: boolean + /** Live positions at all. Everything below is moot without it. */ + players: boolean + names: boolean + /** Draw skin heads, which means sending names to an avatar service. */ + heads: boolean + /** Coordinates are snapped to this many blocks. */ + round: number + structures: boolean + /** Named chunk areas (#144). On: they are labels written to be read. */ + areas: boolean + /** A density overlay. Off — it defeats the point of rounding coordinates. */ + heatmap: boolean +} + +/** + * Off, and cautious about everything except the two things that make it a map. + * + * Terrain and areas are on because a page with neither is a grid with dots on + * it. Positions are on but rounded and nameless-by-default is NOT the choice + * here — names are on, because a map of anonymous dots is not what anybody opens + * a map page for, and the operator turning the page on has already decided to + * publish. Precision stays coarse: 64 blocks is enough to see where the server + * is busy and not enough to walk to somebody's door. + */ +export const MAP_PAGE_DEFAULTS: MapPageConfig = { + enabled: false, + port: 8724, + serverId: '', + title: 'Live Map', + access: 'open', + fixedDim: '', + world: true, + players: true, + names: true, + heads: true, + round: 64, + structures: false, + areas: true, + heatmap: false +} + +export const MAX_MAP_TITLE = 60 + +/** + * A dimension name becomes a FOLDER NAME when its regions are read, so anything + * that is not a plain name is refused at the boundary rather than trusted at the + * point of use. Shared with the public site's pinned world, which is where this + * check was first needed. + */ +export function safeDimName(d: unknown): string { + const s = typeof d === 'string' ? d.trim() : '' + if (!s || s.length > 64) return '' + if (!/^[A-Za-z0-9_.:-]+$/.test(s)) return '' + if (s === '.' || s === '..' || s.includes('/') || s.includes('\\')) return '' + return normalizeDimension(s) +} + +/** Clamped on the way IN, so a wrong number is never written to the config. */ +export function normalizeMapPage(raw: unknown): MapPageConfig { + const c = (raw && typeof raw === 'object' ? raw : {}) as Partial + const port = Number(c.port) + const access: MapPageAccess = + c.access === 'password' || c.access === 'players' ? c.access : 'open' + const title = typeof c.title === 'string' ? c.title.trim().slice(0, MAX_MAP_TITLE) : '' + const bool = (v: unknown, d: boolean): boolean => (typeof v === 'boolean' ? v : d) + return { + enabled: bool(c.enabled, false), + // Not the panel's or the site's default, and not zero: a port the operating + // system picks is a port nobody can bookmark. + port: Number.isFinite(port) && port >= 1 && port <= 65535 ? Math.floor(port) : MAP_PAGE_DEFAULTS.port, + serverId: typeof c.serverId === 'string' ? c.serverId : '', + title: title || MAP_PAGE_DEFAULTS.title, + access, + fixedDim: safeDimName(c.fixedDim), + world: bool(c.world, MAP_PAGE_DEFAULTS.world), + players: bool(c.players, MAP_PAGE_DEFAULTS.players), + names: bool(c.names, MAP_PAGE_DEFAULTS.names), + heads: bool(c.heads, MAP_PAGE_DEFAULTS.heads), + round: clampRound(c.round), + structures: bool(c.structures, MAP_PAGE_DEFAULTS.structures), + areas: bool(c.areas, MAP_PAGE_DEFAULTS.areas), + heatmap: bool(c.heatmap, MAP_PAGE_DEFAULTS.heatmap) + } +} + +/** What the caller has proved about themselves, as far as this page cares. */ +export interface MapPageViewer { + /** Presented the passphrase and holds the cookie for it. */ + passed?: boolean + /** Signed in as a linked player on the public site. */ + player?: boolean +} + +/** + * May this viewer see the map? + * + * Pure, and the single answer — the HTML route, every feed route and the smoke + * all call this one function. A page whose door is checked in one place and + * whose data is checked in another is a page that leaks its data. + */ +export function mapPageAllows(cfg: MapPageConfig, viewer: MapPageViewer): boolean { + if (!cfg.enabled || !cfg.serverId) return false + if (cfg.access === 'open') return true + if (cfg.access === 'password') return !!viewer.passed + return !!viewer.player +} + +/** + * The settings the PAGE is told about, which is not the whole config. + * + * The port is how it was reached, the server id names a machine, and the access + * mode says how the door is guarded — none of that is a visitor's business, and + * two of them are worth something to somebody probing. + */ +export interface MapPagePublic { + title: string + fixedDim: string + world: boolean + players: boolean + names: boolean + heads: boolean + structures: boolean + areas: boolean + heatmap: boolean + round: number +} + +export function mapPagePublic(cfg: MapPageConfig): MapPagePublic { + return { + title: cfg.title, + fixedDim: cfg.fixedDim, + world: cfg.world, + players: cfg.players, + names: cfg.names, + // A head identifies a player exactly as well as a name does, so drawing one + // while claiming names are hidden would be a lie (#116). + heads: cfg.heads && cfg.names, + structures: cfg.structures, + areas: cfg.areas, + heatmap: cfg.heatmap, + round: cfg.round + } +} diff --git a/src/shared/mapUi.ts b/src/shared/mapUi.ts index af967d4..bc754ac 100644 --- a/src/shared/mapUi.ts +++ b/src/shared/mapUi.ts @@ -360,6 +360,10 @@ function mapAreaAt(areas,cx,cz,dim){ (a.updatedAt===best.updatedAt&&a.id>best.id)))){best=a;bestSize=size}} return best} function mapAreasUrl(){ + /* A host may answer for itself. The map page (#146) is served from its own + listener and has neither an admin id nor the public site's routes, so + guessing from mapAdminId() would send it to a 404 on both branches. */ + if(typeof mapAreasUrlFor==='function')return mapAreasUrlFor(MAP.dim); var sid=mapAdminId(); return sid?('/api/servers/'+sid+'/areas'):'/api/public/map/areas?dim='+encodeURIComponent(MAP.dim)} function mapFetchAreas(){ diff --git a/src/shared/web.ts b/src/shared/web.ts index c568e51..27a69a0 100644 --- a/src/shared/web.ts +++ b/src/shared/web.ts @@ -1,6 +1,7 @@ import type { CrateAnimation } from './crate' import type { StoreLayout } from './storefront' import type { PublicMapConfig } from './livemap' +import type { MapPageConfig } from './mapPage' import type { ProfilePublishing } from './profile' // Per-server permission scopes for web-panel users. @@ -47,6 +48,23 @@ export interface WebConfig { * the right default for a surface authenticated with long-lived keys. */ apiOrigins?: string[] + /** + * The map page (#146): a third listener that serves one fullscreen map. + * + * Its own port rather than a path on the public site, so an operator can hand + * out the map without handing out the shop — a firewall rule rather than + * trust. See `normalizeMapPage`. + */ + mapPage?: MapPageConfig + /** + * The map page passphrase, in the clear. + * + * Deliberately not hashed: this is a shared doorcode an operator has to be + * able to read back and tell people, not a credential belonging to a person. + * Hashing it would only mean they cannot look it up, while anyone who can + * read this config file can already change it. + */ + mapPagePass?: string } export interface ListenerStatus { @@ -330,6 +348,15 @@ export interface WebStatus { bindLan: boolean panel: ListenerStatus site: ListenerStatus + /** The fullscreen map page (#146). */ + map: ListenerStatus + /** + * What the map page is set to show. Carried here so the settings UI has one + * round trip rather than two, and because every field of it is an operator + * decision rather than a secret - the passphrase lives in the config and is + * deliberately NOT part of this. + */ + mapPage: MapPageConfig /** Browser origins allowed to call the API (#50). Empty = deny all. */ apiOrigins: string[] }