diff --git a/.prettierrc.json b/.prettierrc.json index d3c2dda..d27acdd 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -9,5 +9,6 @@ "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], "importOrder": ["^[./]"], "importOrderSeparation": true, - "importOrderSortSpecifiers": true + "importOrderSortSpecifiers": true, + "endOfLine": "auto" } diff --git a/CLAUDE.md b/CLAUDE.md index 14ee26b..e40194c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,8 +91,15 @@ All routes follow consistent error handling and validation: - `GET/POST/PUT/DELETE /api/categories` - Category CRUD (admin-only for mutations) - `GET/DELETE /api/photos` - Photo listing and deletion (ownership checks) - `POST /api/share` - Create shareable link with optional password/expiry -- `GET /api/share?token={token}` - Access shared category (validates password/expiry) +- `DELETE /api/share` - Revoke a share link by id (admin) +- `POST /api/share/unlock` - Submit a share password; sets a server-verified HMAC cookie + (`lib/share-auth.ts`). Password travels in the request body, never in a query string. + Note there is **no** `GET /api/share`: the landing page `app/share/[token]/page.tsx` + reads Prisma directly and gates on that cookie. - `GET/PUT /api/users` - User management (admin approves/rejects pending users) +- `DELETE /api/users` - Delete a user; their photos and cloud-drive assets are + **transferred** to another user (there is no asset-destroying path; `driveDecision: +'delete'` is rejected by `planUserDelete`) ### Component Organization @@ -147,11 +154,32 @@ All routes follow consistent error handling and validation: ## Technical Constraints -- **No test suite**: Manual testing required (consider adding tests under `__tests__/`) +- **Tests cover pure logic only**: `pnpm test` runs vitest over `__tests__/`, which is + 161 cases across 7 files — all of them pure modules (`access-rules`, `media-type`, + `asset-deletion`, `prisma-errors`, `validation`, `login-feedback`, `schema-invariants`). + **Route handlers cannot be unit-tested here**: `lib/access.ts`, `lib/storage.ts` and + `lib/share-auth.ts` all `import 'server-only'`, a package that is not installed (it only + type-checks via Next's ambient declaration), and there is no `vi.mock`/`setupFiles` + precedent. So put decisions in a pure `lib/*.ts` and keep I/O in the impure half, which + is the existing `access-rules.ts` / `access.ts` split. Anything touching TOS or Prisma + still needs a machine with `.env` + MySQL + a bucket. +- **Prisma mistakes are invisible**: `types/prisma-client.d.ts` declares `PrismaClient` + with `[key: string]: any` and `*WhereInput = Record`, so a misspelled + `where` field or `_count` relation name passes `tsc` and CI. `__tests__/schema-invariants.test.ts` + exists to catch the ones that matter; extend it rather than trusting the type checker. +- **Two response envelopes coexist** (accepted debt): album/user routes return `{ error }` + while cloud-drive routes return `{ message }`, and the clients read exactly those keys. + Unifying them means touching 11 routes plus 6 components for a cosmetic inconsistency + that breaks nothing, so new error paths keep the file's existing key and add a stable + `code` field instead. - **Database migrations**: Use `prisma:push` instead of formal migrations -- **Image formats**: Limited to JPG, PNG, GIF, WebP (enforced in lib/storage.ts:6) -- **Thumbnail size**: Fixed at 400x400 WebP, quality 80 (lib/storage.ts:63-64) -- **File size limit**: 10MB per upload (lib/storage.ts:7) +- **Image formats**: Limited to JPG, PNG, GIF, WebP (allow-list lives in + `lib/media-type.ts:ALLOWED_IMAGE_MIME`, consumed by `lib/storage.ts`) +- **Thumbnail size**: Fixed at 400x400 WebP, quality 80 (`persistImage` in lib/storage.ts) +- **File size limit**: 10MB per image, 512MB for videos and cloud-drive files + (`MAX_FILE_SIZE` / `MAX_VIDEO_FILE_SIZE` / `MAX_GENERAL_FILE_SIZE` in lib/storage.ts) +- **Line endings**: `core.autocrlf=true` with no `.gitattributes`, so a new `.ts` written + with CRLF fails `pnpm format:check` locally and in CI. Write new files as LF. ## Integration Points diff --git a/__tests__/asset-deletion.test.ts b/__tests__/asset-deletion.test.ts new file mode 100644 index 0000000..bfb3250 --- /dev/null +++ b/__tests__/asset-deletion.test.ts @@ -0,0 +1,505 @@ +import { + type AssetDeleter, + type CleanupUnit, + type PhotoLike, + type UserDeleteInput, + deleteAssetsThenRowsWith, + objectCount, + planFileUnits, + planPhotoUnits, + planUserDelete, + unitForFile, + unitForPhoto, +} from '@/lib/asset-deletion'; +import { describe, expect, it } from 'vitest'; + +const imagePhoto: PhotoLike = { filename: '1700-x.png', mediaType: 'image' }; +const videoPhoto: PhotoLike = { filename: '1700-y.mp4', mediaType: 'video' }; + +describe('unitForPhoto 对 mediaType 的取值矩阵', () => { + const cases: Array<[string, unknown, CleanupUnit['kind']]> = [ + ['image', 'image', 'image-assets'], + ['video', 'video', 'upload-object'], + ['空串', '', 'image-assets'], + ['null', null, 'image-assets'], + ['undefined', undefined, 'image-assets'], + ['大写变体', 'IMAGE', 'image-assets'], + ['枚举漂移出的新值', 'raw', 'image-assets'], + ]; + + for (const [label, mediaType, expected] of cases) { + it(`mediaType=${label} ⇒ ${expected}`, () => { + expect(unitForPhoto({ filename: 'a', mediaType: mediaType as string }).kind).toBe(expected); + }); + } + + it('未知取值一律偏向超集:宁可多删一个不存在的缩略图(204 no-op),不可漏删真实缩略图(永久泄漏)', () => { + // 与旧实现方向相反:app/api/photos/route.ts 原来是 `mediaType === 'image' ? 图片 : 视频`, + // 脏数据会落到少删一侧。 + for (const mediaType of ['', 'null', 'IMAGE', 'raw', undefined]) { + expect(unitForPhoto({ filename: 'a', mediaType: mediaType as string }).kind).toBe( + 'image-assets' + ); + } + }); +}); + +describe('域不可能被路由错(这是本模块存在的理由)', () => { + it('云盘文件在任何输入下都只产 file-asset / drive,没有任何入参能把它送去 uploads/', () => { + for (const filename of ['a.pdf', '', ' ', 'x.png', 'x.mp4']) { + const unit = unitForFile({ filename }); + expect(unit.kind).toBe('file-asset'); + expect(unit.domain).toBe('drive'); + } + }); + + it('相册域的视频与图片都留在 photo 域,不会跑到 files/', () => { + const units = planPhotoUnits([imagePhoto, videoPhoto]).units; + expect(units.every(unit => unit.domain === 'photo')).toBe(true); + expect(units.some(unit => unit.domain === 'drive')).toBe(false); + }); + + it('kind 与 domain 的对应关系是唯一绑定的,不是调用方选的', () => { + const byKind: Record = {}; + for (const unit of [ + ...planPhotoUnits([imagePhoto, videoPhoto]).units, + ...planFileUnits([{ filename: 'a.pdf' }]).units, + ]) { + byKind[unit.kind] = unit.domain; + } + expect(byKind).toEqual({ + 'image-assets': 'photo', + 'upload-object': 'photo', + 'file-asset': 'drive', + }); + }); +}); + +describe('planPhotoUnits / planFileUnits 对空 filename 的处理', () => { + it('空 filename 不产生删除单位', () => { + expect(planPhotoUnits([{ filename: '', mediaType: 'image' }]).units).toEqual([]); + expect(planPhotoUnits([{ filename: ' ', mediaType: 'video' }]).units).toEqual([]); + expect(planFileUnits([{ filename: '' }]).units).toEqual([]); + }); + + it('被跳过的原始值要报出去,不能假装无事发生', () => { + const plan = planPhotoUnits([imagePhoto, { filename: '', mediaType: 'image' }]); + expect(plan.units).toHaveLength(1); + expect(plan.skipped).toEqual(['']); + }); + + it('空的含义是不含任何非空白字符,而不是去掉首尾空白后非空——键名必须原样使用', () => { + const plan = planPhotoUnits([{ filename: ' a.jpg ', mediaType: 'image' }]); + expect(plan.units).toHaveLength(1); + expect(plan.units[0].filename).toBe(' a.jpg '); + }); + + it('理由:buildObjectKey("") 得到的就是 uploads/ 这个前缀标记对象,而删它会成功', () => { + expect(planPhotoUnits([{ filename: '', mediaType: 'video' }]).units).toEqual([]); + }); +}); + +describe('objectCount 区分"删除调用数"与"应当消失的键数"', () => { + it('图片记 2 个键,相册视频与云盘文件各记 1 个', () => { + expect(objectCount(planPhotoUnits([imagePhoto]).units)).toBe(2); + expect(objectCount(planPhotoUnits([videoPhoto]).units)).toBe(1); + expect(objectCount(planFileUnits([{ filename: 'a.pdf' }]).units)).toBe(1); + }); + + it('混合批次里两者相加', () => { + const units = [ + ...planPhotoUnits([imagePhoto, videoPhoto]).units, + ...planFileUnits([{ filename: 'a.pdf' }, { filename: 'b.txt' }]).units, + ]; + expect(units).toHaveLength(4); + expect(objectCount(units)).toBe(5); + }); + + it('去重后 objectCount 与实际键数一致,重复行不会双报', () => { + const units = planPhotoUnits([imagePhoto, imagePhoto, imagePhoto]).units; + expect(units).toHaveLength(1); + expect(objectCount(units)).toBe(2); + }); +}); + +describe('按 (kind, filename) 去重并保留首次出现顺序', () => { + it('同一文件名的图片与视频记录是两种单位,不应互相吞掉', () => { + const units = planPhotoUnits([ + { filename: 'a', mediaType: 'image' }, + { filename: 'a', mediaType: 'video' }, + ]).units; + expect(units.map(unit => unit.kind)).toEqual(['image-assets', 'upload-object']); + }); + + it('相册与云盘同名文件各自保留', () => { + const photoUnits = planPhotoUnits([{ filename: 'same', mediaType: 'video' }]).units; + const fileUnits = planFileUnits([{ filename: 'same' }]).units; + expect(photoUnits).toHaveLength(1); + expect(fileUnits).toHaveLength(1); + expect(photoUnits[0].domain).not.toBe(fileUnits[0].domain); + }); + + it('空输入产出空计划:执行层据此仍会继续删行,没有照片的分类必须还能被删掉', () => { + expect(planPhotoUnits([])).toEqual({ units: [], skipped: [] }); + expect(planFileUnits([])).toEqual({ units: [], skipped: [] }); + expect(objectCount([])).toBe(0); + }); +}); + +// ========= 删除编排:顺序与失败语义 ========= + +type Fake = AssetDeleter & { + log: string[]; + configured: boolean; + failingFilenames: string[]; + configFailures: string[]; + throwingFilenames: string[]; +}; + +function fakeDeleter(overrides: Partial = {}): Fake { + const log: string[] = []; + return { + log, + configured: true, + failingFilenames: [], + configFailures: [], + throwingFilenames: [], + ensureConfigured() { + log.push('configure'); + return this.configured; + }, + async deleteUnit(unit: CleanupUnit) { + log.push(`delete:${unit.filename}`); + if (this.throwingFilenames.includes(unit.filename)) { + throw new Error(`执行器违约抛错:${unit.filename}`); + } + if (this.configFailures.includes(unit.filename)) { + return { ok: false as const, reason: 'config' as const }; + } + if (this.failingFilenames.includes(unit.filename)) { + return { ok: false as const, reason: 'storage' as const }; + } + return { ok: true } as const; + }, + ...overrides, + }; +} + +const mixedUnits = [ + ...planPhotoUnits([imagePhoto, videoPhoto]).units, + ...planFileUnits([{ filename: 'doc.pdf' }]).units, +]; + +describe('deleteAssetsThenRowsWith:对象先、行后', () => { + it('全部成功时按"每个对象一次、删行最后一次"的顺序发生', async () => { + const deleter = fakeDeleter(); + const outcome = await deleteAssetsThenRowsWith(mixedUnits, deleter, async () => { + deleter.log.push('rows'); + return { count: 3 }; + }); + + expect(outcome.ok).toBe(true); + // 删行必须排在所有删除调用之后——这正是缺陷 C 反过来时的样子。 + expect(deleter.log).toEqual([ + 'configure', + 'delete:1700-x.png', + 'delete:1700-y.mp4', + 'delete:doc.pdf', + 'rows', + ]); + }); + + it('objectTotal 数的是键而不是调用数:一张图片要带走两个对象', async () => { + const outcome = await deleteAssetsThenRowsWith( + planPhotoUnits([imagePhoto]).units, + fakeDeleter(), + async () => null + ); + expect(outcome.ok).toBe(true); + if (outcome.ok) expect(outcome.objectTotal).toBe(2); + }); + + it('有任何一个对象失败就绝不写行,行保留以便重试收敛(缺陷 C 的正解)', async () => { + const deleter = fakeDeleter({ failingFilenames: ['1700-y.mp4'] }); + let rowsTouched = 0; + const outcome = await deleteAssetsThenRowsWith(mixedUnits, deleter, async () => { + rowsTouched += 1; + return null; + }); + + expect(outcome.ok).toBe(false); + expect(rowsTouched).toBe(0); + if (!outcome.ok) { + expect(outcome).toEqual({ + ok: false, + attempted: 3, + objectTotal: 4, + failed: 1, + misconfigured: false, + }); + } + }); + + it('一个失败不会把另外两个的成功藏起来:failed 是计数而不是布尔', async () => { + const outcome = await deleteAssetsThenRowsWith( + mixedUnits, + fakeDeleter({ failingFilenames: ['1700-x.png', 'doc.pdf'] }), + async () => null + ); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.failed).toBe(2); + expect(outcome.attempted).toBe(3); + } + }); + + it('缺陷 F:整批全失败也必须回报为非 2xx,而不是旧的 allSettled 从不查看那样返回成功', async () => { + const all = mixedUnits.map(unit => unit.filename); + const outcome = await deleteAssetsThenRowsWith( + mixedUnits, + fakeDeleter({ failingFilenames: all }), + async () => { + throw new Error('不该走到这里'); + } + ); + expect(outcome.ok).toBe(false); + if (!outcome.ok) expect(outcome.failed).toBe(3); + }); +}); + +describe('deleteAssetsThenRowsWith:配置与环境失败要单独可辨', () => { + it('预检不过时一个对象都不发起、一行都不删', async () => { + const deleter = fakeDeleter({ configured: false }); + let rowsTouched = 0; + const outcome = await deleteAssetsThenRowsWith(mixedUnits, deleter, async () => { + rowsTouched += 1; + return null; + }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.misconfigured).toBe(true); + expect(outcome.attempted).toBe(0); + expect(outcome.failed).toBe(0); + } + expect(deleter.log).toEqual(['configure']); + expect(rowsTouched).toBe(0); + }); + + it('单元级 config 失败会汇总成 misconfigured,让客户端收到 500 而不是可重试的 502', async () => { + const outcome = await deleteAssetsThenRowsWith( + mixedUnits, + fakeDeleter({ configFailures: ['doc.pdf'] }), + async () => null + ); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.misconfigured).toBe(true); + expect(outcome.failed).toBe(1); + } + }); + + it('纯传输故障不算 misconfigured:它是重试可得的上游错误', async () => { + const outcome = await deleteAssetsThenRowsWith( + mixedUnits, + fakeDeleter({ failingFilenames: ['doc.pdf'] }), + async () => null + ); + expect(outcome.ok).toBe(false); + if (!outcome.ok) expect(outcome.misconfigured).toBe(false); + }); +}); + +describe('deleteAssetsThenRowsWith:空计划与违约执行器', () => { + it('没有对象要删时直接删行,不预检配置:没有照片的分类必须还能被删掉', async () => { + const deleter = fakeDeleter({ configured: false }); + const outcome = await deleteAssetsThenRowsWith([], deleter, async () => ({ count: 1 })); + + expect(outcome.ok).toBe(true); + if (outcome.ok) expect(outcome.result).toEqual({ count: 1 }); + expect(deleter.log).toEqual([]); + }); + + it('执行器抛异常(违反不抛的约定)时按可重试的存储故障处理,行照样保住', async () => { + const deleter = fakeDeleter({ throwingFilenames: ['1700-x.png'] }); + let rowsTouched = 0; + const outcome = await deleteAssetsThenRowsWith(mixedUnits, deleter, async () => { + rowsTouched += 1; + return null; + }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.failed).toBe(1); + expect(outcome.misconfigured).toBe(false); + } + expect(rowsTouched).toBe(0); + }); + + it('删行阶段自己抛错时不吞:对象已消失,这是唯一需要人介入且唯一可追溯的状态', async () => { + await expect( + deleteAssetsThenRowsWith(mixedUnits, fakeDeleter(), async () => { + throw new Error('P2003'); + }) + ).rejects.toThrow('P2003'); + }); +}); + +// ========= 删除用户的决策表 ========= + +const NO_ASSETS = { photoCount: 0, fileCount: 0, fileSetCount: 0 }; +const base: UserDeleteInput = { + ...NO_ASSETS, + targetUserId: 5, + isSelf: false, + isTargetAdmin: false, + adminCount: 2, + transferToUserId: 9, + transferTargetExists: true, + transferTargetActive: true, + photoDecision: 'transfer', + driveDecision: 'transfer', +}; + +function planFor(overrides: Partial) { + return planUserDelete({ ...base, ...overrides }); +} + +describe('planUserDelete 决策矩阵', () => { + it('零资产用户不需要任何决定,可以直接删除', () => { + expect( + planFor({ + ...NO_ASSETS, + photoDecision: undefined, + driveDecision: undefined, + transferToUserId: undefined, + }) + ).toEqual({ ok: true, photos: 'none', drive: 'none', transferToUserId: undefined }); + }); + + it('该域没有行时多给的决定被忽略而不是报错', () => { + expect(planFor({ ...NO_ASSETS })).toEqual({ + ok: true, + photos: 'none', + drive: 'none', + transferToUserId: undefined, + }); + }); + + it('有照片却没给决定 ⇒ 拒绝', () => { + const plan = planFor({ photoCount: 3, photoDecision: undefined, driveDecision: undefined }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('照片'); + }); + + it('有云盘文件却没被问到 ⇒ 这正是缺陷 D:旧实现只看 _count.photos,云盘从不进问题', () => { + const plan = planFor({ + photoCount: 0, + fileCount: 4, + photoDecision: undefined, + driveDecision: undefined, + }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('云盘'); + }); + + it('只有文件集、没有散装文件时同样需要云盘决定', () => { + const plan = planFor({ fileSetCount: 2, driveDecision: undefined, photoDecision: undefined }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('云盘'); + }); + + it('两个域各自缺决定时两条都报,而不是修完一条再撞下一条', () => { + const plan = planFor({ + photoCount: 1, + fileCount: 1, + photoDecision: undefined, + driveDecision: undefined, + }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors).toHaveLength(2); + }); + + it("'delete' 目前一律拒绝:用户删除只转移资产,不销毁对象", () => { + for (const overrides of [ + { photoCount: 1, photoDecision: 'delete' as const }, + { fileCount: 1, driveDecision: 'delete' as const }, + ]) { + const plan = planFor(overrides); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('只能转移'); + } + }); + + it('相册与云盘都转移成功时给出两个域的计划', () => { + expect(planFor({ photoCount: 2, fileCount: 3, fileSetCount: 1 })).toEqual({ + ok: true, + photos: 'transfer', + drive: 'transfer', + transferToUserId: 9, + }); + }); +}); + +describe('planUserDelete:转移目标必须是能收的人', () => { + it('缺少目标用户 ⇒ 拒绝', () => { + const plan = planFor({ photoCount: 1, transferToUserId: undefined }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('目标用户'); + }); + + it('目标不能是被删的那个用户自己', () => { + const plan = planFor({ photoCount: 1, transferToUserId: 5 }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('正在被删除'); + }); + + it('目标不存在 ⇒ 拒绝(旧实现返回 404 之后就不再检查别的条件了)', () => { + const plan = planFor({ photoCount: 1, transferTargetExists: false }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('目标用户不存在'); + }); + + it('目标尚未激活 ⇒ 不能把资产挂到 pending 账户上', () => { + const plan = planFor({ photoCount: 1, transferTargetActive: false }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('尚未激活'); + }); + + it('rejected 的接收方同样被拒:active 是唯一可收状态', () => { + expect(planFor({ photoCount: 1, transferTargetActive: false }).ok).toBe(false); + }); +}); + +describe('planUserDelete:不让管理员把自己或把系统锁死', () => { + it('不能删除自己(此前无任何检查)', () => { + const plan = planFor({ isSelf: true, photoDecision: undefined, driveDecision: undefined }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('自己'); + }); + + it('不能删除唯一的管理员', () => { + const plan = planFor({ isTargetAdmin: true, adminCount: 1 }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors.join('')).toContain('唯一的管理员'); + }); + + it('还有第二个管理员时允许', () => { + expect(planFor({ isTargetAdmin: true, adminCount: 2, ...NO_ASSETS }).ok).toBe(true); + }); + + it('多条违规一次全部返回:三个问题就报三条', () => { + const plan = planFor({ + photoCount: 4, + fileCount: 2, + isSelf: true, + isTargetAdmin: true, + adminCount: 1, + photoDecision: undefined, + driveDecision: undefined, + transferToUserId: undefined, + }); + expect(plan.ok).toBe(false); + if (!plan.ok) expect(plan.errors).toHaveLength(5); + }); +}); diff --git a/__tests__/login-feedback.test.ts b/__tests__/login-feedback.test.ts new file mode 100644 index 0000000..52b4d27 --- /dev/null +++ b/__tests__/login-feedback.test.ts @@ -0,0 +1,73 @@ +import { LOGIN_FEEDBACK_TEXT, classifySignInError, isAccountBlocked } from '@/lib/login-feedback'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('classifySignInError 分类矩阵', () => { + const table: Array<[string, Parameters[0], string]> = [ + ['待审核文案', LOGIN_FEEDBACK_TEXT.pending, 'pending'], + ['被拒文案', LOGIN_FEEDBACK_TEXT.rejected, 'rejected'], + ['密码错(NextAuth 字面量)', 'CredentialsSignin', 'invalid'], + ['undefined', undefined, 'invalid'], + ['null', null, 'invalid'], + ['空串', '', 'invalid'], + ['任意其它散文', 'Something went wrong', 'invalid'], + ['配置问题也不外泄', 'MissingSecret', 'invalid'], + ]; + + for (const [label, input, expected] of table) { + it(`${label} ⇒ ${expected}`, () => { + expect(classifySignInError(input)).toBe(expected); + }); + } + + it('被拒用户不再被告知"用户名或密码错误"(本次修复的那条谎报)', () => { + // 旧实现只测 `待审核|审核`,而「账户已被拒绝,无法登录」一个都不含,于是密码 + // 正确却被拒绝的人看到的是一句让他继续重试的话。 + expect(classifySignInError(LOGIN_FEEDBACK_TEXT.rejected)).toBe('rejected'); + expect(classifySignInError(LOGIN_FEEDBACK_TEXT.rejected)).not.toBe('invalid'); + }); +}); + +describe('两个特征串必须各自独有,否则匹配顺序会互相抢', () => { + it('pending 的文案里不含 rejected 的特征串,反之亦然', () => { + expect(LOGIN_FEEDBACK_TEXT.pending).toContain('待审核'); + expect(LOGIN_FEEDBACK_TEXT.pending).not.toContain('被拒绝'); + expect(LOGIN_FEEDBACK_TEXT.rejected).toContain('被拒绝'); + expect(LOGIN_FEEDBACK_TEXT.rejected).not.toContain('待审核'); + }); + + it('旧匹配式的漏检原因:被拒文案一个特征词都不含,于是整条分支永不命中', () => { + // 旧代码判的是 `response.error.includes('待审核') || includes('审核')`。 + // pending 命中,而「账户已被拒绝,无法登录」两个都不含 —— 不是顺序抢错, + // 是那条分支根本不存在,所以它一路掉进 else 的"用户名或密码错误"。 + expect(LOGIN_FEEDBACK_TEXT.pending).toContain('审核'); + expect(LOGIN_FEEDBACK_TEXT.rejected).not.toContain('审核'); + expect(LOGIN_FEEDBACK_TEXT.rejected).not.toContain('待审核'); + }); + + it('invalid 的文案不能被误分类成受限状态', () => { + expect(isAccountBlocked(classifySignInError(LOGIN_FEEDBACK_TEXT.invalid))).toBe(false); + }); +}); + +describe('文案只有一个来源(lib/auth.ts 抛的就是它)', () => { + const authSource = readFileSync('lib/auth.ts', 'utf8').replace(/\r\n/g, '\n'); + + it('authorize() 引用 LOGIN_FEEDBACK_TEXT 而不是自己再写一遍', () => { + expect(authSource).toContain('LOGIN_FEEDBACK_TEXT.pending'); + expect(authSource).toContain('LOGIN_FEEDBACK_TEXT.rejected'); + }); + + it('lib/auth.ts 里不再存在手写的状态文案,因此改文案不会把某个分支降级成 invalid', () => { + expect(authSource).not.toContain("'账户待审核"); + expect(authSource).not.toContain("'账户已被拒绝"); + }); +}); + +describe('isAccountBlocked', () => { + it('待审核与被拒绝算受限,密码错不算', () => { + expect(isAccountBlocked('pending')).toBe(true); + expect(isAccountBlocked('rejected')).toBe(true); + expect(isAccountBlocked('invalid')).toBe(false); + }); +}); diff --git a/__tests__/media-type.test.ts b/__tests__/media-type.test.ts new file mode 100644 index 0000000..bcee148 --- /dev/null +++ b/__tests__/media-type.test.ts @@ -0,0 +1,245 @@ +import { + ALLOWED_IMAGE_MIME, + ALLOWED_VIDEO_MIME, + AmbiguousMediaError, + MEDIA_KINDS, + type MediaKind, + extensionForMime, + extensionForUpload, + kindForMime, + mimeFromFilename, + resolveUploadMedia, +} from '@/lib/media-type'; +import { describe, expect, it } from 'vitest'; + +type MediaCase = { + declaredType: string; + name: string; + kind?: MediaKind; + mimeType?: string; + source?: 'declared' | 'extension'; + rejects?: boolean; + why: string; +}; + +const CASES: MediaCase[] = [ + { + declaredType: 'image/png', + name: 'a.png', + kind: 'image', + mimeType: 'image/png', + source: 'declared', + why: '声明可用时直接采信', + }, + { + declaredType: '', + name: 'a.jpg', + kind: 'image', + mimeType: 'image/jpeg', + source: 'extension', + why: '缺陷 G:空 type 的真图片在旧实现里掉进 persistVideo 的视频白名单,被以「仅支持 MP4 / WebM / MOV 视频」误拒', + }, + { + declaredType: '', + name: 'a.mp4', + kind: 'video', + mimeType: 'video/mp4', + source: 'extension', + why: '空 type 的视频靠扩展名回到视频路径', + }, + { + declaredType: '', + name: 'a.MOV', + kind: 'video', + mimeType: 'video/quicktime', + source: 'extension', + why: '扩展名判定不分大小写', + }, + { + declaredType: 'application/octet-stream', + name: 'a.webp', + kind: 'image', + mimeType: 'image/webp', + source: 'extension', + why: 'octet-stream 不算诚实的声明,继续退回扩展名', + }, + { + declaredType: 'video/mp4', + name: 'a.jpg', + kind: 'video', + mimeType: 'video/mp4', + source: 'declared', + why: '说谎的扩展名不盖掉诚实的声明:contentType、缩略图分支与前端渲染都跟着声明走', + }, + { + declaredType: 'text/plain', + name: 'evil.jpg', + kind: 'image', + mimeType: 'image/jpeg', + source: 'extension', + why: '声明不在白名单时退回扩展名,真正的字节由 sharp 在 persistImage 里验证', + }, + { + declaredType: 'video/webm', + name: 'x', + kind: 'video', + mimeType: 'video/webm', + source: 'declared', + why: '无文件名时声明仍然够用', + }, + { + declaredType: 'image/heic', + name: 'a.heic', + rejects: true, + why: '白名单外的声明与未知扩展名都不放行', + }, + { + declaredType: 'image/svg+xml', + name: 'a.svg', + rejects: true, + why: 'svg 刻意不在表里:sharp 处理它会失败', + }, + { + declaredType: '', + name: 'noext', + rejects: true, + why: '既无声明又无可用扩展名 ⇒ 宁可 400 也不猜', + }, + { + declaredType: '', + name: '', + rejects: true, + why: '空文件名同样拒绝', + }, + { + declaredType: 'application/pdf', + name: 'a.pdf', + rejects: true, + why: 'pdf 属于云盘域,相册只收图片与视频', + }, +]; + +const ACCEPTED = CASES.filter(testCase => !testCase.rejects); +const REFUSED = CASES.filter(testCase => testCase.rejects); + +describe('resolveUploadMedia 判定矩阵', () => { + for (const testCase of ACCEPTED) { + it(`declaredType='${testCase.declaredType}' name='${testCase.name}' ⇒ ${testCase.kind}/${testCase.mimeType} via ${testCase.source}(${testCase.why})`, () => { + expect( + resolveUploadMedia({ name: testCase.name, declaredType: testCase.declaredType }) + ).toEqual({ + kind: testCase.kind, + mimeType: testCase.mimeType, + source: testCase.source, + }); + }); + } + + for (const testCase of REFUSED) { + it(`declaredType='${testCase.declaredType}' name='${testCase.name}' ⇒ 抛 AmbiguousMediaError(${testCase.why})`, () => { + expect(() => + resolveUploadMedia({ name: testCase.name, declaredType: testCase.declaredType }) + ).toThrow(AmbiguousMediaError); + }); + } +}); + +describe('mediaType 与 mimeType 永不互斥(两者由同一次调用同时产出)', () => { + for (const testCase of ACCEPTED) { + it(`${testCase.declaredType || '(空声明)'} + ${testCase.name || '(空文件名)'} ⇒ kind 跟着 mimeType 走`, () => { + const media = resolveUploadMedia({ + name: testCase.name, + declaredType: testCase.declaredType, + }); + expect(media.kind).toBe(media.mimeType.startsWith('image/') ? 'image' : 'video'); + }); + } + + it('拒绝时不返回半成品,因此调用方无从写入矛盾的两列', () => { + for (const testCase of REFUSED) { + expect(() => + resolveUploadMedia({ name: testCase.name, declaredType: testCase.declaredType }) + ).toThrow(); + } + }); +}); + +describe('两张白名单', () => { + it('图片与视频白名单无交集', () => { + for (const mime of ALLOWED_IMAGE_MIME) { + expect(ALLOWED_VIDEO_MIME.has(mime)).toBe(false); + } + }); + + it('每个允许的 mime 都能反推出扩展名,否则键名没有扩展名、读时按扩展名认类型就永远失败', () => { + for (const mime of [...ALLOWED_IMAGE_MIME, ...ALLOWED_VIDEO_MIME]) { + expect(kindForMime(mime)).not.toBeNull(); + expect(extensionForMime(mime)).not.toBe(''); + } + }); + + it('反推出来的扩展名再正推回同一个 mime(两张表不能各自漂移)', () => { + for (const mime of [...ALLOWED_IMAGE_MIME, ...ALLOWED_VIDEO_MIME]) { + expect(mimeFromFilename(`x${extensionForMime(mime)}`)).toBe(mime); + } + }); + + it('白名单里的 mime 全部是 image/ 或 video/ 前缀', () => { + for (const mime of [...ALLOWED_IMAGE_MIME, ...ALLOWED_VIDEO_MIME]) { + expect(mime.startsWith('image/') || mime.startsWith('video/')).toBe(true); + } + }); +}); + +describe('mimeFromFilename(原先这张表里一个视频条目都没有)', () => { + it('视频扩展名能被反推', () => { + expect(mimeFromFilename('a.mp4')).toBe('video/mp4'); + expect(mimeFromFilename('a.webm')).toBe('video/webm'); + expect(mimeFromFilename('a.MOV')).toBe('video/quicktime'); + }); + + it('云盘类型保持可反推,preview-proxy 的读时兜底不退化', () => { + expect(mimeFromFilename('a.pdf')).toBe('application/pdf'); + expect(mimeFromFilename('a.md')).toBe('text/markdown'); + expect(mimeFromFilename('a.txt')).toBe('text/plain'); + }); + + it('未知与无扩展名返回 null,由调用方决定兜底值', () => { + expect(mimeFromFilename('a.xyz')).toBeNull(); + expect(mimeFromFilename('noext')).toBeNull(); + expect(mimeFromFilename('')).toBeNull(); + }); +}); + +describe('extensionForUpload(键名扩展名的唯一来源)', () => { + it('文件名里的扩展名优先,不被 MIME 推导覆盖', () => { + expect(extensionForUpload('holiday.jpeg', 'image/jpeg')).toBe('.jpeg'); + }); + + it('文件名没有扩展名时退回 MIME 推导,而不是存成无扩展名的键', () => { + expect(extensionForUpload('noext', 'video/quicktime')).toBe('.mov'); + }); + + it('两头都拿不到时返回空串,调用方仍能得到一个唯一键', () => { + expect(extensionForUpload('noext', 'application/pdf')).toBe(''); + }); + + it('产出的扩展名总是带点且小写', () => { + for (const mime of [...ALLOWED_IMAGE_MIME, ...ALLOWED_VIDEO_MIME]) { + const extension = extensionForUpload('noext', mime); + expect(extension.startsWith('.')).toBe(true); + expect(extension).toBe(extension.toLowerCase()); + } + }); +}); + +describe('kindForMime / MEDIA_KINDS', () => { + it('相册域之外的 mime 返回 null', () => { + expect(kindForMime('application/octet-stream')).toBeNull(); + expect(kindForMime('')).toBeNull(); + }); + + it('联合只有 image 与 video,与 Prisma 的 MediaType 枚举同形', () => { + expect([...MEDIA_KINDS].sort()).toEqual(['image', 'video']); + }); +}); diff --git a/__tests__/params.test.ts b/__tests__/params.test.ts new file mode 100644 index 0000000..b6d166f --- /dev/null +++ b/__tests__/params.test.ts @@ -0,0 +1,19 @@ +import { readId, readInt } from '@/lib/params'; +import { describe, expect, it } from 'vitest'; + +describe('search parameter parsing', () => { + it('accepts only complete decimal integers for numeric pagination values', () => { + expect(readInt({ page: '12' }, 'page')).toBe(12); + expect(readInt({ page: '-2' }, 'page')).toBe(-2); + for (const value of [' 1', '1 ', '1abc', '1.5', '1e2', '9007199254740992']) { + expect(readInt({ page: value }, 'page')).toBeNull(); + } + }); + + it('requires query IDs to be positive int32 decimal values', () => { + expect(readId({ photo: '2147483647' }, 'photo')).toBe(2147483647); + for (const value of ['', '0', '-1', '1abc', '1.5', '2147483648']) { + expect(readId({ photo: value }, 'photo')).toBeNull(); + } + }); +}); diff --git a/__tests__/prisma-errors.test.ts b/__tests__/prisma-errors.test.ts new file mode 100644 index 0000000..82af38b --- /dev/null +++ b/__tests__/prisma-errors.test.ts @@ -0,0 +1,137 @@ +import { mapPrismaError, prismaErrorCode, prismaErrorResponse } from '@/lib/prisma-errors'; +import { describe, expect, it } from 'vitest'; + +/** 造一个尽可能像生成版客户端的对象。 */ +function prismaError(code: unknown, meta: unknown = { target: 'User' }) { + return Object.assign(new Error(`Prisma error ${code}`), { + name: 'PrismaClientKnownRequestError', + code, + meta, + }); +} + +describe('mapPrismaError 状态码矩阵', () => { + const table: Array<[string, number, string]> = [ + ['P2025', 404, 'not_found'], + ['P2003', 409, 'related_records_remain'], + ['P2002', 409, 'unique_conflict'], + ]; + + for (const [code, status, expectCode] of table) { + it(`${code} ⇒ ${status} / ${expectCode}`, () => { + const mapped = mapPrismaError(prismaError(code)); + expect(mapped.status).toBe(status); + expect(mapped.code).toBe(expectCode); + expect(mapped.message.length).toBeGreaterThan(0); + }); + } + + it('未知 P 码与一切非 Prisma 错误都落到 500,不猜语义', () => { + for (const error of [prismaError('P9999'), new Error('boom'), null, undefined, 'P2025', {}]) { + expect(mapPrismaError(error)).toEqual({ + status: 500, + message: '操作失败', + code: 'internal_error', + }); + } + }); +}); + +describe('TosServerError 不能被误认成 Prisma 错误', () => { + it('它同样带一个字符串 code,但那是对象存储的错误码', () => { + const tosLike = Object.assign(new Error('NoSuchKey'), { + name: 'TosServerError', + code: 'NoSuchKey', + }); + expect(prismaErrorCode(tosLike)).toBeNull(); + expect(mapPrismaError(tosLike).status).toBe(500); + }); + + it('AccessDenied 这种可重试的上游故障不能被洗成 404/409', () => { + const tosLike = Object.assign(new Error('AccessDenied'), { + name: 'TosServerError', + code: 'AccessDenied', + }); + const mapped = mapPrismaError(tosLike); + expect(mapped.status).toBe(500); + expect(mapped.code).toBe('internal_error'); + }); +}); + +describe('识别条件必须 name 与 P 形码同时成立', () => { + it('只有 code、没有 name ⇒ 不认', () => { + expect(prismaErrorCode({ code: 'P2025' })).toBeNull(); + }); + + it('只有 name、没有 code ⇒ 不认', () => { + expect(prismaErrorCode({ name: 'PrismaClientKnownRequestError' })).toBeNull(); + }); + + it('code 不是字符串(数字 2025)⇒ 不认', () => { + expect(prismaErrorCode(prismaError(2025))).toBeNull(); + }); + + it('code 形似但非 P 开头(2025、P20、P20255)⇒ 不认', () => { + for (const code of ['2025', 'P20', 'P20255', 'X2025']) { + expect(prismaErrorCode(prismaError(code))).toBeNull(); + } + }); + + it('name 是子类型(PrismaClientValidationError)⇒ 不认,交给 500', () => { + expect( + prismaErrorCode( + Object.assign(new Error('validation'), { + name: 'PrismaClientValidationError', + code: 'P2025', + }) + ) + ).toBeNull(); + }); +}); + +describe('错误文案不外泄内部标识', () => { + it('meta.target、表名与 SQL 片段都不出现在给客户端的消息里', () => { + const leaky = prismaError('P2003', { + target: '`album`.`File_uploader_fkey`', + model: 'User', + database: 'DROP TABLE', + }); + const mapped = mapPrismaError(leaky); + expect(mapped.message).not.toContain('album'); + expect(mapped.message).not.toContain('File_uploader_fkey'); + expect(mapped.message).not.toContain('User'); + expect(mapped.message).not.toContain('DROP'); + expect(mapped).not.toHaveProperty('meta'); + }); + + it('P2025 不能被读成"删除失败":缺陷 F 里不存在 id 的文件集应是 404 而非误导性的 500', () => { + expect(mapPrismaError(prismaError('P2025')).status).toBe(404); + }); +}); + +describe('prismaErrorResponse 保留各域既有包络键', () => { + it('相册与用户域用 error(前端读 body.error)', async () => { + const response = prismaErrorResponse(prismaError('P2025')); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: '记录不存在', + code: 'not_found', + }); + }); + + it('云盘域用 message(前端读 json.message),不能被统一掉', async () => { + const response = prismaErrorResponse(prismaError('P2025'), 'message'); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + message: '记录不存在', + code: 'not_found', + }); + }); + + it('两条包络都不吞掉 code:它才是客户端能稳定分支的东西', async () => { + const response = prismaErrorResponse(prismaError('P2003'), 'message'); + expect(response.status).toBe(409); + const body = (await response.json()) as { code?: string }; + expect(body.code).toBe('related_records_remain'); + }); +}); diff --git a/__tests__/schema-invariants.test.ts b/__tests__/schema-invariants.test.ts new file mode 100644 index 0000000..d530826 --- /dev/null +++ b/__tests__/schema-invariants.test.ts @@ -0,0 +1,143 @@ +import { USER_RESTRICTING_RELATIONS } from '@/lib/asset-deletion'; +import { MEDIA_KINDS } from '@/lib/media-type'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** + * schema.prisma 的离线门禁。 + * + * 为什么需要它:本环境的 Prisma 委托是 `[key: string]: any`(types/prisma-client.d.ts), + * 所以关系字段改名、少一个反向关系、把归属关系改成 Cascade——tsc 与 CI 全部沉默, + * 只有真正跑到那条查询时才会炸。prisma validate 能查关系完整性,但它不在 CI 里, + * 而且看不见"某个字段名被 lib/ 里的代码硬编码引用"这层耦合。 + */ + +/** + * 工作副本是 CRLF(core.autocrlf=true 且仓库没有 .gitattributes),而索引里是 LF。 + * 必须先归一化:任何用 `$` 锚定的正则在 `\r` 存在时都会静默失配,测试会"绿着"什么 + * 都没检查——这比没有测试更糟。 + */ +function readSchema(): string { + return readFileSync('prisma/schema.prisma', 'utf8').replace(/\r\n/g, '\n'); +} + +function modelBlock(source: string, model: string): string { + const match = new RegExp(`^model ${model} \\{\\n([\\s\\S]*?)^\\}`, 'm').exec(source); + if (!match) { + throw new Error(`prisma/schema.prisma 里找不到 model ${model}`); + } + return match[1]; +} + +/** 取某个模型里指定字段的整行声明。 */ +function fieldLine(source: string, model: string, field: string): string { + const block = modelBlock(source, model); + const match = new RegExp(`^\\s+${field}\\s+.*$`, 'm').exec(block); + if (!match) { + throw new Error(`model ${model} 里没有字段 ${field}`); + } + return match[0]; +} + +/** 模型里所有指向另一模型的列表型反向关系字段名。 */ +function backRelationFields(source: string, model: string): string[] { + const block = modelBlock(source, model); + return [...block.matchAll(/^\s+(\w+)\s+\w+\[\]/gm)].map(entry => entry[1]); +} + +function enumMembers(source: string, enumName: string): string[] { + const match = new RegExp(`^enum ${enumName} \\{\\n([\\s\\S]*?)^\\}`, 'm').exec(source); + if (!match) { + throw new Error(`prisma/schema.prisma 里没有 enum ${enumName}`); + } + return [...match[1].matchAll(/^\s+(\w+)\s*$/gm)].map(entry => entry[1]); +} + +const schema = readSchema(); + +describe('归属关系禁止 onDelete: Cascade(缺陷 B 的成因)', () => { + const ownership: Array<[string, string]> = [ + ['Photo', 'uploader'], + ['File', 'uploader'], + ['FileSet', 'creator'], + ]; + + for (const [model, field] of ownership) { + it(`${model}.${field} 没有 onDelete: Cascade`, () => { + const line = fieldLine(schema, model, field); + expect(line).not.toMatch(/onDelete:\s*Cascade/); + }); + } + + it('理由是:Cascade 会让子行在库内消失,应用从此读不到 filename,泄漏不可追溯', () => { + // 用户删除因此不能靠 Cascade "修"好 P2003——必须像现在这样先显式处置子行。 + for (const [model, field] of ownership) { + expect(fieldLine(schema, model, field)).not.toMatch(/onDelete:/); + } + }); +}); + +describe('生命周期关系必须保留 onDelete: Cascade', () => { + const lifecycle: Array<[string, string]> = [ + ['Photo', 'category'], + ['File', 'fileSet'], + ['ShareLink', 'category'], + ]; + + for (const [model, field] of lifecycle) { + it(`${model}.${field} 带 onDelete: Cascade`, () => { + expect(fieldLine(schema, model, field)).toMatch(/onDelete:\s*Cascade/); + }); + } + + it('防止有人用"去掉级联"来绕过缺陷 B:正解是在删父行之前枚举子行的对象键', () => { + // lib/asset-cleanup.ts 的 listPhotoUnitsOfCategory / listFileUnitsOfFileSet 依赖 + // 这个级联仍然存在(它负责清掉子行),去掉级联只会让删除变成半成功。 + expect(fieldLine(schema, 'Photo', 'category')).toMatch(/onDelete:\s*Cascade/); + expect(fieldLine(schema, 'File', 'fileSet')).toMatch(/onDelete:\s*Cascade/); + }); +}); + +describe('model User 上会阻止删除的关系与 lib/asset-deletion.ts 的清单逐字一致', () => { + it('这是 D 里最危险的静默错误:字段名拼错会数出一个错误的 0,tsc 与 CI 都不会报', () => { + expect(backRelationFields(schema, 'User').sort()).toEqual( + [...USER_RESTRICTING_RELATIONS].sort() + ); + }); + + it('清单里的每个名字都真的是 model User 上的字段(app/api/users/route.ts 按名字 _count 它们)', () => { + for (const relation of USER_RESTRICTING_RELATIONS) { + expect(fieldLine(schema, 'User', relation)).toMatch(/\w+\[\]/); + } + }); +}); + +describe('MediaType 枚举与 lib/media-type.ts 的联合同形', () => { + it('两边成员一致,扩枚举而不改纯层会被拦下', () => { + expect(enumMembers(schema, 'MediaType').sort()).toEqual([...MEDIA_KINDS].sort()); + }); +}); + +describe('解析器本身没有静默失配(否则上面所有断言都是空的)', () => { + it('能取到 model 与 enum,且已知字段可寻', () => { + expect(modelBlock(schema, 'Photo')).toContain('filename'); + expect(enumMembers(schema, 'CategoryVisibility').sort()).toEqual([ + 'internal', + 'private', + 'public', + ]); + expect(fieldLine(schema, 'User', 'username')).toContain('@unique'); + }); + + it('找不到时抛错而不是返回空串', () => { + expect(() => modelBlock(schema, 'NoSuchModel')).toThrow(); + expect(() => fieldLine(schema, 'Photo', 'nonexistent')).toThrow(); + }); + + it('LF 与 CRLF 输入都能归一化后正确解析', () => { + const crlfSchema = schema.replace(/\n/g, '\r\n'); + const normalized = crlfSchema.replace(/\r\n/g, '\n'); + expect(normalized).toBe(schema); + expect(modelBlock(normalized, 'Photo')).toContain('filename'); + }); +}); diff --git a/__tests__/upload-intent-cleanup.test.ts b/__tests__/upload-intent-cleanup.test.ts new file mode 100644 index 0000000..5aea8db --- /dev/null +++ b/__tests__/upload-intent-cleanup.test.ts @@ -0,0 +1,58 @@ +import { cleanupExpiredUploadIntents } from '@/lib/upload-intent-cleanup'; +import { describe, expect, it, vi } from 'vitest'; + +describe('cleanupExpiredUploadIntents', () => { + it('deletes storage before intent rows and respects batch bound', async () => { + const order: string[] = []; + const result = await cleanupExpiredUploadIntents( + Array.from({ length: 3 }, (_, index) => ({ + id: `id-${index}`, + storageKeys: [`key-${index}`], + })), + { + deleteObject: async key => { + order.push(`object:${key}`); + }, + deleteIntent: async id => { + order.push(`intent:${id}`); + }, + }, + 2 + ); + expect(result).toEqual({ cleaned: 2, failures: [] }); + expect(order).toEqual(['object:key-0', 'intent:id-0', 'object:key-1', 'intent:id-1']); + }); + + it('removes image and thumbnail objects before deleting the intent row', async () => { + const order: string[] = []; + const result = await cleanupExpiredUploadIntents( + [{ id: 'image-intent', storageKeys: ['uploads/photo.png', 'thumbs/thumb-photo.png'] }], + { + deleteObject: async key => { + order.push(`object:${key}`); + }, + deleteIntent: async id => { + order.push(`intent:${id}`); + }, + } + ); + expect(result).toEqual({ cleaned: 1, failures: [] }); + expect(order).toEqual([ + 'object:uploads/photo.png', + 'object:thumbs/thumb-photo.png', + 'intent:image-intent', + ]); + }); + + it('keeps an intent discoverable when storage deletion fails', async () => { + const deleteIntent = vi.fn(); + const result = await cleanupExpiredUploadIntents([{ id: 'retry', storageKeys: ['key'] }], { + deleteObject: async () => { + throw new Error('storage unavailable'); + }, + deleteIntent, + }); + expect(result).toEqual({ cleaned: 0, failures: ['retry'] }); + expect(deleteIntent).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/upload-intent-schema.test.ts b/__tests__/upload-intent-schema.test.ts new file mode 100644 index 0000000..4ddfb4c --- /dev/null +++ b/__tests__/upload-intent-schema.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const schema = readFileSync('prisma/schema.prisma', 'utf8').replace(/\r\n/g, '\n'); + +describe('upload intent persistence', () => { + it('uses a unique Photo-to-intent relation as the atomic completion claim', () => { + expect(schema).toMatch(/^ uploadIntentId\s+String\?\s+@unique\s+@db\.VarChar\(36\)$/m); + expect(schema).toMatch( + /^ uploadIntent\s+UploadIntent\?\s+@relation\(fields: \[uploadIntentId\], references: \[id\], onDelete: SetNull\)$/m + ); + }); + + it('persists expiry, expected byte size, and object key for bounded cleanup and verification', () => { + const intent = /^model UploadIntent \{([\s\S]*?)^\}/m.exec(schema)?.[1]; + expect(intent).toBeDefined(); + expect(intent).toMatch(/^ storageKey\s+String\s+@unique\s+@db\.VarChar\(512\)$/m); + expect(intent).toMatch(/^ thumbnailKey\s+String\?\s+@db\.VarChar\(512\)$/m); + expect(intent).toMatch(/^ expectedSize\s+Int$/m); + expect(intent).toMatch(/^ expiresAt\s+DateTime$/m); + expect(intent).toMatch(/^ completedAt\s+DateTime\?$/m); + expect(intent).toMatch(/^ photo\s+Photo\?$/m); + }); +}); diff --git a/__tests__/upload-intent-validation.test.ts b/__tests__/upload-intent-validation.test.ts new file mode 100644 index 0000000..0d48e60 --- /dev/null +++ b/__tests__/upload-intent-validation.test.ts @@ -0,0 +1,56 @@ +import { validateUploadObjectMetadata } from '@/lib/upload-intent-validation'; +import { describe, expect, it } from 'vitest'; + +describe('upload object verification', () => { + const expected = { mediaType: 'image' as const, mimeType: 'image/jpeg', size: 2048 }; + + it('accepts the exact size and MIME type case-insensitively', () => { + expect( + validateUploadObjectMetadata(expected, { size: 2048, contentType: 'IMAGE/JPEG' }) + ).toBeNull(); + }); + + it('rejects absent, changed, oversized, or non-finite object sizes', () => { + for (const size of [ + 0, + 2047, + 2049, + 10 * 1024 * 1024 + 1, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + expect(validateUploadObjectMetadata(expected, { size })).toBe('size_mismatch'); + } + }); + + it('rejects a stored MIME type that differs from the authorized intent', () => { + expect(validateUploadObjectMetadata(expected, { size: 2048, contentType: 'image/png' })).toBe( + 'mime_mismatch' + ); + }); + + it('allows a missing stored MIME header when the provider omits it', () => { + expect(validateUploadObjectMetadata(expected, { size: 2048 })).toBeNull(); + }); + + it('applies separate image and video maximum sizes', () => { + expect( + validateUploadObjectMetadata( + { mediaType: 'video', mimeType: 'video/mp4', size: 512 * 1024 * 1024 }, + { size: 512 * 1024 * 1024 } + ) + ).toBeNull(); + expect( + validateUploadObjectMetadata( + { mediaType: 'image', mimeType: 'image/png', size: 10 * 1024 * 1024 + 1 }, + { size: 10 * 1024 * 1024 + 1 } + ) + ).toBe('size_mismatch'); + expect( + validateUploadObjectMetadata( + { mediaType: 'video', mimeType: 'video/mp4', size: 512 * 1024 * 1024 + 1 }, + { size: 512 * 1024 * 1024 + 1 } + ) + ).toBe('size_mismatch'); + }); +}); diff --git a/__tests__/validation.test.ts b/__tests__/validation.test.ts new file mode 100644 index 0000000..0f69f95 --- /dev/null +++ b/__tests__/validation.test.ts @@ -0,0 +1,83 @@ +import { idSchema, idStringSchema, optionalIdSchema, visibilitySchema } from '@/lib/validation'; +import { describe, expect, it } from 'vitest'; + +describe('idSchema', () => { + it('接受合法自增主键', () => { + for (const value of [1, 42, 2147483647]) { + expect(idSchema.safeParse(value).success).toBe(true); + } + }); + + it('拒绝 0 与负数(此前 9 处放行,会一路流到 prisma.update/delete 变成裸 500)', () => { + for (const value of [0, -1, -99999]) { + expect(idSchema.safeParse(value).success).toBe(false); + } + }); + + it('拒绝非整数与 NaN/Infinity', () => { + for (const value of [1.5, NaN, Infinity, -Infinity]) { + expect(idSchema.safeParse(value).success).toBe(false); + } + }); + + it('拒绝字符串 id:JSON 接口不做隐式转换', () => { + for (const value of ['1', '42']) { + expect(idSchema.safeParse(value).success).toBe(false); + } + }); + + it('拒绝 null 与 undefined', () => { + expect(idSchema.safeParse(null).success).toBe(false); + expect(idSchema.safeParse(undefined).success).toBe(false); + }); + + it('拒绝超出 MySQL 有符号 INT 的值:1e21 被 .int() 连坐拒掉,3e9 只能靠 .max()', () => { + // zod 的 .int() 要求"安全整数",所以它自己就拒 1e21;真正需要 .max() 兜住的是 + // 2^31-1 到安全整数上限之间那段——3000000000 是合法安全整数,却是 MySQL INT 溢出。 + expect(Number.isSafeInteger(1e21)).toBe(false); + expect(Number.isSafeInteger(3000000000)).toBe(true); + + expect(idSchema.safeParse(1e21).success).toBe(false); + expect(idSchema.safeParse(2147483648).success).toBe(false); + expect(idSchema.safeParse(3000000000).success).toBe(false); + }); + + it('错误信息说清是哪一条规则没通过', () => { + expect(idSchema.safeParse(0).error?.issues[0].message).toBe('ID 必须为正整数'); + expect(idSchema.safeParse(1.5).error?.issues[0].message).toBe('ID 必须是整数'); + expect(idSchema.safeParse(3000000000).error?.issues[0].message).toBe('ID 超出范围'); + }); +}); + +describe('idStringSchema(路径与 query 参数)', () => { + it('接受十进制正整数并统一转成数据库 int', () => { + expect(idStringSchema.parse('1')).toBe(1); + expect(idStringSchema.parse('2147483647')).toBe(2147483647); + }); + + it('拒绝空白、前后缀、零、负数、小数与超范围值', () => { + for (const value of ['', ' 1', '1 ', '1abc', '1.5', '0', '-1', '2147483648']) { + expect(idStringSchema.safeParse(value).success).toBe(false); + } + }); +}); + +describe('optionalIdSchema', () => { + it('缺省合法,但给了就必须是合法 id(transferToUserId 就是这个形状)', () => { + expect(optionalIdSchema.safeParse(undefined).success).toBe(true); + expect(optionalIdSchema.safeParse(7).success).toBe(true); + expect(optionalIdSchema.safeParse(0).success).toBe(false); + expect(optionalIdSchema.safeParse(null).success).toBe(false); + }); +}); + +describe('visibilitySchema(既有导出,此前无测试)', () => { + it('只认 private/internal/public', () => { + for (const value of ['private', 'internal', 'public']) { + expect(visibilitySchema.safeParse(value).success).toBe(true); + } + for (const value of ['restricted', 'PUBLIC', '', null, undefined]) { + expect(visibilitySchema.safeParse(value).success).toBe(false); + } + }); +}); diff --git a/app/admin/_tab-content.tsx b/app/admin/_tab-content.tsx index 62462e7..c2fd4de 100644 --- a/app/admin/_tab-content.tsx +++ b/app/admin/_tab-content.tsx @@ -9,6 +9,7 @@ import type { ShareLinkItem, UserItem, } from '@/components/admin/types'; +import { USER_ASSET_COUNT_SELECT, type UserAssetCounts } from '@/lib/asset-deletion'; import { prisma } from '@/lib/db'; import { type SearchParams, clampPage, readInt, readString } from '@/lib/params'; @@ -31,7 +32,7 @@ type UserRow = { role: 'admin' | 'member'; status: 'pending' | 'active' | 'rejected'; createdAt: Date; - _count: { photos: number }; + _count: UserAssetCounts; }; type ShareLinkRow = { @@ -59,6 +60,8 @@ function toUserItem(row: UserRow): UserItem { role: row.role, status: row.status, photoCount: row._count.photos, + fileCount: row._count.filesUploaded, + fileSetCount: row._count.fileSetsCreated, createdAt: row.createdAt.toISOString(), }; } @@ -137,12 +140,12 @@ export async function AdminTabContent({ orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, - include: { _count: { select: { photos: true } } }, + include: { _count: { select: { ...USER_ASSET_COUNT_SELECT } } }, }), prisma.user.findMany({ where: { status: 'pending' }, orderBy: { createdAt: 'desc' }, - include: { _count: { select: { photos: true } } }, + include: { _count: { select: { ...USER_ASSET_COUNT_SELECT } } }, }), ])) as [UserRow[], UserRow[]]; diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index 835263f..13d554d 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -6,7 +6,8 @@ import { UploadDialog } from '@/components/upload-dialog'; import { getViewer } from '@/lib/access'; import { type Visibility, canViewCategory, categoryWhereFor } from '@/lib/access-rules'; import { prisma } from '@/lib/db'; -import { type SearchParams, clampPage, readInt, readSort } from '@/lib/params'; +import { type SearchParams, clampPage, readId, readInt, readSort } from '@/lib/params'; +import { idStringSchema } from '@/lib/validation'; import { format } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import { CalendarClock, CalendarDays, Image as ImageIcon, Images } from 'lucide-react'; @@ -31,10 +32,9 @@ export default async function AlbumPage({ const { id } = await params; const q = (await searchParams) ?? {}; const sort = readSort(q); - const categoryId = Number.parseInt(id, 10); - if (!Number.isInteger(categoryId)) { - notFound(); - } + const parsedCategoryId = idStringSchema.safeParse(id); + if (!parsedCategoryId.success) notFound(); + const categoryId = parsedCategoryId.data; const viewer = await getViewer(); @@ -67,7 +67,7 @@ export default async function AlbumPage({ const page = clampPage(readInt(q, 'p'), total, PHOTO_PAGE_SIZE); // ?photo= 一般只由客户端浅层写入,这里读一次是为了让深链指向的照片 // 即使不在当前页窗口里也能打开灯箱 - const deepPhotoId = readInt(q, 'photo'); + const deepPhotoId = readId(q, 'photo'); const uploadCategories = viewer ? await prisma.category.findMany({ diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index 77a10b5..8d4cc22 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/route.ts @@ -1,8 +1,15 @@ import { getViewer } from '@/lib/access'; import { type Visibility, categoryWhereFor } from '@/lib/access-rules'; +import { + cleanupErrorResponse, + deleteAssetsThenRows, + listPhotoUnitsOfCategory, + reportSkippedNames, +} from '@/lib/asset-cleanup'; import { requireAdmin } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; -import { visibilitySchema } from '@/lib/validation'; +import { prismaErrorResponse } from '@/lib/prisma-errors'; +import { idSchema, visibilitySchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -13,11 +20,11 @@ const categoryCreateSchema = z.object({ }); const categoryUpdateSchema = categoryCreateSchema.extend({ - id: z.number().int(), + id: idSchema, }); const categoryDeleteSchema = z.object({ - id: z.number().int(), + id: idSchema, }); type CategoryWithCount = { @@ -57,7 +64,7 @@ export async function GET() { export async function POST(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parseResult = categoryCreateSchema.safeParse(body); @@ -77,7 +84,7 @@ export async function POST(request: Request) { export async function PUT(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parseResult = categoryUpdateSchema.safeParse(body); @@ -85,20 +92,25 @@ export async function PUT(request: Request) { return NextResponse.json({ error: parseResult.error.flatten().fieldErrors }, { status: 400 }); } - const category = await prisma.category.update({ - where: { id: parseResult.data.id }, - data: { - name: parseResult.data.name, - description: parseResult.data.description, - visibility: parseResult.data.visibility, - }, - }); - return NextResponse.json(category); + try { + const category = await prisma.category.update({ + where: { id: parseResult.data.id }, + data: { + name: parseResult.data.name, + description: parseResult.data.description, + visibility: parseResult.data.visibility, + }, + }); + return NextResponse.json(category); + } catch (error) { + // DELETE 不在此列:它的清理与错误处理在同一个改动里落地(缺陷 B)。 + return prismaErrorResponse(error); + } } export async function DELETE(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parseResult = categoryDeleteSchema.safeParse(body); @@ -106,8 +118,29 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: parseResult.error.flatten().fieldErrors }, { status: 400 }); } - await prisma.category.delete({ - where: { id: parseResult.data.id }, + const { id } = parseResult.data; + + const category = await prisma.category.findUnique({ + where: { id }, + select: { id: true }, }); - return NextResponse.json({ success: true }); + if (!category) { + return NextResponse.json({ error: '分类不存在' }, { status: 404 }); + } + + // 必须在删分类之前取走子照片的对象键:Photo.category 的 onDelete: Cascade 会让这些行 + // 在库内消失,而它们是那些对象存在过的唯一记录。缺这一步就是缺陷 B 本身。 + const plan = await listPhotoUnitsOfCategory(id); + reportSkippedNames('DELETE /api/categories', plan.skipped); + + try { + const outcome = await deleteAssetsThenRows(plan.units, () => + prisma.category.delete({ where: { id } }) + ); + if (!outcome.ok) return cleanupErrorResponse(outcome); + + return NextResponse.json({ success: true }); + } catch (error) { + return prismaErrorResponse(error); + } } diff --git a/app/api/files/[id]/route.ts b/app/api/files/[id]/route.ts index d94b0be..aea6a06 100644 --- a/app/api/files/[id]/route.ts +++ b/app/api/files/[id]/route.ts @@ -1,7 +1,15 @@ import { canTouchFileSet } from '@/lib/access-rules'; -import { requireAdmin, requireAuth } from '@/lib/auth-guards'; +import { + cleanupErrorResponse, + deleteAssetsThenRows, + reportSkippedNames, +} from '@/lib/asset-cleanup'; +import { planFileUnits } from '@/lib/asset-deletion'; +import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; -import { deleteFileAsset, getPublicFileUrl } from '@/lib/storage'; +import { prismaErrorResponse } from '@/lib/prisma-errors'; +import { getPublicFileUrl } from '@/lib/storage'; +import { idStringSchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -15,8 +23,9 @@ const updateSchema = z.object({ export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id: idStr } = await params; - const id = Number(idStr); - if (Number.isNaN(id)) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const parsedId = idStringSchema.safeParse(idStr); + if (!parsedId.success) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const id = parsedId.data; const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; @@ -77,8 +86,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id: idStr } = await params; - const id = Number(idStr); - if (Number.isNaN(id)) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const parsedId = idStringSchema.safeParse(idStr); + if (!parsedId.success) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const id = parsedId.data; const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; @@ -137,39 +147,43 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri * DELETE /api/files/:id - Delete file (uploader or admin only) */ export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const { id: idStr } = await params; - const id = Number(idStr); - if (Number.isNaN(id)) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const { id: idStr } = await params; + const parsedId = idStringSchema.safeParse(idStr); + if (!parsedId.success) { + return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + } + const id = parsedId.data; - const authCheck = await requireAuth(); - if (!authCheck.ok) return authCheck.error; - const { viewer } = authCheck; + const authCheck = await requireAuth(); + if (!authCheck.ok) return authCheck.error; + const { viewer } = authCheck; - const file = await prisma.file.findUnique({ - where: { id }, - select: { filename: true, uploaderId: true }, - }); + const file = await prisma.file.findUnique({ + where: { id }, + select: { filename: true, uploaderId: true }, + }); - if (!file) return NextResponse.json({ message: '未找到' }, { status: 404 }); + if (!file) return NextResponse.json({ message: '未找到' }, { status: 404 }); - // Only uploader or admin can delete - if (viewer.role !== 'admin' && file.uploaderId !== viewer.id) { - return NextResponse.json({ message: '无权限' }, { status: 403 }); - } + // Only uploader or admin can delete + if (viewer.role !== 'admin' && file.uploaderId !== viewer.id) { + return NextResponse.json({ message: '无权限' }, { status: 403 }); + } - // Delete from storage - await deleteFileAsset(file.filename); + const plan = planFileUnits([file]); + reportSkippedNames('DELETE /api/files/:id', plan.skipped); - // Delete from database - await prisma.file.delete({ where: { id } }); + try { + // 这里原本已是"存储先、库后",换用同一入口不是为了改顺序,而是为了拿到 + // 配置预检、失败计数与可区分的状态码:裸 deleteFileAsset 抛错时客户端只能收到 + // 一句笼统的「删除失败」500,分不清是重试可得还是永远不行。 + const outcome = await deleteAssetsThenRows(plan.units, () => + prisma.file.delete({ where: { id } }) + ); + if (!outcome.ok) return cleanupErrorResponse(outcome, 'message'); return NextResponse.json({ ok: true }); - } catch (e: any) { - console.error('[DELETE /api/files/:id]', e); - if (e?.message === 'Unauthorized') { - return NextResponse.json({ message: '未登录' }, { status: 401 }); - } - return NextResponse.json({ message: '删除失败' }, { status: 500 }); + } catch (error) { + return prismaErrorResponse(error, 'message'); } } diff --git a/app/api/files/inline-url/route.ts b/app/api/files/inline-url/route.ts index dc67171..e84893e 100644 --- a/app/api/files/inline-url/route.ts +++ b/app/api/files/inline-url/route.ts @@ -2,6 +2,7 @@ import { canTouchFileSet } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { getPresignedInlineFileUrl } from '@/lib/storage'; +import { idStringSchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; /** @@ -16,10 +17,11 @@ export async function GET(req: Request) { const url = new URL(req.url); const fileIdStr = url.searchParams.get('fileId'); - const fileId = fileIdStr ? Number(fileIdStr) : NaN; - if (!fileIdStr || Number.isNaN(fileId)) { - return NextResponse.json({ message: '缺少 fileId' }, { status: 400 }); + const parsedFileId = fileIdStr === null ? undefined : idStringSchema.safeParse(fileIdStr); + if (!parsedFileId?.success) { + return NextResponse.json({ message: 'fileId 错误' }, { status: 400 }); } + const fileId = parsedFileId.data; // Fetch file and fileset to validate visibility const file = await prisma.file.findUnique({ diff --git a/app/api/files/preview-proxy/route.ts b/app/api/files/preview-proxy/route.ts index 1ae9d47..e37a86a 100644 --- a/app/api/files/preview-proxy/route.ts +++ b/app/api/files/preview-proxy/route.ts @@ -2,6 +2,7 @@ import { canTouchFileSet } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { getFileBuffer, guessMimeFromFilename } from '@/lib/storage'; +import { idStringSchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; /** @@ -17,10 +18,11 @@ export async function GET(req: Request) { const url = new URL(req.url); const fileIdStr = url.searchParams.get('fileId'); - const fileId = fileIdStr ? Number(fileIdStr) : NaN; - if (!fileIdStr || Number.isNaN(fileId)) { - return NextResponse.json({ message: '缺少 fileId' }, { status: 400 }); + const parsedFileId = fileIdStr === null ? undefined : idStringSchema.safeParse(fileIdStr); + if (!parsedFileId?.success) { + return NextResponse.json({ message: 'fileId 错误' }, { status: 400 }); } + const fileId = parsedFileId.data; const file = await prisma.file.findUnique({ where: { id: fileId }, diff --git a/app/api/files/route.ts b/app/api/files/route.ts index 9539fa3..11ae84c 100644 --- a/app/api/files/route.ts +++ b/app/api/files/route.ts @@ -2,6 +2,7 @@ import { canTouchFileSet } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { deleteFileAsset, getPublicFileUrl, persistFile } from '@/lib/storage'; +import { idStringSchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; type FileItem = { @@ -26,16 +27,22 @@ export async function GET(req: Request) { const { viewer } = authCheck; const url = new URL(req.url); - const filesetId = url.searchParams.get('filesetId'); + const filesetIdParam = url.searchParams.get('filesetId'); const q = url.searchParams.get('q'); - - if (!filesetId) { - return NextResponse.json({ message: '缺少 filesetId' }, { status: 400 }); + const parsedFilesetId = + filesetIdParam === null ? undefined : idStringSchema.safeParse(filesetIdParam); + + if (!parsedFilesetId?.success) { + return NextResponse.json( + { message: filesetIdParam === null ? '缺少 filesetId' : 'filesetId 错误' }, + { status: 400 } + ); } + const filesetId = parsedFilesetId.data; // Check fileset access permission const fileset = await prisma.fileSet.findUnique({ - where: { id: Number(filesetId) }, + where: { id: filesetId }, select: { id: true, visibility: true, createdBy: true }, }); @@ -47,7 +54,7 @@ export async function GET(req: Request) { return NextResponse.json({ message: '无权限' }, { status: 403 }); } - const where: any = { filesetId: Number(filesetId) }; + const where: any = { filesetId }; if (q) where.originalName = { contains: q }; const items = (await prisma.file.findMany({ @@ -87,21 +94,25 @@ export async function GET(req: Request) { * Uses multipart/form-data */ export async function POST(req: Request) { + let uploadedFilename: string | null = null; try { const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; const { viewer } = authCheck; const formData = await req.formData(); - const file = formData.get('file') as File | null; - const filesetIdStr = formData.get('filesetId') as string | null; + const file = formData.get('file'); + const filesetIdValue = formData.get('filesetId'); const description = (formData.get('description') as string | null) || undefined; - if (!file || !filesetIdStr) { + if (!(file instanceof File) || typeof filesetIdValue !== 'string') { return NextResponse.json({ message: '缺少文件或文件集ID' }, { status: 400 }); } - - const filesetId = Number(filesetIdStr); + const parsedFilesetId = idStringSchema.safeParse(filesetIdValue); + if (!parsedFilesetId.success) { + return NextResponse.json({ message: 'filesetId 错误' }, { status: 400 }); + } + const filesetId = parsedFilesetId.data; // Check fileset exists and has permission const fileset = await prisma.fileSet.findUnique({ @@ -119,6 +130,7 @@ export async function POST(req: Request) { // Upload file to storage const { filename, originalName } = await persistFile(file); + uploadedFilename = filename; // Create file record const created = await prisma.file.create({ @@ -141,6 +153,7 @@ export async function POST(req: Request) { createdAt: true, }, }); + uploadedFilename = null; return NextResponse.json( { @@ -152,6 +165,17 @@ export async function POST(req: Request) { { status: 201 } ); } catch (e: any) { + if (uploadedFilename) { + try { + await deleteFileAsset(uploadedFilename); + } catch (cleanupError) { + console.error( + '[POST /api/files] DB 写入失败后的对象补偿清理失败', + uploadedFilename, + cleanupError + ); + } + } console.error('[POST /api/files]', e); if (e?.message === 'Unauthorized') { return NextResponse.json({ message: '未登录' }, { status: 401 }); diff --git a/app/api/filesets/[id]/route.ts b/app/api/filesets/[id]/route.ts index 4735aa7..7229a22 100644 --- a/app/api/filesets/[id]/route.ts +++ b/app/api/filesets/[id]/route.ts @@ -1,7 +1,14 @@ import { canTouchFileSet } from '@/lib/access-rules'; +import { + cleanupErrorResponse, + deleteAssetsThenRows, + listFileUnitsOfFileSet, + reportSkippedNames, +} from '@/lib/asset-cleanup'; import { requireAdmin, requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; -import { deleteFileAsset } from '@/lib/storage'; +import { prismaErrorResponse } from '@/lib/prisma-errors'; +import { idStringSchema } from '@/lib/validation'; import { visibilitySchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -18,8 +25,9 @@ const updateSchema = z.object({ export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id: idStr } = await params; - const id = Number(idStr); - if (Number.isNaN(id)) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const parsedId = idStringSchema.safeParse(idStr); + if (!parsedId.success) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const id = parsedId.data; const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; @@ -66,8 +74,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id: idStr } = await params; - const id = Number(idStr); - if (Number.isNaN(id)) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const parsedId = idStringSchema.safeParse(idStr); + if (!parsedId.success) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const id = parsedId.data; const adminCheck = await requireAdmin(); if (!adminCheck.ok) return adminCheck.error; @@ -111,35 +120,33 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri * DELETE /api/filesets/:id - Delete fileset with all its files (admin only) */ export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const { id: idStr } = await params; - const id = Number(idStr); - if (Number.isNaN(id)) return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + const { id: idStr } = await params; + const parsedId = idStringSchema.safeParse(idStr); + if (!parsedId.success) { + return NextResponse.json({ message: 'ID 错误' }, { status: 400 }); + } + const id = parsedId.data; - const adminCheck = await requireAdmin(); - if (!adminCheck.ok) return adminCheck.error; + const adminCheck = await requireAdmin(); + if (!adminCheck.ok) return adminCheck.error; - // Get all files in this fileset to delete from storage - const files = (await prisma.file.findMany({ - where: { filesetId: id }, - select: { filename: true }, - })) as Array<{ filename: string }>; + const fileset = await prisma.fileSet.findUnique({ where: { id }, select: { id: true } }); + if (!fileset) { + return NextResponse.json({ message: '文件集不存在' }, { status: 404 }); + } - // Delete from storage (best effort) - await Promise.allSettled(files.map(f => deleteFileAsset(f.filename))); + // 先取子文件名:File.fileSet 是级联删除,父行没了这些行也就没了。 + const plan = await listFileUnitsOfFileSet(id); + reportSkippedNames('DELETE /api/filesets/:id', plan.skipped); - // Delete fileset (cascade will delete all file records) - await prisma.fileSet.delete({ where: { id } }); + try { + const outcome = await deleteAssetsThenRows(plan.units, () => + prisma.fileSet.delete({ where: { id } }) + ); + if (!outcome.ok) return cleanupErrorResponse(outcome, 'message'); return NextResponse.json({ ok: true }); - } catch (e: any) { - console.error('[DELETE /api/filesets/:id]', e); - if (e?.message === 'Unauthorized') { - return NextResponse.json({ message: '未登录' }, { status: 401 }); - } - if (e?.message === 'Forbidden') { - return NextResponse.json({ message: '仅管理员可删除文件集' }, { status: 403 }); - } - return NextResponse.json({ message: '删除失败' }, { status: 500 }); + } catch (error) { + return prismaErrorResponse(error, 'message'); } } diff --git a/app/api/photos/route.ts b/app/api/photos/route.ts index d95fb72..e3c840b 100644 --- a/app/api/photos/route.ts +++ b/app/api/photos/route.ts @@ -1,16 +1,21 @@ import { getViewer } from '@/lib/access'; import { categoryWhereFor } from '@/lib/access-rules'; +import { + cleanupErrorResponse, + deleteAssetsThenRows, + reportSkippedNames, +} from '@/lib/asset-cleanup'; +import { planPhotoUnits } from '@/lib/asset-deletion'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { ConfigurationError, - deleteImageAssets, - deleteUploadObject, getOriginalBuffer, getPublicObjectUrl, getPublicThumbnailUrl, isNotFoundError, } from '@/lib/storage'; +import { idSchema, idStringSchema } from '@/lib/validation'; import JSZip from 'jszip'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -34,7 +39,7 @@ type PhotoForDeletion = { mediaType: 'image' | 'video'; }; -const idArraySchema = z.array(z.number().int().positive()).min(1); +const idArraySchema = z.array(idSchema).min(1); const deleteSchema = z.object({ ids: idArraySchema, @@ -45,7 +50,7 @@ const downloadSchema = z.object({ }); const renameSchema = z.object({ - id: z.number().int().positive(), + id: idSchema, description: z .string() .max(300) @@ -62,10 +67,14 @@ export async function GET(request: Request) { const page = Math.max(Number.parseInt(pageParam, 10) || 1, 1); const pageSize = Math.min(Math.max(Number.parseInt(pageSizeParam, 10) || 24, 1), 96); - const parsedCategoryId = categoryIdParam ? Number.parseInt(categoryIdParam, 10) : undefined; + const parsedCategoryId = + categoryIdParam === null ? undefined : idStringSchema.safeParse(categoryIdParam); + if (parsedCategoryId && !parsedCategoryId.success) { + return NextResponse.json({ error: '分类 ID 错误' }, { status: 400 }); + } const viewer = await getViewer(); const where = { - ...(Number.isInteger(parsedCategoryId) ? { categoryId: parsedCategoryId } : {}), + ...(parsedCategoryId?.success ? { categoryId: parsedCategoryId.data } : {}), category: categoryWhereFor(viewer), }; @@ -210,25 +219,19 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: '仅可操作自己上传的照片' }, { status: 403 }); } - await prisma.photo.deleteMany({ where: { id: { in: targetPhotos.map(photo => photo.id) } } }); + const plan = planPhotoUnits(targetPhotos); + reportSkippedNames('DELETE /api/photos', plan.skipped); - try { - await Promise.all( - targetPhotos.map(photo => - photo.mediaType === 'image' - ? deleteImageAssets(photo.filename) - : deleteUploadObject(photo.filename) - ) - ); - } catch (error) { - if (error instanceof ConfigurationError) { - console.error(error); - return NextResponse.json({ error: '对象存储配置错误' }, { status: 500 }); - } - throw error; - } + // 对象先、行后:反过来时行一旦没了,filename 就再也找不到,泄漏不可追溯。 + // 泛型显式写出:本环境的 Prisma 客户端是手写声明,不写就会退化成 unknown, + // 而这个 .count 恰恰是"到底删了几行"的真话来源。 + const outcome = await deleteAssetsThenRows<{ count: number }>(plan.units, () => + prisma.photo.deleteMany({ where: { id: { in: targetPhotos.map(photo => photo.id) } } }) + ); + + if (!outcome.ok) return cleanupErrorResponse(outcome); - return NextResponse.json({ deleted: targetPhotos.length }); + return NextResponse.json({ deleted: outcome.result.count }); } export async function PATCH(request: Request) { diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index c836de2..d1e13e3 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -17,7 +17,7 @@ const updatePasswordSchema = z.object({ export async function PATCH(request: Request) { const authCheck = await requireAuth(); - if ('error' in authCheck) return authCheck.error; + if (!authCheck.ok) return authCheck.error; const body = await request.json().catch(() => null); const parsed = parsePayload(body); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 0a22b5a..89dfac8 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -2,6 +2,7 @@ import { getViewer } from '@/lib/access'; import { canViewCategory, categoryWhereFor } from '@/lib/access-rules'; import { prisma } from '@/lib/db'; import { getPublicObjectUrl, getPublicThumbnailUrl } from '@/lib/storage'; +import { idStringSchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; const CATEGORY_LIMIT = 5; @@ -31,16 +32,19 @@ type PhotoRow = { export async function GET(request: Request) { const { searchParams } = new URL(request.url); const q = (searchParams.get('q') ?? '').trim(); + const rawCategoryId = searchParams.get('categoryId'); + const parsedCategoryId = + rawCategoryId === null ? undefined : idStringSchema.safeParse(rawCategoryId); + if (parsedCategoryId && !parsedCategoryId.success) { + return NextResponse.json({ error: '分类 ID 错误' }, { status: 400 }); + } if (!q) { return NextResponse.json({ categories: [], photos: [] }); } const viewer = await getViewer(); const categoryFilter = categoryWhereFor(viewer); - - const rawCategoryId = searchParams.get('categoryId'); - const parsedCategoryId = rawCategoryId ? Number.parseInt(rawCategoryId, 10) : NaN; - const scopedCategoryId = Number.isInteger(parsedCategoryId) ? parsedCategoryId : null; + const scopedCategoryId = parsedCategoryId?.success ? parsedCategoryId.data : null; if (scopedCategoryId !== null) { const scope = await prisma.category.findUnique({ diff --git a/app/api/share/route.ts b/app/api/share/route.ts index 5e9048b..4261c8b 100644 --- a/app/api/share/route.ts +++ b/app/api/share/route.ts @@ -1,53 +1,43 @@ import { requireAdmin } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; -import { ConfigurationError, getPublicObjectUrl, getPublicThumbnailUrl } from '@/lib/storage'; +import { idSchema } from '@/lib/validation'; import bcrypt from 'bcryptjs'; -import { addHours, isAfter } from 'date-fns'; +import { addHours } from 'date-fns'; import { NextResponse } from 'next/server'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; -type SharedPhoto = { - id: number; - filename: string; - originalName: string; - description: string | null; - createdAt: Date; - mediaType: 'image' | 'video'; - mimeType: string; - uploader: { username: string }; -}; - -type ShareLinkWithCategory = { - token: string; - expiresAt: Date | null; - password?: string | null; - category: { - id: number; - name: string; - description: string | null; - photos: SharedPhoto[]; - }; -}; +/** + * 只有两个方法:POST 创建分享链接,DELETE 撤销它。 + * + * 原来还有一个 GET /api/share?token=…&password=…,一次返回整册的照片元数据与可访问 + * fileUrl。它被删掉而不是被修,三条理由: + * + * 1. 没有调用方。分享落地页 app/share/[token]/page.tsx 直接查 Prisma,门禁由 + * lib/share-auth.ts 在服务端用 HMAC cookie 判定;admin-share-tab 只发 POST 与 + * DELETE。留下的是一份没人用的重复实现,还得跟着 token/过期/密码逻辑一起维护。 + * 2. 它把密码放在查询串里。兄弟路由 app/api/share/unlock/route.ts 的注释早已写明 + * 本项目的政策:「密码走请求体而不是查询串:查询串会留在浏览器历史与服务器访问 + * 日志里」。所以这不是一个被权衡过的设计,是同一族重构做了一半。 + * 3. 它无鉴权也无限流,任何拿到 token 的人都能一次取回整册清单。 + * + * 若确有外部集成在用这个 URL,它会开始收到 405。对外承诺的入口一直是分享落地页本身 + * (admin-share-tab 复制给用户的就是那个页面地址)。 + */ const createShareSchema = z.object({ - categoryId: z.number().int(), + categoryId: idSchema, password: z.string().min(4).max(50).optional(), expireInHours: z.number().int().positive().max(720).optional(), }); -const shareAccessSchema = z.object({ - token: z.string().min(8), - password: z.string().optional(), -}); - const deleteShareSchema = z.object({ - id: z.number().int(), + id: idSchema, }); export async function POST(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parsed = createShareSchema.safeParse(body); @@ -89,97 +79,9 @@ export async function POST(request: Request) { return NextResponse.json(shareLink, { status: 201 }); } -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const parsed = shareAccessSchema.safeParse({ - token: searchParams.get('token'), - password: searchParams.get('password') ?? undefined, - }); - - if (!parsed.success) { - return NextResponse.json({ error: '无效的分享链接' }, { status: 400 }); - } - - const shareLink = (await prisma.shareLink.findUnique({ - where: { token: parsed.data.token }, - include: { - category: { - select: { - id: true, - name: true, - description: true, - photos: { - orderBy: { createdAt: 'desc' }, - select: { - id: true, - filename: true, - originalName: true, - description: true, - createdAt: true, - mediaType: true, - mimeType: true, - uploader: { - select: { username: true }, - }, - }, - }, - }, - }, - }, - })) as ShareLinkWithCategory | null; - - if (!shareLink) { - return NextResponse.json({ error: '分享链接不存在' }, { status: 404 }); - } - - if (shareLink.expiresAt && isAfter(new Date(), shareLink.expiresAt)) { - return NextResponse.json({ error: '分享链接已过期' }, { status: 410 }); - } - - if (shareLink.password) { - if (!parsed.data.password) { - return NextResponse.json({ error: '需要访问密码' }, { status: 401 }); - } - const match = await bcrypt.compare(parsed.data.password, shareLink.password); - if (!match) { - return NextResponse.json({ error: '密码错误' }, { status: 401 }); - } - } - - try { - return NextResponse.json({ - token: shareLink.token, - expiresAt: shareLink.expiresAt?.toISOString() ?? null, - category: { - id: shareLink.category.id, - name: shareLink.category.name, - description: shareLink.category.description, - photos: shareLink.category.photos.map(photo => ({ - id: photo.id, - filename: photo.filename, - originalName: photo.originalName, - description: photo.description, - createdAt: photo.createdAt.toISOString(), - uploader: photo.uploader.username, - mediaType: photo.mediaType, - mimeType: photo.mimeType, - fileUrl: getPublicObjectUrl(photo.filename), - thumbnailUrl: photo.mediaType === 'image' ? getPublicThumbnailUrl(photo.filename) : null, - })), - }, - }); - } catch (error) { - if (error instanceof ConfigurationError) { - console.error(error); - return NextResponse.json({ error: '对象存储配置错误' }, { status: 500 }); - } - throw error; - } -} - export async function DELETE(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parsed = deleteShareSchema.safeParse(body); diff --git a/app/api/upload/cleanup/route.ts b/app/api/upload/cleanup/route.ts new file mode 100644 index 0000000..0b165e9 --- /dev/null +++ b/app/api/upload/cleanup/route.ts @@ -0,0 +1,37 @@ +import { requireAdmin } from '@/lib/auth-guards'; +import { prisma } from '@/lib/db'; +import { deleteUploadStorageKey } from '@/lib/storage'; +import { cleanupExpiredUploadIntents } from '@/lib/upload-intent-cleanup'; +import { NextResponse } from 'next/server'; + +const BATCH_SIZE = 100; + +export async function POST() { + const authCheck = await requireAdmin(); + if (!authCheck.ok) return authCheck.error; + try { + const intents = await prisma.uploadIntent.findMany({ + where: { expiresAt: { lte: new Date() }, photo: null }, + select: { id: true, storageKey: true, thumbnailKey: true }, + orderBy: { expiresAt: 'asc' }, + take: BATCH_SIZE, + }); + const result = await cleanupExpiredUploadIntents( + intents.map((intent: { id: string; storageKey: string; thumbnailKey: string | null }) => ({ + id: intent.id, + storageKeys: [intent.storageKey, ...(intent.thumbnailKey ? [intent.thumbnailKey] : [])], + })), + { + deleteObject: deleteUploadStorageKey, + deleteIntent: async id => { + await prisma.uploadIntent.delete({ where: { id } }); + }, + }, + BATCH_SIZE + ); + return NextResponse.json(result); + } catch (error) { + console.error('[upload cleanup] Failed to clean expired intents', error); + return NextResponse.json({ error: '清理失败' }, { status: 500 }); + } +} diff --git a/app/api/upload/complete/route.ts b/app/api/upload/complete/route.ts new file mode 100644 index 0000000..1efd145 --- /dev/null +++ b/app/api/upload/complete/route.ts @@ -0,0 +1,144 @@ +import { canUploadToCategory } from '@/lib/access-rules'; +import { requireAuth } from '@/lib/auth-guards'; +import { prisma } from '@/lib/db'; +import { + ConfigurationError, + UploadError, + createImageThumbnail, + getPublicObjectUrl, + getPublicThumbnailUrl, + getUploadObjectBuffer, + inspectUploadObject, +} from '@/lib/storage'; +import { validateUploadObjectMetadata } from '@/lib/upload-intent-validation'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const schema = z.object({ intentId: z.string().uuid() }); + +function photoResponse(photo: { + id: number; + filename: string; + originalName: string; + description: string | null; + categoryId: number; + createdAt: Date; + mediaType: 'image' | 'video'; + mimeType: string; + uploader: { username: string }; +}) { + return { + id: photo.id, + filename: photo.filename, + originalName: photo.originalName, + description: photo.description, + categoryId: photo.categoryId, + uploader: photo.uploader.username, + createdAt: photo.createdAt, + mediaType: photo.mediaType, + mimeType: photo.mimeType, + fileUrl: getPublicObjectUrl(photo.filename), + thumbnailUrl: photo.mediaType === 'image' ? getPublicThumbnailUrl(photo.filename) : null, + }; +} + +export async function POST(request: Request) { + const authCheck = await requireAuth(); + if (!authCheck.ok) return authCheck.error; + let input: unknown; + try { + input = await request.json(); + } catch { + return NextResponse.json({ error: '请求格式无效' }, { status: 400 }); + } + const parsed = schema.safeParse(input); + if (!parsed.success) return NextResponse.json({ error: '上传凭证无效' }, { status: 400 }); + + const intent = await prisma.uploadIntent.findUnique({ + where: { id: parsed.data.intentId }, + include: { photo: { include: { uploader: { select: { username: true } } } } }, + }); + if (!intent || intent.uploaderId !== authCheck.viewer.id) { + return NextResponse.json({ error: '上传凭证不存在' }, { status: 404 }); + } + if (intent.expiresAt <= new Date()) { + return NextResponse.json({ error: '上传凭证已过期' }, { status: 410 }); + } + + const category = await prisma.category.findUnique({ + where: { id: intent.categoryId }, + select: { id: true, visibility: true }, + }); + if (!category) return NextResponse.json({ error: '分类不存在' }, { status: 404 }); + if (!canUploadToCategory(authCheck.viewer, category)) { + return NextResponse.json({ error: '无权在该分类上传' }, { status: 403 }); + } + + if (intent.completedAt) { + return intent.photo + ? NextResponse.json(photoResponse(intent.photo)) + : NextResponse.json({ error: '上传凭证已完成但媒体记录不可用' }, { status: 410 }); + } + + try { + const object = await inspectUploadObject(intent.storageKey); + const metadataIssue = validateUploadObjectMetadata( + { mediaType: intent.mediaType, mimeType: intent.mimeType, size: intent.expectedSize }, + object + ); + if (metadataIssue === 'size_mismatch') { + return NextResponse.json({ error: '上传文件大小与凭证不符' }, { status: 400 }); + } + if (metadataIssue === 'mime_mismatch') { + return NextResponse.json({ error: '上传文件类型与凭证不符' }, { status: 400 }); + } + + if (intent.mediaType === 'image') { + if (!intent.thumbnailKey) throw new Error('图片上传意图缺少缩略图对象键'); + const buffer = await getUploadObjectBuffer(intent.storageKey); + await createImageThumbnail(intent.thumbnailKey, buffer); + } + + try { + const photo = await prisma.$transaction(async (tx: typeof prisma) => { + const created = await tx.photo.create({ + data: { + filename: intent.storageKey.slice(intent.storageKey.lastIndexOf('/') + 1), + originalName: intent.originalName, + description: intent.description, + categoryId: intent.categoryId, + uploaderId: intent.uploaderId, + mediaType: intent.mediaType, + mimeType: intent.mimeType, + uploadIntentId: intent.id, + }, + include: { uploader: { select: { username: true } } }, + }); + await tx.uploadIntent.update({ + where: { id: intent.id }, + data: { completedAt: new Date() }, + }); + return created; + }); + return NextResponse.json(photoResponse(photo)); + } catch (error) { + // The unique Photo.uploadIntentId constraint is the atomic idempotency claim. + const winner = await prisma.photo.findUnique({ + where: { uploadIntentId: intent.id }, + include: { uploader: { select: { username: true } } }, + }); + if (winner) return NextResponse.json(photoResponse(winner)); + throw error; + } + } catch (error) { + if (error instanceof UploadError) { + return NextResponse.json({ error: error.message }, { status: error.statusCode }); + } + if (error instanceof ConfigurationError) { + console.error(error); + return NextResponse.json({ error: '对象存储配置错误' }, { status: 500 }); + } + console.error('[upload complete] Failed to complete upload', error); + return NextResponse.json({ error: '上传处理失败,可重试' }, { status: 500 }); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index e9726b0..bf4ccd8 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,27 +1,29 @@ import { canUploadToCategory } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; +import { AmbiguousMediaError, resolveUploadMedia } from '@/lib/media-type'; import { ConfigurationError, UploadError, + deleteImageAssets, + deleteUploadObject, getPublicObjectUrl, getPublicThumbnailUrl, persistImage, persistVideo, } from '@/lib/storage'; +import { idStringSchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; const uploadSchema = z.object({ - categoryId: z.coerce.number().int().positive(), + categoryId: idStringSchema, description: z.string().max(300).optional(), }); export async function POST(request: Request) { const authCheck = await requireAuth(); - if ('error' in authCheck) { - return authCheck.error; - } + if (!authCheck.ok) return authCheck.error; const formData = await request.formData(); const file = formData.get('file'); @@ -54,12 +56,19 @@ export async function POST(request: Request) { const uploaderId = viewer.id; + let uploadedAsset: { filename: string; kind: 'image' | 'video' } | null = null; try { - const isImage = file.type.startsWith('image/'); - const mimeType = file.type || (isImage ? 'image/jpeg' : 'video/mp4'); - const { filename, originalName } = isImage - ? await persistImage(file) - : await persistVideo(file); + // mediaType 与 mimeType 由同一个函数同源产出。旧写法分两处推导: + // `file.type.startsWith('image/')` 决定 mediaType,`file.type || (isImage ? … : 'video/mp4')` + // 决定 mimeType。空 type 时 isImage 为 false,于是真图片会掉进 persistVideo 的 + // 视频白名单,被以「仅支持 MP4 / WebM / MOV 视频」这个误导性的 400 拒掉。 + const media = resolveUploadMedia({ name: file.name, declaredType: file.type }); + const stored = + media.kind === 'image' + ? await persistImage(file, media.mimeType) + : await persistVideo(file, media.mimeType); + const { filename, originalName } = stored; + uploadedAsset = { filename, kind: media.kind }; const photo = await prisma.photo.create({ data: { @@ -68,13 +77,14 @@ export async function POST(request: Request) { description: parsed.data.description, categoryId: parsed.data.categoryId, uploaderId, - mediaType: isImage ? 'image' : 'video', - mimeType, + mediaType: media.kind, + mimeType: media.mimeType, }, include: { uploader: { select: { username: true } }, }, }); + uploadedAsset = null; return NextResponse.json({ id: photo.id, @@ -90,6 +100,24 @@ export async function POST(request: Request) { thumbnailUrl: photo.mediaType === 'image' ? getPublicThumbnailUrl(photo.filename) : null, }); } catch (error) { + if (uploadedAsset) { + try { + if (uploadedAsset.kind === 'image') await deleteImageAssets(uploadedAsset.filename); + else await deleteUploadObject(uploadedAsset.filename); + } catch (cleanupError) { + console.error( + '[POST /api/upload] DB 写入失败后的对象补偿清理失败', + uploadedAsset.filename, + cleanupError + ); + } + } + if (error instanceof AmbiguousMediaError) { + return NextResponse.json( + { error: error.message, code: 'ambiguous_media_type' }, + { status: 400 } + ); + } if (error instanceof UploadError) { return NextResponse.json({ error: error.message }, { status: error.statusCode }); } diff --git a/app/api/upload/token/route.ts b/app/api/upload/token/route.ts new file mode 100644 index 0000000..a3e32cd --- /dev/null +++ b/app/api/upload/token/route.ts @@ -0,0 +1,109 @@ +import { canUploadToCategory } from '@/lib/access-rules'; +import { requireAuth } from '@/lib/auth-guards'; +import { prisma } from '@/lib/db'; +import { AmbiguousMediaError, resolveUploadMedia } from '@/lib/media-type'; +import { + createUploadFilename, + getPresignedPhotoPutUrl, + uploadStorageKey, + uploadThumbnailStorageKey, +} from '@/lib/storage'; +import { idSchema } from '@/lib/validation'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; +const MAX_VIDEO_SIZE = 512 * 1024 * 1024; +const tokenSchema = z.object({ + categoryId: idSchema, + name: z.string().min(1).max(255), + description: z.string().max(300).optional(), + mimeType: z.string().max(100), + size: z.number().int().positive(), +}); +const PRESIGNED_URL_TTL_SECONDS = 15 * 60; +// Keep an extra minute so cleanup cannot remove an intent while its signed PUT is still valid. +const INTENT_TTL_MS = (PRESIGNED_URL_TTL_SECONDS + 60) * 1000; + +export async function POST(request: Request) { + const authCheck = await requireAuth(); + if (!authCheck.ok) return authCheck.error; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: '请求格式无效' }, { status: 400 }); + } + const parsed = tokenSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten().fieldErrors }, { status: 400 }); + } + + let media; + try { + media = resolveUploadMedia({ name: parsed.data.name, declaredType: parsed.data.mimeType }); + } catch (error) { + if (error instanceof AmbiguousMediaError) { + return NextResponse.json( + { error: error.message, code: 'ambiguous_media_type' }, + { status: 400 } + ); + } + throw error; + } + const limit = media.kind === 'image' ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE; + if (parsed.data.size > limit) { + return NextResponse.json( + { error: `文件大小超出限制 (${Math.floor(limit / 1024 / 1024)}MB)` }, + { status: 400 } + ); + } + + const category = await prisma.category.findUnique({ + where: { id: parsed.data.categoryId }, + select: { id: true, visibility: true }, + }); + if (!category) return NextResponse.json({ error: '分类不存在' }, { status: 404 }); + if (!canUploadToCategory(authCheck.viewer, category)) { + return NextResponse.json({ error: '无权在该分类上传' }, { status: 403 }); + } + + const filename = createUploadFilename(parsed.data.name, media.mimeType); + const storageKey = uploadStorageKey(filename); + const thumbnailKey = media.kind === 'image' ? uploadThumbnailStorageKey(filename) : null; + const expiresAt = new Date(Date.now() + INTENT_TTL_MS); + try { + const uploadUrl = await getPresignedPhotoPutUrl( + storageKey, + media.mimeType, + PRESIGNED_URL_TTL_SECONDS + ); + const intent = await prisma.uploadIntent.create({ + data: { + storageKey, + thumbnailKey, + uploaderId: authCheck.viewer.id, + categoryId: category.id, + originalName: parsed.data.name, + description: parsed.data.description, + mimeType: media.mimeType, + mediaType: media.kind, + expectedSize: parsed.data.size, + expiresAt, + }, + select: { id: true }, + }); + return NextResponse.json({ + intentId: intent.id, + uploadUrl, + storageKey, + expiresAt, + method: 'PUT', + headers: { 'Content-Type': media.mimeType }, + }); + } catch (error) { + console.error('[upload token] Failed to create upload intent', error); + return NextResponse.json({ error: '无法创建上传凭证' }, { status: 500 }); + } +} diff --git a/app/api/users/check/route.ts b/app/api/users/check/route.ts deleted file mode 100644 index 722a43b..0000000 --- a/app/api/users/check/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { prisma } from '@/lib/db'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - -const checkSchema = z.object({ - username: z.string().min(1, '用户名不能为空'), -}); - -export async function POST(request: Request) { - const body = await request.json().catch(() => null); - const parsed = checkSchema.safeParse(body); - if (!parsed.success) { - const errors = parsed.error.flatten().fieldErrors; - const errorMessage = Object.values(errors).flat()[0] || '请求参数错误'; - return NextResponse.json({ error: errorMessage }, { status: 400 }); - } - - const user = await prisma.user.findUnique({ - where: { username: parsed.data.username }, - select: { status: true }, - }); - - return NextResponse.json({ status: user?.status ?? 'unknown' }); -} diff --git a/app/api/users/password/route.ts b/app/api/users/password/route.ts index 610022b..bade1c7 100644 --- a/app/api/users/password/route.ts +++ b/app/api/users/password/route.ts @@ -1,17 +1,18 @@ import { requireAdmin } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; +import { idSchema } from '@/lib/validation'; import bcrypt from 'bcryptjs'; import { NextResponse } from 'next/server'; import { z } from 'zod'; const resetPasswordSchema = z.object({ - userId: z.number().int(), + userId: idSchema, newPassword: z.string().min(6, '密码至少 6 位'), }); export async function POST(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parsed = resetPasswordSchema.safeParse(body); diff --git a/app/api/users/route.ts b/app/api/users/route.ts index d1d821a..c7b7b47 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -1,5 +1,13 @@ +import { + USER_ASSET_COUNT_SELECT, + type UserAssetCounts, + planUserDelete, +} from '@/lib/asset-deletion'; import { requireAdmin } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; +import { prismaErrorResponse } from '@/lib/prisma-errors'; +import { selfRegistrationPrivacyResponse } from '@/lib/user-registration'; +import { idSchema, optionalIdSchema } from '@/lib/validation'; import type { Prisma } from '@prisma/client'; import bcrypt from 'bcryptjs'; import { NextResponse } from 'next/server'; @@ -21,24 +29,25 @@ const createUserSchema = z.object({ }); const updateRoleSchema = z.object({ - id: z.number().int(), + id: idSchema, role: z.enum(['admin', 'member']), }); const updateStatusSchema = z.object({ - id: z.number().int(), + id: idSchema, status: z.enum(['pending', 'active', 'rejected']), }); const deleteUserSchema = z.object({ - id: z.number().int(), - transferToUserId: z.number().int().optional(), - deletePhotos: z.boolean().optional(), + id: idSchema, + photoDecision: z.enum(['transfer', 'delete']).optional(), + driveDecision: z.enum(['transfer', 'delete']).optional(), + transferToUserId: optionalIdSchema, }); export async function GET(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const { searchParams } = new URL(request.url); const pageParam = searchParams.get('page') ?? '1'; @@ -111,41 +120,52 @@ export async function POST(request: Request) { status = 'active'; } else if (parsed.data.role && parsed.data.role !== 'member') { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) { + if (!adminCheck.ok) { return adminCheck.error; } status = 'active'; } - const existing = await prisma.user.findUnique({ where: { username: parsed.data.username } }); - if (existing) { - return NextResponse.json({ error: '用户名已存在' }, { status: 409 }); - } - + const selfRegistration = totalUsers > 0 && (!parsed.data.role || parsed.data.role === 'member'); const hashed = await bcrypt.hash(parsed.data.password, 10); - const user = await prisma.user.create({ - data: { - username: parsed.data.username, - password: hashed, - role, - status, - }, - select: { - id: true, - username: true, - role: true, - status: true, - createdAt: true, - }, - }); + try { + const user = await prisma.user.create({ + data: { + username: parsed.data.username, + password: hashed, + role, + status, + }, + select: { + id: true, + username: true, + role: true, + status: true, + createdAt: true, + }, + }); - return NextResponse.json(user, { status: 201 }); + const privacyResponse = selfRegistrationPrivacyResponse(selfRegistration, { ok: true }); + if (privacyResponse) { + return NextResponse.json(privacyResponse.body, { status: privacyResponse.status }); + } + return NextResponse.json(user, { status: 201 }); + } catch (error) { + const privacyResponse = selfRegistrationPrivacyResponse(selfRegistration, { + ok: false, + error, + }); + if (privacyResponse) { + return NextResponse.json(privacyResponse.body, { status: privacyResponse.status }); + } + return prismaErrorResponse(error); + } } export async function PUT(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parsed = updateRoleSchema.safeParse(body); @@ -153,23 +173,27 @@ export async function PUT(request: Request) { return NextResponse.json({ error: '请求参数错误' }, { status: 400 }); } - const user = await prisma.user.update({ - where: { id: parsed.data.id }, - data: { role: parsed.data.role }, - select: { - id: true, - username: true, - role: true, - createdAt: true, - }, - }); + try { + const user = await prisma.user.update({ + where: { id: parsed.data.id }, + data: { role: parsed.data.role }, + select: { + id: true, + username: true, + role: true, + createdAt: true, + }, + }); - return NextResponse.json(user); + return NextResponse.json(user); + } catch (error) { + return prismaErrorResponse(error); + } } export async function PATCH(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parsed = updateStatusSchema.safeParse(body); @@ -177,24 +201,28 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: '请求参数错误' }, { status: 400 }); } - const user = await prisma.user.update({ - where: { id: parsed.data.id }, - data: { status: parsed.data.status }, - select: { - id: true, - username: true, - role: true, - status: true, - createdAt: true, - }, - }); + try { + const user = await prisma.user.update({ + where: { id: parsed.data.id }, + data: { status: parsed.data.status }, + select: { + id: true, + username: true, + role: true, + status: true, + createdAt: true, + }, + }); - return NextResponse.json(user); + return NextResponse.json(user); + } catch (error) { + return prismaErrorResponse(error); + } } export async function DELETE(request: Request) { const adminCheck = await requireAdmin(); - if ('error' in adminCheck) return adminCheck.error; + if (!adminCheck.ok) return adminCheck.error; const body = await request.json().catch(() => null); const parsed = deleteUserSchema.safeParse(body); @@ -202,51 +230,76 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: '请求参数错误' }, { status: 400 }); } - const { id, transferToUserId, deletePhotos } = parsed.data; + const { id, photoDecision, driveDecision, transferToUserId } = parsed.data; - // 检查要删除的用户是否存在 - const userToDelete = await prisma.user.findUnique({ + const userToDelete = (await prisma.user.findUnique({ where: { id }, - include: { _count: { select: { photos: true } } }, - }); + select: { + id: true, + role: true, + _count: { select: { ...USER_ASSET_COUNT_SELECT } }, + }, + })) as { id: number; role: string; _count: UserAssetCounts } | null; if (!userToDelete) { return NextResponse.json({ error: '用户不存在' }, { status: 404 }); } - // 如果有照片需要处理 - if (userToDelete._count.photos > 0) { - if (deletePhotos) { - // 删除该用户的所有照片 - await prisma.photo.deleteMany({ - where: { uploaderId: id }, - }); - } else if (transferToUserId) { - // 转移到指定用户 - const targetUser = await prisma.user.findUnique({ - where: { id: transferToUserId }, - }); + const adminCount = (await prisma.user.count({ where: { role: 'admin' } })) as number; - if (!targetUser) { - return NextResponse.json({ error: '目标用户不存在' }, { status: 404 }); - } + const receiver = transferToUserId + ? ((await prisma.user.findUnique({ + where: { id: transferToUserId }, + select: { id: true, status: true }, + })) as { id: number; status: string } | null) + : null; + + const plan = planUserDelete({ + photoCount: userToDelete._count.photos, + fileCount: userToDelete._count.filesUploaded, + fileSetCount: userToDelete._count.fileSetsCreated, + photoDecision, + driveDecision, + transferToUserId, + targetUserId: id, + isSelf: adminCheck.viewer.id === id, + isTargetAdmin: userToDelete.role === 'admin', + adminCount, + transferTargetExists: receiver !== null, + transferTargetActive: receiver?.status === 'active', + }); - await prisma.photo.updateMany({ - where: { uploaderId: id }, - data: { uploaderId: transferToUserId }, - }); - } else { - return NextResponse.json( - { error: '用户有照片,请选择转移到其他用户或直接删除' }, - { status: 400 } - ); - } + if (!plan.ok) { + return NextResponse.json( + { error: plan.errors.join(';'), errors: plan.errors, code: 'invalid_deletion_request' }, + { status: 400 } + ); } - // 删除用户 - await prisma.user.delete({ - where: { id }, - }); + const receiverId = plan.transferToUserId; + + try { + // 三个转移和用户删除必须同成同败;语义仍然是只转移、不销毁资产。 + await prisma.$transaction(async (tx: typeof prisma) => { + if (receiverId !== undefined) { + await tx.photo.updateMany({ + where: { uploaderId: id }, + data: { uploaderId: receiverId }, + }); + await tx.file.updateMany({ where: { uploaderId: id }, data: { uploaderId: receiverId } }); + await tx.fileSet.updateMany({ + where: { createdBy: id }, + data: { createdBy: receiverId }, + }); + } + + await tx.user.delete({ where: { id } }); + }); - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }); + } catch (error) { + // 枚举与写入之间用户又上传了的话,这里会拿到 P2003 → 409,重试即收敛。 + // 不再额外做一次 count 复查:那只是把同一个竞态窗口挪近一点,并不会关掉它。 + return prismaErrorResponse(error); + } } diff --git a/app/files/page.tsx b/app/files/page.tsx index 0693498..562c619 100644 --- a/app/files/page.tsx +++ b/app/files/page.tsx @@ -5,7 +5,7 @@ import { EmptyState } from '@/components/ui/empty-state'; import { requireViewer } from '@/lib/access'; import { fileSetWhereFor } from '@/lib/access-rules'; import { prisma } from '@/lib/db'; -import { type SearchParams, readInt, readString } from '@/lib/params'; +import { type SearchParams, readId, readInt, readString } from '@/lib/params'; import { FileIcon } from 'lucide-react'; import { Suspense } from 'react'; @@ -29,7 +29,7 @@ export default async function FilesPage({ searchParams }: { searchParams: Promis })) as FileSetRow[]; // ?fileset= 指向无权访问或不存在的集合时,静默回落到第一个可见集合 - const requestedId = readInt(params, 'fileset'); + const requestedId = readId(params, 'fileset'); const activeSet = fileSets.find(set => set.id === requestedId) ?? fileSets[0] ?? null; const query = readString(params, 'q'); diff --git a/app/loading.tsx b/app/loading.tsx deleted file mode 100644 index 195c269..0000000 --- a/app/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PageSkeleton } from '@/components/skeletons/page-skeleton'; - -export default function Loading() { - return ; -} diff --git a/app/share/[token]/page.tsx b/app/share/[token]/page.tsx index a46453b..faceaf8 100644 --- a/app/share/[token]/page.tsx +++ b/app/share/[token]/page.tsx @@ -4,7 +4,7 @@ import { PhotoGridSkeleton } from '@/components/skeletons/photo-grid-skeleton'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { EmptyState } from '@/components/ui/empty-state'; import { prisma } from '@/lib/db'; -import { type SearchParams, clampPage, readInt } from '@/lib/params'; +import { type SearchParams, clampPage, readId, readInt } from '@/lib/params'; import { isShareUnlocked } from '@/lib/share-auth'; import { format, isAfter } from 'date-fns'; import { zhCN } from 'date-fns/locale'; @@ -85,7 +85,7 @@ export default async function SharePage({ sort="desc" page={page} total={total} - deepPhotoId={readInt(q, 'photo')} + deepPhotoId={readId(q, 'photo')} viewerId={null} canManageAll={false} downloadStrategy="public" diff --git a/components/admin/admin-users-tab.tsx b/components/admin/admin-users-tab.tsx index 9a3f2f6..0b28690 100644 --- a/components/admin/admin-users-tab.tsx +++ b/components/admin/admin-users-tab.tsx @@ -1,6 +1,8 @@ 'use client'; import type { UserItem } from '@/components/admin/types'; +import type { UserDeletePayload } from '@/components/admin/user-delete-dialog'; +import { UserDeleteDialog } from '@/components/admin/user-delete-dialog'; import { PaginationControls } from '@/components/pagination-controls'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -15,7 +17,6 @@ import { import { ErrorAlert } from '@/components/ui/error-alert'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, @@ -66,8 +67,7 @@ export function AdminUsersTab({ const [selectedUserRole, setSelectedUserRole] = useState>({}); const [deletingUserId, setDeletingUserId] = useState(null); - const [deleteTransferUserId, setDeleteTransferUserId] = useState(null); - const [deletePhotosDirectly, setDeletePhotosDirectly] = useState(false); + const [deleting, setDeleting] = useState(false); const [resettingPasswordUserId, setResettingPasswordUserId] = useState(null); const [newPassword, setNewPassword] = useState(''); @@ -161,43 +161,32 @@ export function AdminUsersTab({ const resetDeleteDialog = () => { setDeletingUserId(null); - setDeleteTransferUserId(null); - setDeletePhotosDirectly(false); + setDeleting(false); }; - const handleUserDelete = async () => { - if (!deletingUserId || !deletingUser) return; + const handleUserDelete = async (payload: UserDeletePayload) => { setError(null); - - if (deletingUser.photoCount > 0 && !deletePhotosDirectly && !deleteTransferUserId) { - setError('该用户有照片,请选择转移到其他用户或直接删除照片'); - return; - } - - const payload: { id: number; transferToUserId?: number; deletePhotos?: boolean } = { - id: deletingUserId, - }; - - if (deletePhotosDirectly) { - payload.deletePhotos = true; - } else if (deleteTransferUserId) { - payload.transferToUserId = deleteTransferUserId; - } - - const response = await fetch('/api/users', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '删除用户失败'); - return; + setDeleting(true); + + try { + const response = await fetch('/api/users', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + // 服务端的 planUserDelete 会把所有违规一次返回,error 已是拼接好的一句。 + setError(body.error ?? '删除用户失败'); + return; + } + + resetDeleteDialog(); + startTransition(() => router.refresh()); + } finally { + setDeleting(false); } - - resetDeleteDialog(); - startTransition(() => router.refresh()); }; return ( @@ -367,70 +356,13 @@ export function AdminUsersTab({ - !open && resetDeleteDialog()}> - - - 删除用户 - - {deletingUser?.photoCount - ? `该用户有 ${deletingUser.photoCount} 张照片,请选择如何处理这些照片:` - : '确认删除该用户?'} - - - {deletingUser?.photoCount ? ( - { - if (value === 'delete') { - setDeletePhotosDirectly(true); - setDeleteTransferUserId(null); - } else { - setDeletePhotosDirectly(false); - } - }} - > -
- - -
-
- - -
-
- ) : null} - {deletingUser?.photoCount && !deletePhotosDirectly ? ( -
- - -
- ) : null} - - - - -
-
+ void; + onConfirm: (payload: UserDeletePayload) => Promise | void; +}; + +function summarize(user: UserItem): string[] { + const lines: string[] = []; + if (user.photoCount > 0) { + lines.push(`${user.photoCount} 张照片`); + } + const drive: string[] = []; + if (user.fileSetCount > 0) { + drive.push(`${user.fileSetCount} 个文件集`); + } + if (user.fileCount > 0) { + drive.push(`${user.fileCount} 个文件`); + } + if (drive.length > 0) { + lines.push(drive.join('、')); + } + return lines; +} + +/** + * 删除成员前处置其资产的地方。 + * + * 只做转移:用户删除不销毁任何对象,所以这里没有"直接删除"这个选项—— + * 一个后端会拒绝的开关留在界面上,只是把 400 换个位置出现。 + * + * 决定仍由服务端逐条校验(lib/asset-deletion.ts 的 planUserDelete):这个组件是 + * 让管理员**看见**后果,不是替代那道闸。 + */ +export function UserDeleteDialog({ user, candidates, busy = false, onClose, onConfirm }: Props) { + const [targetId, setTargetId] = useState(null); + const [error, setError] = useState(null); + + // 对话框在父组件里常驻,换一个用户时必须把上一次的选择清掉。 + useEffect(() => { + setTargetId(null); + setError(null); + }, [user?.id]); + + const lines = user ? summarize(user) : []; + const needsTarget = lines.length > 0; + const receiver = candidates.find(candidate => candidate.id === targetId) ?? null; + + const confirm = async () => { + if (!user) return; + if (needsTarget && targetId === null) { + setError('请先选择接收这些资产的用户'); + return; + } + + const payload: UserDeletePayload = { id: user.id }; + if (user.photoCount > 0) payload.photoDecision = 'transfer'; + if (user.fileCount > 0 || user.fileSetCount > 0) payload.driveDecision = 'transfer'; + if (needsTarget && targetId !== null) payload.transferToUserId = targetId; + + setError(null); + await onConfirm(payload); + }; + + return ( + !open && onClose()}> + + + 删除用户 + + {needsTarget && user + ? `该用户拥有 ${lines.join(' 与 ')},删除前必须指定接收方。` + : '确认删除该用户?'} + + + + {needsTarget ? ( +
+
+ + +
+ +

+ {receiver + ? `${receiver.username} 将成为这些相册与文件的上传者与所有者;资产不会被删除。` + : '这些相册与文件不会被删除,只是换一个人归属。'} +

+ {user && user.fileSetCount > 0 ? ( +

+ 其中 internal 与 public 的文件集对其他成员仍然可见,转移后由{' '} + {receiver?.username ?? '接收方'} 管理。 +

+ ) : null} +
+ ) : ( +

该用户没有相册与云盘资产,可直接删除。

+ )} + + {error ? : null} + + + + + +
+
+ ); +} diff --git a/components/login-form.tsx b/components/login-form.tsx index bdc6b27..6763b75 100644 --- a/components/login-form.tsx +++ b/components/login-form.tsx @@ -5,6 +5,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { ErrorAlert } from '@/components/ui/error-alert'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { LOGIN_FEEDBACK_TEXT, classifySignInError, isAccountBlocked } from '@/lib/login-feedback'; +import type { LoginFeedback } from '@/lib/login-feedback'; import { signIn } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { FormEvent, useState } from 'react'; @@ -15,31 +17,17 @@ export function LoginForm({ onSuccess }: { onSuccess?: () => void } = {}) { const callbackUrl = params.get('callbackUrl') ?? '/'; const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(null); - const [pendingHint, setPendingHint] = useState(null); + const [feedback, setFeedback] = useState(null); const [isLoading, setIsLoading] = useState(false); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - setError(null); + setFeedback(null); setIsLoading(true); try { - setPendingHint(null); - // 先检查账户状态,避免不必要的登录尝试 - const check = await fetch('/api/users/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username }), - }) - .then(r => r.json()) - .catch(() => ({})); - - if (check?.status === 'pending') { - setPendingHint('账户待审核,请等待管理员通过'); - setIsLoading(false); - return; - } - + // 不再在登录前先探测一次账户状态:那需要一个未鉴权的接口回答"这个用户名存在吗、 + // 审核过了吗",任何人都能拿它枚举账号;而 signIn 在密码正确时本来就会带回同一个 + // 结论(lib/auth.ts 的 authorize 抛出待审核/已拒绝)。代价是一次 bcrypt.compare。 const response = await signIn('credentials', { username, password, @@ -48,13 +36,7 @@ export function LoginForm({ onSuccess }: { onSuccess?: () => void } = {}) { }); if (response?.error) { - if (response.error.includes('待审核') || response.error.includes('审核')) { - setError(null); - setPendingHint('账户待审核,请等待管理员通过'); - } else { - setError('用户名或密码错误'); - } - setIsLoading(false); + setFeedback(classifySignInError(response.error)); return; } @@ -90,13 +72,15 @@ export function LoginForm({ onSuccess }: { onSuccess?: () => void } = {}) { required /> - {error && } - {pendingHint && ( + {feedback === 'invalid' ? ( + + ) : null} + {feedback && isAccountBlocked(feedback) ? ( 登录受限 - {pendingHint} + {LOGIN_FEEDBACK_TEXT[feedback]} - )} + ) : null} 登录 diff --git a/components/register-form.tsx b/components/register-form.tsx index 07cbaf3..df5150a 100644 --- a/components/register-form.tsx +++ b/components/register-form.tsx @@ -42,7 +42,9 @@ export function RegisterForm({ throw new Error(payload.error ?? '注册失败'); } - if (payload.status === 'pending') { + if (typeof payload.message === 'string') { + setMessage(payload.message); + } else if (payload.status === 'pending') { setMessage('注册成功,请等待管理员审核通过后登录'); } else if (payload.role === 'admin') { setMessage('注册成功,已创建管理员账户,请使用该账号登录'); diff --git a/components/skeletons/page-skeleton.tsx b/components/skeletons/page-skeleton.tsx deleted file mode 100644 index baf939f..0000000 --- a/components/skeletons/page-skeleton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function PageSkeleton({ rows = 4 }: { rows?: number }) { - return ( -
-
- - -
-
- {Array.from({ length: rows }, (_, index) => ( - - ))} -
-
- ); -} diff --git a/components/upload-form.tsx b/components/upload-form.tsx index 0c8a63a..4989073 100644 --- a/components/upload-form.tsx +++ b/components/upload-form.tsx @@ -96,26 +96,48 @@ export function UploadForm({ categories, defaultCategoryId, onSuccess }: UploadF const file = files.item(index); if (!file) continue; - const formData = new FormData(); - formData.append('file', file); - formData.append('categoryId', categoryId); - if (description.trim()) { - formData.append('description', description.trim()); - } - try { - const response = await fetch('/api/upload', { + const tokenResponse = await fetch('/api/upload/token', { method: 'POST', - body: formData, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + categoryId: Number(categoryId), + name: file.name, + description: description.trim() || undefined, + mimeType: file.type, + size: file.size, + }), }); + const token = await tokenResponse.json(); + if (!tokenResponse.ok) throw new Error(token.error ?? '无法创建上传凭证'); - if (!response.ok) { - const payload = await response.json().catch(() => ({ error: '上传失败' })); - throw new Error(payload.error ?? '上传失败'); - } + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', token.uploadUrl); + xhr.setRequestHeader('Content-Type', token.headers['Content-Type']); + xhr.upload.onprogress = progressEvent => { + if (progressEvent.lengthComputable) { + const percent = (progressEvent.loaded / progressEvent.total) * 100; + setProgress(Math.round(((index + percent / 100) / total) * 100)); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`对象存储上传失败 (${xhr.status})`)); + }; + xhr.onerror = () => reject(new Error('对象存储连接失败')); + xhr.onabort = () => reject(new Error('上传已取消')); + xhr.send(file); + }); - const payload = (await response.json()) as UploadedPhoto; - uploads.push(payload); + const completeResponse = await fetch('/api/upload/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intentId: token.intentId }), + }); + const payload = await completeResponse.json(); + if (!completeResponse.ok) throw new Error(payload.error ?? '上传完成处理失败'); + uploads.push(payload as UploadedPhoto); setProgress(Math.round(((index + 1) / total) * 100)); } catch (err) { const message = err instanceof Error ? err.message : '上传失败'; diff --git a/docs/design-upload-direct-flow.md b/docs/design-upload-direct-flow.md index ed716f3..fd3cfc6 100644 --- a/docs/design-upload-direct-flow.md +++ b/docs/design-upload-direct-flow.md @@ -165,6 +165,21 @@ function useFileUpload(options: UseFileUploadOptions) { 拆分阶段的原因:让 UI 可以展示「当前在哪一步」。 +## 当前代码实现 + +当前运行流程与上面的早期方案草图略有不同: + +1. 已登录客户端向 `POST /api/upload/token` 请求上传凭证。服务端检查分类权限、MIME 类型和声明大小,生成不可由客户端指定的对象键,并创建短时 `UploadIntent`。 +2. 客户端用凭证返回的 URL 和 `Content-Type` 直接向 TOS 执行 `PUT`,并通过 XHR 显示进度。 +3. 客户端向 `POST /api/upload/complete` 提交 `intentId`。服务端验证所有者、权限、凭证有效期和 TOS 对象的大小/MIME;图片由服务端读取并生成 WebP 缩略图,再以数据库事务创建 Photo 并标记凭证完成。唯一关联约束支持安全重试。 +4. 管理员可调用 `POST /api/upload/cleanup` 清除已过期且尚无 Photo 记录的意图及原图/缩略图对象。应由运维定期触发此端点;只有对象清理成功后才删除意图记录。 + +兼容的 `POST /api/upload` 仍保留原有服务端中转行为;云盘 `/api/files` 不使用此直传流程。部署时需先同步 `prisma/schema.prisma` 并生成 Prisma Client,再开放新流程。 + +## TOS 跨域配置 + +浏览器直传前,TOS bucket 必须配置 CORS:允许应用的实际 Origin(生产/预览环境按需逐项配置),允许 `PUT` 方法,并允许请求头 `Content-Type`。浏览器读取上传状态使用 XHR;响应头无需额外暴露给客户端。此仓库只记录要求,不会修改外部 bucket 配置。 + ## 兼容性 - **存量文件**:不受影响 diff --git a/eslint.config.mjs b/eslint.config.mjs index b33adb1..7eaed3a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,7 +19,7 @@ const eslintConfig = [ prettier, }, rules: { - 'prettier/prettier': 'error', + 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }, { diff --git a/lib/asset-cleanup.ts b/lib/asset-cleanup.ts new file mode 100644 index 0000000..a083a83 --- /dev/null +++ b/lib/asset-cleanup.ts @@ -0,0 +1,148 @@ +import { + type AssetDeleter, + type CleanupFailureResult, + type CleanupPlan, + type CleanupResult, + type CleanupUnit, + assertNever, + deleteAssetsThenRowsWith, + planFileUnits, + planPhotoUnits, +} from '@/lib/asset-deletion'; +import { prisma } from '@/lib/db'; +import { + ConfigurationError, + deleteFileAsset, + deleteImageAssets, + deleteUploadObject, + ensureStorageConfigured, +} from '@/lib/storage'; +import { NextResponse } from 'next/server'; +import 'server-only'; + +/** + * 资产清理的执行层:把 lib/asset-deletion.ts 里那套与存储无关的策略接到 TOS 上, + * 外加必须走数据库的子项枚举。 + * + * 顺序、失败聚合、"有任何失败就不写行"这些判断都不在这里——它们在纯层, + * 因此能脱离数据库与对象存储被单测。这个文件只负责三件事:调哪个单元算子、 + * 异常如何分类、以及失败响应长什么样。 + */ + +const tosDeleter: AssetDeleter = { + ensureConfigured() { + try { + ensureStorageConfigured(); + return true; + } catch (error) { + console.error('[asset-cleanup] 对象存储未配置,放弃删除并保留数据行', error); + return false; + } + }, + + /** 按 AssetDeleter 的约定不抛异常,只回报失败原因。 */ + async deleteUnit(unit: CleanupUnit) { + try { + await runUnit(unit); + return { ok: true } as const; + } catch (error) { + console.error('[asset-cleanup] 单个对象删除失败', unit.kind, unit.filename, error); + return { + ok: false as const, + reason: error instanceof ConfigurationError ? ('config' as const) : ('storage' as const), + }; + } + }, +}; + +/** + * 仓库里唯一的删除入口:先删对象,全部成功后才执行 deleteRows()。 + * 语义与四条失败策略见 lib/asset-deletion.ts 的 deleteAssetsThenRowsWith。 + */ +export function deleteAssetsThenRows( + units: readonly CleanupUnit[], + deleteRows: () => Promise +): Promise> { + return deleteAssetsThenRowsWith(units, tosDeleter, deleteRows); +} + +/** + * 在删除父行之前枚举其下所有照片的对象键。 + * + * 这个"之前"是承重的:Photo.category 带 onDelete: Cascade,父行一删,这些行就在库内 + * 消失了,而它们是这些对象存在过的唯一记录。缺陷 B 的成因正是没有这一步。 + */ +export async function listPhotoUnitsOfCategory(categoryId: number): Promise { + const rows = (await prisma.photo.findMany({ + where: { categoryId }, + select: { filename: true, mediaType: true }, + })) as Array<{ filename: string; mediaType: string }>; + + return planPhotoUnits(rows); +} + +/** 同上:File.fileSet 也是级联,必须先取文件名。 */ +export async function listFileUnitsOfFileSet(filesetId: number): Promise { + const rows = (await prisma.file.findMany({ + where: { filesetId }, + select: { filename: true }, + })) as Array<{ filename: string }>; + + return planFileUnits(rows); +} + +/** + * 穷尽的 switch:加第四种 CleanupUnit 却忘了在这里处理会是编译错误。 + * 这已经是这个仓库能拿到的最强保证——Prisma 的委托参数是 any,字段名拼错什么都不会报。 + */ +async function runUnit(unit: CleanupUnit): Promise { + switch (unit.kind) { + case 'image-assets': + return deleteImageAssets(unit.filename); + case 'upload-object': + return deleteUploadObject(unit.filename); + case 'file-asset': + return deleteFileAsset(unit.filename); + default: + return assertNever(unit); + } +} + +/** + * 包络键沿用各域现状(相册/用户 {error},云盘 {message}),理由见 + * lib/prisma-errors.ts 里的同一个参数。 + * + * 部分失败必须是非 2xx:行还在,客户端若把这当成功,用户会以为删掉了而对象与行都活着。 + * 502 而不是 500——那是上游存储的故障,与本应用自己的错误分开,也提示重试有可能成功。 + */ +export function cleanupErrorResponse( + outcome: { ok: false } & CleanupFailureResult, + envelope: 'error' | 'message' = 'error' +): NextResponse { + if (outcome.misconfigured) { + return NextResponse.json( + { [envelope]: '对象存储配置错误', code: 'storage_misconfigured' }, + { status: 500 } + ); + } + + return NextResponse.json( + { + [envelope]: '对象存储删除失败,媒体已保留未删除,请重试', + code: 'storage_cleanup_failed', + attempted: outcome.attempted, + objectTotal: outcome.objectTotal, + failed: outcome.failed, + }, + { status: 502 } + ); +} + +/** 空 filename 被跳过是异常数据信号,值得出声,但不必因此让整个请求失败。 */ +export function reportSkippedNames(scope: string, skipped: string[]): void { + if (skipped.length > 0) { + console.warn( + `[asset-cleanup] ${scope}:${skipped.length} 条记录的 filename 为空,已跳过其对象删除` + ); + } +} diff --git a/lib/asset-deletion.ts b/lib/asset-deletion.ts new file mode 100644 index 0000000..8f6a0f0 --- /dev/null +++ b/lib/asset-deletion.ts @@ -0,0 +1,333 @@ +/** + * "删掉这批行要动存储里的哪些对象"——这条决定的唯一归属地。 + * 纯函数、零运行时依赖,分层理由与 lib/access-rules.ts 相同:同一份判断既能被 route + * handler 使用,也能脱离数据库与对象存储单测。 + * + * 存在的理由:这个判断此前散在三处,且每处都不一样—— + * - app/api/photos/route.ts 用 `photo.mediaType === 'image' ? … : …` 现场重推; + * - app/api/filesets/[id]/route.ts 硬编码 deleteFileAsset; + * - app/api/categories/route.ts 与 app/api/users/route.ts 干脆什么都没做。 + * + * 关键是 kind 与 domain 被绑在同一个联合成员上:域决定前缀,于是"云盘文件被路由到 + * uploads/ 前缀"在这个类型下**不可表示**。这不是洁癖——TOS 删一个不存在的键返回 204 + * 而不是 NoSuchKey,所以 deleteUploadObject(云盘 filename) 不只是空操作,它会报告成功, + * 泄漏掉真正的对象而没有任何调用方能察觉。唯一可靠的防线是让它写不出来。 + */ + +export type AssetDomain = 'photo' | 'drive'; + +/** + * 删除动作的最小单位。kind 是一个封闭标签联合:它唯一决定 lib/storage 的哪个单元 + * 算子被调用,而 domain 由 kind 决定,调用方无从选择。 + */ +export type CleanupUnit = + /** uploads/{filename} + uploads/thumbnails/thumb-{filename} */ + | { readonly kind: 'image-assets'; readonly domain: 'photo'; readonly filename: string } + /** uploads/{filename} —— 相册域的视频,没有缩略图 */ + | { readonly kind: 'upload-object'; readonly domain: 'photo'; readonly filename: string } + /** files/{filename} —— 云盘域 */ + | { readonly kind: 'file-asset'; readonly domain: 'drive'; readonly filename: string }; + +export type CleanupUnitKind = CleanupUnit['kind']; + +/** + * mediaType 故意用 string 而不是 'image' | 'video':types/prisma-client.d.ts 让 + * Prisma 的真实类型在这个环境里不存在,运行时拿到的值未必落在枚举内。 + * 用字面量联合来自证清白是谎话,宁可让 unitForPhoto 对脏数据做保守处理。 + */ +export type PhotoLike = { readonly filename: string; readonly mediaType: string }; +export type FileLike = { readonly filename: string }; + +export type CleanupPlan = { + units: CleanupUnit[]; + /** 因 filename 为空而被跳过的原始值,调用方需要把它报出去而不是假装无事发生。 */ + skipped: string[]; +}; + +export type CleanupFailure = { + /** config = ConfigurationError(环境没配好,重试也不会成功);storage = 传输层错误。 */ + reason: 'config' | 'storage'; + kind: CleanupUnitKind; + filename: string; + message: string; +}; + +const OBJECTS_PER_UNIT: Record = { + 'image-assets': 2, + 'upload-object': 1, + 'file-asset': 1, +}; + +/** + * 只有明确写着 video 的才按单个原图对象处理,其它任何值(''、null、'IMAGE'、 + * 枚举漂移出来的新值)一律按图片处理。 + * + * 方向是故意的:多删一个本不存在的缩略图只是 204 no-op,漏删一个真实缩略图则是 + * 永久且无人可寻的泄漏。注意这与旧实现相反——旧写法对损坏行会走少删一侧。 + */ +export function unitForPhoto(photo: PhotoLike): CleanupUnit { + return photo.mediaType === 'video' + ? { kind: 'upload-object', domain: 'photo', filename: photo.filename } + : { kind: 'image-assets', domain: 'photo', filename: photo.filename }; +} + +/** 没有任何入参能改变这个结果:云盘域永远只走 files/ 前缀。 */ +export function unitForFile(file: FileLike): CleanupUnit { + return { kind: 'file-asset', domain: 'drive', filename: file.filename }; +} + +export function objectsTouchedBy(unit: CleanupUnit): number { + return OBJECTS_PER_UNIT[unit.kind]; +} + +/** 应当从桶里消失的键数,与 units.length(删除调用数)不同。 */ +export function objectCount(units: readonly CleanupUnit[]): number { + return units.reduce((total, unit) => total + objectsTouchedBy(unit), 0); +} + +export function planPhotoUnits(rows: readonly PhotoLike[]): CleanupPlan { + return planUnits(rows.map(unitForPhoto)); +} + +export function planFileUnits(rows: readonly FileLike[]): CleanupPlan { + return planUnits(rows.map(unitForFile)); +} + +/** + * 丢掉空 filename 的单位,并按 (kind, filename) 去重、保留首次出现顺序。 + * + * 空值必须丢而不是传下去:buildObjectKey('') 得到的就是 `uploads/` 这个前缀标记对象, + * 而对它的删除会成功。去重则让 objectCount 成为"本该死掉多少个"的可信数字。 + */ +function planUnits(units: readonly CleanupUnit[]): CleanupPlan { + const seen = new Set(); + const kept: CleanupUnit[] = []; + const skipped: string[] = []; + + for (const unit of units) { + if (typeof unit.filename !== 'string' || unit.filename.trim() === '') { + skipped.push(String(unit.filename)); + continue; + } + const key = `${unit.kind}:${unit.filename}`; + if (seen.has(key)) continue; + seen.add(key); + kept.push(unit); + } + + return { units: kept, skipped }; +} + +/** 让执行器的 switch 穷尽性成为编译期检查,而不是靠人盯。 */ +export function assertNever(value: never): never { + throw new Error(`未处理的清理单位类型:${JSON.stringify(value)}`); +} + +// ========= 编排(纯,靠注入执行器脱离对象存储单测) ========= + +/** + * 单个单位的结局。执行器**不抛异常**而是返回它,是为了让"这是环境没配好"与 + * "这是一次可以重试的传输故障"这两种失败在纯层就能被区分——那个判断决定客户端 + * 收到 500 还是 502,不该被锁在只有真实 TOS 才能触发的代码路径里。 + */ +export type UnitOutcome = { ok: true } | { ok: false; reason: 'config' | 'storage' }; + +export interface AssetDeleter { + /** 预检。返回 false 时不应发起任何删除,也不应删任何行。 */ + ensureConfigured(): boolean; + deleteUnit(unit: CleanupUnit): Promise; +} + +/** 失败结果只带数量:具体是哪个键失败进日志,不透给客户端。 */ +export type CleanupFailureResult = { + attempted: number; + objectTotal: number; + failed: number; + misconfigured: boolean; +}; + +export type CleanupResult = + | { ok: true; result: T; objectTotal: number } + | ({ ok: false } & CleanupFailureResult); + +/** + * 先删对象,全部成功后才删行。这是仓库里唯一的删除顺序,且没有任何导出函数能单独删行。 + * + * 四条策略,每条都对应一次真实的失败模式: + * 1. units 为空直通 deleteRows()——一个没有照片的分类仍应能被删掉。 + * 2. 预检失败时不发起 N 次注定失败的删除,也不碰行:4000 张照片的场合会把同一句 + * 错误重复 4000 遍。 + * 3. 用 allSettled 收集而不是 all:一个死键不该把另外 400 个成功藏起来(缺陷 F 正是 + * allSettled 的结果从不上报,于是整桶失败仍返回 {ok:true})。 + * 4. 有任何失败就不写行、返回非 2xx:行还在,重试可收敛。 + * + * 反过来(先删行)时行一旦没了,filename 就是那些对象唯一的记录,泄漏从此不可追溯。 + * + * deleteRows() 的异常刻意不吞:那时对象已经删掉了,这是唯一需要人介入的状态, + * 也正是选了"对象先"才换来它可被追溯(行仍带着 filename,重试会删一个已不存在的键 + * 并得到 204)。由调用方交给 prismaErrorResponse 映射。 + */ +export async function deleteAssetsThenRowsWith( + units: readonly CleanupUnit[], + deleter: AssetDeleter, + deleteRows: () => Promise +): Promise> { + const objectTotal = objectCount(units); + + if (units.length === 0) { + return { ok: true, result: await deleteRows(), objectTotal }; + } + + if (!deleter.ensureConfigured()) { + return { ok: false, attempted: 0, objectTotal, failed: 0, misconfigured: true }; + } + + const settled = await Promise.allSettled(units.map(unit => deleter.deleteUnit(unit))); + + const failures: Array<'config' | 'storage'> = []; + for (const entry of settled) { + // 执行器按约定不抛,rejected 只能是编程错误;按可重试的存储故障处理,行照样保住。 + if (entry.status === 'rejected') { + failures.push('storage'); + } else if (!entry.value.ok) { + failures.push(entry.value.reason); + } + } + + if (failures.length > 0) { + return { + ok: false, + attempted: units.length, + objectTotal, + failed: failures.length, + misconfigured: failures.includes('config'), + }; + } + + return { ok: true, result: await deleteRows(), objectTotal }; +} + +// ========= 删除用户:资产处置决策 ========= + +/** + * User 上每一个会阻止删除的关系(Prisma 对必填关系默认 Restrict,所以删除会撞 P2003)。 + * + * 这些字段名必须与 prisma/schema.prisma 里 model User 的反向关系逐字一致。本环境的 + * Prisma 委托参数是 any,写错一个字母 tsc 与 CI 都不会报,而它会把"这个用户到底有 + * 什么"数成一个错误的 0,于是放行一次必然撞外键的删除。 + * __tests__/schema-invariants.test.ts 把这条钉住。 + * + * 下面这个 select 对象是这三处 _count 查询(删除接口、管理页 RSC)唯一的书写处: + * 名单本身只长在这里,调用点抄不出第四个名字,也漏不掉一个。 + */ +export const USER_ASSET_COUNT_SELECT = { + photos: true, + filesUploaded: true, + fileSetsCreated: true, +} as const; + +export const USER_RESTRICTING_RELATIONS = Object.keys( + USER_ASSET_COUNT_SELECT +) as (keyof typeof USER_ASSET_COUNT_SELECT)[]; + +/** 上面那份 select 的返回形状:两个消费点都从这里取,名字不再各自硬写一遍。 */ +export type UserAssetCounts = { [K in keyof typeof USER_ASSET_COUNT_SELECT]: number }; + +/** + * 'delete' 目前是**写出来就会被拒**的取值:本系统的处置方式是转移,用户删除不销毁任何 + * 资产。留着这个标签是有意的——将来放开"连资产一并销毁"时,需要的是在这里加一条规则, + * 而不是再设计一遍决策表,且销毁路径可以直接复用 deleteAssetsThenRowsWith。 + */ +export type AssetDisposition = 'transfer' | 'delete'; + +export type UserDeleteInput = { + photoCount: number; + fileCount: number; + fileSetCount: number; + photoDecision?: AssetDisposition; + driveDecision?: AssetDisposition; + transferToUserId?: number; + /** 正要被删除的那个用户。 */ + targetUserId: number; + isSelf: boolean; + isTargetAdmin: boolean; + adminCount: number; + transferTargetExists: boolean; + transferTargetActive: boolean; +}; + +export type UserDeletePlan = + | { ok: false; errors: string[] } + | { + ok: true; + photos: AssetDisposition | 'none'; + drive: AssetDisposition | 'none'; + transferToUserId?: number; + }; + +/** + * 删除用户前把每一条违规**一次全部**报出来。 + * + * 旧实现是逐条揭示:先只问照片(app/api/users/route.ts 的 deletePhotos 分支), + * 照片处置完才在 prisma.user.delete 上撞它从没问过的云盘外键,返回一个裸 500—— + * 此时照片行已被销毁而它们的对象永远留在桶里。把决策做成前置的纯函数表之后, + * 那条路径不存在了:任何写发生之前,所有会被 Restrict 的关系都已处置干净。 + */ +export function planUserDelete(input: UserDeleteInput): UserDeletePlan { + const errors: string[] = []; + const hasPhotos = input.photoCount > 0; + const hasDrive = input.fileCount > 0 || input.fileSetCount > 0; + + if (input.isSelf) { + errors.push('不能删除自己的账户'); + } + if (input.isTargetAdmin && input.adminCount <= 1) { + errors.push('这是系统中唯一的管理员,删除后将无人可管理后台'); + } + + const domains: Array<[string, AssetDisposition | undefined, boolean]> = [ + ['照片', input.photoDecision, hasPhotos], + ['云盘', input.driveDecision, hasDrive], + ]; + + for (const [label, decision, hasRows] of domains) { + // 该域没有行就不需要决定:没有资产的用户应能被直接删除,今天恰恰是这个分支 + // 被 `userToDelete._count.photos > 0` 之外的条件挡住了。 + if (!hasRows) continue; + if (!decision) { + errors.push(`该用户有${label}资产,请选择如何处置`); + continue; + } + if (decision !== 'transfer') { + errors.push(`${label}目前只能转移给其他用户,不支持随删除一并销毁`); + } + } + + const needsTransfer = hasPhotos || hasDrive; + + if (needsTransfer) { + if (input.transferToUserId === undefined) { + errors.push('缺少资产转移的目标用户'); + } else { + if (input.transferToUserId === input.targetUserId) { + errors.push('不能把资产转移给正在被删除的用户'); + } + if (!input.transferTargetExists) { + errors.push('目标用户不存在'); + } else if (!input.transferTargetActive) { + errors.push('目标账户尚未激活,不能接收资产'); + } + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { + ok: true, + photos: hasPhotos ? 'transfer' : 'none', + drive: hasDrive ? 'transfer' : 'none', + transferToUserId: needsTransfer ? input.transferToUserId : undefined, + }; +} diff --git a/lib/auth.ts b/lib/auth.ts index c0a32bd..5e236b6 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -5,6 +5,7 @@ import Credentials from 'next-auth/providers/credentials'; import { cache } from 'react'; import { prisma } from './db'; +import { LOGIN_FEEDBACK_TEXT } from './login-feedback'; export const authOptions: NextAuthOptions = { session: { @@ -38,13 +39,14 @@ export const authOptions: NextAuthOptions = { return null; } - // 检查账户状态 + // 检查账户状态。文案取自 lib/login-feedback.ts:抛出的是散文、匹配回的也是 + // 散文,两处各写一遍时改文案会静默把某个分支降级成"用户名或密码错误"。 if (user.status === 'pending') { - throw new Error('账户待审核,请等待管理员通过'); + throw new Error(LOGIN_FEEDBACK_TEXT.pending); } if (user.status === 'rejected') { - throw new Error('账户已被拒绝,无法登录'); + throw new Error(LOGIN_FEEDBACK_TEXT.rejected); } return { diff --git a/lib/login-feedback.ts b/lib/login-feedback.ts new file mode 100644 index 0000000..9b1b95c --- /dev/null +++ b/lib/login-feedback.ts @@ -0,0 +1,45 @@ +/** + * 把 NextAuth 回传的字符串变成一个可渲染的结论。纯函数、零依赖。 + * + * 为什么需要它:NextAuth 的 credentials 流程只给我们一个字符串——authorize() 抛出 + * 的错误消息会被编码进 /api/auth/error?error=…,再由 next-auth/react 解出来塞进 + * response.error。于是"这次登录失败到底是密码错了、还是账户没通过审核"完全取决于 + * 对一段散文做子串匹配。 + * + * 而组件里那段匹配是错的:`components/login-form.tsx` 原来只测 + * `待审核|审核`,而 lib/auth.ts 抛出的「账户已被拒绝,无法登录」两个词都不含, + * 于是**密码正确但被管理员拒绝的用户被告知"用户名或密码错误"**——一个会说谎的 + * 提示,用户会一直重试自己的正确密码。 + * + * 匹配用的是两个词各自独有的特征串(`待审核` / `被拒绝`)而不是共享的"审核", + * 且文案本身由下面的常量唯一提供、lib/auth.ts 也从这里取:两处各写一遍文案时, + * 改文案就会静默把某个分支降级成 invalid。 + */ + +export type LoginFeedback = 'pending' | 'rejected' | 'invalid'; + +/** 既是给用户看的话,也是 authorize() 抛出去的话——只有这一个来源。 */ +export const LOGIN_FEEDBACK_TEXT: Record = { + pending: '账户待审核,请等待管理员通过', + rejected: '账户已被拒绝,无法登录', + invalid: '用户名或密码错误', +}; + +/** + * 一切未知都归到 invalid。 + * + * 特意如此:'CredentialsSignin' 是 authorize() 返回 null(即密码错或用户不存在)时 + * NextAuth 发的字面量;此外还可能是 'Configuration'、网络层文本、任何东西。把它们 + * 统统显示成"用户名或密码错误"既是最保守的措辞,也不会把内部配置问题泄露到登录页。 + */ +export function classifySignInError(error: string | undefined | null): LoginFeedback { + if (!error) return 'invalid'; + if (error.includes('待审核')) return 'pending'; + if (error.includes('被拒绝')) return 'rejected'; + return 'invalid'; +} + +/** 受限(而非"密码错了")的两种状态,UI 用它决定渲染成告警还是错误。 */ +export function isAccountBlocked(feedback: LoginFeedback): feedback is 'pending' | 'rejected' { + return feedback === 'pending' || feedback === 'rejected'; +} diff --git a/lib/media-type.ts b/lib/media-type.ts new file mode 100644 index 0000000..07c40e4 --- /dev/null +++ b/lib/media-type.ts @@ -0,0 +1,151 @@ +/** + * 媒体类型的唯一归属地:纯函数、零运行时依赖,因此既能被 route handler 用, + * 也能脱离数据库与对象存储单测。 + * + * 这里存在的理由:mediaType 与 mimeType 是两个必须互相对应的字段,而 + * app/api/upload/route.ts 原来分别从 `file.type.startsWith('image/')` 和 + * `file.type || (isImage ? … : 'video/mp4')` 两处独立推导它们。同一个值被两处 + * 决定,就意味着它们可以互相矛盾。现在只允许 resolveUploadMedia 产出这两个字段。 + * + * 仓库里原先有三张 mime/扩展名对照表(ALLOWED_*_MIME、私有的 getExtensionFromMime、 + * guessMimeFromFilename),全部收在这里,且都是下面两张表的视图。 + */ + +export const MEDIA_KINDS = ['image', 'video'] as const; + +export type MediaKind = (typeof MEDIA_KINDS)[number]; + +/** 相册域允许的图片类型。sharp 会二次验证真正的字节。 */ +export const ALLOWED_IMAGE_MIME: ReadonlySet = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +]); + +/** 相册域允许的视频类型。 */ +export const ALLOWED_VIDEO_MIME: ReadonlySet = new Set([ + 'video/mp4', + 'video/webm', + 'video/quicktime', +]); + +/** + * 扩展名 → MIME。原先的 guessMimeFromFilename 一个视频条目都没有, + * 于是 `a.mp4` 会落到 application/octet-stream,任何靠它反推类型的路径都是半残的。 + * 不含 .svg:sharp 处理它会失败,不该被当作可上传的图片。 + */ +const EXTENSION_TO_MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', +}; + +/** MIME → 扩展名,用于文件名不带扩展名时给存储键补一个。 */ +const MIME_TO_EXTENSION: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/quicktime': '.mov', +}; + +/** + * 既没有可信的声明类型、文件名也没有可用扩展名。 + * + * 刻意不做 magic byte 嗅探:要覆盖 MP4/MOV 就得解析偏移 4 处的 ftyp box, + * 那要么引依赖要么手写几十行。而"猜不出"的正确响应是一个说清问题的 400, + * 不是一个大约 95% 情况下猜对的启发式。 + */ +export class AmbiguousMediaError extends Error { + constructor(name: string) { + super( + `无法识别文件类型:${name || '(无文件名)'}。图片请使用 jpg/png/gif/webp,视频请使用 mp4/webm/mov` + ); + this.name = 'AmbiguousMediaError'; + } +} + +export function isImageMime(mime: string): boolean { + return ALLOWED_IMAGE_MIME.has(mime); +} + +/** 该 MIME 是否属于相册域可接受的媒体;云盘的 pdf/txt 等返回 false。 */ +export function isUploadableMedia(mime: string): boolean { + return ALLOWED_IMAGE_MIME.has(mime) || ALLOWED_VIDEO_MIME.has(mime); +} + +export function kindForMime(mime: string): MediaKind | null { + if (ALLOWED_IMAGE_MIME.has(mime)) return 'image'; + if (ALLOWED_VIDEO_MIME.has(mime)) return 'video'; + return null; +} + +export function extensionForMime(mime: string): string { + return MIME_TO_EXTENSION[mime] ?? ''; +} + +/** 从文件名取扩展名(小写,含点);没有则 ''。 */ +export function extensionFromName(name: string): string { + const match = /\.([^.]+)$/u.exec(name ?? ''); + return match?.[1] ? `.${match[1].toLowerCase()}` : ''; +} + +/** 由扩展名反推 MIME;未知返回 null,由调用方决定兜底值。 */ +export function mimeFromFilename(name: string): string | null { + const extension = extensionFromName(name); + return extension ? (EXTENSION_TO_MIME[extension] ?? null) : null; +} + +/** + * 存储键的扩展名:优先用真实文件名的,缺失时退回 MIME 推导。 + * 原实现只在 persistVideo 里做 MIME 兜底,于是空类型 + 无扩展名的视频会存成没有扩展名的键。 + */ +export function extensionForUpload(name: string, mime: string): string { + return extensionFromName(name) || extensionForMime(mime); +} + +export type ResolvedMedia = { + kind: MediaKind; + mimeType: string; + /** 判定依据,同时是将来加 'sniffed' 的扩展位。 */ + source: 'declared' | 'extension'; +}; + +/** + * 产出 mediaType 与 mimeType 的唯一地方。 + * + * 顺序与理由:浏览器声明的 type 优先于扩展名,因为对象存储的 contentType、缩略图分支 + * 与前端渲染都跟着它走,一个说谎的扩展名(vacation.jpg 实为 PNG)不该盖掉诚实的声明。 + * 声明可伪造,但图片路径有 sharp 兜底验证(persistImage 遇到非图片字节会抛), + * 且两种媒体都在同一个 uploads/ 前缀下,所以谎报既不会漏删对象也不会跨域。 + * application/octet-stream 不算诚实的声明,会继续退回扩展名判定。 + */ +export function resolveUploadMedia(input: { name: string; declaredType: string }): ResolvedMedia { + const declared = (input.declaredType ?? '').trim().toLowerCase(); + + const declaredKind = kindForMime(declared); + if (declaredKind) { + return { kind: declaredKind, mimeType: declared, source: 'declared' }; + } + + const fromName = mimeFromFilename(input.name ?? ''); + if (fromName) { + const nameKind = kindForMime(fromName); + if (nameKind) { + return { kind: nameKind, mimeType: fromName, source: 'extension' }; + } + } + + throw new AmbiguousMediaError(input.name ?? ''); +} diff --git a/lib/params.ts b/lib/params.ts index b83ba33..f046d7a 100644 --- a/lib/params.ts +++ b/lib/params.ts @@ -1,6 +1,7 @@ /** * 读取 searchParams 的纯函数。服务端组件与未来的单测共用,不依赖 Next。 */ +import { idStringSchema } from '@/lib/validation'; export type ParamValue = string | string[] | undefined; export type SearchParams = Record; @@ -16,9 +17,17 @@ export function readString(params: SearchParams, key: string): string { export function readInt(params: SearchParams, key: string): number | null { const raw = first(params[key]); - if (!raw) return null; - const parsed = Number.parseInt(raw, 10); - return Number.isInteger(parsed) ? parsed : null; + if (!raw || !/^-?\d+$/u.test(raw)) return null; + const parsed = Number(raw); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** Reads a strict positive int32 path/query ID. */ +export function readId(params: SearchParams, key: string): number | null { + const raw = first(params[key]); + if (raw === undefined) return null; + const parsed = idStringSchema.safeParse(raw); + return parsed.success ? parsed.data : null; } export function readSort(params: SearchParams): 'asc' | 'desc' { diff --git a/lib/prisma-errors.ts b/lib/prisma-errors.ts new file mode 100644 index 0000000..d0777af --- /dev/null +++ b/lib/prisma-errors.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server'; + +/** + * 把 Prisma 的已知请求错误映射成 HTTP 状态码。 + * + * 存在的理由:P2025/P2003 在整个仓库里从来没有出现过一次,所以一个"格式合法但不存在" + * 的 id 会一路冒到 Next 的默认错误处理,客户端拿到裸 500;而前端一律读 `body.error` + * 或 `json.message`,500 的响应体里没有这两个键,于是只显示一句通用的兜底文案。 + * 缺陷 D(删除用户时撞外键)与缺陷 F(删除不存在的文件集)都需要这个映射才有意义, + * 没有它,那些修复只会把一种误导性 500 换成另一种。 + * + * 为什么是鸭子类型而不是 instanceof:本机与 CI 都没有生成 Prisma 客户端 + * (types/prisma-client.d.ts 是手写声明,运行时来自 scripts/create-prisma-stub.cjs), + * 那个 stub 的 Prisma 对象里根本没有 PrismaClientKnownRequestError 这个类。 + * + * 但鸭子类型有个这仓库真会踩的坑:@volcengine/tos-sdk 的 TosServerError 同样带一个 + * `code: string`('NoSuchKey'、'AccessDenied'…)。所以必须同时要求 name 与 P 形码, + * 否则一次对象存储故障会被误报成 404/409,把可重试的上游错误洗成客户端错误。 + * + * 本模块可以 import next/server:next/server 不拉 server-only,因此 prismaErrorCode + * 与 mapPrismaError 仍然能在纯 node 环境下脱离数据库单测。 + */ + +const PRISMA_KNOWN_REQUEST_ERROR = 'PrismaClientKnownRequestError'; +const PRISMA_CODE = /^P\d{4}$/; + +export type MappedError = { + status: 400 | 404 | 409 | 500; + message: string; + code: string; +}; + +/** 命中 Prisma 已知错误时返回其 P 形码,否则 null(包括一切 TosServerError)。 */ +export function prismaErrorCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null) return null; + + const candidate = error as { name?: unknown; code?: unknown }; + if (candidate.name !== PRISMA_KNOWN_REQUEST_ERROR) return null; + + const { code } = candidate; + return typeof code === 'string' && PRISMA_CODE.test(code) ? code : null; +} + +/** + * 文案只说结论,不带 `meta.target`、表名或任何 SQL 片段:这些接口的调用方 + * 包含匿名访客能看到的分享页,错误信息不是日志。 + */ +export function mapPrismaError(error: unknown): MappedError { + switch (prismaErrorCode(error)) { + case 'P2025': + return { status: 404, message: '记录不存在', code: 'not_found' }; + case 'P2003': + return { + status: 409, + message: '仍存在关联数据,请先处置后重试', + code: 'related_records_remain', + }; + case 'P2002': + return { status: 409, message: '该值已被占用', code: 'unique_conflict' }; + default: + return { status: 500, message: '操作失败', code: 'internal_error' }; + } +} + +/** + * 相册域与云盘域的响应包络历来不同:前者是 `{ error }`,后者是 `{ message }` + * (前端分别读 body.error 与 json.message)。统一它要同时改 11 个 route 和 6 个 + * 组件,关掉的却是一个不会坏的不一致,所以本批保留现状——但把差异收在这一个参数里, + * 而不是让它以两份复制的样板长在每个文件里。 + * + * 只有落到 500 的才打日志:404/409 是调用方的问题,不是服务端故障, + * 混在错误日志里只会淹掉后者。 + */ +export function prismaErrorResponse( + error: unknown, + envelope: 'error' | 'message' = 'error' +): NextResponse { + const mapped = mapPrismaError(error); + if (mapped.status === 500) { + console.error(error); + } + return NextResponse.json( + { [envelope]: mapped.message, code: mapped.code }, + { status: mapped.status } + ); +} diff --git a/lib/share-auth.ts b/lib/share-auth.ts index ac1c992..94ae4d7 100644 --- a/lib/share-auth.ts +++ b/lib/share-auth.ts @@ -2,9 +2,20 @@ import { cookies } from 'next/headers'; import { createHmac, timingSafeEqual } from 'node:crypto'; import 'server-only'; -/** 解锁会话最长 8 小时,且不超过分享链接本身的有效期 */ +/** 解锁凭证最长 8 小时,且不超过分享链接本身的有效期 */ const MAX_UNLOCK_MINUTES = 8 * 60; +/** + * Secure 必须跟"实际用的协议"走,而不是 NODE_ENV:`next start` 在生产模式下 + * NODE_ENV=production,但用 http 提供服务时浏览器会直接丢弃 Secure cookie, + * 表现为密码正确却仍停在门后。NEXTAUTH_URL 是应用对外地址,以它为准。 + */ +function cookieSecure(): boolean { + const publicUrl = process.env.NEXTAUTH_URL; + if (publicUrl) return publicUrl.startsWith('https://'); + return process.env.NODE_ENV === 'production'; +} + function gateValue(token: string): string { const secret = process.env.NEXTAUTH_SECRET; if (!secret) { @@ -42,7 +53,7 @@ export function buildUnlockCookie(token: string, expiresAt: Date | null) { options: { httpOnly: true, sameSite: 'lax' as const, - secure: process.env.NODE_ENV === 'production', + secure: cookieSecure(), path: `/share/${token}`, maxAge: minutes * 60, }, diff --git a/lib/storage.ts b/lib/storage.ts index c8c4d33..aefbe8c 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,10 +1,14 @@ +import { + ALLOWED_IMAGE_MIME, + ALLOWED_VIDEO_MIME, + extensionForUpload, + mimeFromFilename, +} from '@/lib/media-type'; import { TosClient, TosServerCode, TosServerError } from '@volcengine/tos-sdk'; import { randomUUID } from 'crypto'; import 'server-only'; import sharp from 'sharp'; -const ALLOWED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); -const ALLOWED_VIDEO_MIME = new Set(['video/mp4', 'video/webm', 'video/quicktime']); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_VIDEO_FILE_SIZE = 512 * 1024 * 1024; // 512MB for videos const MAX_GENERAL_FILE_SIZE = 512 * 1024 * 1024; // 512MB for general files @@ -37,11 +41,16 @@ export class UploadError extends Error { } } -export async function persistImage(file: File) { +/** + * mimeType 由调用方(resolveUploadMedia)决定,而不是这里再看一次 file.type: + * 空 type 的上传在旧实现里会掉进 persistVideo 的视频白名单,被以 + * 「仅支持 MP4 / WebM / MOV 视频」拒绝。真正的字节仍由下面的 sharp 验证。 + */ +export async function persistImage(file: File, mimeType: string) { const config = getConfig(); const client = getClient(); - if (!ALLOWED_IMAGE_MIME.has(file.type)) { + if (!ALLOWED_IMAGE_MIME.has(mimeType)) { throw new UploadError('仅支持 JPG/PNG/GIF/WebP 图片'); } @@ -53,33 +62,55 @@ export async function persistImage(file: File) { const buffer = Buffer.from(arrayBuffer); const originalName = file.name; - const extension = getExtensionFromFile(file); + const extension = extensionForUpload(file.name, mimeType); const filename = `${Date.now()}-${randomUUID()}${extension}`; const objectKey = buildObjectKey(filename, config); + // 缩略图解码必须在原图上传之前:反过来时一张坏图片已经把一个永远没有数据库行 + // 引用它的原图写进了桶里,而这条孤儿没有任何代码能再找到它。 + let thumbnailBuffer: Buffer; + try { + const { data } = await sharp(buffer, { sequentialRead: true }) + .resize(400, 400, { fit: 'inside', withoutEnlargement: true, fastShrinkOnLoad: true }) + .webp({ quality: 80 }) + .toBuffer({ resolveWithObject: true }); + thumbnailBuffer = data; + } catch (error) { + // 走到这里说明声明/扩展名说它是图片,但字节不是 ⇒ 客户端得到诚实的 400, + // 真实原因留在日志里,不把 sharp 的内部信息透给前端。 + console.error('[persistImage] 图片解码失败', file.name, error); + throw new UploadError('图片内容无法解析,请确认文件确实是图片'); + } + const originalUpload = client.putObject({ bucket: config.bucket, key: objectKey, body: buffer, - contentType: file.type, + contentType: mimeType, }); - const { data: thumbnailBuffer } = await sharp(buffer, { sequentialRead: true }) - .resize(400, 400, { fit: 'inside', withoutEnlargement: true, fastShrinkOnLoad: true }) - .webp({ quality: 80 }) - .toBuffer({ resolveWithObject: true }); - const thumbnailKey = buildThumbnailKey(filename, config); - await Promise.all([ - originalUpload, - client.putObject({ - bucket: config.bucket, - key: thumbnailKey, - body: thumbnailBuffer, - contentType: 'image/webp', - }), - ]); + try { + const uploads = await Promise.allSettled([ + originalUpload, + client.putObject({ + bucket: config.bucket, + key: thumbnailKey, + body: thumbnailBuffer, + contentType: 'image/webp', + }), + ]); + const failedUpload = uploads.find(result => result.status === 'rejected'); + if (failedUpload?.status === 'rejected') throw failedUpload.reason; + } catch (error) { + try { + await deleteImageAssets(filename); + } catch (cleanupError) { + console.error('[persistImage] 上传失败后的对象补偿清理失败', filename, cleanupError); + } + throw error; + } return { filename, @@ -87,11 +118,11 @@ export async function persistImage(file: File) { }; } -export async function persistVideo(file: File) { +export async function persistVideo(file: File, mimeType: string) { const config = getConfig(); const client = getClient(); - if (!ALLOWED_VIDEO_MIME.has(file.type)) { + if (!ALLOWED_VIDEO_MIME.has(mimeType)) { throw new UploadError('仅支持 MP4 / WebM / MOV 视频'); } @@ -103,16 +134,27 @@ export async function persistVideo(file: File) { const buffer = Buffer.from(arrayBuffer); const originalName = file.name; - const extension = getExtensionFromFile(file) || getExtensionFromMime(file.type); + // 旧写法 getExtensionFromFile(file) || getExtensionFromMime(file.type) 在空 type 时 + // 两头都拿不到东西,于是键名没有扩展名,任何按扩展名反推类型的读取都会失败。 + const extension = extensionForUpload(file.name, mimeType); const filename = `${Date.now()}-${randomUUID()}${extension}`; const objectKey = buildObjectKey(filename, config); - await client.putObject({ - bucket: config.bucket, - key: objectKey, - body: buffer, - contentType: file.type || 'video/mp4', - }); + try { + await client.putObject({ + bucket: config.bucket, + key: objectKey, + body: buffer, + contentType: mimeType, + }); + } catch (error) { + try { + await deleteUploadObject(filename); + } catch (cleanupError) { + console.error('[persistVideo] 上传失败后的对象补偿清理失败', filename, cleanupError); + } + throw error; + } return { filename, @@ -120,6 +162,18 @@ export async function persistVideo(file: File) { }; } +/** + * 只做配置解析、不碰任何对象。供批量删除在发起 N 次请求之前预检: + * 环境没配好时应当一次报出,而不是让 4000 次删除各抛一遍同样的错误。 + * + * 注意 getConfig() 即使只为删除也会检查公网 base URL(见其内部)。不修: + * StorageConfig.publicBaseUrl 是 string,做成可空要波及 6 个 route 的 URL 构造, + * 而没有哪条缺陷要求这个收益。调用方用 misconfigured 标记 + 可操作文案兜住即可。 + */ +export function ensureStorageConfigured(): void { + getConfig(); +} + export async function deleteImageAssets(filename: string) { const config = getConfig(); const client = getClient(); @@ -157,6 +211,74 @@ export async function getOriginalBuffer(filename: string) { return getObjectBuffer(buildObjectKey(filename, getConfig())); } +export function createUploadFilename(name: string, mimeType: string) { + return `${Date.now()}-${randomUUID()}${extensionForUpload(name, mimeType)}`; +} + +export function uploadStorageKey(filename: string) { + return buildObjectKey(filename, getConfig()); +} + +export function uploadThumbnailStorageKey(filename: string) { + return buildThumbnailKey(filename, getConfig()); +} + +export async function getPresignedPhotoPutUrl( + storageKey: string, + mimeType: string, + expiresSeconds = 900 +) { + const config = getConfig(); + return getClient().getPreSignedUrl({ + bucket: config.bucket, + key: storageKey, + method: 'PUT', + expires: expiresSeconds, + response: { contentType: mimeType }, + }); +} + +export async function inspectUploadObject(storageKey: string) { + const result = await getClient().headObject({ bucket: getConfig().bucket, key: storageKey }); + return { + size: Number(result.data['content-length']), + contentType: result.data['content-type'], + }; +} + +export async function getUploadObjectBuffer(storageKey: string) { + return getObjectBuffer(storageKey); +} + +export async function createImageThumbnail(thumbnailKey: string, buffer: Buffer) { + const config = getConfig(); + let thumbnailBuffer: Buffer; + try { + thumbnailBuffer = await sharp(buffer, { sequentialRead: true }) + .resize(400, 400, { fit: 'inside', withoutEnlargement: true, fastShrinkOnLoad: true }) + .webp({ quality: 80 }) + .toBuffer(); + } catch (error) { + console.error('[createImageThumbnail] 图片解码失败', thumbnailKey, error); + throw new UploadError('图片内容无法解析,请确认文件确实是图片'); + } + await getClient().putObject({ + bucket: config.bucket, + key: thumbnailKey, + body: thumbnailBuffer, + contentType: 'image/webp', + }); +} + +export async function deleteUploadStorageKey(storageKey: string) { + const config = getConfig(); + try { + await getClient().deleteObject({ bucket: config.bucket, key: storageKey }); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } +} + export function getPublicObjectUrl(filename: string) { const config = getConfig(); return joinUrl(config.publicBaseUrl, buildObjectKey(filename, config)); @@ -281,16 +403,25 @@ export async function persistFile(file: File) { const buffer = Buffer.from(arrayBuffer); const originalName = file.name; - const extension = getExtensionFromFileName(file.name); + const extension = extensionForUpload(file.name, file.type || 'application/octet-stream'); const filename = `${Date.now()}-${randomUUID()}${extension}`; const objectKey = buildFileObjectKey(filename, config); - await client.putObject({ - bucket: config.bucket, - key: objectKey, - body: buffer, - contentType: file.type || 'application/octet-stream', - }); + try { + await client.putObject({ + bucket: config.bucket, + key: objectKey, + body: buffer, + contentType: file.type || 'application/octet-stream', + }); + } catch (error) { + try { + await deleteFileAsset(filename); + } catch (cleanupError) { + console.error('[persistFile] 上传失败后的对象补偿清理失败', filename, cleanupError); + } + throw error; + } return { filename, @@ -330,17 +461,6 @@ function buildFileObjectKey(filename: string, config: StorageConfig) { return `${config.filesPrefix}${filename}`; } -/** - * Get file extension from filename - */ -function getExtensionFromFileName(filename: string) { - const match = /\.([^.]+)$/u.exec(filename); - if (match?.[1]) { - return `.${match[1].toLowerCase()}`; - } - return ''; -} - // ========= 直传/直下签名 ========= export function buildFilesStorageKey(parts: { filesetId: number | string; @@ -356,7 +476,7 @@ function sanitizeName(name: string) { return name.replace(/[^a-zA-Z0-9._-]/g, '_'); } -export async function getPresignedPutUrl(storageKey: string, mime: string, size?: number) { +export async function getPresignedPutUrl(storageKey: string, mime: string) { const config = getConfig(); const client = getClient(); const expires = config.presignExpiresSeconds ?? 900; @@ -441,17 +561,10 @@ export async function getFileBuffer(filename: string) { /** * Best-effort MIME guess from filename extension for preview responses. + * 表在 lib/media-type.ts,这里只补它的兜底值。 */ export function guessMimeFromFilename(name: string) { - const lower = name.toLowerCase(); - if (lower.endsWith('.pdf')) return 'application/pdf'; - if (lower.endsWith('.png')) return 'image/png'; - if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; - if (lower.endsWith('.gif')) return 'image/gif'; - if (lower.endsWith('.webp')) return 'image/webp'; - if (lower.endsWith('.txt')) return 'text/plain'; - if (lower.endsWith('.md')) return 'text/markdown'; - return 'application/octet-stream'; + return mimeFromFilename(name) ?? 'application/octet-stream'; } function sanitizePrefix(input: string) { @@ -472,37 +585,6 @@ function joinUrl(base: string, path: string) { return `${base}/${normalizedPath}`; } -function getExtensionFromFile(file: File) { - if (file.name) { - const match = /\.([^.]+)$/u.exec(file.name); - if (match?.[1]) { - return `.${match[1].toLowerCase()}`; - } - } - return getExtensionFromMime(file.type); -} - -function getExtensionFromMime(mime: string) { - switch (mime) { - case 'image/jpeg': - return '.jpg'; - case 'image/png': - return '.png'; - case 'image/gif': - return '.gif'; - case 'image/webp': - return '.webp'; - case 'video/mp4': - return '.mp4'; - case 'video/webm': - return '.webm'; - case 'video/quicktime': - return '.mov'; - default: - return ''; - } -} - export function isNotFoundError(error: unknown) { return error instanceof TosServerError && error.code === TosServerCode.NoSuchKey; } diff --git a/lib/upload-intent-cleanup.ts b/lib/upload-intent-cleanup.ts new file mode 100644 index 0000000..ef0ec32 --- /dev/null +++ b/lib/upload-intent-cleanup.ts @@ -0,0 +1,28 @@ +export interface ExpiredUploadIntent { + id: string; + storageKeys: string[]; +} + +export async function cleanupExpiredUploadIntents( + intents: ExpiredUploadIntent[], + operations: { + deleteObject: (storageKey: string) => Promise; + deleteIntent: (id: string) => Promise; + }, + limit = 100 +) { + let cleaned = 0; + const failures: string[] = []; + for (const intent of intents.slice(0, limit)) { + try { + for (const storageKey of intent.storageKeys) { + await operations.deleteObject(storageKey); + } + await operations.deleteIntent(intent.id); + cleaned += 1; + } catch { + failures.push(intent.id); + } + } + return { cleaned, failures }; +} diff --git a/lib/upload-intent-validation.ts b/lib/upload-intent-validation.ts new file mode 100644 index 0000000..e4e90a2 --- /dev/null +++ b/lib/upload-intent-validation.ts @@ -0,0 +1,25 @@ +export type UploadMediaType = 'image' | 'video'; + +export type UploadMetadataIssue = 'size_mismatch' | 'mime_mismatch'; + +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; +const MAX_VIDEO_SIZE = 512 * 1024 * 1024; + +export function validateUploadObjectMetadata( + expected: { mediaType: UploadMediaType; mimeType: string; size: number }, + actual: { size: number; contentType?: unknown } +): UploadMetadataIssue | null { + const maxSize = expected.mediaType === 'image' ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE; + if (!Number.isFinite(actual.size) || actual.size !== expected.size || actual.size > maxSize) { + return 'size_mismatch'; + } + + if ( + typeof actual.contentType === 'string' && + actual.contentType.toLowerCase() !== expected.mimeType.toLowerCase() + ) { + return 'mime_mismatch'; + } + + return null; +} diff --git a/lib/user-registration.ts b/lib/user-registration.ts new file mode 100644 index 0000000..463ee67 --- /dev/null +++ b/lib/user-registration.ts @@ -0,0 +1,16 @@ +export const SELF_REGISTRATION_RESPONSE = { + message: '如果符合注册条件,账户将按流程处理。', +} as const; + +export function isUsernameUniqueConstraintError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +export function selfRegistrationPrivacyResponse( + isSelfRegistration: boolean, + outcome: { ok: true } | { ok: false; error: unknown } +) { + if (!isSelfRegistration) return null; + if (!outcome.ok && !isUsernameUniqueConstraintError(outcome.error)) return null; + return { status: 200 as const, body: SELF_REGISTRATION_RESPONSE }; +} diff --git a/lib/validation.ts b/lib/validation.ts index 51af172..d1e793b 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -2,3 +2,30 @@ import { VISIBILITIES } from '@/lib/access-rules'; import { z } from 'zod'; export const visibilitySchema = z.enum(VISIBILITIES); + +/** + * 所有自增主键的共用校验。 + * + * .positive() 之前只在 photos/upload 两处手写,其余 9 处放行了 0 与负数:它们对 + * findUnique 无害(匹配不到),但会流到 prisma.update/delete 上变成 P2025 → 裸 500。 + * + * .max() 挡住的是 2^31-1 到安全整数上限之间的那些值。zod 的 .int() 只要求它是 + * 安全整数(所以它连 1e21 都会拒),而这里每一列都是 MySQL 有符号 INT: + * 3000000000 能通过 .int(),却会在驱动层报错,放行它只是把 400 换成 500。 + */ +const MAX_INT32 = 2147483647; + +export const idSchema = z + .number() + .int('ID 必须是整数') + .positive('ID 必须为正整数') + .max(MAX_INT32, 'ID 超出范围'); + +export const optionalIdSchema = idSchema.optional(); + +/** Path/query/form IDs arrive as decimal strings; reject whitespace, fractions, and suffix junk. */ +export const idStringSchema = z + .string() + .regex(/^\d+$/u, 'ID 必须是十进制整数') + .transform(value => Number(value)) + .pipe(idSchema); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24d7516..06b2451 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,43 +12,69 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - username String @unique - password String - role UserRole @default(member) - status UserStatus @default(pending) - photos Photo[] - fileSetsCreated FileSet[] @relation("UserFileSetCreator") - filesUploaded File[] @relation("UserFileCreator") - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + username String @unique + password String + role UserRole @default(member) + status UserStatus @default(pending) + photos Photo[] + fileSetsCreated FileSet[] @relation("UserFileSetCreator") + filesUploaded File[] @relation("UserFileCreator") + createdAt DateTime @default(now()) } model Category { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String description String? visibility CategoryVisibility @default(internal) photos Photo[] shareLinks ShareLink[] - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) } model Photo { - id Int @id @default(autoincrement()) - filename String - originalName String - description String? - categoryId Int - uploaderId Int - mediaType MediaType @default(image) - mimeType String - category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) - uploader User @relation(fields: [uploaderId], references: [id]) - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + filename String + originalName String + description String? + categoryId Int + uploaderId Int + mediaType MediaType @default(image) + mimeType String + uploadIntentId String? @unique @db.VarChar(36) + uploadIntent UploadIntent? @relation(fields: [uploadIntentId], references: [id], onDelete: SetNull) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + // 归属关系刻意不加 onDelete:Cascade 会让子行在库内消失,应用代码从此读不到 + // filename,那些对象就成了永久且不可追溯的泄漏(缺陷 B 的成因正是已经存在的 + // Cascade)。默认 Restrict 反而是安全的:它逼着删除方先处置子行。 + // 这条不变量由 __tests__/schema-invariants.test.ts 钉住。 + uploader User @relation(fields: [uploaderId], references: [id]) + createdAt DateTime @default(now()) @@index([categoryId, createdAt]) } +model UploadIntent { + id String @id @default(uuid()) @db.VarChar(36) + storageKey String @unique @db.VarChar(512) + thumbnailKey String? @db.VarChar(512) + // Intentionally scalar snapshots: pending intents must not block user/category deletion. + uploaderId Int + categoryId Int + originalName String @db.VarChar(255) + description String? @db.VarChar(300) + mimeType String + mediaType MediaType + expectedSize Int + expiresAt DateTime + completedAt DateTime? + photo Photo? + createdAt DateTime @default(now()) + + @@index([expiresAt]) +} + model ShareLink { id Int @id @default(autoincrement()) categoryId Int @@ -96,7 +122,7 @@ model FileSet { description String? visibility FileSetVisibility @default(internal) createdBy Int - creator User @relation("UserFileSetCreator", fields: [createdBy], references: [id]) + creator User @relation("UserFileSetCreator", fields: [createdBy], references: [id]) // 归属关系,理由同 Photo.uploader:不加 onDelete files File[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -106,15 +132,15 @@ model FileSet { model File { id Int @id @default(autoincrement()) - filename String // Generated unique filename (like photo filename) - originalName String // Original upload name + filename String // Generated unique filename (like photo filename) + originalName String // Original upload name description String? mimeType String size Int filesetId Int uploaderId Int fileSet FileSet @relation(fields: [filesetId], references: [id], onDelete: Cascade) - uploader User @relation("UserFileCreator", fields: [uploaderId], references: [id]) + uploader User @relation("UserFileCreator", fields: [uploaderId], references: [id]) // 归属关系,理由同 Photo.uploader:不加 onDelete createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/user-registration.test.ts b/user-registration.test.ts new file mode 100644 index 0000000..86ee9a8 --- /dev/null +++ b/user-registration.test.ts @@ -0,0 +1,36 @@ +import { + SELF_REGISTRATION_RESPONSE, + isUsernameUniqueConstraintError, + selfRegistrationPrivacyResponse, +} from '@/lib/user-registration'; +import { describe, expect, it } from 'vitest'; + +describe('self-registration response policy', () => { + it('hides both successful creation and a duplicate-username race behind the same response', () => { + const successfulCreationResponse = selfRegistrationPrivacyResponse(true, { ok: true }); + const duplicateUsernameResponse = selfRegistrationPrivacyResponse(true, { + ok: false, + error: { code: 'P2002' }, + }); + + expect(successfulCreationResponse).toEqual(duplicateUsernameResponse); + expect(successfulCreationResponse).toEqual({ + status: 200, + body: SELF_REGISTRATION_RESPONSE, + }); + expect(JSON.stringify(successfulCreationResponse)).not.toContain('用户名已存在'); + }); + + it('does not hide non-self-registration results or unrelated database errors', () => { + expect(selfRegistrationPrivacyResponse(false, { ok: true })).toBeNull(); + expect( + selfRegistrationPrivacyResponse(true, { ok: false, error: { code: 'P2003' } }) + ).toBeNull(); + }); + + it('recognizes Prisma unique-constraint errors, including the create race', () => { + expect(isUsernameUniqueConstraintError({ code: 'P2002' })).toBe(true); + expect(isUsernameUniqueConstraintError(new Error('duplicate'))).toBe(false); + expect(isUsernameUniqueConstraintError({ code: 'P2003' })).toBe(false); + }); +});