168 lines
5.2 KiB
JavaScript
168 lines
5.2 KiB
JavaScript
'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 <settingsDir> (inside the
|
||
// data volume), so they survive container re-creation.
|
||
// settings.json { name, owner, from, logo }
|
||
// logo.<ext> 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]*?-->\s*)*(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i.test(head)) {
|
||
if (/<script|javascript:|\son[a-z]+\s*=|<foreignObject|<iframe|<embed|<object/i.test(buf.toString('utf8'))) {
|
||
throw new SettingsError('SVG must not contain scripts or embedded content');
|
||
}
|
||
return { ext: 'svg', mime: 'image/svg+xml' };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function logoInfo() {
|
||
if (logoCache) return logoCache;
|
||
const s = load();
|
||
let file = DEFAULT_LOGO;
|
||
let mime = 'image/svg+xml';
|
||
if (s.logo) {
|
||
const custom = path.join(config.settingsDir, `logo.${ s.logo }`);
|
||
if (fs.existsSync(custom)) {
|
||
file = custom;
|
||
mime = { png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml' }[s.logo] || 'application/octet-stream';
|
||
}
|
||
}
|
||
const hash = createHash('sha1').update(fs.readFileSync(file)).digest('hex').slice(0, 10);
|
||
logoCache = { path: file, mime, hash, custom: file !== DEFAULT_LOGO };
|
||
return logoCache;
|
||
}
|
||
|
||
function footerText(owner, from, year = new Date().getFullYear()) {
|
||
const years = year > 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 };
|