const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const db = require('../db'); const { authMiddleware, adminOnly } = require('../middleware/auth'); const locks = require('../lib/locks'); // 上传扩展名白名单 const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.zip', '.txt', '.md']); // 版块图标专用白名单:仅图片格式,排除 svg(svg 可内嵌脚本有 XSS 风险,头像/图标一律不收) const ICON_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']); // 搜索引擎验证文件白名单:仅 .xml/.html/.txt(Bing/Google/Yandex 站长验证文件就这三类) const VERIFY_EXT = new Set(['.xml', '.html', '.txt']); // 验证文件名安全:不含路径分隔符/控制字符/.. 穿越,≤64 字符,保留原名(站长工具要求完全同名) const VERIFY_NAME_RE = /^[^\\\/\x00-\x1f]{1,64}\.(xml|html|txt)$/i; // 站点核心文件黑名单:删除验证文件时额外拒绝(即使扩展名在白名单内,防误删 index.html 等关键文件) const VERIFY_CORE_FILES = new Set(['index.html', 'admin.html', 'favicon.svg', 'og-default.png', 'og-default.svg', 'robots.txt']); const router = express.Router(); const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); const AVATAR_DIR = path.join(UPLOAD_DIR, 'avatars'); const ICONS_DIR = path.join(UPLOAD_DIR, 'icons'); [UPLOAD_DIR, AVATAR_DIR, ICONS_DIR].forEach(d => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }); // Temporary storage, then rename to SHA-256 hash function shaFileUpload(subdir, maxSize, allowedExts, fieldName = 'file') { const targetDir = subdir === 'avatar' ? AVATAR_DIR : (subdir === 'icon' ? ICONS_DIR : UPLOAD_DIR); const extSet = allowedExts || ALLOWED_EXT; return multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, targetDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname) || ''; const tmpName = Date.now() + '-' + Math.random().toString(36).slice(2, 8) + ext; cb(null, tmpName); } }), fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (!extSet.has(ext)) return cb(new Error('不支持的文件类型')); // 图片类文件需 MIME 也以 image/ 开头,防止扩展名伪装 const isImageExt = ['.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext); if (isImageExt && (!file.mimetype || !file.mimetype.startsWith('image/'))) { return cb(new Error('不支持的文件类型')); } cb(null, true); }, limits: { fileSize: maxSize }, }).single(fieldName); } // After multer saves the file, rename it to its SHA-256 hash (dedup) function hashify(filePath) { const dir = path.dirname(filePath); const ext = path.extname(filePath); const buf = fs.readFileSync(filePath); const hash = crypto.createHash('sha256').update(buf).digest('hex'); const newPath = path.join(dir, hash + ext); if (fs.existsSync(newPath)) { fs.unlinkSync(filePath); // remove duplicate } else { fs.renameSync(filePath, newPath); } return hash + ext; } // Avatar upload router.post('/avatar', authMiddleware, (req, res) => { const upload = shaFileUpload('avatar', 2 * 1024 * 1024); upload(req, res, (err) => { if (err) return res.status(400).json({ error: '上传失败: ' + (err.message || '文件过大') }); if (!req.file) return res.status(400).json({ error: '请选择文件' }); const filename = hashify(req.file.path); const url = '/uploads/avatars/' + filename; db.run('UPDATE users SET avatar = ? WHERE id = ?', [url, req.user.id]); res.json({ url, message: '头像已更新' }); }); }); // 版块图标上传:仅图片(png/jpg/jpeg/gif/webp,排除 svg 防 XSS),最大 1MB。 // 文件名用内容 SHA-256(shaFileUpload→hashify),无用户可控路径。 // 清理策略:上传新图标不自动删旧文件(sha 去重,同名不覆盖;替换由管理端更新 // forum_categories.icon 引用;如需清理可后续按旧 URL 删除,本期保持简单)。 router.post('/icon', authMiddleware, (req, res) => { const upload = shaFileUpload('icon', 1 * 1024 * 1024, ICON_EXT, 'icon'); upload(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件超过 1MB 限制' }); return res.status(400).json({ error: '上传失败: ' + err.message }); } if (!req.file) return res.status(400).json({ error: '请选择文件' }); const filename = hashify(req.file.path); res.json({ url: '/uploads/icons/' + filename, message: '上传成功' }); }); }); // Wallpaper upload router.post('/wallpaper', authMiddleware, (req, res) => { const upload = shaFileUpload('wallpaper', 10 * 1024 * 1024); upload(req, res, (err) => { if (err) return res.status(400).json({ error: '上传失败: ' + err.message }); if (!req.file) return res.status(400).json({ error: '请选择文件' }); const filename = hashify(req.file.path); res.json({ url: '/uploads/' + filename, filename, message: '上传成功' }); }); }); // General file upload router.post('/file', authMiddleware, (req, res) => { const user = db.get('SELECT role FROM users WHERE id = ?', [req.user.id]); const isAdmin = user && user.role === 'admin'; const maxSize = isAdmin ? 100 * 1024 * 1024 : 3 * 1024 * 1024; const upload = shaFileUpload('file', maxSize); upload(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: isAdmin ? '文件过大' : '文件超过 3MB 限制' }); return res.status(400).json({ error: '上传失败: ' + err.message }); } if (!req.file) return res.status(400).json({ error: '请选择文件' }); const filename = hashify(req.file.path); const { ref_type, ref_id } = req.body; const id = db.run( 'INSERT INTO attachments (filename, original_name, size, mime_type, user_id, ref_type, ref_id) VALUES (?, ?, ?, ?, ?, ?, ?)', [filename, req.file.originalname, req.file.size, req.file.mimetype, req.user.id, ref_type || '', ref_id || 0]); const isImage = req.file.mimetype && req.file.mimetype.startsWith('image/'); const tag = isImage ? '[image:' + filename + ']' : '[file:' + filename + ']'; res.json({ id, url: '/uploads/' + filename, original_name: req.file.originalname, size: req.file.size, tag, message: '上传成功' }); }); }); // Avatar by UID endpoint router.get('/avatar-url', (req, res) => { const { uid } = req.query; if (!uid) return res.json({ url: '' }); const user = db.get('SELECT id, email, avatar FROM users WHERE id = ?', [uid]); if (!user) return res.json({ url: '' }); // QQ auto-avatar const qqMatch = user.email && user.email.match(/^(\d+)@qq\.com$/i); const url = user.avatar ? user.avatar : (qqMatch ? 'https://q1.qlogo.cn/g?b=qq&nk=' + qqMatch[1] + '&s=100' : ''); res.json({ url }); }); router.get('/list', authMiddleware, (req, res) => { const { ref_type, ref_id } = req.query; let rows; if (ref_type && ref_id) { rows = db.all('SELECT * FROM attachments WHERE ref_type = ? AND ref_id = ? ORDER BY created_at DESC', [ref_type, ref_id]); } else { rows = db.all('SELECT * FROM attachments ORDER BY created_at DESC LIMIT 50'); } res.json(rows); }); // ── 搜索引擎验证文件(Bing/Google/Yandex 站长工具)──────────────── // 验证文件必须与站长工具给的完全同名且位于站点根目录,经 https://域名/文件名 可访问。 // 存 public/(server.js 已把 public/ 静态挂载在根路径)。仅 admin 可操作。 const VERIFY_DIR = path.join(__dirname, '..', 'public'); // 内存缓冲后先校验内容再写盘:≤64KB,避免后台成为任意文件落地/钓鱼页入口 const verifyUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 64 * 1024 }, }).single('file'); // 验证文件上传(adminOnly):保留原名 + 扩展名白名单 + 内容特征校验 + 拒绝覆盖已有文件 router.post('/verify-file', authMiddleware, adminOnly, (req, res) => { verifyUpload(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件超过 64KB 限制' }); return res.status(400).json({ error: '上传失败: ' + err.message }); } if (!req.file) return res.status(400).json({ error: '请选择文件' }); const original = String(req.file.originalname || ''); // 扩展名白名单(只验扩展名——xml/html 的 MIME 常被浏览器标为 text/xml 等,不强求) const ext = path.extname(original).toLowerCase(); if (!VERIFY_EXT.has(ext)) return res.status(400).json({ error: '仅支持 .xml / .html / .txt 验证文件' }); // 文件名安全:保留原名、防路径穿越(.. / \ 控制字符)、≤64 if (!VERIFY_NAME_RE.test(original) || original.includes('..')) { return res.status(400).json({ error: '文件名不合法' }); } // 内容安全:.xml 以 { const files = []; try { fs.readdirSync(VERIFY_DIR).forEach((name) => { if (!VERIFY_NAME_RE.test(name) || name.includes('..')) return; const fp = path.join(VERIFY_DIR, name); try { if (!fs.statSync(fp).isFile()) return; } catch { return; } files.push({ name, url: '/' + name }); }); } catch { /* 目录读取失败返回空列表 */ } files.sort((a, b) => a.name.localeCompare(b.name)); res.json(files); }); // 删除验证文件(adminOnly):仅允许删白名单扩展名文件,且拒绝站点核心文件 router.delete('/verify-file/:filename', authMiddleware, adminOnly, (req, res) => { const name = String(req.params.filename || ''); if (!VERIFY_NAME_RE.test(name) || name.includes('..')) return res.status(400).json({ error: '非法文件名' }); if (VERIFY_CORE_FILES.has(name)) return res.status(400).json({ error: '不允许删除系统核心文件' }); const target = path.join(VERIFY_DIR, name); if (!target.startsWith(VERIFY_DIR + path.sep)) return res.status(400).json({ error: '非法文件名' }); if (!fs.existsSync(target)) return res.status(404).json({ error: '文件不存在' }); try { fs.unlinkSync(target); } catch (e) { return res.status(500).json({ error: '删除失败' }); } res.json({ message: '已删除' }); }); router.delete('/:id', authMiddleware, (req, res) => { const att = db.get('SELECT * FROM attachments WHERE id = ?', [req.params.id]); if (!att) return res.status(404).json({ error: '文件不存在' }); if (att.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: '无权限' }); const filePath = path.join(UPLOAD_DIR, att.filename); try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {} db.run('DELETE FROM attachments WHERE id = ?', [req.params.id]); res.json({ message: '删除成功' }); }); // 锁定块内附件鉴权接口:/api/upload/locked?ref_type=blog|forum&ref_id=&block=&file=&token= // 前端对已解锁块内的 [image:]/[file:] 附件改用此接口加载(token 由详情/unlock 接口签发)。 // 校验:文件名安全 → 文件存在 → 解锁 token 优先放行 → 无 token 按请求身份 + 块类型判定。 // 失败统一 403(不返回 404,防探测块存在性)。 router.get('/locked', (req, res) => { const { ref_type, ref_id, block, file, token } = req.query; // 1. 文件名安全 const fn = String(file || ''); if (!fn || fn.includes('..') || fn.includes('/')) return res.status(400).json({ error: '非法文件名' }); // 2. 文件存在 const filePath = path.join(UPLOAD_DIR, fn); if (!fs.existsSync(filePath)) return res.status(404).json({ error: '文件不存在' }); // 3. token 优先:解锁 token 有效且 payload 与 query 完全一致 → 直接放行 const payload = locks.verifyLockToken(token); if (payload && String(payload.refType) === String(ref_type) && Number(payload.refId) === Number(ref_id) && Number(payload.blockIndex) === parseInt(block)) { return res.sendFile(filePath); } // 4. 无(匹配的)token 时按请求身份判定:从 Authorization Bearer 或 ?token=(会话 JWT)解析 userId let userId = null; const auth = req.headers.authorization; if (auth && auth.startsWith('Bearer ')) { try { const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const p = jwt.verify(auth.slice(7), SECRET); userId = p.id; } catch {} } if (!userId && req.query.token) { try { const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const p = jwt.verify(req.query.token, SECRET); userId = p.id; } catch {} } if (!userId) return res.status(403).json({ error: '无权限访问该文件' }); // 5. 加载内容并判定块类型 const type = String(ref_type || ''); let row = null; if (type === 'blog') { row = db.get('SELECT content, author_id, published FROM blog_posts WHERE id = ?', [ref_id]); } else if (type === 'forum') { row = db.get('SELECT content, author_id FROM forum_posts WHERE id = ?', [ref_id]); } if (!row) return res.status(403).json({ error: '无权限访问该文件' }); const b = locks.parseLocks(row.content).blocks.find(x => x.index === parseInt(block)); if (!b) return res.status(403).json({ error: '无权限访问该文件' }); const owner = db.get('SELECT role FROM users WHERE id = ?', [userId]); const isAdmin = !!(owner && owner.role === 'admin'); const isAuthor = userId === row.author_id; // 博客草稿:仅 admin/作者可访问其锁定附件(与详情接口的草稿门禁一致) if (type === 'blog' && row.published !== 1 && !(isAdmin || isAuthor)) { return res.status(403).json({ error: '无权限访问该文件' }); } if (isAdmin || isAuthor) return res.sendFile(filePath); if (b.type === 'login') { if (userId) return res.sendFile(filePath); return res.status(403).json({ error: '无权限访问该文件' }); } if (b.type === 'reply') { const replied = type === 'blog' ? !!db.get('SELECT 1 x FROM blog_comments WHERE post_id = ? AND author_id = ?', [ref_id, userId]) : !!db.get('SELECT 1 x FROM forum_replies WHERE post_id = ? AND author_id = ?', [ref_id, userId]); if (replied) return res.sendFile(filePath); return res.status(403).json({ error: '无权限访问该文件' }); } if (b.type === 'password') { // password 块必须走 unlock 接口拿到 token,此处不接受无 token 请求 return res.status(403).json({ error: '无权限访问该文件' }); } return res.status(403).json({ error: '无权限访问该文件' }); }); // Download with auth router.get('/download/:filename', (req, res) => { const fn = req.params.filename; if (fn.includes('..') || fn.includes('/')) return res.status(400).json({ error: '非法文件名' }); let userId = null; const auth = req.headers.authorization; if (auth && auth.startsWith('Bearer ')) { try { const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const p = jwt.verify(auth.slice(7), SECRET); userId = p.id; } catch {} } if (!userId && req.query.token) { try { const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const p = jwt.verify(req.query.token, SECRET); userId = p.id; } catch {} } if (!userId) return res.status(401).json({ error: '请先登录' }); const fp = path.join(UPLOAD_DIR, fn); if (!fs.existsSync(fp)) return res.status(404).json({ error: '文件不存在' }); const att = db.get('SELECT original_name, user_id FROM attachments WHERE filename = ?', [fn]); // M3:归属校验——仅文件所有者或管理员可下载(图片经 /uploads/ 静态伺服不受影响) if (att) { const owner = db.get('SELECT role FROM users WHERE id = ?', [userId]); if (att.user_id !== userId && (!owner || owner.role !== 'admin')) { return res.status(403).json({ error: '无权限访问该文件' }); } } res.download(fp, att ? att.original_name : fn); }); module.exports = router;