// 图片: multipart 上传 + 短时签名 URL 鉴权 (决策点 1) // - 上传: POST /api/images (登录即可, 归属家庭由后续挂到物品时确定) // - 签名: POST /api/images/sign {path} -> {url}, 15 分钟内有效, 校验家庭成员 // - 读取: GET /api/images/:familyId/:file?exp=&sig=, 校验签名与成员身份 // - 物理文件: //., 不落入任何静态目录 import { Router } from 'express' import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import multer from 'multer' import * as auth from '../auth.js' import { dbGetRole, dbListFamiliesForUser } from '../db.js' const router = Router() const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next) const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve(process.cwd(), 'data', 'uploads') fs.mkdirSync(UPLOAD_DIR, { recursive: true }) const SIGN_TTL_MS = 15 * 60 * 1000 const MAX_SIZE = 8 * 1024 * 1024 // 签名密钥: 优先环境变量; 否则持久化到 data/.image-secret, 保证重启后旧签名仍可校验 function secret() { if (process.env.IMAGE_SECRET) return process.env.IMAGE_SECRET const f = path.resolve(process.cwd(), 'data', '.image-secret') fs.mkdirSync(path.dirname(f), { recursive: true }) if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8').trim() const s = crypto.randomBytes(32).toString('hex') fs.writeFileSync(f, s, { mode: 0o600 }) return s } const extWhitelist = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']) async function requireAuth(req, res, next) { try { const h = req.headers.authorization || '' const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null const user = await auth.resolveSession(token) if (!user) return res.status(401).json({ error: '未登录或登录已过期' }) req.user = user next() } catch (e) { next(e) } } const upload = multer({ storage: multer.diskStorage({ destination: (_req, _file, cb) => cb(null, UPLOAD_DIR), filename: (_req, file, cb) => cb(null, crypto.randomUUID() + path.extname(file.originalname).toLowerCase()), }), limits: { fileSize: MAX_SIZE }, fileFilter: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() cb(extWhitelist.has(ext) ? null : new Error('仅支持 jpg/png/webp/gif'), extWhitelist.has(ext)) }, }) function sign(fileKey, exp) { return crypto.createHmac('sha256', secret()).update(`${exp}:${fileKey}`).digest('hex') } // 上传: 存到 staging 目录, 返回临时文件名 (带 familyId 前缀由客户端后续挂载时确定) router.post('/images', requireAuth, upload.single('file'), asyncH(async (req, res) => { if (!req.file) return res.status(400).json({ error: '未收到文件' }) const name = req.file.filename res.json({ path: name, name, size: req.file.size }) })) // 生成短时签名 URL; path 形如 / 或 router.post('/images/sign', requireAuth, asyncH(async (req, res) => { const rawPath = String(req.body?.path || '').trim() if (!rawPath) return res.status(400).json({ error: '缺少 path' }) const parts = rawPath.split('/') const fileName = parts[parts.length - 1] const familyId = parts.length >= 2 ? parts[0] : null // 旧格式 (无 familyId 前缀): 从用户所属家庭中匹配该文件 let fileKey if (familyId) { const role = await dbGetRole(familyId, req.user.id) if (role) fileKey = `${familyId}/${fileName}` } else { const families = await dbListFamiliesForUser(req.user.id) for (const f of families) { const p = path.join(UPLOAD_DIR, f.id, fileName) if (fs.existsSync(p)) { fileKey = `${f.id}/${fileName}`; break } const p2 = path.join(UPLOAD_DIR, fileName) if (fs.existsSync(p2)) { fileKey = fileName; break } } } if (!fileKey) return res.status(403).json({ error: '无权访问该图片' }) res.json({ url: signedUrl(fileKey) }) })) function signedUrl(fileKey) { const exp = Date.now() + SIGN_TTL_MS const sig = sign(fileKey, exp) return `/api/images/${fileKey}?exp=${exp}&sig=${sig}` } // 读取: 验签即鉴权 (签名只能由服务端在成员校验通过后签发, 15 分钟过期) // 不要求 Bearer 头, 以便 直接加载 (决策点 1: 短时签名 URL 免登录浏览) router.get('/images/:familyId/:file', asyncH(async (req, res) => { const { familyId, file } = req.params const { exp, sig } = req.query if (!exp || !sig) return res.status(403).json({ error: '缺少签名参数' }) const expNum = Number(exp) if (!Number.isFinite(expNum) || expNum < Date.now()) return res.status(403).json({ error: '链接已过期' }) const fileKey = `${familyId}/${file}` const expect = sign(fileKey, expNum) const sigBuf = Buffer.from(String(sig), 'utf8') const expectBuf = Buffer.from(expect, 'utf8') if (sigBuf.length !== expectBuf.length || !crypto.timingSafeEqual(sigBuf, expectBuf)) { return res.status(403).json({ error: '签名无效' }) } const full = path.join(UPLOAD_DIR, familyId, file) if (!fs.existsSync(full)) return res.status(404).json({ error: '文件不存在' }) res.sendFile(full) })) // 旧格式兼容: /api/images/:file (无家庭前缀, 文件名反查; 同样验签) router.get('/images/:file([a-zA-Z0-9-]+[.][a-zA-Z0-9]+)', asyncH(async (req, res) => { const { file } = req.params const { exp, sig } = req.query if (!exp || !sig) return res.status(403).json({ error: '缺少签名参数' }) const expNum = Number(exp) if (!Number.isFinite(expNum) || expNum < Date.now()) return res.status(403).json({ error: '链接已过期' }) const fileKey = file const expect = sign(fileKey, expNum) const sigBuf = Buffer.from(String(sig), 'utf8') const expectBuf = Buffer.from(expect, 'utf8') if (sigBuf.length !== expectBuf.length || !crypto.timingSafeEqual(sigBuf, expectBuf)) { return res.status(403).json({ error: '签名无效' }) } const full = path.join(UPLOAD_DIR, file) if (!fs.existsSync(full)) return res.status(404).json({ error: '文件不存在' }) res.sendFile(full) })) // 物品保存时把暂存的上传文件归入家庭目录, 得到规范路径 / // 返回清理后的路径数组; 已是本家庭路径的保持不变 export function finalizeImages(images, familyId) { const dir = path.join(UPLOAD_DIR, familyId) fs.mkdirSync(dir, { recursive: true }) const out = [] for (const img of images || []) { const s = String(img).trim() if (!s) continue if (s.startsWith(familyId + '/')) { out.push(s); continue } const fileName = s.includes('/') ? s.split('/').pop() : s const staging = path.join(UPLOAD_DIR, fileName) if (!fs.existsSync(staging)) continue const dest = path.join(dir, fileName) if (!fs.existsSync(dest)) fs.copyFileSync(staging, dest) try { fs.unlink(staging) } catch { /* ignore */ } out.push(`${familyId}/${fileName}`) } return out } // 删除物品时顺带清理其图片文件 export function deleteImageFiles(images) { for (const img of images || []) { try { fs.unlinkSync(path.join(UPLOAD_DIR, img)) } catch { /* ignore */ } } } export default router