b48fa62eea
* feat(gallery): info banner above the photo grid (#932) A short informational note rendered at the TOP of a gallery, above the photos. Distinct from the promotional banner (#440), which stays by the footer for marketing copy — the reporter's case is an onboarding hint ("use the menu button to filter"), which is useless below a gallery the guest has to scroll past first. Mirrors the promo feature's shape rather than inventing a second one: a global default in Settings → Branding (branding_info_markdown) plus a per-event inherit/custom/off override. Markdown via the existing MarkdownContent sanitiser — no raw HTML, no CSS injection. Empty global default means nothing renders, so upgrading changes nothing visible. Deliberately NOT included: an alignment knob (this is short helper copy, not marketing layout) and guest dismissal — the issue lists dismissal as a nice-to-have, and it needs per-guest persistence that is its own decision. Migration 176 is idempotent (hasColumn / existing-key guarded). Note on the payload plumbing: the per-event fields travel in the /photos response, not just /info. GalleryAuthContext seeds its cached event from the gallery LOGIN response — a small identity subset — so anything absent there is undefined right after a guest signs in. /photos is the payload that refreshes on every gallery load, which is why the fields were added there and why GalleryView reads them from `data.event`. Verified in a browser across all three modes; reading them from the context event instead silently collapsed every override back to 'inherit'. * fix(branding): map branding_info_markdown on read so saving can't wipe it (#932) External review caught this. BrandingSettings declared no info_markdown and formatBrandingSettings never mapped branding_info_markdown, so BrandingPage's hydration — setBrandingSettings(prev => ({ ...prev, ...formatted })) — kept the empty-string initializer instead of the persisted value. The form loaded blank and the next Save posted '' back, wiping a configured banner. Silently: the gallery keeps rendering the old copy until that save lands. This is the same bug the footer/promo fields hit in #441 + #440 / #460, which the read mapper still carries a comment about. Add the field to the interface and the mapper, and pin the round-trip for the whole editable branding set so the next field added is caught by a test rather than by a user losing copy. Verified: the new test fails 3/4 with the mapper line removed. * fix(gallery): honour the info-banner override in the reveal-hidden view (#932) External review, round 2. The hidden-until-reveal branch renders GalleryLayout with the context `event`, which is seeded from the gallery login response and carries no banner fields — so while a gallery was hidden, a per-event 'off' silently resolved to 'inherit' and the global banner appeared on a gallery the admin had muted. Resolve the fields there the same way the main render path does. The two full-page layouts (gallery-premium, gallery-story) are deliberately left alone: they return before GalleryLayout and render no header, footer or promo banner either — injecting a wrapper into layouts documented as having 'their own integrated UI' would be a design change, not a fix. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2056 lines
82 KiB
JavaScript
2056 lines
82 KiB
JavaScript
const express = require('express');
|
||
const multer = require('multer');
|
||
const path = require('path');
|
||
const fs = require('fs').promises;
|
||
const { body, validationResult } = require('express-validator');
|
||
const { db, logActivity } = require('../database/db');
|
||
const { formatBoolean } = require('../utils/dbCompat');
|
||
const { adminAuth } = require('../middleware/auth');
|
||
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
|
||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||
const {
|
||
DEFAULT_PUBLIC_SITE_HTML,
|
||
DEFAULT_PUBLIC_SITE_CSS,
|
||
} = require('../constants/publicSiteDefaults');
|
||
const {
|
||
clearPublicSiteCache,
|
||
getDefaultPublicSitePayload,
|
||
getRawPublicSiteSettings,
|
||
} = require('../services/publicSiteService');
|
||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||
const { upsertAppSetting } = require('../utils/appSettings');
|
||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||
const { errorResponse } = require('../utils/routeHelpers');
|
||
const logger = require('../utils/logger');
|
||
const router = express.Router();
|
||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD, clearMaxFileSizeCache, MAX_ALLOWED_FILE_SIZE_MB } = require('../services/uploadSettings');
|
||
const watermarkService = require('../services/watermarkService');
|
||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||
|
||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||
|
||
// Reserved first-run bootstrap keys — never writable through the generic
|
||
// settings upserts in this file: setup_wizard_completed is a one-way marker
|
||
// (#800; writing false would reopen system-event-type deletion) and
|
||
// setup_token is the first-run bootstrap secret. Every handler that loops
|
||
// arbitrary request keys into app_settings must strip these first.
|
||
// oidc_client_secret is reserved too: it is AES-encrypted at rest and only
|
||
// writable through PUT /sso below — a generic upsert would store plaintext
|
||
// and break decryption (#798).
|
||
// Branding *path* keys (GHSA-665x) are server-computed by the logo-upload
|
||
// flow and feed a filesystem logo resolver; letting the general settings PUT
|
||
// set them to arbitrary strings makes them an input to path resolution.
|
||
// Reserve them here — the dedicated upload endpoints still write them.
|
||
const RESERVED_SETTING_KEYS = [
|
||
'setup_wizard_completed', 'setup_token',
|
||
'branding_logo_path', 'branding_logo_path_dark', 'branding_watermark_logo_path',
|
||
];
|
||
// EVERY oidc_* key is reserved (#798 phase 2): the client secret would be
|
||
// clobbered with plaintext, and the policy/mapping keys carry invariants
|
||
// (role targets exist, break-glass account present) that only the dedicated
|
||
// PUT /sso validates — a generic upsert would bypass all of them.
|
||
// Every download_* key is reserved too (#858): the cached download-all zip is
|
||
// built AT the standard resolution, so changing it has to invalidate those
|
||
// zips and re-validate the value against the preset list. A generic upsert
|
||
// would do neither, leaving galleries handing out archives at the old size.
|
||
const isReservedSettingKey = (key) => RESERVED_SETTING_KEYS.includes(key)
|
||
|| key.startsWith('oidc_')
|
||
|| key.startsWith('download_');
|
||
const stripReservedSettingKeys = (settings) => {
|
||
for (const key of Object.keys(settings)) {
|
||
if (isReservedSettingKey(key)) delete settings[key];
|
||
}
|
||
return settings;
|
||
};
|
||
|
||
// Migration 174 hardening — per-key permission boundary for the GENERIC settings
|
||
// writers. /general, /analytics, /seo and /security all upsert arbitrary
|
||
// setting_keys, so without this a role holding only the broad `settings.edit`
|
||
// (or `settings.security`) could set keys owned by a NARROWER permission —
|
||
// repointing the public site URL, security policy, or VAT/accounting config —
|
||
// via the wrong endpoint, defeating the settings.edit split. Any protected key
|
||
// the caller isn't permitted to write is stripped before the upsert. The
|
||
// dedicated routes still work because their caller holds the matching perm
|
||
// (e.g. PUT /accounting is gated by settings.banking, so accounting_* survives).
|
||
const PROTECTED_SETTING_KEY_PERMS = [
|
||
{ match: (k) => k === 'general_site_url', perm: 'settings.domains' },
|
||
{ match: (k) => k.startsWith('security_'), perm: 'settings.security' },
|
||
{ match: (k) => k.startsWith('accounting_'), perm: 'settings.banking' },
|
||
];
|
||
// Returns the list of {key, perm} the caller tried to CHANGE without the owning
|
||
// permission. Callers 403 when it's non-empty rather than silently no-op'ing a
|
||
// permission boundary. Change-detection matters: the General tab re-posts
|
||
// general_site_url on every save, so a no-op round-trip of the stored value must
|
||
// not 403 an otherwise-safe settings.edit save (the office-manager role this PR
|
||
// exists to enable) — only an actual change is rejected. A denied key the caller
|
||
// couldn't change is left in `settings` (the request 403s before the upsert); an
|
||
// unauthorized no-op is dropped so the rest of the save proceeds.
|
||
const collectUnauthorizedProtectedKeys = async (settings, adminId) => {
|
||
const denied = [];
|
||
for (const key of Object.keys(settings)) {
|
||
const rule = PROTECTED_SETTING_KEY_PERMS.find((r) => r.match(key));
|
||
if (!rule) continue;
|
||
if (await userHasAnyPermission(adminId, [rule.perm])) continue;
|
||
const row = await db('app_settings').where({ setting_key: key }).first();
|
||
let stored = null;
|
||
if (row) {
|
||
try { stored = JSON.parse(row.setting_value); } catch (_) { stored = row.setting_value; }
|
||
}
|
||
if (String(stored ?? '') === String(settings[key] ?? '')) {
|
||
delete settings[key]; // unchanged — let the rest of the save through
|
||
continue;
|
||
}
|
||
denied.push({ key, perm: rule.perm });
|
||
}
|
||
return denied;
|
||
};
|
||
// Express helper: 403 (naming the keys + required perms) when the caller tried
|
||
// to write a protected key they don't hold; returns true if the request was
|
||
// rejected so the route can stop.
|
||
const rejectUnauthorizedProtectedKeys = async (settings, req, res) => {
|
||
const denied = await collectUnauthorizedProtectedKeys(settings, req.admin.id);
|
||
if (denied.length > 0) {
|
||
res.status(403).json({
|
||
error: `You don't have permission to change: ${denied.map((d) => d.key).join(', ')}`,
|
||
code: 'FORBIDDEN',
|
||
keys: denied,
|
||
});
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Configure multer for logo uploads
|
||
const storage = multer.diskStorage({
|
||
destination: async (req, file, cb) => {
|
||
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||
await fs.mkdir(uploadDir, { recursive: true });
|
||
cb(null, uploadDir);
|
||
},
|
||
filename: (req, file, cb) => {
|
||
const ext = path.extname(file.originalname);
|
||
cb(null, `logo-${Date.now()}${ext}`);
|
||
}
|
||
});
|
||
|
||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||
|
||
const upload = multer({
|
||
storage,
|
||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||
fileFilter: (req, file, cb) => {
|
||
// Note: SVG files are excluded from magic number validation for logos
|
||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||
|
||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||
return cb(null, true);
|
||
} else {
|
||
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||
}
|
||
}
|
||
});
|
||
|
||
// Configure multer for favicon uploads
|
||
const faviconStorage = multer.diskStorage({
|
||
destination: async (req, file, cb) => {
|
||
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||
await fs.mkdir(uploadDir, { recursive: true });
|
||
cb(null, uploadDir);
|
||
},
|
||
filename: (req, file, cb) => {
|
||
const ext = path.extname(file.originalname);
|
||
cb(null, `favicon-${Date.now()}${ext}`);
|
||
}
|
||
});
|
||
|
||
const faviconUpload = multer({
|
||
storage: faviconStorage,
|
||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
|
||
fileFilter: (req, file, cb) => {
|
||
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
|
||
const name = file.originalname.toLowerCase();
|
||
|
||
// For ICO files, we can't use the standard validateFileType
|
||
if (file.mimetype === 'image/png') {
|
||
if (validateFileType(file.originalname, file.mimetype, ['image/png'])) {
|
||
cb(null, true);
|
||
} else {
|
||
cb(new Error('Invalid PNG file'));
|
||
}
|
||
} else if (file.mimetype === 'image/svg+xml' && name.endsWith('.svg')) {
|
||
// SVG favicons are supported by modern browsers and are crisp at any
|
||
// size. Served SVGs are CSP-locked (no script execution) by the
|
||
// secureStatic middleware, so an admin-uploaded SVG is render-only.
|
||
cb(null, true);
|
||
} else if (allowedMimeTypes.includes(file.mimetype) &&
|
||
(name.endsWith('.ico') || name.endsWith('.png'))) {
|
||
cb(null, true);
|
||
} else {
|
||
cb(new Error('Favicon must be PNG, ICO, or SVG format'));
|
||
}
|
||
}
|
||
});
|
||
|
||
// Get all settings, or a subset when ?keys=k1,k2,… is supplied.
|
||
// Many caller pages only need a handful of keys (e.g. ReminderTemplates
|
||
// reads 2 of the ~100 rows). The keys filter is allowlist-bounded by
|
||
// what's stored, so passing unknown keys just returns them as `null`
|
||
// — no enumeration risk beyond what GET / returned already.
|
||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
const keysParam = typeof req.query.keys === 'string' ? req.query.keys : null;
|
||
const keysFilter = keysParam
|
||
? keysParam.split(',').map((k) => k.trim()).filter(Boolean).slice(0, 100)
|
||
: null;
|
||
|
||
const query = db('app_settings').select('*');
|
||
if (keysFilter && keysFilter.length > 0) {
|
||
query.whereIn('setting_key', keysFilter);
|
||
}
|
||
const settings = await query;
|
||
|
||
// Convert to object format
|
||
const settingsObject = {};
|
||
settings.forEach(setting => {
|
||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||
// If the value is already parsed (from json column), use it directly
|
||
if (typeof setting.setting_value !== 'string') {
|
||
settingsObject[setting.setting_key] = setting.setting_value;
|
||
} else {
|
||
try {
|
||
// Try to parse as JSON first
|
||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||
} catch (e) {
|
||
// If it's not valid JSON, use the raw value
|
||
settingsObject[setting.setting_key] = setting.setting_value;
|
||
}
|
||
}
|
||
} else {
|
||
settingsObject[setting.setting_key] = null;
|
||
}
|
||
});
|
||
|
||
// Reserved bootstrap/credential keys are NEVER readable through the
|
||
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||
stripReservedSettingKeys(settingsObject);
|
||
|
||
// Mask sensitive secrets before sending to client
|
||
if (settingsObject.security_recaptcha_secret_key) {
|
||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||
}
|
||
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
|
||
// were returned in plaintext to any settings.view holder. Same masking
|
||
// pattern as the recaptcha/umami/rybbit keys; the dedicated
|
||
// /admin/backup/config endpoints handle the edit round-trip.
|
||
if (settingsObject.backup_s3_secret_key) {
|
||
settingsObject.backup_s3_secret_key = '••••••••';
|
||
}
|
||
if (settingsObject.backup_rsync_ssh_key) {
|
||
settingsObject.backup_rsync_ssh_key = '••••••••';
|
||
}
|
||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||
// outbound calls to the operator's Umami instance for the device
|
||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||
if (settingsObject.analytics_umami_api_key) {
|
||
settingsObject.analytics_umami_api_key = '••••••••';
|
||
}
|
||
// Rybbit API key (#663 Phase 1) — same pattern.
|
||
if (settingsObject.analytics_rybbit_api_key) {
|
||
settingsObject.analytics_rybbit_api_key = '••••••••';
|
||
}
|
||
|
||
res.json(settingsObject);
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to fetch settings');
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Customer-surface branding settings (#354 follow-up).
|
||
*
|
||
* Two toggles control what shows in the customer dashboard header:
|
||
* customer_show_logo (default true)
|
||
* customer_show_company_name (default true)
|
||
*
|
||
* The Calendar / Quotes / Bills feature globals that used to live here
|
||
* have moved to the maintainer's Features tab (feature_flags table).
|
||
*
|
||
* IMPORTANT: both routes MUST be registered before the generic
|
||
* `router.get('/:type', ...)` below — Express matches routes in
|
||
* registration order.
|
||
*/
|
||
router.get('/customer-surface', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
const rows = await db('app_settings')
|
||
.where('setting_type', 'customer_surface')
|
||
.select('setting_key', 'setting_value');
|
||
|
||
const settings = {};
|
||
for (const r of rows) {
|
||
let value = r.setting_value;
|
||
if (value === null || value === undefined) {
|
||
settings[r.setting_key] = null;
|
||
continue;
|
||
}
|
||
if (typeof value !== 'string') {
|
||
settings[r.setting_key] = value;
|
||
} else {
|
||
try { settings[r.setting_key] = JSON.parse(value); }
|
||
catch { settings[r.setting_key] = value; }
|
||
}
|
||
}
|
||
|
||
res.json(settings);
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to fetch customer surface settings');
|
||
}
|
||
});
|
||
|
||
router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
// Branding-only whitelist (calendar/quotes/bills feature globals
|
||
// moved to the Features tab / feature_flags table).
|
||
const allowed = [
|
||
'customer_show_logo',
|
||
'customer_show_company_name',
|
||
];
|
||
const updates = [];
|
||
for (const key of allowed) {
|
||
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
|
||
const value = !!req.body[key];
|
||
updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'customer_surface' });
|
||
}
|
||
}
|
||
|
||
for (const u of updates) {
|
||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
||
}
|
||
|
||
// Clear the public-site cache so any consumer relying on it
|
||
// (e.g. customer login footer if it picks these up) refetches.
|
||
clearPublicSiteCache();
|
||
|
||
res.json({ message: 'Customer surface settings updated', updated: updates.map((u) => u.setting_key) });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to save customer surface settings');
|
||
}
|
||
});
|
||
|
||
// Accounting settings (km rate, per-diem rate, require-proof). Read via the
|
||
// generic GET /:type ('accounting'); this is the typed write. Rates are
|
||
// integer minor units; verify legal/tax guidance with a Treuhaender.
|
||
// Migration 174: VAT/accounting config is money-adjacent → settings.banking.
|
||
router.put('/accounting', adminAuth, requirePermission('settings.banking'), async (req, res) => {
|
||
try {
|
||
const updates = [];
|
||
const setInt = (key) => {
|
||
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
|
||
const n = Math.max(0, Math.round(Number(req.body[key]) || 0));
|
||
updates.push({ setting_key: key, setting_value: JSON.stringify(n), setting_type: 'accounting' });
|
||
}
|
||
};
|
||
setInt('accounting_km_rate_minor');
|
||
setInt('accounting_per_diem_rate_minor');
|
||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_require_proof')) {
|
||
updates.push({
|
||
setting_key: 'accounting_require_proof',
|
||
setting_value: JSON.stringify(!!req.body.accounting_require_proof),
|
||
setting_type: 'accounting',
|
||
});
|
||
}
|
||
// Global default for "attach the supplier proof PDF to the client-invoice
|
||
// email when a re-bill/passthrough is issued" (issue #866). Off by default;
|
||
// a per-customer override (customer_accounts.rebill_attach_proof) and the
|
||
// per-file selection in the Send dialog both build on top of this default.
|
||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_rebill_attach_proof')) {
|
||
updates.push({
|
||
setting_key: 'accounting_rebill_attach_proof',
|
||
setting_value: JSON.stringify(!!req.body.accounting_rebill_attach_proof),
|
||
setting_type: 'accounting',
|
||
});
|
||
}
|
||
// Filename template for the attached supplier proof (like the invoice/quote
|
||
// number formats). Tokens: {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd}.
|
||
// Empty falls back to the default at render time.
|
||
if (Object.prototype.hasOwnProperty.call(req.body, 'crm_rebill_proof_filename_format')) {
|
||
const fmt = String(req.body.crm_rebill_proof_filename_format || '').trim().slice(0, 120);
|
||
updates.push({
|
||
setting_key: 'crm_rebill_proof_filename_format',
|
||
setting_value: JSON.stringify(fmt),
|
||
setting_type: 'accounting',
|
||
});
|
||
}
|
||
// VAT registration + reclaim. `registered` drives whether output VAT applies
|
||
// + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of
|
||
// countries whose input VAT can be reclaimed (drives cost tax-treatment +
|
||
// the report's VAT-payable).
|
||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_registered')) {
|
||
updates.push({
|
||
setting_key: 'accounting_vat_registered',
|
||
setting_value: JSON.stringify(!!req.body.accounting_vat_registered),
|
||
setting_type: 'accounting',
|
||
});
|
||
}
|
||
// Default OUTPUT VAT code stamped onto NEW invoices/quotes (the editor
|
||
// seeds its VAT picker from it). Stored as the code string; '' clears it.
|
||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_default_output_vat_code')) {
|
||
const code = String(req.body.accounting_default_output_vat_code || '').trim().slice(0, 16);
|
||
updates.push({
|
||
setting_key: 'accounting_default_output_vat_code',
|
||
setting_value: JSON.stringify(code),
|
||
setting_type: 'accounting',
|
||
});
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_reclaim_countries')) {
|
||
const arr = Array.isArray(req.body.accounting_vat_reclaim_countries)
|
||
? req.body.accounting_vat_reclaim_countries
|
||
.map((c) => String(c || '').toUpperCase().trim())
|
||
.filter((c) => /^[A-Z]{2}$/.test(c))
|
||
: [];
|
||
updates.push({
|
||
setting_key: 'accounting_vat_reclaim_countries',
|
||
setting_value: JSON.stringify(arr),
|
||
setting_type: 'accounting',
|
||
});
|
||
}
|
||
for (const u of updates) {
|
||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
||
}
|
||
res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to save accounting settings');
|
||
}
|
||
});
|
||
|
||
// Global Live Slideshow defaults (migration 139). The per-event watermark is
|
||
// tri-state (events.show_watermark NULL = inherit these). Read via the generic
|
||
// GET /:type ('slideshow'); this is the typed write.
|
||
router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const updates = [];
|
||
const push = (key, value) => updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'slideshow' });
|
||
const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k);
|
||
|
||
if (has('slideshow_fit')) {
|
||
push('slideshow_fit', req.body.slideshow_fit === 'contain' ? 'contain' : 'cover');
|
||
}
|
||
// Picpeak-wide display preset (default style new events inherit).
|
||
if (has('slideshow_interval_ms')) {
|
||
const n = Math.min(120000, Math.max(1000, Math.round(Number(req.body.slideshow_interval_ms) || 5000)));
|
||
push('slideshow_interval_ms', n);
|
||
}
|
||
if (has('slideshow_transition')) {
|
||
const allowed = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||
push('slideshow_transition', allowed.includes(req.body.slideshow_transition) ? req.body.slideshow_transition : 'crossfade');
|
||
}
|
||
if (has('slideshow_transition_ms')) {
|
||
const n = Math.min(5000, Math.max(100, Math.round(Number(req.body.slideshow_transition_ms) || 800)));
|
||
push('slideshow_transition_ms', n);
|
||
}
|
||
if (has('slideshow_colorfilter')) {
|
||
const allowed = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||
push('slideshow_colorfilter', allowed.includes(req.body.slideshow_colorfilter) ? req.body.slideshow_colorfilter : 'none');
|
||
}
|
||
if (has('slideshow_watermark_enabled')) push('slideshow_watermark_enabled', !!req.body.slideshow_watermark_enabled);
|
||
if (has('slideshow_watermark_source')) {
|
||
const v = ['logo', 'logo_dark', 'favicon', 'event'].includes(req.body.slideshow_watermark_source) ? req.body.slideshow_watermark_source : 'logo';
|
||
push('slideshow_watermark_source', v);
|
||
}
|
||
if (has('slideshow_watermark_position')) {
|
||
const allowed = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||
const v = allowed.includes(req.body.slideshow_watermark_position) ? req.body.slideshow_watermark_position : 'bottom-right';
|
||
push('slideshow_watermark_position', v);
|
||
}
|
||
if (has('slideshow_watermark_opacity')) {
|
||
const n = Math.min(100, Math.max(0, Math.round(Number(req.body.slideshow_watermark_opacity) || 0)));
|
||
push('slideshow_watermark_opacity', n);
|
||
}
|
||
if (has('slideshow_watermark_style')) {
|
||
const v = ['white', 'original'].includes(req.body.slideshow_watermark_style) ? req.body.slideshow_watermark_style : 'white';
|
||
push('slideshow_watermark_style', v);
|
||
}
|
||
if (has('slideshow_watermark_size')) {
|
||
const n = Math.min(40, Math.max(3, Math.round(Number(req.body.slideshow_watermark_size) || 12)));
|
||
push('slideshow_watermark_size', n);
|
||
}
|
||
// QR overlay (#837) — same option shape as the watermark.
|
||
if (has('slideshow_qr_enabled')) push('slideshow_qr_enabled', !!req.body.slideshow_qr_enabled);
|
||
if (has('slideshow_qr_position')) {
|
||
const allowed = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||
const v = allowed.includes(req.body.slideshow_qr_position) ? req.body.slideshow_qr_position : 'bottom-left';
|
||
push('slideshow_qr_position', v);
|
||
}
|
||
if (has('slideshow_qr_opacity')) {
|
||
const n = Math.min(100, Math.max(0, Math.round(Number(req.body.slideshow_qr_opacity) || 0)));
|
||
push('slideshow_qr_opacity', n);
|
||
}
|
||
if (has('slideshow_qr_size')) {
|
||
const n = Math.min(40, Math.max(5, Math.round(Number(req.body.slideshow_qr_size) || 14)));
|
||
push('slideshow_qr_size', n);
|
||
}
|
||
|
||
for (const u of updates) {
|
||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
||
}
|
||
// Drop the slideshow-globals cache so a running projector picks up the
|
||
// change on its next poll rather than after the 5s TTL.
|
||
require('../utils/slideshowGlobals').invalidateSlideshowGlobals();
|
||
res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to save slideshow settings');
|
||
}
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
// Download resolutions (#858). The standard resolution is what every ordinary
|
||
// download hands out; the picker is an opt-in modal letting guests choose a
|
||
// different size. Dedicated endpoints because a change here has to invalidate
|
||
// the pre-built download-all zips, which are built AT the standard resolution.
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
|
||
router.get('/downloads', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
const { getDownloadGlobals } = require('../utils/downloadResolutions');
|
||
res.json(await getDownloadGlobals());
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to load download settings');
|
||
}
|
||
});
|
||
|
||
router.put('/downloads', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const {
|
||
invalidateDownloadGlobals, getDownloadGlobals, ORIGINAL,
|
||
} = require('../utils/downloadResolutions');
|
||
const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k);
|
||
const updates = [];
|
||
const push = (key, value) => updates.push({
|
||
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'download',
|
||
});
|
||
|
||
// Presets first — the standard is validated against the resulting list,
|
||
// so a single request can add a size and select it in one go.
|
||
const before = await getDownloadGlobals();
|
||
const previousStandard = before.standard_resolution;
|
||
let presets = before.resolutions;
|
||
if (has('download_resolutions')) {
|
||
const raw = Array.isArray(req.body.download_resolutions) ? req.body.download_resolutions : [];
|
||
const cleaned = [];
|
||
const seen = new Set();
|
||
for (const p of raw) {
|
||
const width = Math.round(Number(p?.width));
|
||
const height = Math.round(Number(p?.height));
|
||
// 20000px ceiling keeps a typo ("30000000") from asking sharp for a
|
||
// multi-terabyte canvas on every subsequent download.
|
||
if (!width || !height || width < 1 || height < 1 || width > 20000 || height > 20000) continue;
|
||
const id = `${width}x${height}`;
|
||
if (seen.has(id)) continue;
|
||
seen.add(id);
|
||
cleaned.push({ label: String(p.label || id).slice(0, 40), width, height });
|
||
}
|
||
if (cleaned.length === 0) {
|
||
return res.status(400).json({ error: 'At least one valid resolution is required' });
|
||
}
|
||
push('download_resolutions', cleaned);
|
||
presets = cleaned.map((p) => ({ ...p, id: `${p.width}x${p.height}` }));
|
||
}
|
||
|
||
if (has('download_standard_resolution')) {
|
||
const v = String(req.body.download_standard_resolution || ORIGINAL);
|
||
if (v !== ORIGINAL && !presets.some((p) => p.id === v)) {
|
||
return res.status(400).json({ error: `Unknown resolution "${v}"` });
|
||
}
|
||
push('download_standard_resolution', v);
|
||
} else if (has('download_resolutions')) {
|
||
// Replacing the preset list without naming a standard can orphan the
|
||
// CURRENT standard — galleries would keep handing out a size the picker
|
||
// no longer offers, breaking the "standard is always a preset" invariant.
|
||
if (previousStandard !== ORIGINAL && !presets.some((p) => p.id === previousStandard)) {
|
||
return res.status(400).json({
|
||
error: `The current standard resolution "${previousStandard}" is not in the new list — set download_standard_resolution in the same request`,
|
||
});
|
||
}
|
||
}
|
||
if (has('download_resolution_picker_enabled')) {
|
||
push('download_resolution_picker_enabled', !!req.body.download_resolution_picker_enabled);
|
||
}
|
||
if (has('download_allow_original')) {
|
||
push('download_allow_original', !!req.body.download_allow_original);
|
||
}
|
||
|
||
for (const u of updates) {
|
||
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
|
||
}
|
||
invalidateDownloadGlobals();
|
||
|
||
// The cached download-all zip is built at the standard resolution, so a
|
||
// change to the GLOBAL standard makes every INHERITING gallery's zip
|
||
// stale. Events with their own override are unaffected and keep theirs.
|
||
// Only a REAL change to the standard invalidates. The settings form
|
||
// submits every field on every save, so keying off "was it present" would
|
||
// schedule a rebuild of every inheriting gallery each time an admin
|
||
// renamed a preset — a stampede on installs with many galleries.
|
||
const standardUpdate = updates.find((u) => u.setting_key === 'download_standard_resolution');
|
||
const standardChanged = standardUpdate
|
||
&& JSON.parse(standardUpdate.setting_value) !== previousStandard;
|
||
|
||
let invalidatedZips = 0;
|
||
if (standardChanged) {
|
||
// Every inheriting event, whether or not it currently HAS a cached zip:
|
||
// one may be mid-build against the old standard right now. Going through
|
||
// downloadZipService.invalidate bumps its generation counter, which
|
||
// aborts that build — a raw UPDATE would let it finish and re-publish a
|
||
// permanently stale archive.
|
||
const downloadZipService = require('../services/downloadZipService');
|
||
const inheriting = await db('events')
|
||
.whereNull('download_standard_resolution')
|
||
.select('id');
|
||
for (const ev of inheriting) {
|
||
downloadZipService.invalidate(ev.id);
|
||
}
|
||
invalidatedZips = inheriting.length;
|
||
}
|
||
|
||
res.json({
|
||
message: 'Download settings updated',
|
||
updated: updates.map((u) => u.setting_key),
|
||
invalidated_zips: invalidatedZips,
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to save download settings');
|
||
}
|
||
});
|
||
|
||
// Get settings by type
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
|
||
// because the client secret must be encrypted at rest and never echoed back.
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
|
||
// Read the SSO config. The secret is redacted to a set/unset flag; the
|
||
// computed redirect URI is included for copy-paste into the IdP client.
|
||
// Migration 174: SSO/OIDC + security config → settings.security.
|
||
router.get('/sso', adminAuth, requirePermission(['settings.view', 'settings.security']), async (req, res) => {
|
||
try {
|
||
const oidcService = require('../services/oidcService');
|
||
const cfg = await oidcService.getOidcConfig();
|
||
// No public base URL configured → surface an empty redirect_uri rather
|
||
// than failing the whole settings read; the login route refuses to start
|
||
// the flow in that state anyway (OIDC_BAD_CONFIG).
|
||
const redirectUri = await oidcService.getRedirectUri().catch(() => '');
|
||
const postLogoutRedirectUri = await oidcService.getPostLogoutRedirectUri().catch(() => '');
|
||
res.json({
|
||
oidc_enabled: cfg.enabled,
|
||
oidc_issuer_url: cfg.issuerUrl || '',
|
||
oidc_client_id: cfg.clientId || '',
|
||
oidc_client_secret_set: Boolean(cfg.clientSecret),
|
||
oidc_autoprovision: cfg.autoprovision,
|
||
oidc_default_role: cfg.defaultRole,
|
||
oidc_button_label: cfg.buttonLabel || '',
|
||
oidc_scopes: cfg.scopes,
|
||
oidc_role_mapping_enabled: cfg.roleMappingEnabled,
|
||
oidc_roles_claim: cfg.rolesClaim,
|
||
oidc_role_mappings: cfg.roleMappings,
|
||
oidc_require_mapped_role: cfg.requireMappedRole,
|
||
oidc_disable_local_login: cfg.disableLocalLogin,
|
||
oidc_logout_from_idp: cfg.logoutFromIdp,
|
||
redirect_uri: redirectUri,
|
||
post_logout_redirect_uri: postLogoutRedirectUri,
|
||
});
|
||
} catch (error) {
|
||
logger.error('Failed to read SSO settings', { error: error.message });
|
||
res.status(500).json({ error: 'Failed to read SSO settings' });
|
||
}
|
||
});
|
||
|
||
router.put('/sso', adminAuth, requirePermission('settings.security'), [
|
||
body('oidc_enabled').optional().isBoolean(),
|
||
body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }),
|
||
body('oidc_client_id').optional().isString().trim(),
|
||
body('oidc_client_secret').optional().isString(),
|
||
body('oidc_autoprovision').optional().isBoolean(),
|
||
body('oidc_default_role').optional().isString().trim(),
|
||
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
|
||
body('oidc_scopes').optional().isString().trim(),
|
||
body('oidc_role_mapping_enabled').optional().isBoolean(),
|
||
body('oidc_roles_claim').optional().isString().trim().isLength({ max: 200 }),
|
||
body('oidc_role_mappings').optional().isObject(),
|
||
body('oidc_require_mapped_role').optional().isBoolean(),
|
||
body('oidc_disable_local_login').optional().isBoolean(),
|
||
body('oidc_logout_from_idp').optional().isBoolean(),
|
||
], async (req, res) => {
|
||
try {
|
||
const errors = validationResult(req);
|
||
if (!errors.isEmpty()) {
|
||
return res.status(400).json({ errors: errors.array() });
|
||
}
|
||
const oidcService = require('../services/oidcService');
|
||
|
||
// Validate the MERGED resulting state, not just the request: enabling
|
||
// requires a complete config, and a partial PUT must not be able to
|
||
// blank the issuer/client while a stored enabled=true keeps a login
|
||
// button alive that can only fail.
|
||
const current = await oidcService.getOidcConfig();
|
||
const effectiveEnabled = req.body.oidc_enabled ?? current.enabled;
|
||
if (effectiveEnabled === true) {
|
||
const issuer = req.body.oidc_issuer_url ?? current.issuerUrl;
|
||
const clientId = req.body.oidc_client_id ?? current.clientId;
|
||
const secretPresent = (typeof req.body.oidc_client_secret === 'string' && req.body.oidc_client_secret.length > 0)
|
||
|| Boolean(current.clientSecret);
|
||
if (!issuer || !clientId || !secretPresent) {
|
||
return res.status(400).json({ error: 'Issuer URL, client ID and client secret must be configured while SSO is enabled — disable SSO first to clear them' });
|
||
}
|
||
// The redirect URI must be derivable too, or the login button leads
|
||
// straight to an error (needs API_URL / FRONTEND_URL / general_site_url).
|
||
try {
|
||
await oidcService.getRedirectUri();
|
||
} catch (err) {
|
||
return res.status(400).json({ error: err.message });
|
||
}
|
||
}
|
||
|
||
// Default role must exist — a typo here would brick JIT provisioning.
|
||
if (req.body.oidc_default_role !== undefined) {
|
||
const role = await db('roles').where('name', req.body.oidc_default_role).first();
|
||
if (!role) {
|
||
return res.status(400).json({ error: `Unknown role: ${req.body.oidc_default_role}` });
|
||
}
|
||
}
|
||
|
||
// Every role-mapping target must exist too (#798 phase 2) — a typo'd
|
||
// role name would silently map users to nothing.
|
||
if (req.body.oidc_role_mappings !== undefined) {
|
||
const targets = [...new Set(Object.values(req.body.oidc_role_mappings).map((r) => String(r).trim()).filter(Boolean))];
|
||
if (targets.length > 0) {
|
||
const known = (await db('roles').whereIn('name', targets)).map((r) => r.name);
|
||
const unknown = targets.filter((t) => !known.includes(t));
|
||
if (unknown.length > 0) {
|
||
return res.status(400).json({ error: `Unknown role(s) in mapping: ${unknown.join(', ')}` });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Turning OFF local login requires SSO to be (staying) enabled. Only the
|
||
// explicit request is checked — a stored true must never block disabling
|
||
// SSO itself (runtime enforcement ignores the flag while SSO is off or
|
||
// unconfigured, and OIDC_BREAK_GLASS=true always re-opens local login).
|
||
if (req.body.oidc_disable_local_login === true && effectiveEnabled !== true) {
|
||
return res.status(400).json({ error: 'Local login can only be disabled while SSO is enabled' });
|
||
}
|
||
|
||
// …and an active LOCAL-password super_admin must exist as the break-glass
|
||
// account: OIDC_BREAK_GLASS only re-opens the password route, but
|
||
// OIDC-owned accounts are refused there and carry unusable random hashes.
|
||
// settings.edit is super_admin-only, so a lesser local account couldn't
|
||
// fix the SSO config either — without this check an all-OIDC instance
|
||
// would be unrecoverable during an IdP outage. Checked on the MERGED
|
||
// state, not just the request: re-enabling SSO while a stored true flag
|
||
// re-arms SSO-only mode just as much as setting the flag itself.
|
||
const effectiveDisableLocal = req.body.oidc_disable_local_login ?? current.disableLocalLogin;
|
||
if (effectiveDisableLocal === true && effectiveEnabled === true) {
|
||
if (!(await oidcService.hasActiveLocalSuperAdmin())) {
|
||
return res.status(400).json({ error: 'Disabling local login requires at least one active Super Admin with a local password (the break-glass account)' });
|
||
}
|
||
}
|
||
|
||
await oidcService.saveOidcSettings(req.body);
|
||
|
||
await logActivity('sso_settings_updated',
|
||
{ changes: Object.keys(req.body).filter((k) => k !== 'oidc_client_secret') },
|
||
null,
|
||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||
);
|
||
|
||
res.json({ message: 'SSO settings saved' });
|
||
} catch (error) {
|
||
logger.error('Failed to save SSO settings', { error: error.message });
|
||
res.status(500).json({ error: 'Failed to save SSO settings' });
|
||
}
|
||
});
|
||
|
||
// Server-side discovery probe: confirms the issuer is reachable and speaks
|
||
// OIDC before the admin flips the enable toggle. Uses the SAVED config.
|
||
router.post('/sso/test', adminAuth, requirePermission('settings.security'), async (req, res) => {
|
||
try {
|
||
const oidcService = require('../services/oidcService');
|
||
const cfg = await oidcService.getOidcConfig();
|
||
if (!oidcService.isConfigured(cfg)) {
|
||
return res.status(400).json({ ok: false, error: 'Issuer URL, client ID and client secret must be saved first' });
|
||
}
|
||
oidcService.invalidateDiscoveryCache();
|
||
const { issuerMetadata } = await oidcService.getClient(cfg);
|
||
res.json({
|
||
ok: true,
|
||
issuer: issuerMetadata.issuer,
|
||
authorization_endpoint: issuerMetadata.authorization_endpoint,
|
||
token_endpoint: issuerMetadata.token_endpoint,
|
||
});
|
||
} catch (error) {
|
||
logger.warn('SSO discovery test failed', { error: error.message });
|
||
res.status(400).json({ ok: false, error: `Discovery failed: ${error.message}` });
|
||
}
|
||
});
|
||
|
||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
const { type } = req.params;
|
||
const settings = await db('app_settings')
|
||
.where('setting_type', type)
|
||
.select('*');
|
||
|
||
// Convert to object format
|
||
const settingsObject = {};
|
||
settings.forEach(setting => {
|
||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||
// If the value is already parsed (from json column), use it directly
|
||
if (typeof setting.setting_value !== 'string') {
|
||
settingsObject[setting.setting_key] = setting.setting_value;
|
||
} else {
|
||
try {
|
||
// Try to parse as JSON first
|
||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||
} catch (e) {
|
||
// If it's not valid JSON, use the raw value
|
||
settingsObject[setting.setting_key] = setting.setting_value;
|
||
}
|
||
}
|
||
} else {
|
||
settingsObject[setting.setting_key] = null;
|
||
}
|
||
});
|
||
|
||
// Reserved bootstrap/credential keys are NEVER readable through the
|
||
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||
stripReservedSettingKeys(settingsObject);
|
||
|
||
// Mask sensitive secrets before sending to client
|
||
if (settingsObject.security_recaptcha_secret_key) {
|
||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||
}
|
||
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
|
||
// were returned in plaintext to any settings.view holder. Same masking
|
||
// pattern as the recaptcha/umami/rybbit keys; the dedicated
|
||
// /admin/backup/config endpoints handle the edit round-trip.
|
||
if (settingsObject.backup_s3_secret_key) {
|
||
settingsObject.backup_s3_secret_key = '••••••••';
|
||
}
|
||
if (settingsObject.backup_rsync_ssh_key) {
|
||
settingsObject.backup_rsync_ssh_key = '••••••••';
|
||
}
|
||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||
// outbound calls to the operator's Umami instance for the device
|
||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||
if (settingsObject.analytics_umami_api_key) {
|
||
settingsObject.analytics_umami_api_key = '••••••••';
|
||
}
|
||
// Rybbit API key (#663 Phase 1) — same pattern.
|
||
if (settingsObject.analytics_rybbit_api_key) {
|
||
settingsObject.analytics_rybbit_api_key = '••••••••';
|
||
}
|
||
|
||
res.json(settingsObject);
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to fetch settings');
|
||
}
|
||
});
|
||
|
||
// Get password complexity settings for frontend
|
||
router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
|
||
|
||
// Get current complexity level from database
|
||
const complexityLevel = await getPasswordComplexitySettings();
|
||
|
||
// Get configuration for the complexity level
|
||
const config = getPasswordConfigForComplexity(complexityLevel);
|
||
|
||
res.json({
|
||
complexityLevel,
|
||
config
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to fetch password complexity settings');
|
||
}
|
||
});
|
||
|
||
// Update branding settings
|
||
router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const {
|
||
company_name,
|
||
company_tagline,
|
||
support_email,
|
||
footer_text,
|
||
watermark_enabled,
|
||
watermark_position,
|
||
watermark_opacity,
|
||
watermark_size,
|
||
favicon_url,
|
||
logo_url,
|
||
watermark_logo_url,
|
||
logo_size,
|
||
logo_max_height,
|
||
logo_position,
|
||
logo_display_header,
|
||
logo_display_hero,
|
||
logo_display_mode,
|
||
hide_powered_by,
|
||
force_color_mode,
|
||
// Login-page-only branding (#354 follow-up). Both toggles apply
|
||
// exclusively to /admin/login and /customer/login — the gallery
|
||
// and admin chrome use their own logo_size / logo_max_height.
|
||
// - login_logo_frame_enabled: true (default) renders the tinted
|
||
// square behind the logo; false drops it.
|
||
// - login_logo_size: 'small' | 'medium' | 'large' | 'xlarge'
|
||
// matches the gallery logo_size token set but applies only to
|
||
// the two login screens.
|
||
login_logo_frame_enabled,
|
||
login_logo_size,
|
||
// Footer overhaul (#441 + #440). Socials are URL strings (empty
|
||
// hides the icon). Promo content is markdown (rendered via
|
||
// marked → DOMPurify on the frontend, no raw HTML accepted).
|
||
facebook_url,
|
||
instagram_url,
|
||
whatsapp_url,
|
||
twitter_url,
|
||
youtube_url,
|
||
promo_markdown,
|
||
promo_position,
|
||
promo_alignment,
|
||
// Info banner (#932). Markdown only, same sanitiser path as promo.
|
||
info_markdown
|
||
} = req.body;
|
||
|
||
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
|
||
const normalizedForceColorMode = force_color_mode === 'dark'
|
||
? 'dark'
|
||
: force_color_mode === 'light'
|
||
? 'light'
|
||
: null;
|
||
|
||
// Get current watermark settings hash for change detection
|
||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||
|
||
// Normalize promo_position: only 'above_footer' | 'below_footer' valid.
|
||
const normalizedPromoPosition = promo_position === 'below_footer'
|
||
? 'below_footer'
|
||
: 'above_footer';
|
||
|
||
// Normalize promo_alignment: 'left' | 'center' | 'right'. Defaults
|
||
// to 'center' to match the gallery footer's full-width centering
|
||
// (#482 — the previous default left the markdown left-aligned in
|
||
// a max-w-3xl block, which read as visually offset from the footer).
|
||
const allowedPromoAlignments = ['left', 'center', 'right'];
|
||
const normalizedPromoAlignment = allowedPromoAlignments.includes(promo_alignment)
|
||
? promo_alignment
|
||
: 'center';
|
||
|
||
// Normalize login_logo_size to the same token set as logo_size.
|
||
// Anything else falls back to 'medium' on the next render.
|
||
const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge'];
|
||
const normalizedLoginLogoSize = allowedLoginLogoSizes.includes(login_logo_size)
|
||
? login_logo_size
|
||
: undefined;
|
||
|
||
const brandingSettings = {
|
||
company_name,
|
||
company_tagline,
|
||
support_email,
|
||
footer_text,
|
||
watermark_enabled,
|
||
watermark_position,
|
||
watermark_opacity,
|
||
watermark_size,
|
||
favicon_url,
|
||
logo_url,
|
||
watermark_logo_url,
|
||
logo_size,
|
||
logo_max_height,
|
||
logo_position,
|
||
logo_display_header,
|
||
logo_display_hero,
|
||
logo_display_mode,
|
||
hide_powered_by,
|
||
force_color_mode: normalizedForceColorMode,
|
||
// Login-only knobs (only persist when the request actually
|
||
// included the key, so a partial PUT from another tab doesn't
|
||
// accidentally clear them).
|
||
...(login_logo_frame_enabled !== undefined && { login_logo_frame_enabled }),
|
||
...(normalizedLoginLogoSize !== undefined && { login_logo_size: normalizedLoginLogoSize }),
|
||
// Footer overhaul (#441 + #440). String fields normalize empty/
|
||
// undefined → '' so the column is always a known type. Only persist
|
||
// when the request actually included the key (partial PUTs).
|
||
...(facebook_url !== undefined && { facebook_url: String(facebook_url || '').trim() }),
|
||
...(instagram_url !== undefined && { instagram_url: String(instagram_url || '').trim() }),
|
||
...(whatsapp_url !== undefined && { whatsapp_url: String(whatsapp_url || '').trim() }),
|
||
...(twitter_url !== undefined && { twitter_url: String(twitter_url || '').trim() }),
|
||
...(youtube_url !== undefined && { youtube_url: String(youtube_url || '').trim() }),
|
||
...(promo_markdown !== undefined && { promo_markdown: typeof promo_markdown === 'string' ? promo_markdown : '' }),
|
||
...(info_markdown !== undefined && { info_markdown: typeof info_markdown === 'string' ? info_markdown : '' }),
|
||
...(promo_position !== undefined && { promo_position: normalizedPromoPosition }),
|
||
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
|
||
};
|
||
|
||
// Handle favicon deletion if empty string or null is provided
|
||
if (favicon_url === '' || favicon_url === null || favicon_url === undefined) {
|
||
// Get current favicon path to delete file
|
||
const currentFaviconSetting = await db('app_settings')
|
||
.where('setting_key', 'branding_favicon_url')
|
||
.first();
|
||
|
||
if (currentFaviconSetting && currentFaviconSetting.setting_value) {
|
||
let currentFaviconUrl;
|
||
try {
|
||
// Try to parse as JSON first
|
||
currentFaviconUrl = JSON.parse(currentFaviconSetting.setting_value);
|
||
} catch (e) {
|
||
// If it's not valid JSON, use the raw value
|
||
currentFaviconUrl = currentFaviconSetting.setting_value;
|
||
}
|
||
|
||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||
// Delete the file from filesystem
|
||
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||
try {
|
||
await fs.unlink(faviconPath);
|
||
logger.info('Deleted favicon file:', faviconPath);
|
||
} catch (err) {
|
||
logger.error('Error deleting favicon file:', err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle logo deletion if empty string or null is provided
|
||
if (logo_url === '' || logo_url === null || logo_url === undefined) {
|
||
// Get current logo path to delete file
|
||
const currentLogoSetting = await db('app_settings')
|
||
.where('setting_key', 'branding_logo_url')
|
||
.first();
|
||
|
||
if (currentLogoSetting && currentLogoSetting.setting_value) {
|
||
let currentLogoUrl;
|
||
try {
|
||
// Try to parse as JSON first
|
||
currentLogoUrl = JSON.parse(currentLogoSetting.setting_value);
|
||
} catch (e) {
|
||
// If it's not valid JSON, use the raw value
|
||
currentLogoUrl = currentLogoSetting.setting_value;
|
||
}
|
||
|
||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||
// Delete the file from filesystem
|
||
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||
const logoPath = path.join(getStoragePath(), relativePath);
|
||
try {
|
||
await fs.unlink(logoPath);
|
||
logger.info('Deleted logo file:', logoPath);
|
||
} catch (err) {
|
||
logger.error('Error deleting logo file:', err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update or insert each setting
|
||
for (const [key, value] of Object.entries(brandingSettings)) {
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: `branding_${key}`,
|
||
setting_value: JSON.stringify(value),
|
||
setting_type: 'branding',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
// Log activity
|
||
await db('activity_logs').insert({
|
||
activity_type: 'branding_updated',
|
||
actor_type: 'admin',
|
||
actor_id: req.admin.id,
|
||
actor_name: req.admin.username,
|
||
metadata: JSON.stringify({ company_name })
|
||
});
|
||
|
||
clearPublicSiteCache();
|
||
|
||
// Check if watermark settings changed and trigger regeneration
|
||
const newSettingsHash = await watermarkService.getSettingsHash();
|
||
let watermarkRegenerationStarted = false;
|
||
|
||
if (oldSettingsHash !== newSettingsHash) {
|
||
// Clear watermark cache
|
||
watermarkService.clearCache();
|
||
|
||
// Check if watermarking is now enabled or settings changed
|
||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||
|
||
if (currentSettings && currentSettings.enabled) {
|
||
// Start background regeneration of all watermarks
|
||
logger.info('Watermark settings changed, starting background regeneration');
|
||
watermarkGeneratorService.regenerateAll()
|
||
.then(result => {
|
||
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||
})
|
||
.catch(err => {
|
||
logger.error('Watermark regeneration failed:', err);
|
||
});
|
||
watermarkRegenerationStarted = true;
|
||
} else {
|
||
// Watermarking was disabled, clear all pre-generated watermarks
|
||
logger.info('Watermarking disabled, clearing pre-generated watermarks');
|
||
watermarkGeneratorService.clearAllWatermarks()
|
||
.catch(err => logger.error('Failed to clear watermarks:', err));
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
message: 'Branding settings updated successfully',
|
||
watermarkRegenerationStarted
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update branding settings');
|
||
}
|
||
});
|
||
|
||
// Upload logo
|
||
router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => {
|
||
try {
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'No logo file uploaded' });
|
||
}
|
||
|
||
// ?variant=dark stores a separate dark-mode logo (branding_logo_*_dark);
|
||
// anything else is the default (light) logo. Consumers pick the dark
|
||
// variant when the active theme is dark, falling back to the light one.
|
||
const isDark = req.query.variant === 'dark' || req.body.variant === 'dark';
|
||
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
|
||
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
|
||
|
||
// Get old logo to delete
|
||
const oldLogoSetting = await db('app_settings')
|
||
.where('setting_key', pathKey)
|
||
.first();
|
||
|
||
if (oldLogoSetting && oldLogoSetting.setting_value) {
|
||
try {
|
||
// Handle both JSON-serialized and legacy raw path values
|
||
let oldPath = oldLogoSetting.setting_value;
|
||
if (oldPath.startsWith('"')) {
|
||
oldPath = JSON.parse(oldPath);
|
||
}
|
||
await fs.unlink(oldPath);
|
||
} catch (error) {
|
||
logger.error('Failed to delete old logo:', error);
|
||
}
|
||
}
|
||
|
||
// Save new logo path
|
||
const logoPath = req.file.path;
|
||
const publicPath = `/uploads/logos/${req.file.filename}`;
|
||
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: pathKey,
|
||
setting_value: JSON.stringify(logoPath),
|
||
setting_type: 'branding',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(logoPath),
|
||
updated_at: new Date()
|
||
});
|
||
|
||
// Save public URL
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: urlKey,
|
||
setting_value: JSON.stringify(publicPath),
|
||
setting_type: 'branding',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(publicPath),
|
||
updated_at: new Date()
|
||
});
|
||
|
||
res.json({
|
||
message: 'Logo uploaded successfully',
|
||
logoUrl: publicPath
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to upload logo');
|
||
}
|
||
});
|
||
|
||
// Remove a logo. ?variant=dark clears the dark-mode logo
|
||
// (branding_logo_*_dark); otherwise the default logo. Best-effort file
|
||
// unlink, then blanks the url + path settings.
|
||
router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const isDark = req.query.variant === 'dark';
|
||
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
|
||
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
|
||
|
||
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
|
||
if (pathSetting && pathSetting.setting_value) {
|
||
try {
|
||
let p = pathSetting.setting_value;
|
||
if (p.startsWith('"')) p = JSON.parse(p);
|
||
await fs.unlink(p);
|
||
} catch (error) {
|
||
logger.error('Failed to delete logo file:', error);
|
||
}
|
||
}
|
||
await db('app_settings')
|
||
.whereIn('setting_key', [pathKey, urlKey])
|
||
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
|
||
|
||
res.json({ message: 'Logo removed' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to remove logo');
|
||
}
|
||
});
|
||
|
||
// Upload watermark logo
|
||
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
|
||
try {
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'No file uploaded' });
|
||
}
|
||
|
||
// Delete old watermark logo if exists
|
||
const oldWatermarkLogoSetting = await db('app_settings')
|
||
.where('setting_key', 'branding_watermark_logo_path')
|
||
.first();
|
||
|
||
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
|
||
let oldPath;
|
||
try {
|
||
// Try to parse as JSON first (for JSON-stringified paths)
|
||
oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
|
||
} catch (e) {
|
||
// If it's not valid JSON, use the raw value
|
||
oldPath = oldWatermarkLogoSetting.setting_value;
|
||
}
|
||
|
||
if (oldPath && typeof oldPath === 'string') {
|
||
try {
|
||
await fs.unlink(oldPath);
|
||
} catch (error) {
|
||
logger.error('Failed to delete old watermark logo:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save new watermark logo path
|
||
const logoPath = req.file.path;
|
||
const publicPath = `/uploads/logos/${req.file.filename}`;
|
||
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: 'branding_watermark_logo_path',
|
||
setting_value: JSON.stringify(logoPath),
|
||
setting_type: 'branding',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(logoPath),
|
||
updated_at: new Date()
|
||
});
|
||
|
||
// Save public URL
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: 'branding_watermark_logo_url',
|
||
setting_value: JSON.stringify(publicPath),
|
||
setting_type: 'branding',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(publicPath),
|
||
updated_at: new Date()
|
||
});
|
||
|
||
// Trigger watermark regeneration since the logo changed
|
||
watermarkService.clearCache();
|
||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||
let watermarkRegenerationStarted = false;
|
||
|
||
if (currentSettings && currentSettings.enabled) {
|
||
logger.info('Watermark logo changed, starting background regeneration');
|
||
watermarkGeneratorService.regenerateAll()
|
||
.then(result => {
|
||
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||
})
|
||
.catch(err => {
|
||
logger.error('Watermark regeneration failed:', err);
|
||
});
|
||
watermarkRegenerationStarted = true;
|
||
}
|
||
|
||
res.json({
|
||
message: 'Watermark logo uploaded successfully',
|
||
watermarkLogoUrl: publicPath,
|
||
watermarkRegenerationStarted
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to upload watermark logo');
|
||
}
|
||
});
|
||
|
||
// Update theme settings
|
||
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const themeSettings = req.body;
|
||
|
||
// Save theme settings
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: 'theme_config',
|
||
setting_value: JSON.stringify(themeSettings),
|
||
setting_type: 'theme',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(themeSettings),
|
||
updated_at: new Date()
|
||
});
|
||
|
||
// Log activity
|
||
await db('activity_logs').insert({
|
||
activity_type: 'theme_updated',
|
||
actor_type: 'admin',
|
||
actor_id: req.admin.id,
|
||
actor_name: req.admin.username,
|
||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||
});
|
||
|
||
clearPublicSiteCache();
|
||
|
||
res.json({ message: 'Theme settings updated successfully' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update theme settings');
|
||
}
|
||
});
|
||
|
||
// Update general settings
|
||
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const settings = stripReservedSettingKeys({ ...req.body });
|
||
let uploadLimitTouched = false;
|
||
|
||
// Migration 174: drop any protected key (site URL / security / accounting)
|
||
// the caller isn't permitted to write, so the settings.edit bucket can't be
|
||
// used to repoint the install via this generic writer. See
|
||
// rejectUnauthorizedProtectedKeys (403s when a protected key is denied).
|
||
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
|
||
|
||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
|
||
uploadLimitTouched = true;
|
||
const rawValue = Number(settings.general_max_files_per_upload);
|
||
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||
|
||
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||
return res.status(400).json({
|
||
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
|
||
});
|
||
}
|
||
|
||
settings.general_max_files_per_upload = normalizedValue;
|
||
}
|
||
|
||
// Per-file size limit (MB). Validate/clamp on save, mirroring the count
|
||
// above, so an out-of-range value can't be persisted — otherwise the public
|
||
// endpoint would advertise the raw value while getMaxFileSizeMb() normalizes
|
||
// it, and the guest UI would reject files the backend actually accepts.
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_file_size_mb')) {
|
||
uploadLimitTouched = true;
|
||
const rawValue = Number(settings.general_max_file_size_mb);
|
||
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||
|
||
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILE_SIZE_MB) {
|
||
return res.status(400).json({
|
||
error: `general_max_file_size_mb must be an integer between 1 and ${MAX_ALLOWED_FILE_SIZE_MB}`
|
||
});
|
||
}
|
||
|
||
settings.general_max_file_size_mb = normalizedValue;
|
||
}
|
||
|
||
if (publicSiteKeysTouched) {
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
|
||
settings.general_public_site_html = settings.general_public_site_html.trim();
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
|
||
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
|
||
}
|
||
|
||
const enableToggle = settings.general_public_site_enabled;
|
||
if (enableToggle === true) {
|
||
let htmlValue = settings.general_public_site_html;
|
||
|
||
if (htmlValue === undefined) {
|
||
const currentSettings = await getRawPublicSiteSettings();
|
||
htmlValue = currentSettings.general_public_site_html;
|
||
}
|
||
|
||
if (!htmlValue || !String(htmlValue).trim()) {
|
||
return res.status(400).json({
|
||
error: 'Public site HTML must be provided before enabling the public landing page.'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update or insert each setting
|
||
for (const [key, value] of Object.entries(settings)) {
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: key,
|
||
setting_value: JSON.stringify(value),
|
||
setting_type: 'general',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
// Clear maintenance mode cache if it was updated
|
||
if ('general_maintenance_mode' in settings) {
|
||
clearMaintenanceCache();
|
||
}
|
||
|
||
if (publicSiteKeysTouched) {
|
||
clearPublicSiteCache();
|
||
}
|
||
if (uploadLimitTouched) {
|
||
clearMaxFilesPerUploadCache();
|
||
clearMaxFileSizeCache();
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||
clearShareLinkSettingsCache();
|
||
}
|
||
// Toggling the original-filenames setting (#493) requires busting the
|
||
// per-event pre-generated zips so the next download-all rebuilds with the
|
||
// new entry names. Single-photo downloads pick up the change as soon as
|
||
// the in-memory cache TTL in downloadFilenameService expires (cleared
|
||
// here for immediacy).
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'general_use_original_filenames_for_downloads')) {
|
||
try {
|
||
require('../services/downloadFilenameService').clearCache();
|
||
require('../services/downloadZipService').invalidateAll();
|
||
} catch (e) {
|
||
logger.warn('Failed to invalidate download caches after filename setting change:', e.message);
|
||
}
|
||
}
|
||
|
||
// Log activity
|
||
await db('activity_logs').insert({
|
||
activity_type: 'general_settings_updated',
|
||
actor_type: 'admin',
|
||
actor_id: req.admin.id,
|
||
actor_name: req.admin.username,
|
||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||
});
|
||
|
||
res.json({ message: 'General settings updated successfully' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update general settings');
|
||
}
|
||
});
|
||
|
||
// Update security settings
|
||
router.put('/security', adminAuth, requirePermission('settings.security'), async (req, res) => {
|
||
try {
|
||
const settings = stripReservedSettingKeys({ ...req.body });
|
||
// A settings.security holder still can't write domain/accounting keys here.
|
||
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
|
||
|
||
// Update or insert each setting
|
||
for (const [key, value] of Object.entries(settings)) {
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: key,
|
||
setting_value: JSON.stringify(value),
|
||
setting_type: 'security',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
resetSecurityConfigCache();
|
||
|
||
// Log activity
|
||
await db('activity_logs').insert({
|
||
activity_type: 'security_settings_updated',
|
||
actor_type: 'admin',
|
||
actor_id: req.admin.id,
|
||
actor_name: req.admin.username,
|
||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||
});
|
||
|
||
res.json({ message: 'Security settings updated successfully' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update security settings');
|
||
}
|
||
});
|
||
|
||
// Update analytics settings
|
||
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const settings = stripReservedSettingKeys({ ...req.body });
|
||
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
|
||
|
||
// Validate the provider switch (#663 Phase 1). Reject unknown values
|
||
// so the dashboard route's factory doesn't have to defensively guard.
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'analytics_tracker_provider')) {
|
||
const valid = ['none', 'umami', 'rybbit', 'custom'];
|
||
if (!valid.includes(settings.analytics_tracker_provider)) {
|
||
return res.status(400).json({
|
||
error: `analytics_tracker_provider must be one of: ${valid.join(', ')}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sanitise the custom-mode HTML snippet on save (#663 Phase 1). Stored
|
||
// pre-sanitised so the publicSettings endpoint surfaces it as-is on
|
||
// every gallery request — never re-running sanitize-html on the hot path.
|
||
if (Object.prototype.hasOwnProperty.call(settings, 'analytics_custom_head_html')) {
|
||
const { sanitizeTrackerSnippet } = require('../services/trackers/customScriptSanitiser');
|
||
settings.analytics_custom_head_html = sanitizeTrackerSnippet(settings.analytics_custom_head_html);
|
||
}
|
||
|
||
// Update or insert each setting
|
||
for (const [key, value] of Object.entries(settings)) {
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: key,
|
||
setting_value: JSON.stringify(value),
|
||
setting_type: 'analytics',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
// Log activity
|
||
await db('activity_logs').insert({
|
||
activity_type: 'analytics_settings_updated',
|
||
actor_type: 'admin',
|
||
actor_id: req.admin.id,
|
||
actor_name: req.admin.username,
|
||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||
});
|
||
|
||
res.json({ message: 'Analytics settings updated successfully' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update analytics settings');
|
||
}
|
||
});
|
||
|
||
// Update SEO settings
|
||
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const settings = stripReservedSettingKeys({ ...req.body });
|
||
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
|
||
|
||
// Validate seo_blocked_ai_agents is an array of strings
|
||
if (settings.seo_blocked_ai_agents !== undefined) {
|
||
if (!Array.isArray(settings.seo_blocked_ai_agents) ||
|
||
!settings.seo_blocked_ai_agents.every(a => typeof a === 'string')) {
|
||
return res.status(400).json({ error: 'seo_blocked_ai_agents must be an array of strings' });
|
||
}
|
||
}
|
||
|
||
// Validate seo_custom_rules structure
|
||
if (settings.seo_custom_rules !== undefined) {
|
||
if (!Array.isArray(settings.seo_custom_rules)) {
|
||
return res.status(400).json({ error: 'seo_custom_rules must be an array' });
|
||
}
|
||
for (const rule of settings.seo_custom_rules) {
|
||
if (!rule.userAgent || typeof rule.userAgent !== 'string') {
|
||
return res.status(400).json({ error: 'Each custom rule must have a userAgent string' });
|
||
}
|
||
if (!Array.isArray(rule.disallow) || !rule.disallow.every(d => typeof d === 'string')) {
|
||
return res.status(400).json({ error: 'Each custom rule must have a disallow array of strings' });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update or insert each setting
|
||
for (const [key, value] of Object.entries(settings)) {
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: key,
|
||
setting_value: JSON.stringify(value),
|
||
setting_type: 'seo',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
// Clear robots.txt cache
|
||
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
|
||
clearRobotsTxtCache();
|
||
|
||
// Log activity
|
||
await db('activity_logs').insert({
|
||
activity_type: 'seo_settings_updated',
|
||
actor_type: 'admin',
|
||
actor_id: req.admin.id,
|
||
actor_name: req.admin.username,
|
||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||
});
|
||
|
||
res.json({ message: 'SEO settings updated successfully' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update SEO settings');
|
||
}
|
||
});
|
||
|
||
// Get storage info
|
||
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
// Get total storage used
|
||
const totalStorage = await db('photos')
|
||
.sum('size_bytes as total')
|
||
.first();
|
||
|
||
// Get storage by event
|
||
const storageByEvent = await db('photos')
|
||
.select('events.event_name', 'events.id')
|
||
.sum('photos.size_bytes as size')
|
||
.join('events', 'photos.event_id', 'events.id')
|
||
.groupBy('events.id')
|
||
.orderBy('size', 'desc')
|
||
.limit(10);
|
||
|
||
// Get archive storage
|
||
const archives = await db('events')
|
||
.where('is_archived', formatBoolean(true))
|
||
.whereNotNull('archive_path')
|
||
.select('archive_path');
|
||
|
||
let archiveStorage = 0;
|
||
for (const archive of archives) {
|
||
if (archive.archive_path) {
|
||
try {
|
||
const storagePath = getStoragePath();
|
||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||
const stats = await fs.stat(fullArchivePath);
|
||
archiveStorage += stats.size;
|
||
} catch (error) {
|
||
logger.error('Archive file not found:', archive.archive_path, error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||
const storagePath = getStoragePath();
|
||
|
||
let diskStats = null;
|
||
let rawDiskTotal = null;
|
||
let rawDiskFree = null;
|
||
let rawDiskAvailable = null;
|
||
try {
|
||
diskStats = await fs.statfs(storagePath);
|
||
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
|
||
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||
} catch (diskError) {
|
||
logger.error('Disk stats error:', diskError.message);
|
||
}
|
||
|
||
const clampDiskValue = (value) => {
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return null;
|
||
}
|
||
|
||
// Treat unusually large virtualised values as unreliable (>50TB)
|
||
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
|
||
if (value > MAX_REASONABLE_BYTES) {
|
||
return null;
|
||
}
|
||
|
||
return value;
|
||
};
|
||
|
||
let diskTotal = null;
|
||
let diskFree = null;
|
||
let diskAvailable = null;
|
||
|
||
if (diskStats) {
|
||
diskTotal = clampDiskValue(rawDiskTotal);
|
||
diskFree = clampDiskValue(rawDiskFree);
|
||
diskAvailable = clampDiskValue(rawDiskAvailable);
|
||
|
||
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
|
||
diskAvailable = null;
|
||
}
|
||
if (diskTotal && diskFree && diskFree > diskTotal) {
|
||
diskFree = null;
|
||
}
|
||
}
|
||
|
||
const totalUsed = totalStorage?.total || 0;
|
||
|
||
const parseBytesValue = (value) => {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||
return null;
|
||
}
|
||
return Math.floor(numeric);
|
||
};
|
||
|
||
const parseEnvOverride = (bytesVar, gbVar) => {
|
||
if (process.env[bytesVar]) {
|
||
return parseBytesValue(process.env[bytesVar]);
|
||
}
|
||
if (process.env[gbVar]) {
|
||
const value = parseBytesValue(process.env[gbVar]);
|
||
return value ? value * 1024 * 1024 * 1024 : null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
let configuredSoftLimit = null;
|
||
let capacityOverrideDb = null;
|
||
let availableOverrideDb = null;
|
||
|
||
try {
|
||
const storageSettings = await db('app_settings')
|
||
.whereIn('setting_key', [
|
||
'general_storage_soft_limit_bytes',
|
||
'general_storage_capacity_override_bytes',
|
||
'general_storage_available_override_bytes'
|
||
])
|
||
.select('setting_key', 'setting_value');
|
||
|
||
storageSettings.forEach((setting) => {
|
||
let parsedValue = null;
|
||
if (setting.setting_value) {
|
||
try {
|
||
parsedValue = JSON.parse(setting.setting_value);
|
||
} catch (error) {
|
||
parsedValue = setting.setting_value;
|
||
}
|
||
}
|
||
|
||
switch (setting.setting_key) {
|
||
case 'general_storage_soft_limit_bytes':
|
||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||
configuredSoftLimit = parsedValue;
|
||
}
|
||
break;
|
||
case 'general_storage_capacity_override_bytes':
|
||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||
capacityOverrideDb = parsedValue;
|
||
}
|
||
break;
|
||
case 'general_storage_available_override_bytes':
|
||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||
availableOverrideDb = parsedValue;
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
});
|
||
} catch (error) {
|
||
logger.error('Storage settings read error:', error.message);
|
||
}
|
||
|
||
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
|
||
|
||
let capacityOverrideBytes = null;
|
||
let availableOverrideBytes = null;
|
||
let overrideSource = null;
|
||
|
||
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
|
||
capacityOverrideBytes = capacityOverrideEnv;
|
||
availableOverrideBytes = availableOverrideEnv;
|
||
overrideSource = 'env';
|
||
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
|
||
capacityOverrideBytes = capacityOverrideDb;
|
||
availableOverrideBytes = availableOverrideDb;
|
||
overrideSource = 'settings';
|
||
}
|
||
|
||
if (capacityOverrideBytes != null) {
|
||
diskTotal = capacityOverrideBytes;
|
||
if (availableOverrideBytes == null) {
|
||
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
|
||
} else {
|
||
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
|
||
}
|
||
diskFree = diskAvailable;
|
||
} else if (availableOverrideBytes != null) {
|
||
diskAvailable = Math.max(availableOverrideBytes, 0);
|
||
diskFree = diskAvailable;
|
||
}
|
||
|
||
let recommendedSoftLimit = null;
|
||
if (diskTotal && diskAvailable) {
|
||
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
|
||
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
|
||
} else if (diskTotal) {
|
||
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
|
||
} else if (diskAvailable) {
|
||
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
|
||
}
|
||
|
||
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
|
||
recommendedSoftLimit = totalUsed;
|
||
}
|
||
|
||
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
|
||
if (!recommendedSoftLimit && fallbackSoftLimit) {
|
||
recommendedSoftLimit = fallbackSoftLimit;
|
||
}
|
||
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
|
||
|
||
const diskMetricsReliable = Boolean(diskTotal);
|
||
|
||
res.json({
|
||
total_used: totalUsed,
|
||
archive_storage: archiveStorage,
|
||
storage_by_event: storageByEvent,
|
||
storage_limit: effectiveSoftLimit,
|
||
storage_soft_limit: effectiveSoftLimit,
|
||
configured_soft_limit: configuredSoftLimit,
|
||
recommended_soft_limit: recommendedSoftLimit,
|
||
soft_limit_configured: Boolean(configuredSoftLimit),
|
||
disk_total: diskTotal,
|
||
disk_free: diskFree,
|
||
disk_available: diskAvailable,
|
||
disk_total_raw: rawDiskTotal,
|
||
disk_free_raw: rawDiskFree,
|
||
disk_available_raw: rawDiskAvailable,
|
||
disk_metrics_reliable: diskMetricsReliable,
|
||
disk_override_source: overrideSource
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to fetch storage information');
|
||
}
|
||
});
|
||
|
||
// Upload favicon endpoint
|
||
router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => {
|
||
try {
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'No favicon file provided' });
|
||
}
|
||
|
||
// The file is already in the correct location from multer
|
||
const faviconUrl = `/uploads/favicons/${req.file.filename}`;
|
||
|
||
// Save to database
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: 'branding_favicon_url',
|
||
setting_value: JSON.stringify(faviconUrl),
|
||
setting_type: 'branding',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(faviconUrl),
|
||
updated_at: new Date()
|
||
});
|
||
|
||
// Log activity
|
||
await logActivity('favicon_uploaded',
|
||
{ faviconUrl },
|
||
null,
|
||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||
);
|
||
|
||
res.json({ faviconUrl });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to upload favicon');
|
||
}
|
||
});
|
||
|
||
// Update rate limit settings
|
||
router.put('/security/rate-limit', adminAuth, requirePermission('settings.security'), [
|
||
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||
body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'),
|
||
body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'),
|
||
body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean')
|
||
], async (req, res) => {
|
||
try {
|
||
const errors = validationResult(req);
|
||
if (!errors.isEmpty()) {
|
||
return res.status(400).json({ errors: errors.array() });
|
||
}
|
||
|
||
const {
|
||
rate_limit_enabled,
|
||
rate_limit_window_minutes,
|
||
rate_limit_max_requests,
|
||
rate_limit_auth_max_requests,
|
||
rate_limit_skip_authenticated,
|
||
rate_limit_public_endpoints_only
|
||
} = req.body;
|
||
|
||
// Update each setting
|
||
const settings = [
|
||
{ key: 'rate_limit_enabled', value: rate_limit_enabled },
|
||
{ key: 'rate_limit_window_minutes', value: rate_limit_window_minutes },
|
||
{ key: 'rate_limit_max_requests', value: rate_limit_max_requests },
|
||
{ key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests },
|
||
{ key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated },
|
||
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
|
||
];
|
||
|
||
for (const { key, value } of settings) {
|
||
await db('app_settings')
|
||
.where('setting_key', key)
|
||
.update({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
// Clear the rate limit settings cache to apply changes immediately
|
||
clearSettingsCache();
|
||
|
||
// Log activity
|
||
await logActivity('settings_updated',
|
||
{
|
||
category: 'security',
|
||
subcategory: 'rate_limit',
|
||
changes: settings.length
|
||
},
|
||
null,
|
||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||
);
|
||
|
||
res.json({ message: 'Rate limit settings updated successfully' });
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to update rate limit settings');
|
||
}
|
||
});
|
||
|
||
// Get default public site template
|
||
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
const defaults = await getDefaultPublicSitePayload();
|
||
|
||
res.json({
|
||
enabled: false,
|
||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||
css: '',
|
||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||
branding: defaults.branding,
|
||
meta: {
|
||
title: defaults.title,
|
||
}
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to load defaults');
|
||
}
|
||
});
|
||
|
||
// Reset public site template to defaults
|
||
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||
try {
|
||
const entries = [
|
||
{
|
||
key: 'general_public_site_html',
|
||
value: DEFAULT_PUBLIC_SITE_HTML.trim()
|
||
},
|
||
{
|
||
key: 'general_public_site_custom_css',
|
||
value: ''
|
||
}
|
||
];
|
||
|
||
for (const { key, value } of entries) {
|
||
await db('app_settings')
|
||
.insert({
|
||
setting_key: key,
|
||
setting_value: JSON.stringify(value),
|
||
setting_type: 'general',
|
||
updated_at: new Date()
|
||
})
|
||
.onConflict('setting_key')
|
||
.merge({
|
||
setting_value: JSON.stringify(value),
|
||
updated_at: new Date()
|
||
});
|
||
}
|
||
|
||
clearPublicSiteCache();
|
||
|
||
const defaults = await getDefaultPublicSitePayload();
|
||
|
||
await logActivity('public_site_reset_to_default',
|
||
{
|
||
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
|
||
},
|
||
null,
|
||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||
);
|
||
|
||
res.json({
|
||
message: 'Public site template reset to defaults',
|
||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||
css: '',
|
||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||
branding: defaults.branding
|
||
});
|
||
} catch (error) {
|
||
errorResponse(res, error, 500, 'Failed to reset template');
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|