const express = require('express'); const db = require('../db'); const { authMiddleware, adminOnly } = require('../middleware/auth'); const router = express.Router(); const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color', 'recaptcha_site_key','turnstile_site_key', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style', 'glass_blur','glass_opacity','theme_force_dark', 'captcha_type','captcha_login','captcha_register','captcha_forum', 'rainid_enabled','rainid_register_redirect', 'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar', 'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout', 'footer_style','footer_copyright','footer_powered','footer_desc', 'footer_columns', 'show_uid_in_comments', 'forum_guest_visible']; const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key', 'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark', 'captcha_type','captcha_login','captcha_register','captcha_forum', 'rainid_enabled','rainid_client_id','rainid_discovery_url','rainid_register_redirect', 'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar', 'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout', 'footer_style','footer_copyright','footer_powered','footer_desc', 'footer_columns', 'comment_moderate','comment_notify','show_uid_in_comments', 'proxy_allowed_hosts', 'forum_guest_visible', 'feed_forum_enabled','feed_show_full','feed_max_items']; const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key', 'rainid_client_secret']; // footer_columns JSON 校验:数组(最多 3 栏,并行展示上限);每项 {title: string ≤20, links: [{label ≤50, url ≤200}]}。 // url 白名单:站内相对路径以 / 开头(排除 // 协议相对),或 http(s):// 外链;拒绝 javascript:/data:/vbscript: 等危险协议。 // 返回解析后的数组,非法返回 null。 function validateFooterColumns(raw) { let parsed; try { parsed = JSON.parse(raw); } catch { return null; } if (!Array.isArray(parsed) || parsed.length > 3) return null; for (const col of parsed) { if (!col || typeof col !== 'object') return null; if (typeof col.title !== 'string' || !col.title.trim() || col.title.trim().length > 20) return null; if (!Array.isArray(col.links) || col.links.length > 20) return null; for (const link of col.links) { if (!link || typeof link !== 'object') return null; if (typeof link.label !== 'string' || !link.label.trim() || link.label.trim().length > 50) return null; const url = String(link.url || '').trim(); if (!url || url.length > 200) return null; // 站内相对路径:/ 开头且非 // 开头;外链:http(s):// 开头 if (!(/^\/(?!\/)/.test(url) || /^https?:\/\//i.test(url))) return null; // 危险协议兜底(上方协议白名单已排除,双保险) if (/^(javascript|data|vbscript):/i.test(url)) return null; } } return parsed; } router.get('/public', (req, res) => { const settings = {}; PUBLIC_KEYS.forEach(k => settings[k] = db.getSetting(k)); res.json(settings); }); router.get('/', authMiddleware, adminOnly, (req, res) => { const settings = {}; ALL_KEYS.forEach(k => settings[k] = db.getSetting(k)); res.json(settings); }); router.put('/', authMiddleware, adminOnly, (req, res) => { // footer_columns 特判校验:非法 JSON/结构/危险协议 → 400 不落库(空串表示回退硬编码,放行) if (req.body.footer_columns !== undefined && String(req.body.footer_columns) !== '') { if (!validateFooterColumns(String(req.body.footer_columns))) { return res.status(400).json({ error: '页脚栏目格式无效:需为 [{title, links:[{label,url}]}],标题≤20字、链接标签≤50字、url 仅限站内路径(/开头)或 http(s) 链接' }); } } for (const [key, value] of Object.entries(req.body)) { if (ALLOWED_SET.includes(key)) db.setSetting(key, String(value)); } res.json({ message: '设置已保存' }); }); module.exports = router;