Files
picpeak/backend/src/routes/adminSettings.js
T
Paul Nothaft b48fa62eea feat(gallery): info banner above the photo grid (#932) (#1063)
* 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>
2026-08-16 21:33:13 +02:00

2056 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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;