337 lines
17 KiB
JavaScript
337 lines
17 KiB
JavaScript
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 以 <?xml 开头;.html/.txt 必须含 verification(Google/Bing/Yandex 验证内容均含该词)
|
||
const content = req.file.buffer.toString('utf8');
|
||
if (ext === '.xml' && !content.trimStart().startsWith('<?xml')) {
|
||
return res.status(400).json({ error: '文件内容不是有效的搜索引擎验证文件' });
|
||
}
|
||
if (ext !== '.xml' && !/verification/i.test(content)) {
|
||
return res.status(400).json({ error: '文件内容不是有效的搜索引擎验证文件' });
|
||
}
|
||
// 拒绝覆盖 public/ 下已有文件(防覆盖 index.html/dist/css 等核心文件)
|
||
const target = path.join(VERIFY_DIR, original);
|
||
if (!target.startsWith(VERIFY_DIR + path.sep)) return res.status(400).json({ error: '文件名不合法' });
|
||
if (fs.existsSync(target)) return res.status(409).json({ error: '同名文件已存在,请先删除' });
|
||
fs.writeFileSync(target, req.file.buffer);
|
||
res.json({ url: '/' + original, message: '上传成功' });
|
||
});
|
||
});
|
||
|
||
// 已上传验证文件列表(adminOnly):扫描 public/ 下白名单扩展名文件
|
||
router.get('/verify-files', authMiddleware, adminOnly, (req, res) => {
|
||
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;
|