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,356 @@
|
||||
// Storage 业务 REST API: 家庭 / 盒子 / 物品 / 分类 / 转移 / 统计 / MCP
|
||||
// 权限模型: 管理员(admin)可写, 成员(member)只读。服务端为唯一权威。
|
||||
import { Router } from 'express'
|
||||
import * as auth from '../auth.js'
|
||||
import { finalizeImages, deleteImageFiles } from './images.js'
|
||||
import {
|
||||
// users/sessions
|
||||
dbGetUserById, dbUpdateUserNickname,
|
||||
// families
|
||||
dbCreateFamily, dbListFamiliesForUser, dbGetFamily, dbUpdateFamily, dbDeleteFamily,
|
||||
dbGetRole, dbAddMember, dbRemoveMember, dbSetRole, dbSetInviteCode, dbJoinByCode,
|
||||
dbListMembers,
|
||||
// categories
|
||||
dbListCategories, dbCreateCategory, dbUpdateCategory, dbDeleteCategory,
|
||||
// boxes
|
||||
dbCreateBox, dbGetBox, dbUpdateBox, dbMoveBox, dbSetBoxPosition, dbDeleteBox,
|
||||
dbListChildBoxes, dbBuildTree, dbBoxPath,
|
||||
// items
|
||||
dbCreateItem, dbGetItem, dbListItems, dbUpdateItem, dbDeleteItem, dbMoveItem,
|
||||
// transfer
|
||||
dbTransfer, dbListTransferLogs,
|
||||
// stats / mcp
|
||||
dbGetFamilyStats, mcpTools, mcpCall,
|
||||
uu,
|
||||
} from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
|
||||
|
||||
// ── 鉴权中间件: Authorization: Bearer <token> ──
|
||||
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
|
||||
req.token = token
|
||||
next()
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 家庭成员校验: familyId 取 URL 中的 :id
|
||||
async function requireMember(req, res, next) {
|
||||
const role = await dbGetRole(req.params.id, req.user.id)
|
||||
if (!role) return res.status(403).json({ error: '你不是该家庭成员' })
|
||||
req.role = role
|
||||
next()
|
||||
}
|
||||
|
||||
// 家庭管理员校验 (所有写操作)
|
||||
async function requireAdmin(req, res, next) {
|
||||
const role = await dbGetRole(req.params.id, req.user.id)
|
||||
if (role !== 'admin') return res.status(403).json({ error: '仅管理员可执行此操作' })
|
||||
req.role = role
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
router.post('/auth/send-code', asyncH(async (req, res) => {
|
||||
const target = String(req.body?.target || '').trim()
|
||||
if (!target) return res.status(400).json({ error: '请提供邮箱或手机号' })
|
||||
res.json({ message: auth.sendVerification(target) })
|
||||
}))
|
||||
|
||||
router.post('/auth/register', asyncH(async (req, res) => {
|
||||
const { email, phone, code, nickname } = req.body || {}
|
||||
res.json(await auth.register({ email: email || null, phone: phone || null, code, nickname }))
|
||||
}))
|
||||
|
||||
router.post('/auth/login', asyncH(async (req, res) => {
|
||||
const { email, phone, code } = req.body || {}
|
||||
res.json(await auth.login({ email: email || null, phone: phone || null, code }))
|
||||
}))
|
||||
|
||||
router.post('/auth/logout', requireAuth, asyncH(async (req, res) => {
|
||||
await auth.logout(req.token)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.get('/auth/me', requireAuth, asyncH(async (req, res) => {
|
||||
res.json({ user: await dbGetUserById(req.user.id) })
|
||||
}))
|
||||
|
||||
router.put('/auth/me', requireAuth, asyncH(async (req, res) => {
|
||||
const nickname = String(req.body?.nickname || '').trim()
|
||||
if (!nickname) return res.status(400).json({ error: '昵称不能为空' })
|
||||
await dbUpdateUserNickname(req.user.id, nickname)
|
||||
res.json({ user: await dbGetUserById(req.user.id) })
|
||||
}))
|
||||
|
||||
// ── Families ──
|
||||
router.get('/families', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbListFamiliesForUser(req.user.id))
|
||||
}))
|
||||
|
||||
router.post('/families', requireAuth, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入家庭名称' })
|
||||
res.json(await dbCreateFamily(req.user.id, name, req.body?.icon || '🏠'))
|
||||
}))
|
||||
|
||||
router.get('/families/:id', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
res.json({
|
||||
family: await dbGetFamily(req.params.id),
|
||||
members: await dbListMembers(req.params.id),
|
||||
my_role: req.role,
|
||||
})
|
||||
}))
|
||||
|
||||
router.put('/families/:id', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入家庭名称' })
|
||||
await dbUpdateFamily(req.params.id, { name, icon: req.body?.icon || '🏠' })
|
||||
res.json({ success: true, family: await dbGetFamily(req.params.id) })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const family = await dbGetFamily(req.params.id)
|
||||
if (family.owner_user_id !== req.user.id) return res.status(403).json({ error: '仅创建者可解散家庭' })
|
||||
await dbDeleteFamily(req.params.id)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// 通过邀请码加入
|
||||
router.post('/families/:id/join', requireAuth, asyncH(async (req, res) => {
|
||||
const code = String(req.body?.invite_code || '').trim().toUpperCase()
|
||||
if (!code) return res.status(400).json({ error: '请输入邀请码' })
|
||||
res.json(await dbJoinByCode(code, req.user.id))
|
||||
}))
|
||||
|
||||
// 邀请码管理 (管理员)
|
||||
router.post('/families/:id/invite', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const action = req.body?.action || 'refresh'
|
||||
if (action === 'clear') {
|
||||
await dbSetInviteCode(req.params.id, null)
|
||||
return res.json({ invite_code: null })
|
||||
}
|
||||
let code
|
||||
do { code = 'ST-' + uu().replaceAll('-', '').slice(0, 8).toUpperCase() } while (code.includes('0') || code.includes('O'))
|
||||
await dbSetInviteCode(req.params.id, code)
|
||||
res.json({ invite_code: code })
|
||||
}))
|
||||
|
||||
router.post('/families/:id/members/:userId/role', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const role = req.body?.role
|
||||
if (!['admin', 'member'].includes(role)) return res.status(400).json({ error: 'role 非法' })
|
||||
const family = await dbGetFamily(req.params.id)
|
||||
const targetExists = (await dbListMembers(req.params.id)).some((m) => m.user_id === req.params.userId)
|
||||
if (!targetExists) return res.status(404).json({ error: '成员不存在' })
|
||||
if (req.params.userId === family.owner_user_id) return res.status(403).json({ error: '不能修改创建者角色' })
|
||||
await dbSetRole(req.params.id, req.params.userId, role)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.post('/families/:id/members/:userId/add', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
await dbAddMember(req.params.id, req.params.userId, req.body?.role || 'member')
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// 移除成员; userId === 'me' 表示自己离开
|
||||
router.delete('/families/:id/members/:userId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const family = await dbGetFamily(req.params.id)
|
||||
const target = req.params.userId === 'me' ? req.user.id : req.params.userId
|
||||
if (target === family.owner_user_id) return res.status(403).json({ error: '创建者不能离开/被移除, 请先解散家庭' })
|
||||
if (target === req.user.id && req.params.userId === 'me') {
|
||||
await dbRemoveMember(req.params.id, target)
|
||||
return res.json({ success: true, left: true })
|
||||
}
|
||||
await dbRemoveMember(req.params.id, target)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Categories ──
|
||||
router.get('/families/:id/categories', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
res.json(await dbListCategories(req.params.id))
|
||||
}))
|
||||
|
||||
router.post('/families/:id/categories', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入分类名称' })
|
||||
res.json(await dbCreateCategory(req.params.id, name))
|
||||
}))
|
||||
|
||||
router.put('/families/:id/categories/:catId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入分类名称' })
|
||||
await dbUpdateCategory(req.params.id, req.params.catId, name)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id/categories/:catId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
await dbDeleteCategory(req.params.id, req.params.catId)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Boxes ──
|
||||
// 直接子盒 (根层 = 不带 parent 参数)
|
||||
router.get('/families/:id/boxes', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
if (req.query.tree === '1') return res.json(await dbBuildTree(req.params.id))
|
||||
const parentId = req.query.parent && req.query.parent !== 'root' ? String(req.query.parent) : null
|
||||
res.json(await dbListChildBoxes(req.params.id, parentId))
|
||||
}))
|
||||
|
||||
router.get('/families/:id/boxes/:boxId', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
const box = await dbGetBox(req.params.id, req.params.boxId)
|
||||
if (!box) return res.status(404).json({ error: '盒子不存在' })
|
||||
const [path, children] = await Promise.all([
|
||||
dbBoxPath(req.params.id, req.params.boxId),
|
||||
dbListChildBoxes(req.params.id, req.params.boxId),
|
||||
])
|
||||
res.json({ box, path, children })
|
||||
}))
|
||||
|
||||
router.post('/families/:id/boxes', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name) return res.status(400).json({ error: '请输入盒子名称' })
|
||||
res.json(await dbCreateBox(req.params.id, {
|
||||
name: String(b.name).trim(),
|
||||
parent_box_id: b.parent_box_id || null,
|
||||
icon: b.icon, color: b.color,
|
||||
pos_x: b.pos_x, pos_y: b.pos_y,
|
||||
width: b.width ?? null, height: b.height ?? null,
|
||||
}))
|
||||
}))
|
||||
|
||||
router.put('/families/:id/boxes/:boxId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const patch = {}
|
||||
if (b.name !== undefined) patch.name = String(b.name).trim()
|
||||
if (b.icon !== undefined) patch.icon = b.icon
|
||||
if (b.color !== undefined) patch.color = b.color
|
||||
if (b.width !== undefined) patch.width = b.width
|
||||
if (b.height !== undefined) patch.height = b.height
|
||||
if (!Object.keys(patch).length) return res.status(400).json({ error: '没有可更新的字段' })
|
||||
await dbUpdateBox(req.params.id, req.params.boxId, patch)
|
||||
res.json({ success: true, box: await dbGetBox(req.params.id, req.params.boxId) })
|
||||
}))
|
||||
|
||||
// 整盒移动 (跨容器 / 根层)
|
||||
router.put('/families/:id/boxes/:boxId/move', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const parent = b.parent_box_id && b.parent_box_id !== 'root' ? String(b.parent_box_id) : null
|
||||
await dbMoveBox(req.params.id, req.params.boxId, parent, b.pos_x ?? 0, b.pos_y ?? 0)
|
||||
res.json({ success: true, box: await dbGetBox(req.params.id, req.params.boxId) })
|
||||
}))
|
||||
|
||||
// 仅调整位置 (画布拖拽)
|
||||
router.put('/families/:id/boxes/:boxId/position', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
await dbSetBoxPosition(req.params.id, req.params.boxId, b.pos_x ?? 0, b.pos_y ?? 0)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id/boxes/:boxId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
await dbDeleteBox(req.params.id, req.params.boxId)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Items ──
|
||||
router.get('/families/:id/items', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
const q = req.query
|
||||
const filters = {
|
||||
boxId: q.box && q.box !== 'root' ? String(q.box) : null,
|
||||
recursive: q.recursive === '1',
|
||||
looseOnly: q.loose === '1',
|
||||
categoryId: q.category || null,
|
||||
tag: q.tag || null,
|
||||
q: q.q ? String(q.q) : null,
|
||||
favorite: q.favorite === '1',
|
||||
expiring: q.expiring === '1',
|
||||
limit: q.limit,
|
||||
}
|
||||
res.json(await dbListItems(req.params.id, filters))
|
||||
}))
|
||||
|
||||
router.post('/families/:id/items', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name) return res.status(400).json({ error: '请输入物品名称' })
|
||||
b.images = finalizeImages(b.images || [], req.params.id)
|
||||
res.json(await dbCreateItem(req.params.id, b))
|
||||
}))
|
||||
|
||||
router.get('/families/:id/items/:itemId', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
const item = await dbGetItem(req.params.id, req.params.itemId)
|
||||
if (!item) return res.status(404).json({ error: '物品不存在' })
|
||||
const path = item.box_id ? await dbBoxPath(req.params.id, item.box_id) : []
|
||||
res.json({ item, box_path: path })
|
||||
}))
|
||||
|
||||
router.put('/families/:id/items/:itemId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (b.images !== undefined) b.images = finalizeImages(b.images || [], req.params.id)
|
||||
await dbUpdateItem(req.params.id, req.params.itemId, b)
|
||||
res.json({ success: true, item: await dbGetItem(req.params.id, req.params.itemId) })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id/items/:itemId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const item = await dbGetItem(req.params.id, req.params.itemId)
|
||||
if (item?.images?.length) deleteImageFiles(item.images)
|
||||
await dbDeleteItem(req.params.id, req.params.itemId)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// 物品移动 (换盒 / 根层)
|
||||
router.put('/families/:id/items/:itemId/move', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const boxId = b.box_id && b.box_id !== 'root' ? String(b.box_id) : null
|
||||
await dbMoveItem(req.params.id, req.params.itemId, boxId)
|
||||
res.json({ success: true, box_id: boxId })
|
||||
}))
|
||||
|
||||
// ── Transfer (跨家庭转移) ──
|
||||
// 权限: 操作者必须同时是 源家庭管理员 与 目标家庭管理员 (见 §14 决策点 2)
|
||||
router.post('/families/:id/transfer', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const dstFamilyId = String(b.dstFamilyId || '')
|
||||
if (!dstFamilyId) return res.status(400).json({ error: '缺少目标家庭' })
|
||||
const dstRole = await dbGetRole(dstFamilyId, req.user.id)
|
||||
if (dstRole !== 'admin') return res.status(403).json({ error: '仅能转移到自己拥有管理权限的家庭' })
|
||||
const dstParent = b.dstParentBoxId && b.dstParentBoxId !== 'root' ? String(b.dstParentBoxId) : null
|
||||
const r = await dbTransfer(req.params.id, {
|
||||
type: b.type,
|
||||
entityId: String(b.entityId || ''),
|
||||
dstFamilyId,
|
||||
dstParentBoxId: dstParent,
|
||||
posX: b.pos_x, posY: b.pos_y,
|
||||
operatorUserId: req.user.id,
|
||||
})
|
||||
res.json(r)
|
||||
}))
|
||||
|
||||
router.get('/families/:id/transfer-logs', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
res.json(await dbListTransferLogs(req.params.id))
|
||||
}))
|
||||
|
||||
// ── Stats ──
|
||||
router.get('/families/:id/stats', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
res.json(await dbGetFamilyStats(req.params.id))
|
||||
}))
|
||||
|
||||
// ── MCP (P1: AI 助手查询) ──
|
||||
router.get('/mcp/tools', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(mcpTools())
|
||||
}))
|
||||
|
||||
router.post('/mcp/call', requireAuth, asyncH(async (req, res) => {
|
||||
const { tool_name, args } = req.body || {}
|
||||
if (!tool_name) return res.status(400).json({ error: '缺少 tool_name' })
|
||||
const r = await mcpCall(tool_name, args || {}, req.user.id)
|
||||
res.json(r.value)
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -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