Files
picpeak/backend/src/routes/adminSettings.js
T

1048 lines
33 KiB
JavaScript

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { 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 router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// 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: 1 * 1024 * 1024 }, // 1MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
// 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 (allowedMimeTypes.includes(file.mimetype) &&
(file.originalname.toLowerCase().endsWith('.ico') ||
file.originalname.toLowerCase().endsWith('.png'))) {
cb(null, true);
} else {
cb(new Error('Favicon must be PNG or ICO format'));
}
}
});
// Get all settings
router.get('/', adminAuth, async (req, res) => {
try {
const settings = await db('app_settings').select('*');
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
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;
}
});
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
}
});
// Get settings by type
router.get('/:type', adminAuth, 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 => {
if (setting.setting_value) {
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;
}
});
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
}
});
// Get password complexity settings for frontend
router.get('/password/complexity', adminAuth, 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) {
console.error('Password complexity settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch password complexity settings' });
}
});
// Update branding settings
router.put('/branding', adminAuth, 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
} = req.body;
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
};
// 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);
console.log('Deleted favicon file:', faviconPath);
} catch (err) {
console.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);
console.log('Deleted logo file:', logoPath);
} catch (err) {
console.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();
res.json({ message: 'Branding settings updated successfully' });
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
}
});
// Upload logo
router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No logo file uploaded' });
}
// Get old logo to delete
const oldLogoSetting = await db('app_settings')
.where('setting_key', 'branding_logo_path')
.first();
if (oldLogoSetting && oldLogoSetting.setting_value) {
const oldPath = JSON.parse(oldLogoSetting.setting_value);
try {
await fs.unlink(oldPath);
} catch (error) {
console.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: 'branding_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_logo_url',
setting_value: publicPath,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
updated_at: new Date()
});
res.json({
message: 'Logo uploaded successfully',
logoUrl: publicPath
});
} catch (error) {
console.error('Logo upload error:', error);
res.status(500).json({ error: 'Failed to upload logo' });
}
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, 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) {
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
try {
await fs.unlink(oldPath);
} catch (error) {
console.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: publicPath,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
updated_at: new Date()
});
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath
});
} catch (error) {
console.error('Watermark logo upload error:', error);
res.status(500).json({ error: 'Failed to upload watermark logo' });
}
});
// Update theme settings
router.put('/theme', adminAuth, 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) {
console.error('Theme update error:', error);
res.status(500).json({ error: 'Failed to update theme settings' });
}
});
// Update general settings
router.put('/general', adminAuth, async (req, res) => {
try {
const settings = { ...req.body };
let uploadLimitTouched = false;
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;
}
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();
}
// 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) {
console.error('General settings update error:', error);
res.status(500).json({ error: 'Failed to update general settings' });
}
});
// Update security settings
router.put('/security', adminAuth, async (req, res) => {
try {
const settings = req.body;
// 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()
});
}
// 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) {
console.error('Security settings update error:', error);
res.status(500).json({ error: 'Failed to update security settings' });
}
});
// Update analytics settings
router.put('/analytics', adminAuth, async (req, res) => {
try {
const settings = req.body;
// 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) {
console.error('Analytics settings update error:', error);
res.status(500).json({ error: 'Failed to update analytics settings' });
}
});
// Get storage info
router.get('/storage/info', adminAuth, 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) {
console.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) {
console.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) {
console.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) {
console.error('Storage info error:', error);
res.status(500).json({ error: 'Failed to fetch storage information' });
}
});
// Upload favicon endpoint
router.post('/favicon', adminAuth, 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: faviconUrl,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: 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) {
console.error('Error uploading favicon:', error);
res.status(500).json({ error: 'Failed to upload favicon' });
}
});
// Update rate limit settings
router.put('/security/rate-limit', adminAuth, [
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) {
console.error('Rate limit settings update error:', error);
res.status(500).json({ error: 'Failed to update rate limit settings' });
}
});
// Get default public site template
router.get('/public-site/default', adminAuth, 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) {
console.error('Failed to load public site defaults:', error);
res.status(500).json({ error: 'Failed to load defaults' });
}
});
// Reset public site template to defaults
router.post('/public-site/reset', adminAuth, 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) {
console.error('Failed to reset public site template:', error);
res.status(500).json({ error: 'Failed to reset template' });
}
});
module.exports = router;