DodoTransfer/lib/settings.js
2026-11-05 10:28:10 +09:00

168 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 };