Files
picpeak/backend/src/routes/adminSettings.js
T
Paul NothaftandPaul Nothaft 849a5807b7 fix(admin): make "Storage used" report storage used (#1164) (#1170)
* fix(admin): make "Storage used" report storage used (#1164)

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which has no relationship to the disk PicPeak runs on. In reference mode those
files are never copied and sit on the NAS; duplicate rows counted the same
file twice (#1162); and it ignored everything PicPeak genuinely does write
locally: thumbnails, previews, hero renditions, watermarks and the per-event
download cache. The reporter's tile read ~80 GB against 21 GB of real usage.

Worse than the label: the same number drove the storage soft-limit warning bar
and, via /storage/info, the recommended soft limit — so a reference-mode
install got a disk-capacity recommendation computed from bytes that are not on
the disk.

- new localStorageUsage service walks the storage root and reports the total
  plus a breakdown. Walking rather than summing DB columns is the point:
  thumbnail/preview/hero rows record a key and never a byte count, and orphans
  from a deleted event or an interrupted import are real bytes. Symlinks are
  not followed, so a link into the media mount cannot put the NAS back in the
  total. Cached for 5 minutes, since the dashboard polls.
- the dashboard tile and /storage/info now report that, with the catalogued
  figure kept and labelled as such next to it. A failed measurement reads as
  "unavailable" rather than substituting a number that means something else.

On the local rig: 64.37 MB used against 15.75 MB catalogued, of which 27.9 MB
is watermarks and 6.8 MB is download cache — none of which the old figure
could see.

Not addressed here: `.download-cache/all.zip` still has no TTL or size cap. It
is now at least visible in the breakdown, which is what makes the case for
capping it.

* fix(admin): exclude the media share from local storage usage (#1164)

External review found the walk could reintroduce the exact over-count it
replaces.

EXTERNAL_MEDIA_ROOT's compose default is `<storage>/external-media`, where the
NAS is bind-mounted. That is a plain directory, not a symlink, so the symlink
guard did not cover it and the walk descended into the share — putting every
referenced original back into a figure whose whole purpose is to leave them
out, and comparing NAS bytes against statfs() of the local disk. On the
reference-mode installs this issue is about, that is the failure mode
reappearing inside its own fix.

The configured root is now skipped when it lies inside the storage root, and
the result reports which path was excluded. A directory that merely shares the
name is still counted, because those really are local bytes.

Also from the review:

- concurrent cold-cache callers now share one walk. /dashboard/stats,
  /storage/info and the sidebar are routinely requested together, and each was
  starting its own stat-per-file traversal of the whole library.
- storage_partial is surfaced in the StorageInfo type and the sidebar tile, not
  just the dashboard and analytics cards. An unreadable subtree makes the total
  a floor, and a floor silently compared against a soft limit reads as "safely
  under".

* fix(admin): do not report a disk walk on an S3 backend (#1164)

Second review round.

S3 installs were regressed. With STORAGE_BACKEND=s3 the originals, renditions,
archives and download caches are objects in the bucket and STORAGE_PATH holds
only incidental local files — so the walk reported near-zero and the soft-limit
recommendation was derived from it. Those installs now keep the catalogued
figure, which is the approximation they had before this PR, and the response
says which measurement it is (`storage_measurement: 'disk' | 'catalog'`) so the
UI labels it instead of implying a disk measurement that never happened.

The Settings → Status storage card ignored storage_partial, formatting a lower
bound as exact and deriving the limit percentage from it — so an unreadable
subtree could read as safely under the limit. It now carries the same `+`
marker as the sidebar and dashboard.

* fix(admin): stop rendering an absent measurement as zero usage (#1164)

Third review round, two findings.

The analytics storage bar coerced a null measurement to 0, drawing an empty
bar labelled "0% of limit" and suppressing the over-limit state — reading as
plenty of room at exactly the moment nothing is known. It now shows the
catalogued figure on S3, where that IS the available answer, and says "no
measurement available" rather than inventing a percentage when there is none.

/storage/info walked the filesystem before checking the backend and then threw
the result away on S3. The sidebar polls that endpoint, so a migrated install
still holding a large local tree paid a full stat-per-file traversal on every
cold cache for nothing. Gated before the walk, as the dashboard route already
was.

* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)

External review of the stable twin.

Both were reported as `storage_measurement: 'catalog'`, so a failed local walk
made the dashboard claim the objects live in S3. They are different things —
one is a fact about the install, the other is a fault — and there is now an
`unavailable` state for the second.

The analytics percentage could reach the billions. `safeSoftLimit` fell back to
`storageUsed || 1`, and on S3 that is null → 1, while the figure beside it came
from `catalogedBytes`. An editor or viewer holds `analytics.view` but not
`settings.view`, so `/storage/info` 403s for them and `storageInfo` is
undefined — which is exactly when that fallback fires. It now falls back to the
measured figure, and suppresses the percentage entirely when there is no real
limit rather than dividing usage by itself and always reading 100%.

Also lands the AnalyticsPage half of the previous round, which the commit
message claimed but the commit did not contain — only its backend counterpart
was staged. The stable twin has carried it since it was written, so this is the
parity gap in the unusual direction.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 08:53:39 +02:00

2152 lines
87 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 validator = require('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 { invalidateSiteUrlCache, isEnvPinned, envPinnedBase } = require('../utils/frontendUrl');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const { measureLocalStorageUsage } = require('../services/localStorageUsage');
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_')
// Derived, read-only fields the GET response adds for the General tab
// (#705). They are computed from the environment, never stored, so a
// round-trip of the GET payload must not create phantom setting rows.
|| key === 'general_site_url_env_pinned'
|| key === 'general_site_url_effective';
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);
// Surface whether FRONTEND_URL pins the public origin (#705). The env var
// OVERRIDES general_site_url, so without this the General tab would offer
// an editable field whose value is silently ignored at runtime.
settingsObject.general_site_url_env_pinned = isEnvPinned();
if (settingsObject.general_site_url_env_pinned) {
settingsObject.general_site_url_effective = envPinnedBase();
}
// 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);
// Same derived read-only fields as GET / (#705) — the General tab reads
// through this typed route, so the env-pinned hint must be here too.
if (type === 'general') {
settingsObject.general_site_url_env_pinned = isEnvPinned();
if (settingsObject.general_site_url_env_pinned) {
settingsObject.general_site_url_effective = envPinnedBase();
}
}
// 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;
// The public origin is no longer just an email link: it feeds the CORS
// allowlist (server.js) and the Access-Control-Allow-Origin header
// (secureImageMiddleware) since #705. A schemeless value like
// "gallery.example.com" therefore produces both links that don't resolve
// AND an allowlist entry no browser origin can ever match, so validate it
// server-side rather than trusting the input type (#1104). require_tld is
// off on purpose: LAN and NAS installs legitimately run on http://nas:3000
// or a bare IP. Same validator as oidc_issuer_url above.
if (Object.prototype.hasOwnProperty.call(settings, 'general_site_url')) {
const siteUrl = typeof settings.general_site_url === 'string'
? settings.general_site_url.trim().replace(/\/+$/, '')
: '';
// allow_underscores for the same reason require_tld is off: this has to
// accept the addresses LAN and NAS installs actually run on. Browsers
// resolve http://my_nas.local happily and the client-side check accepts
// it, so rejecting it here only produced a mismatch between the two
// validators — and the wizard has no way to show a 400 it did not
// predict (#1104 review round 2).
const looksValid = validator.isURL(siteUrl, {
protocols: ['http', 'https'],
require_protocol: true,
require_tld: false,
allow_underscores: true,
});
if (siteUrl && !looksValid) {
return res.status(400).json({
error: 'general_site_url must be an absolute http(s) URL, for example https://gallery.example.com'
});
}
settings.general_site_url = siteUrl;
}
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();
}
// The public origin is cached (it now sits in per-request CORS paths and
// in a synchronous accessor); drop it immediately on write so a corrected
// site URL takes effect without waiting out the TTL.
if (Object.prototype.hasOwnProperty.call(settings, 'general_site_url')) {
invalidateSiteUrlCache();
}
// 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 {
// Catalogued original bytes. Reported, but no longer as "used" (#1164) —
// in reference mode those files are on a NAS and none of them are here.
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;
}
}
// What is actually on this disk. This is what the soft limit is compared
// against and what the recommendation below is derived from, so getting it
// from the catalogued originals was the load-bearing half of #1164: a
// reference-mode install got a disk-capacity recommendation computed from
// bytes that are not on the disk.
const catalogedBytes = Number(totalStorage?.total) || 0;
// Gated BEFORE the walk, not after. This endpoint is polled by the sidebar,
// and an S3 install that still has a large local tree from before the
// migration would otherwise pay a full stat-per-file traversal on every
// cold cache only to discard the result.
const usesLocalBackend = (process.env.STORAGE_BACKEND || 'local').toLowerCase() !== 's3';
let localUsage = null;
try {
if (usesLocalBackend) localUsage = await measureLocalStorageUsage();
} catch (err) {
logger.warn(`Storage measurement failed, falling back to catalogued bytes: ${err.message}`);
}
// On an S3 backend the originals, renditions, archives and download caches
// are all objects in the bucket, and STORAGE_PATH holds only incidental
// local files — so the walk would report near-zero and drag the soft-limit
// recommendation down with it. Those installs keep the catalogued figure,
// which is the approximation they had before #1164, and the response says
// which one this is so the UI can label it rather than implying a disk
// measurement it never made.
const measuredFromDisk = usesLocalBackend && !!localUsage;
const totalUsed = measuredFromDisk ? localUsage.total : catalogedBytes;
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,
// What total_used used to be, kept so the UI can show both and the
// difference stops being invisible.
cataloged_bytes: catalogedBytes,
storage_measurement: measuredFromDisk ? 'disk' : (usesLocalBackend ? 'unavailable' : 'catalog'),
storage_breakdown: measuredFromDisk ? localUsage.breakdown : null,
storage_partial: measuredFromDisk ? localUsage.partial : false,
excluded_external_root: measuredFromDisk ? localUsage.excludedExternalRoot : null,
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;