Files
rainblogweb/routes/upload.js
T

337 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/.txtBing/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-256shaFileUpload→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 必须含 verificationGoogle/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;