'use strict'; const fs = require('fs'); const path = require('path'); const { createHash } = require('node:crypto'); const config = require('../config'); // Settings changed on the admin page. They live in (inside the // data volume), so they survive container re-creation. // settings.json { name, owner, from, logo } // logo. custom logo const DEFAULT_LOGO = path.join(__dirname, '../public/assets/logo.svg'); const MAX_LOGO_SIZE = 1024 * 1024; class SettingsError extends Error { constructor(message) { super(message); this.status = 400; } } let cache = null; let logoCache = null; const settingsFile = () => path.join(config.settingsDir, 'settings.json'); function load() { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(settingsFile(), 'utf8')); } catch (e) { cache = {}; } return cache; } function save() { fs.mkdirSync(config.settingsDir, { recursive: true }); const tmp = settingsFile() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(cache, null, 2)); fs.renameSync(tmp, settingsFile()); } /** Image type by signature, the file extension and the claimed type are ignored. */ function detectLogo(buf) { if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { return { ext: 'png', mime: 'image/png' }; } if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') { return { ext: 'webp', mime: 'image/webp' }; } const head = buf.subarray(0, Math.min(buf.length, 4096)).toString('utf8').replace(/^/, '').trimStart(); if (/^(<\?xml[^>]*\?>\s*)?(\s*)*(]*>\s*)?]/i.test(head)) { if (/ from ? `${ from }–${ year }` : `${ from }`; return `© ${ years }${ owner ? ' ' + owner : '' }`; } function defaults() { return { name: config.appName, owner: config.appOwner, from: config.appFromYear }; } function brand() { const s = load(); const d = defaults(); const name = s.name || d.name; const owner = s.owner !== undefined ? s.owner : d.owner; const from = s.from || d.from; const logo = logoInfo(); return { name, owner, from, footer: footerText(owner, from), logoUrl: `${ config.baseUrl }assets/brand-logo?v=${ logo.hash }`, customLogo: logo.custom, }; } function update(input = {}) { const s = load(); const next = { ...s }; if (input.name !== undefined) { const name = String(input.name).trim(); if (!name || name.length > 40) throw new SettingsError('name must be 1-40 characters'); next.name = name; } if (input.owner !== undefined) { const owner = String(input.owner).trim(); if (owner.length > 60) throw new SettingsError('owner must be at most 60 characters'); next.owner = owner; } if (input.from !== undefined) { const from = parseInt(input.from, 10); if (!Number.isInteger(from) || from < 2000 || from > 2100) throw new SettingsError('from must be a year between 2000 and 2100'); next.from = from; } cache = next; save(); return brand(); } function removeLogoFiles(except) { for (const ext of ['png', 'webp', 'svg']) { if (ext === except) continue; try { fs.unlinkSync(path.join(config.settingsDir, `logo.${ ext }`)); } catch (e) { /* not there */ } } } function setLogo(buf) { if (!Buffer.isBuffer(buf) || buf.length === 0) throw new SettingsError('no image given'); if (buf.length > MAX_LOGO_SIZE) throw new SettingsError('logo must be at most 1MB'); const type = detectLogo(buf); if (!type) throw new SettingsError('logo must be a PNG, WebP or SVG image'); fs.mkdirSync(config.settingsDir, { recursive: true }); fs.writeFileSync(path.join(config.settingsDir, `logo.${ type.ext }`), buf); removeLogoFiles(type.ext); const s = load(); cache = { ...s, logo: type.ext }; save(); logoCache = null; return brand(); } function clearLogo() { const s = load(); const { logo, ...rest } = s; cache = rest; save(); removeLogoFiles(null); logoCache = null; return brand(); } /** for tests */ function reset() { cache = null; logoCache = null; } module.exports = { brand, defaults, update, setLogo, clearLogo, logoInfo, footerText, detectLogo, reset, SettingsError, MAX_LOGO_SIZE };