From fa001657448d5f8edc978e6adce6960dc4f8f55d Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 16:47:18 +0300 Subject: [PATCH 1/2] A third listener that serves one fullscreen map (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LISTENER, not a path on the public site. `WebConfig` already carries a port and an enabled flag per surface, and following that shape here is not about symmetry: a separate port is what lets an operator hand the map to people who must not reach the shop or the 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. Not a fourth map. The page pastes `MAP_CSS`, `MAP_HTML` and `MAP_JS` from `@shared/mapUi` and restyles the wrapper - the canvas fills the window instead of sitting in a card, and the controls float over it. #129 was about three maps that had drifted apart; writing another one here would undo it. The one thing the engine needed was a hook for the areas URL, because this page has neither an admin id nor the public site's routes and would have 404ed on both branches of the guess. Three access modes: open, a shared passphrase, or a signed-in player. The gate refuses the DATA, not just the HTML - a page that gates only its markup is a page whose feed anyone can fetch directly. `mapPageAllows` is the single answer, called by the state route and every feed, so the door and the data cannot disagree. The passphrase is stored in the clear on purpose. It 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 stop them looking it up, while anyone who can read the config can already change it. The COOKIE is a hash of it and an in-memory salt, so restarting MSMS signs everyone out - a cheap way to shut a leaked link without changing the code and telling everybody the new one. What the page may show is eleven separate operator decisions, and every one of them is enforced server-side: `world: false` makes the tile route a 404, not a hidden layer. Heads are refused with names off, because a face identifies a player exactly as well as a name does (#116). Verified: 12/12 gates. The gate proved failable - disabling it answers "protected /api/map answered 200". One test defect fixed: the route-coverage check isolated `handlePanel` by slicing to `startWebServer`, which held only while `handlePanel` was the last thing before it. `handleMapPage` landed in between and its routes - a different listener, deliberately outside the /api/v1 surface - were read as undocumented panel routes. It now slices to the next top-level function, and asserts the slice is big enough to be the real router, because a slice that is too short reads exactly like success. --- src/main/ipc/register.ts | 15 +- src/main/smoke.ts | 136 ++++++++++++++++- src/main/web/mapPageHtml.ts | 151 +++++++++++++++++++ src/main/web/server.ts | 189 +++++++++++++++++++++++- src/renderer/src/locales/en.ts | 24 +++ src/renderer/src/locales/tr.ts | 24 +++ src/renderer/src/views/WebPanelView.tsx | 152 ++++++++++++++++++- src/shared/mapPage.ts | 184 +++++++++++++++++++++++ src/shared/mapUi.ts | 4 + src/shared/web.ts | 27 ++++ 10 files changed, 898 insertions(+), 8 deletions(-) create mode 100644 src/main/web/mapPageHtml.ts create mode 100644 src/shared/mapPage.ts 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..97d5ebe 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,127 @@ 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') + } + + // ---- what the operator switched off is not served ---- + setMap({ access: 'open', world: false, areas: false, players: false }) + await sleep(250) + if ((await mget('/api/map/tiles?c=0,0')).status !== 404) return fail('terrain was served with it off') + const noAreas = (await (await mget('/api/map/areas')).json()) as { areas: unknown[] } + if (noAreas.areas.length !== 0) return fail('areas were served with them off') + const noPlayers = (await (await mget('/api/map')).json()) as { players: unknown[] } + if (noPlayers.players.length !== 0) return fail('positions were served with them off') + + // Off entirely: the port closes. A listener still answering after the + // operator switched the page off is the whole feature failing quietly. + updateConfig((c) => { + 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 +7825,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..375f74b --- /dev/null +++ b/src/main/web/mapPageHtml.ts @@ -0,0 +1,151 @@ +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..29471fe 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,165 @@ 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)) + } +} + +function mapPlayerCookie(req: IncomingMessage): string { + const m = /(?:^|;\s*)msms_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 }) + } + + // 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 +2597,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 +2634,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 +2654,10 @@ export function stopWebServer(): void { siteServer.close() siteServer = null } + if (mapServer) { + mapServer.close() + mapServer = null + } } function urlsFor(port: number, bindLan: boolean): string[] { @@ -2495,6 +2668,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 +2683,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 +2700,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..60927fa 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -886,6 +886,30 @@ export default { enable: 'Enable web panel', port: 'Panel port', panelSection: 'Admin panel', + 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..ff3ffdf 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -890,6 +890,30 @@ const tr: typeof en = { enable: 'Web panelini etkinleştir', port: 'Panel portu', panelSection: 'Yönetici paneli', + 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..c768401 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,139 @@ 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 })} + /> +
+
+ {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[] } From 2b396d01148db411574dc6ad42b143c34289faae Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 16:58:58 +0300 Subject: [PATCH 2/2] Self-review: the players-only mode was a door that never opened (#146) Three access modes shipped; two had tests. The untested one did not work. `players` read the public site's session, which lives in `localStorage` under `msms_ptoken`. localStorage is per ORIGIN, and a different port IS a different origin - so the map page on 8724 could never read what the site on 8723 wrote, whatever the two agreed to call it. The mode was permanently shut, and nothing said so: the state route answered `allowed: false` exactly as it would for a visitor who simply had not signed in yet. The page signs players in itself now, against the same `playerAuth` accounts, and holds the session in a cookie on its own origin. Cookies are not isolated by port, which is a weakness elsewhere and the mechanism here. Session-length rather than the passphrase cookie's thirty days: this one stands for a person. The test signs a real account in and reads the map with the cookie a browser would send. Restoring the old cookie name fails it with "a signed-in player still could not read the map". It also asserts the NEGATIVE - that the public site's cookie name is not accepted - because reading that was the bug. Each mode's login route now 404s in the other's mode. An unused door left open is a door. Also: the page title is operator text interpolated inside a script block, and `JSON.stringify` escapes quotes while leaving `<` alone - a title containing a closing script tag ended the block and the rest of the page became markup. Escaped, and the smoke serves a page titled `evil` and checks it did not break out. The two HTML positions were already escaped; this one had been missed. And a port collision with the panel or the site is now said on screen. It used to be an EADDRINUSE in the log and a card that said "stopped". Verified: 12/12 gates. --- src/main/smoke.ts | 54 +++++++++++++++++++++++++ src/main/web/mapPageHtml.ts | 25 +++++++++++- src/main/web/server.ts | 36 ++++++++++++++++- src/renderer/src/locales/en.ts | 2 + src/renderer/src/locales/tr.ts | 2 + src/renderer/src/views/WebPanelView.tsx | 8 ++++ 6 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 97d5ebe..de4a894 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -5377,6 +5377,60 @@ export async function runWebSmoke(): Promise { 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('
+ + ${MAP_HTML.replace( '
', '
' )}