chore: Storage 初版 — 家庭共享物品管理 (React+TS+Vite+Express+DuckDB)
Build and Deploy / build-image (push) Failing after 2m26s
Build and Deploy / build-image (push) Failing after 2m26s
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// 图片: multipart 上传 + 短时签名 URL 鉴权 (决策点 1)
|
||||
// - 上传: POST /api/images (登录即可, 归属家庭由后续挂到物品时确定)
|
||||
// - 签名: POST /api/images/sign {path} -> {url}, 15 分钟内有效, 校验家庭成员
|
||||
// - 读取: GET /api/images/:familyId/:file?exp=&sig=, 校验签名与成员身份
|
||||
// - 物理文件: <UPLOAD_DIR>/<familyId>/<uuid>.<ext>, 不落入任何静态目录
|
||||
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 形如 <familyId>/<fileName> 或 <fileName>
|
||||
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 头, 以便 <img> 直接加载 (决策点 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)
|
||||
}))
|
||||
|
||||
// 物品保存时把暂存的上传文件归入家庭目录, 得到规范路径 <familyId>/<file>
|
||||
// 返回清理后的路径数组; 已是本家庭路径的保持不变
|
||||
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
|
||||
Reference in New Issue
Block a user