// ── The logic half of configuration-from-the-site ───────────────────────── // // Everything here is about the difference between what a game host reports and // what an admin should be shown. Three jobs: // // 1. **Group a flat file list by plugin**, because one plugin can own several // files and a form that lists 40 paths is not a settings screen. // 2. **Say which file is ours, and which keys inside it are locked** (D38). The // plugin names itself in the catalogue rather than us matching a filename, // so renaming the file cannot quietly unlock the three keys that would cut // the link or split a server's history. // 3. **Decide nothing about paths.** The only process that can say whether a // path resolves inside a configuration directory is the one holding the // directory. This file checks SHAPE, so an obviously malformed request is // refused before it costs a round trip — never as a substitute for the real // check on the host. const configEdit = require('../../configEdit') /** * Keys in the bridge plugin's own config that the website may not change (D38). * * `Host` and `Port` are the link this edit is travelling over, and `ServerId` is * how every row this module has ever stored is keyed — changing it does not * rename a server, it strands its history and starts a new one under a name * nobody chose deliberately. All three are editable on the host, by a person * who is standing on it. */ const LOCKED_KEYS = ['Host', 'Port', 'ServerId'] /** What a locked field says for itself, on the screen and in a refusal. */ const LOCKED_REASON = { host: 'the website reaches this server through this address', port: 'the website reaches this server through this port', serverid: 'every row this site holds for this server is keyed to this id', } /** * A path shaped like something the host could plausibly have listed. * * Deliberately narrow and deliberately **not** the security boundary: no `..`, * nothing absolute, no drive letter, forward slashes, and it ends in `.json`. */ const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/ function isPlausiblePath(path) { return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path) } /** * Shapes the plugin's catalogue into the screen's shape: plugins, each with its * files, each file saying whether it can be edited and why not. * * A file whose guessed plugin is not loaded is kept and **marked**, not dropped. * An operator whose config for an unloaded plugin vanished from the page would * conclude the bridge cannot see it, which is a different and much more alarming * problem than the true one. */ function shapeCatalogue(catalogue) { if (!catalogue || typeof catalogue !== 'object') return null const loaded = Array.isArray(catalogue.plugins) ? catalogue.plugins : [] const byName = new Map(loaded.map((p) => [String(p.name).toLowerCase(), p])) const self = catalogue.self ? String(catalogue.self) : null const groups = new Map() for (const file of Array.isArray(catalogue.files) ? catalogue.files : []) { const plugin = String(file.plugin || 'unknown') const key = plugin.toLowerCase() if (!groups.has(key)) { const match = byName.get(key) groups.set(key, { plugin, loaded: Boolean(match), title: match ? match.title : null, version: match ? match.version : null, // The bridge plugin cannot reload itself — the reload would close the // link carrying the answer — so the screen says so up front rather than // offering a button that always refuses. isBridge: self != null && plugin.toLowerCase() === self.toLowerCase(), files: [], }) } groups.get(key).files.push({ path: String(file.path), bytes: Number(file.bytes) || 0, modified: file.modified ? Number(file.modified) : null, editable: file.editable !== false, ...(file.reason ? { reason: String(file.reason) } : {}), }) } return { root: catalogue.root ? String(catalogue.root) : null, self, truncated: Boolean(catalogue.truncated), limits: catalogue.limits || null, plugins: [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)), loaded: loaded .map((p) => ({ name: String(p.name), title: p.title || null, version: p.version || null })) .sort((a, b) => a.name.localeCompare(b.name)), } } /** Whether this file is the bridge's own config, by the name the plugin gave. */ function isBridgeConfig(path, self) { if (!self) return false const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '') return plugin.toLowerCase() === String(self).toLowerCase() } /** The locked keys for a file: three of them in our own config, none anywhere else. */ function lockedKeysFor(path, self) { return isBridgeConfig(path, self) ? LOCKED_KEYS : [] } /** * Turns one file the host sent into what the form renders. * * The text is passed through untouched. What is added is the READING of it: the * field list, which fields are locked, and which hold something a browser should * mask by default. */ function shapeFile(file, { self = null, maxDepth = 6 } = {}) { if (!file || typeof file.text !== 'string') return null const locked = lockedKeysFor(file.path, self) let fields = null let parseError = null try { fields = configEdit.describe(configEdit.scan(file.text), { maxDepth, locked }) } catch (err) { // A config already broken on disk still opens — in the raw tier, which is // the only thing that can fix it. A page that refused to show a broken file // would send somebody to SSH for the one job this feature exists to do. parseError = err.message } return { path: String(file.path), plugin: file.plugin ? String(file.plugin) : null, version: String(file.version), bytes: Number(file.bytes) || 0, modified: file.modified ? Number(file.modified) : null, text: file.text, fields, parseError, locked: locked.map((key) => ({ key, reason: LOCKED_REASON[key.toLowerCase()] || null })), isBridge: isBridgeConfig(file.path, self), } } /** * Which locked keys differ between two versions of a document. * * The form refuses a locked field by pointer, but the **raw tier submits a whole * document**, and a document can change `Port` without anything resembling an * edit to a field. So the raw tier is checked the only way it can be: by * comparing the literals before and after. * * A file that will not parse is not a way around this — an unparseable document * is refused before it gets here. */ function lockedChanges(before, after, locked) { if (!locked || locked.length === 0) return [] let a let b try { a = configEdit.scan(before) b = configEdit.scan(after) } catch { // Nothing can be compared, so nothing is cleared. The caller refuses. return locked.slice() } const literal = (root, key) => { if (root.type !== 'object') return null const node = root.children.find((c) => String(c.key).toLowerCase() === key.toLowerCase()) return node ? JSON.stringify(node.value) + ':' + (node.raw || '') : null } return locked.filter((key) => literal(a, key) !== literal(b, key)) } /** Every change a report says landed, as one line per file. */ function summariseReport(report) { if (!report || typeof report !== 'object') return null const files = Array.isArray(report.files) ? report.files : [] return { ok: report.ok !== false, reloaded: Boolean(report.reloaded), rolledBack: Boolean(report.rolledBack), reason: report.reason ? String(report.reason) : null, // The plugin only reads this on the failure path, and it is the difference // between "your change was undone" and "your change was undone BECAUSE line // 14 is not valid for that field". log: report.log ? String(report.log).slice(-4000) : null, files: files.map((f) => ({ path: String(f.path), version: f.version ? String(f.version) : null, bytes: f.bytes != null ? Number(f.bytes) : null, // Both frameworks merge missing defaults on load and save the file back, // so the file after a successful reload is regularly not the file we // wrote. Saying so keeps an operator from reading it as our bug. rewritten: Boolean(f.rewritten), })), } } module.exports = { LOCKED_KEYS, LOCKED_REASON, PATH_SHAPE, isPlausiblePath, shapeCatalogue, shapeFile, isBridgeConfig, lockedKeysFor, lockedChanges, summariseReport, }