142 lines
6.3 KiB
JavaScript
142 lines
6.3 KiB
JavaScript
const express = require('express');
|
|
const db = require('../db');
|
|
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
|
|
|
const router = express.Router();
|
|
|
|
router.use((req, res, next) => {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
next();
|
|
});
|
|
|
|
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'];
|
|
|
|
const THEME_RULES = {
|
|
glass_blur: (value) => {
|
|
const raw = String(value).trim();
|
|
const number = Number(raw);
|
|
return /^\d+$/.test(raw) && Number.isInteger(number) && number >= 5 && number <= 40;
|
|
},
|
|
glass_opacity: (value) => {
|
|
const raw = String(value).trim();
|
|
const number = Number(raw);
|
|
return raw !== '' && Number.isFinite(number) && number >= 0.1 && number <= 0.95;
|
|
},
|
|
theme_force_dark: (value) => ['0', '1'].includes(String(value)),
|
|
theme_wallpaper_scale: (value) => ['cover', 'contain', 'repeat', 'stretch'].includes(String(value)),
|
|
nav_style: (value) => ['default', 'glass', 'capsule'].includes(String(value)),
|
|
card_style: (value) => ['default', 'glass'].includes(String(value)),
|
|
};
|
|
|
|
function normalizePrimaryColor(value) {
|
|
const raw = String(value).trim();
|
|
if (/^#[0-9a-f]{3}$/i.test(raw)) {
|
|
return '#' + raw.slice(1).split('').map((c) => c + c).join('').toLowerCase();
|
|
}
|
|
if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.toLowerCase();
|
|
return null;
|
|
}
|
|
|
|
function validateThemeSettings(body) {
|
|
const validated = {};
|
|
|
|
if (body.primary_color !== undefined) {
|
|
const color = normalizePrimaryColor(body.primary_color);
|
|
if (!color) return { error: '主色调格式无效:仅支持 #RGB 或 #RRGGBB' };
|
|
validated.primary_color = color;
|
|
}
|
|
|
|
for (const [key, validate] of Object.entries(THEME_RULES)) {
|
|
if (body[key] === undefined) continue;
|
|
if (!validate(body[key])) return { error: `主题设置 ${key} 的值无效` };
|
|
validated[key] = String(body[key]).trim();
|
|
}
|
|
|
|
return { validated };
|
|
}
|
|
|
|
// 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) => {
|
|
const themeValidation = validateThemeSettings(req.body);
|
|
if (themeValidation.error) return res.status(400).json({ error: themeValidation.error });
|
|
|
|
// 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) 链接'
|
|
});
|
|
}
|
|
}
|
|
const values = { ...req.body, ...themeValidation.validated };
|
|
for (const [key, value] of Object.entries(req.body)) {
|
|
if (ALLOWED_SET.includes(key)) db.setSetting(key, values[key] === undefined ? String(value) : values[key]);
|
|
}
|
|
res.json({ message: '设置已保存' });
|
|
});
|
|
|
|
module.exports = router;
|