From 9a0a2b1da7d953757186234603fe3eedcf5206c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 09:08:42 -0700 Subject: [PATCH] feat: zod-openapi schema, R2 multipart upload, and public /files URL - routes rewritten on OpenAPIHono with zod schemas; /openapi.json via doc31; /api/* auth middleware runs before zod (401 before 422); problem+json kept - multipart upload (worker-orchestrated R2): POST /multipart, PUT /parts/:n, DELETE /multipart; finalize auto-completes multipart or falls back to single-shot - GET /files/:id serves public/shared attachments inline with stored content-type; responses include public_url when available + public/shared - FakeR2 supports multipart; tests cover multipart, /files visibility, openapi Co-Authored-By: Claude --- src/attachment-service.ts | 235 ++++++++++++++- src/attachment-store.ts | 160 ++++++++++ src/object-store.ts | 53 ++++ src/openapi.ts | 66 ----- src/problem.ts | 24 ++ src/routes.ts | 511 +++++++++++++++++++++++++------- src/schemas.ts | 140 +++++++++ tests/attachment-worker.test.ts | 192 +++++++++++- tests/fakes.ts | 114 ++++++- 9 files changed, 1307 insertions(+), 188 deletions(-) delete mode 100644 src/openapi.ts create mode 100644 src/schemas.ts diff --git a/src/attachment-service.ts b/src/attachment-service.ts index a9e679b..20b1b14 100644 --- a/src/attachment-service.ts +++ b/src/attachment-service.ts @@ -3,14 +3,25 @@ import type { CurrentUser } from "./auth-session"; import { HttpProblem } from "./problem"; import { newId } from "./ids"; import { optionalString, requiredString } from "./json"; -import { getObjectOrNull, objectKeyFor, putObject } from "./object-store"; +import { + abortMultipartUpload, + completeMultipartUpload, + createMultipartUpload, + getObjectOrNull, + objectKeyFor, + putObject, + uploadPart, +} from "./object-store"; import { countAttachments, deleteCategory, deleteTag, + deleteUploadParts, + findActiveUploadSession, findAttachmentForUser, findCategoryBySlugForUser, findCategoryForUser, + findPublicAttachmentById, findTagForUser, findTagBySlugForUser, findTagsForUser, @@ -18,24 +29,33 @@ import { insertAuditEvent, insertCategory, insertTag, + insertUploadSession, listCategoriesForUser, listTagsForUser, + listUploadParts, markAvailable, + markAvailableWithObject, markUploaded, + markUploadSessionAborted, + markUploadSessionCompleted, searchAttachments, setAttachmentTagIds, softDeleteAttachment, updateAttachmentFields, updateCategoryFields, updateTagFields, + upsertUploadPart, type AttachmentMutableFields, type AttachmentRecord, type AttachmentSearchQuery, type CategoryRecord, type TagRecord, + type UploadSessionRecord, } from "./attachment-store"; const DEFAULT_MAX_UPLOAD_BYTES = 52_428_800; +const MIN_PART_BYTES = 5 * 1024 * 1024; +const MAX_PARTS = 10_000; const ALLOWED_VISIBILITY = new Set(["private", "shared", "public"]); function nowIso(): string { @@ -60,6 +80,44 @@ function validateVisibility(value: string): string { return value; } +export type AttachmentView = AttachmentRecord & { public_url: string | null }; + +/** 按需计算公开静态 URL:仅 available 且 visibility∈{public,shared} 才有。 */ +export function serializeAttachment(record: AttachmentRecord, env: Env): AttachmentView { + const publicUrl = + record.status === "available" && + (record.visibility === "public" || record.visibility === "shared") && + env.PUBLIC_BASE_URL + ? `${env.PUBLIC_BASE_URL}/files/${record.id}` + : null; + return { ...record, public_url: publicUrl }; +} + +/** /files 公开路由:命中 public/shared + available 才返回流,否则返回 null(由路由转 404)。 */ +export async function getPublicAttachmentResponse(env: Env, id: string): Promise { + const record = await findPublicAttachmentById(env.DB, id); + if (!record || !record.object_key) { + return null; + } + const object = await getObjectOrNull(env.ATTACHMENTS, record.object_key); + if (!object) { + return null; + } + const headers = new Headers({ + "content-type": record.content_type, + "content-length": String(object.size), + "content-disposition": `inline; filename="${sanitizeFilename(record.filename)}"`, + "cache-control": "public, max-age=300", + }); + return new Response(object.body, { headers }); +} + +async function safeAbortSession(env: Env, session: UploadSessionRecord): Promise { + await abortMultipartUpload(env.ATTACHMENTS, session.object_key, session.upload_id).catch(() => undefined); + await markUploadSessionAborted(env.DB, session.id, nowIso()); + await deleteUploadParts(env.DB, session.id); +} + export function slugify(input: string): string { const slug = input .toLowerCase() @@ -165,6 +223,18 @@ export async function finalizeAttachment(env: Env, user: CurrentUser, id: string if (!record) { throw attachmentNotFound(); } + + const session = await findActiveUploadSession(env.DB, user.id, id); + if (session) { + if (record.status === "upload_pending") { + await finalizeMultipart(env, user, record, session); + const refreshed = await findAttachmentForUser(env.DB, user.id, id); + return refreshed ?? record; + } + // 已单次上传却又残留活动会话:防御性 abort,落到下面的单次路径 + await safeAbortSession(env, session); + } + if (record.status !== "uploaded") { throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", { status: record.status, @@ -173,11 +243,60 @@ export async function finalizeAttachment(env: Env, user: CurrentUser, id: string await markAvailable(env.DB, user.id, id); await recordAudit(env, user, id, "attachment.finalized", {}); - const refreshed = await findAttachmentForUser(env.DB, user.id, id); return refreshed ?? record; } +async function finalizeMultipart( + env: Env, + user: CurrentUser, + record: AttachmentRecord, + session: UploadSessionRecord, +): Promise { + const parts = await listUploadParts(env.DB, session.id); + if (parts.length === 0) { + throw new HttpProblem(409, "multipart-empty", "No parts have been uploaded"); + } + + const maxPartNumber = parts.reduce((max, part) => Math.max(max, part.part_number), 0); + for (const part of parts) { + if (part.part_number !== maxPartNumber && part.byte_size < MIN_PART_BYTES) { + throw new HttpProblem(422, "multipart-part-too-small", "A part is smaller than the minimum size", { + part_number: part.part_number, + min_bytes: MIN_PART_BYTES, + }); + } + } + + const totalBytes = parts.reduce((sum, part) => sum + part.byte_size, 0); + const maxBytes = parseMaxUploadBytes(env); + if (totalBytes > maxBytes) { + throw new HttpProblem(413, "payload-too-large", "Multipart upload exceeds the maximum allowed size", { + max_bytes: maxBytes, + byte_size: totalBytes, + }); + } + + const uploadedParts = parts.map((part) => ({ partNumber: part.part_number, etag: part.etag })); + let r2Object: R2Object; + try { + r2Object = await completeMultipartUpload(env.ATTACHMENTS, session.object_key, session.upload_id, uploadedParts); + } catch (error) { + throw new HttpProblem(422, "multipart-invalid", "Could not complete multipart upload", { + reason: error instanceof Error ? error.message : String(error), + }); + } + + const etag = (r2Object.etag ?? "").replace(/^"|"$/g, ""); + await markAvailableWithObject(env.DB, user.id, record.id, session.object_key, etag, r2Object.size, nowIso()); + await markUploadSessionCompleted(env.DB, session.id, r2Object.size, parts.length, nowIso()); + await recordAudit(env, user, record.id, "attachment.finalized", { + multipart: true, + byte_size: r2Object.size, + parts: parts.length, + }); +} + export async function getAttachment(env: Env, user: CurrentUser, id: string): Promise { const record = await findAttachmentForUser(env.DB, user.id, id); if (!record) { @@ -192,6 +311,11 @@ export async function deleteAttachment(env: Env, user: CurrentUser, id: string): throw attachmentNotFound(); } + const session = await findActiveUploadSession(env.DB, user.id, id); + if (session) { + await safeAbortSession(env, session); + } + await softDeleteAttachment(env.DB, user.id, id, user.id); if (record.object_key) { await env.ATTACHMENTS.delete(record.object_key).catch(() => undefined); @@ -226,6 +350,113 @@ export async function downloadAttachment(env: Env, user: CurrentUser, id: string return new Response(object.body, { headers }); } +export interface MultipartInitResult { + upload_id: string; + object_key: string; + part_size_min: number; + max_parts: number; +} + +export async function initMultipartUpload( + env: Env, + user: CurrentUser, + id: string, +): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + if (record.status !== "upload_pending") { + throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not awaiting an upload", { + status: record.status, + }); + } + const existing = await findActiveUploadSession(env.DB, user.id, id); + if (existing) { + throw new HttpProblem(409, "multipart-active", "A multipart upload is already active", { + upload_id: existing.upload_id, + }); + } + + const objectKey = objectKeyFor(user.id, id); + const { uploadId } = await createMultipartUpload(env.ATTACHMENTS, objectKey, record.content_type); + const now = nowIso(); + const session: UploadSessionRecord = { + id: newId("ups"), + attachment_id: id, + user_id: user.id, + object_key: objectKey, + upload_id: uploadId, + status: "active", + content_type: record.content_type, + part_count: 0, + byte_size: 0, + created_at: now, + completed_at: null, + aborted_at: null, + }; + await insertUploadSession(env.DB, session); + await recordAudit(env, user, id, "attachment.multipart-init", { upload_id: uploadId }); + return { upload_id: uploadId, object_key: objectKey, part_size_min: MIN_PART_BYTES, max_parts: MAX_PARTS }; +} + +export interface UploadPartResult { + part_number: number; + etag: string; + byte_size: number; +} + +export async function uploadAttachmentPart( + env: Env, + user: CurrentUser, + id: string, + partNumber: number, + request: Request, +): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + if (record.status !== "upload_pending") { + throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not awaiting an upload", { + status: record.status, + }); + } + const session = await findActiveUploadSession(env.DB, user.id, id); + if (!session) { + throw new HttpProblem(409, "multipart-not-found", "No active multipart upload for this attachment"); + } + + const body = await request.arrayBuffer(); + const uploaded = await uploadPart(env.ATTACHMENTS, session.object_key, session.upload_id, partNumber, body); + await upsertUploadPart( + env.DB, + { + session_id: session.id, + user_id: user.id, + attachment_id: id, + part_number: uploaded.partNumber, + etag: uploaded.etag, + byte_size: body.byteLength, + }, + nowIso(), + ); + return { part_number: uploaded.partNumber, etag: uploaded.etag, byte_size: body.byteLength }; +} + +export async function abortAttachmentMultipart(env: Env, user: CurrentUser, id: string): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + const session = await findActiveUploadSession(env.DB, user.id, id); + if (!session) { + throw new HttpProblem(404, "multipart-not-found", "No active multipart upload for this attachment"); + } + await safeAbortSession(env, session); + await recordAudit(env, user, id, "attachment.multipart-aborted", { upload_id: session.upload_id }); +} + export interface AttachmentSearchInput { q?: string; categoryId?: string; diff --git a/src/attachment-store.ts b/src/attachment-store.ts index ccafe85..81f7c24 100644 --- a/src/attachment-store.ts +++ b/src/attachment-store.ts @@ -49,6 +49,33 @@ export interface AttachmentSearchQuery { offset: number; } +export type UploadSessionStatus = "active" | "completed" | "aborted"; + +export interface UploadSessionRecord { + id: string; + attachment_id: string; + user_id: string; + object_key: string; + upload_id: string; + status: UploadSessionStatus; + content_type: string; + part_count: number; + byte_size: number; + created_at: string; + completed_at: string | null; + aborted_at: string | null; +} + +export interface UploadPartRecord { + session_id: string; + user_id: string; + attachment_id: string; + part_number: number; + etag: string; + byte_size: number; + created_at: string; +} + const ATTACHMENT_COLUMNS = "id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at"; @@ -425,3 +452,136 @@ export async function insertAuditEvent(db: D1Database, event: AuditEvent): Promi .bind(event.id, event.user_id, event.attachment_id, event.actor_user_id, event.action, event.detail_json, event.created_at) .run(); } + +// --- 分片上传会话 / 分片 --- + +export async function insertUploadSession(db: D1Database, session: UploadSessionRecord): Promise { + await db + .prepare( + `INSERT INTO attachment_upload_sessions + (id, attachment_id, user_id, object_key, upload_id, status, content_type, part_count, byte_size, created_at, completed_at, aborted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + session.id, + session.attachment_id, + session.user_id, + session.object_key, + session.upload_id, + session.status, + session.content_type, + session.part_count, + session.byte_size, + session.created_at, + session.completed_at, + session.aborted_at, + ) + .run(); +} + +export async function findActiveUploadSession( + db: D1Database, + userId: string, + attachmentId: string, +): Promise { + return db + .prepare( + `SELECT * FROM attachment_upload_sessions + WHERE user_id = ? AND attachment_id = ? AND status = 'active' + ORDER BY created_at DESC + LIMIT 1`, + ) + .bind(userId, attachmentId) + .first(); +} + +export async function upsertUploadPart( + db: D1Database, + part: Omit, + createdAt: string, +): Promise { + await db + .prepare( + `INSERT OR REPLACE INTO attachment_upload_parts + (session_id, user_id, attachment_id, part_number, etag, byte_size, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(part.session_id, part.user_id, part.attachment_id, part.part_number, part.etag, part.byte_size, createdAt) + .run(); +} + +export async function listUploadParts(db: D1Database, sessionId: string): Promise { + const result = await db + .prepare(`SELECT * FROM attachment_upload_parts WHERE session_id = ? ORDER BY part_number ASC`) + .bind(sessionId) + .all(); + return result.results; +} + +export async function markUploadSessionCompleted( + db: D1Database, + sessionId: string, + byteSize: number, + partCount: number, + completedAt: string, +): Promise { + await db + .prepare( + `UPDATE attachment_upload_sessions + SET status = 'completed', byte_size = ?, part_count = ?, completed_at = ? + WHERE id = ?`, + ) + .bind(byteSize, partCount, completedAt, sessionId) + .run(); +} + +export async function markUploadSessionAborted( + db: D1Database, + sessionId: string, + abortedAt: string, +): Promise { + await db + .prepare( + `UPDATE attachment_upload_sessions SET status = 'aborted', aborted_at = ? WHERE id = ?`, + ) + .bind(abortedAt, sessionId) + .run(); +} + +export async function deleteUploadParts(db: D1Database, sessionId: string): Promise { + await db.prepare(`DELETE FROM attachment_upload_parts WHERE session_id = ?`).bind(sessionId).run(); +} + +export async function markAvailableWithObject( + db: D1Database, + userId: string, + id: string, + objectKey: string, + etag: string | null, + byteSize: number | null, + updatedAt: string, +): Promise { + await db + .prepare( + `UPDATE attachments + SET status = 'available', object_key = ?, etag = ?, byte_size = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + ) + .bind(objectKey, etag, byteSize, updatedAt, id, userId) + .run(); +} + +// --- 公开静态资源(/files)按 id 查询,SQL 内做 visibility/status/未删 gate --- + +export async function findPublicAttachmentById(db: D1Database, id: string): Promise { + return db + .prepare( + `SELECT ${ATTACHMENT_COLUMNS} FROM attachments + WHERE id = ? + AND deleted_at IS NULL + AND status = 'available' + AND visibility IN ('public', 'shared')`, + ) + .bind(id) + .first(); +} diff --git a/src/object-store.ts b/src/object-store.ts index 587d096..6b72c74 100644 --- a/src/object-store.ts +++ b/src/object-store.ts @@ -22,3 +22,56 @@ export async function putObject( export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise { return bucket.get(key); } + +export interface StartedMultipartUpload { + key: string; + uploadId: string; +} + +export async function createMultipartUpload( + bucket: R2Bucket, + key: string, + contentType: string, +): Promise { + const multipart = await bucket.createMultipartUpload(key, { + httpMetadata: { contentType }, + }); + return { key: multipart.key, uploadId: multipart.uploadId }; +} + +export interface UploadedPart { + partNumber: number; + etag: string; +} + +export async function uploadPart( + bucket: R2Bucket, + key: string, + uploadId: string, + partNumber: number, + body: ArrayBuffer | ArrayBufferView | string | Blob | ReadableStream, +): Promise { + // resumeMultipartUpload 是同步的,这里不要 await 取句柄那步 + const multipart = bucket.resumeMultipartUpload(key, uploadId); + const part = await multipart.uploadPart(partNumber, body); + return { partNumber: part.partNumber, etag: part.etag }; +} + +export async function completeMultipartUpload( + bucket: R2Bucket, + key: string, + uploadId: string, + parts: UploadedPart[], +): Promise { + const multipart = bucket.resumeMultipartUpload(key, uploadId); + return multipart.complete(parts); +} + +export async function abortMultipartUpload( + bucket: R2Bucket, + key: string, + uploadId: string, +): Promise { + const multipart = bucket.resumeMultipartUpload(key, uploadId); + await multipart.abort(); +} diff --git a/src/openapi.ts b/src/openapi.ts deleted file mode 100644 index 3b9d8b3..0000000 --- a/src/openapi.ts +++ /dev/null @@ -1,66 +0,0 @@ -// cfw-attachment 对外暴露的最小 OpenAPI 文档,供 cfw-gateway 聚合。 -// 路径只包含 /api/attachments/* 主接口,与网关聚合的 mountPath=/api/attachments 对齐。 -export const ATTACHMENT_OPENAPI_DOCUMENT = { - openapi: "3.1.0", - info: { - title: "cfw-attachment", - version: "0.1.0", - description: "Attachment management service (metadata, upload, download, search).", - }, - servers: [{ url: "https://cfw-attachment.bowong.cc", description: "Production" }], - tags: [{ name: "attachment", description: "Attachment service endpoints" }], - paths: { - "/api/attachments": { - post: { - tags: ["attachment"], - summary: "Create attachment metadata", - responses: { "201": { description: "Attachment created, awaiting upload" } }, - }, - }, - "/api/attachments/search": { - get: { - tags: ["attachment"], - summary: "Search current user attachments", - responses: { "200": { description: "Paginated attachment list" } }, - }, - }, - "/api/attachments/{id}": { - get: { - tags: ["attachment"], - summary: "Read attachment detail", - responses: { "200": { description: "Attachment detail" } }, - }, - patch: { - tags: ["attachment"], - summary: "Update attachment fields", - responses: { "200": { description: "Updated attachment" } }, - }, - delete: { - tags: ["attachment"], - summary: "Soft delete attachment", - responses: { "204": { description: "Attachment deleted" } }, - }, - }, - "/api/attachments/{id}/content": { - put: { - tags: ["attachment"], - summary: "Upload file content", - responses: { "200": { description: "Content uploaded" } }, - }, - }, - "/api/attachments/{id}/finalize": { - post: { - tags: ["attachment"], - summary: "Finalize upload", - responses: { "200": { description: "Attachment available" } }, - }, - }, - "/api/attachments/{id}/download": { - get: { - tags: ["attachment"], - summary: "Download file content", - responses: { "200": { description: "File bytes" } }, - }, - }, - }, -} as const; diff --git a/src/problem.ts b/src/problem.ts index 76a7e13..80fcfad 100644 --- a/src/problem.ts +++ b/src/problem.ts @@ -31,3 +31,27 @@ export function problemResponse(error: unknown): Response { }, ); } + +// @hono/zod-openapi 的 defaultHook:校验失败时直接返回 problem+json 422, +// 与业务层 HttpProblem(422, "validation-failed", ...) 保持一致形状。 +// 成功时不返回(放行到 handler)。 +interface ZodValidationResult { + success: boolean; + data?: unknown; + error?: { + issues?: Array<{ path?: PropertyKey[]; message?: string }>; + }; +} + +export function validationProblemHook(result: ZodValidationResult): Response | undefined { + if (result.success) { + return undefined; + } + const issues = (result.error?.issues ?? []).map((issue) => ({ + path: (issue.path ?? []).map((segment) => String(segment)).join("."), + message: issue.message ?? "invalid", + })); + return problemResponse( + new HttpProblem(422, "validation-failed", "Request validation failed", { issues }), + ); +} diff --git a/src/routes.ts b/src/routes.ts index 24607f2..443c203 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,9 +1,9 @@ -import { Hono } from "hono"; -import { resolveCurrentUser } from "./auth-session"; +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +import { resolveCurrentUser, type CurrentUser } from "./auth-session"; import type { Env } from "./env"; -import { readJsonObject } from "./json"; -import { problemResponse } from "./problem"; +import { problemResponse, validationProblemHook } from "./problem"; import { + abortAttachmentMultipart, createAttachment, createCategory, createTag, @@ -13,137 +13,446 @@ import { downloadAttachment, finalizeAttachment, getAttachment, + getPublicAttachmentResponse, + initMultipartUpload, listCategories, listTags, searchUserAttachments, + serializeAttachment, updateAttachment, updateCategory, updateTag, uploadAttachmentContent, + uploadAttachmentPart, } from "./attachment-service"; -import { ATTACHMENT_OPENAPI_DOCUMENT } from "./openapi"; +import { + AttachmentResponseSchema, + CategoriesResponseSchema, + CategoryCreateSchema, + CategoryResponseSchema, + CategoryUpdateSchema, + CreateAttachmentSchema, + MultipartInitResponseSchema, + PaginatedAttachmentsSchema, + ProblemSchema, + TagCreateSchema, + TagResponseSchema, + TagUpdateSchema, + TagsResponseSchema, + UpdateAttachmentSchema, + UploadPartResponseSchema, +} from "./schemas"; -function paginationFromUrl(url: URL): { page: number; pageSize: number; offset: number } { - const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1); - const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get("pageSize") ?? "20") || 20)); - return { page, pageSize, offset: (page - 1) * pageSize }; +type AppEnv = { Bindings: Env; Variables: { user: CurrentUser } }; + +function errRes(description: string) { + return { description, content: { "application/json": { schema: ProblemSchema } } }; } -export function createRoutes(): Hono<{ Bindings: Env }> { - const app = new Hono<{ Bindings: Env }>(); +const IdParams = z.object({ id: z.string() }).openapi("IdParams"); +const PartParams = z.object({ id: z.string(), partNumber: z.coerce.number().int().min(1).max(10000) }).openapi("PartParams"); +const SearchQuery = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + q: z.string().optional(), + categoryId: z.string().optional(), + tag: z.string().optional(), + status: z.string().optional(), +}); + +function pagination(page: number | undefined, pageSize: number | undefined) { + const p = Math.max(1, page ?? 1); + const ps = Math.min(100, Math.max(1, pageSize ?? 20)); + return { page: p, pageSize: ps, offset: (p - 1) * ps }; +} + +export function createRoutes(): OpenAPIHono { + const app = new OpenAPIHono({ defaultHook: validationProblemHook }); app.onError((error) => problemResponse(error)); + // /api/* 先解析登录用户(401 先于 zod 422);/healthz、/openapi.json、/files 保持公开 + app.use("/api/*", async (c, next) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + c.set("user", user); + await next(); + }); + app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" })); - app.get("/openapi.json", (c) => c.json(ATTACHMENT_OPENAPI_DOCUMENT)); + app.openapi( + createRoute({ + method: "get", + path: "/files/{id}", + request: { params: IdParams }, + responses: { + 200: { description: "File bytes streamed with the stored content-type" }, + 404: { description: "Not found (or not publicly visible)" }, + }, + }), + async (c) => { + const response = await getPublicAttachmentResponse(c.env, c.req.param("id")); + return response ?? c.body(null, 404); + }, + ); - app.get("/api/attachments/search", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const url = new URL(c.req.url); - const { page, pageSize, offset } = paginationFromUrl(url); - const result = await searchUserAttachments(c.env, user, { - q: url.searchParams.get("q") ?? undefined, - categoryId: url.searchParams.get("categoryId") ?? undefined, - tagSlug: url.searchParams.get("tag") ?? undefined, - status: url.searchParams.get("status") ?? undefined, - page, - pageSize, - offset, - }); - return c.json(result); - }); + app.openapi( + createRoute({ + method: "get", + path: "/api/attachments/search", + request: { query: SearchQuery }, + responses: { 200: { description: "Paginated attachments", content: { "application/json": { schema: PaginatedAttachmentsSchema } } }, 401: errRes("Authentication required") }, + }), + async (c) => { + const user = c.get("user"); + const query = c.req.valid("query"); + const { page, pageSize, offset } = pagination(query.page, query.pageSize); + const result = await searchUserAttachments(c.env, user, { + q: query.q, + categoryId: query.categoryId, + tagSlug: query.tag, + status: query.status, + page, + pageSize, + offset, + }); + const items = result.items.map((item) => serializeAttachment(item, c.env)); + return c.json({ items, page, pageSize, total: result.total }, 200); + }, + ); - app.post("/api/attachments", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const body = await readJsonObject(c.req.raw); - const attachment = await createAttachment(c.env, user, body); - return c.json({ attachment }, 201); - }); + app.openapi( + createRoute({ + method: "post", + path: "/api/attachments", + request: { body: { content: { "application/json": { schema: CreateAttachmentSchema } }, required: true } }, + responses: { + 201: { description: "Attachment created", content: { "application/json": { schema: AttachmentResponseSchema } } }, + 401: errRes("Authentication required"), + 422: errRes("Validation failed"), + }, + }), + async (c) => { + const user = c.get("user"); + const attachment = await createAttachment(c.env, user, c.req.valid("json")); + return c.json({ attachment: serializeAttachment(attachment, c.env) }, 201); + }, + ); - app.get("/api/attachments/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - return c.json({ attachment: await getAttachment(c.env, user, c.req.param("id")) }); - }); + app.openapi( + createRoute({ + method: "get", + path: "/api/attachments/{id}", + request: { params: IdParams }, + responses: { + 200: { description: "Attachment detail", content: { "application/json": { schema: AttachmentResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + }, + }), + async (c) => { + const user = c.get("user"); + const attachment = await getAttachment(c.env, user, c.req.param("id")); + return c.json({ attachment: serializeAttachment(attachment, c.env) }, 200); + }, + ); - app.patch("/api/attachments/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const body = await readJsonObject(c.req.raw); - return c.json({ attachment: await updateAttachment(c.env, user, c.req.param("id"), body) }); - }); + app.openapi( + createRoute({ + method: "patch", + path: "/api/attachments/{id}", + request: { + params: IdParams, + body: { content: { "application/json": { schema: UpdateAttachmentSchema } }, required: true }, + }, + responses: { + 200: { description: "Updated attachment", content: { "application/json": { schema: AttachmentResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + 422: errRes("Validation failed"), + }, + }), + async (c) => { + const user = c.get("user"); + const attachment = await updateAttachment(c.env, user, c.req.param("id"), c.req.valid("json")); + return c.json({ attachment: serializeAttachment(attachment, c.env) }, 200); + }, + ); - app.put("/api/attachments/:id/content", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - return c.json({ - attachment: await uploadAttachmentContent(c.env, user, c.req.param("id"), c.req.raw), - }); - }); + app.openapi( + createRoute({ + method: "put", + path: "/api/attachments/{id}/content", + request: { params: IdParams }, + responses: { + 200: { description: "Content uploaded", content: { "application/json": { schema: AttachmentResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + 409: errRes("Attachment not awaiting upload"), + 413: errRes("Payload too large"), + }, + }), + async (c) => { + const user = c.get("user"); + const attachment = await uploadAttachmentContent(c.env, user, c.req.param("id"), c.req.raw); + return c.json({ attachment: serializeAttachment(attachment, c.env) }, 200); + }, + ); - app.post("/api/attachments/:id/finalize", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - return c.json({ attachment: await finalizeAttachment(c.env, user, c.req.param("id")) }); - }); + app.openapi( + createRoute({ + method: "post", + path: "/api/attachments/{id}/multipart", + request: { params: IdParams }, + responses: { + 200: { description: "Multipart upload initialized", content: { "application/json": { schema: MultipartInitResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + 409: errRes("Attachment not awaiting upload, or a multipart upload is already active"), + }, + }), + async (c) => { + const user = c.get("user"); + const init = await initMultipartUpload(c.env, user, c.req.param("id")); + return c.json(init, 200); + }, + ); - app.get("/api/attachments/:id/download", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - return downloadAttachment(c.env, user, c.req.param("id")); - }); + app.openapi( + createRoute({ + method: "put", + path: "/api/attachments/{id}/multipart/parts/{partNumber}", + request: { params: PartParams }, + responses: { + 200: { description: "Part uploaded", content: { "application/json": { schema: UploadPartResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + 409: errRes("No active multipart upload"), + }, + }), + async (c) => { + const user = c.get("user"); + const { partNumber } = c.req.valid("param"); + const result = await uploadAttachmentPart(c.env, user, c.req.param("id"), partNumber, c.req.raw); + return c.json(result, 200); + }, + ); - app.delete("/api/attachments/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - await deleteAttachment(c.env, user, c.req.param("id")); - return c.body(null, 204); - }); + app.openapi( + createRoute({ + method: "delete", + path: "/api/attachments/{id}/multipart", + request: { params: IdParams }, + responses: { + 204: { description: "Multipart upload aborted" }, + 401: errRes("Authentication required"), + 404: errRes("Attachment or active multipart upload not found"), + }, + }), + async (c) => { + const user = c.get("user"); + await abortAttachmentMultipart(c.env, user, c.req.param("id")); + return c.body(null, 204); + }, + ); + + app.openapi( + createRoute({ + method: "post", + path: "/api/attachments/{id}/finalize", + request: { params: IdParams }, + responses: { + 200: { description: "Attachment finalized", content: { "application/json": { schema: AttachmentResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + 409: errRes("Attachment not ready to finalize"), + 422: errRes("Multipart validation failed"), + }, + }), + async (c) => { + const user = c.get("user"); + const attachment = await finalizeAttachment(c.env, user, c.req.param("id")); + return c.json({ attachment: serializeAttachment(attachment, c.env) }, 200); + }, + ); + + app.openapi( + createRoute({ + method: "get", + path: "/api/attachments/{id}/download", + request: { params: IdParams }, + responses: { + 200: { description: "File bytes" }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + 409: errRes("Attachment not available for download"), + 410: errRes("Attachment object is gone"), + }, + }), + async (c) => { + const user = c.get("user"); + return downloadAttachment(c.env, user, c.req.param("id")); + }, + ); + + app.openapi( + createRoute({ + method: "delete", + path: "/api/attachments/{id}", + request: { params: IdParams }, + responses: { + 204: { description: "Attachment deleted" }, + 401: errRes("Authentication required"), + 404: errRes("Attachment not found"), + }, + }), + async (c) => { + const user = c.get("user"); + await deleteAttachment(c.env, user, c.req.param("id")); + return c.body(null, 204); + }, + ); // 分类管理 - app.post("/api/attachment-categories", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const body = await readJsonObject(c.req.raw); - const category = await createCategory(c.env, user, body); - return c.json({ category }, 201); - }); + app.openapi( + createRoute({ + method: "post", + path: "/api/attachment-categories", + request: { body: { content: { "application/json": { schema: CategoryCreateSchema } }, required: true } }, + responses: { + 201: { description: "Category created", content: { "application/json": { schema: CategoryResponseSchema } } }, + 401: errRes("Authentication required"), + 409: errRes("Slug already in use"), + }, + }), + async (c) => { + const user = c.get("user"); + const category = await createCategory(c.env, user, c.req.valid("json")); + return c.json({ category }, 201); + }, + ); - app.get("/api/attachment-categories", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - return c.json({ categories: await listCategories(c.env, user) }); - }); + app.openapi( + createRoute({ + method: "get", + path: "/api/attachment-categories", + responses: { + 200: { description: "Categories", content: { "application/json": { schema: CategoriesResponseSchema } } }, + 401: errRes("Authentication required"), + }, + }), + async (c) => { + const user = c.get("user"); + return c.json({ categories: await listCategories(c.env, user) }, 200); + }, + ); - app.patch("/api/attachment-categories/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const body = await readJsonObject(c.req.raw); - return c.json({ category: await updateCategory(c.env, user, c.req.param("id"), body) }); - }); + app.openapi( + createRoute({ + method: "patch", + path: "/api/attachment-categories/{id}", + request: { params: IdParams, body: { content: { "application/json": { schema: CategoryUpdateSchema } }, required: true } }, + responses: { + 200: { description: "Category updated", content: { "application/json": { schema: CategoryResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Category not found"), + 409: errRes("Slug already in use"), + }, + }), + async (c) => { + const user = c.get("user"); + const category = await updateCategory(c.env, user, c.req.param("id"), c.req.valid("json")); + return c.json({ category }, 200); + }, + ); - app.delete("/api/attachment-categories/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - await deleteCategoryOf(c.env, user, c.req.param("id")); - return c.body(null, 204); - }); + app.openapi( + createRoute({ + method: "delete", + path: "/api/attachment-categories/{id}", + request: { params: IdParams }, + responses: { 204: { description: "Category deleted" }, 401: errRes("Authentication required"), 404: errRes("Category not found") }, + }), + async (c) => { + const user = c.get("user"); + await deleteCategoryOf(c.env, user, c.req.param("id")); + return c.body(null, 204); + }, + ); // 标签管理 - app.post("/api/attachment-tags", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const body = await readJsonObject(c.req.raw); - const tag = await createTag(c.env, user, body); - return c.json({ tag }, 201); - }); + app.openapi( + createRoute({ + method: "post", + path: "/api/attachment-tags", + request: { body: { content: { "application/json": { schema: TagCreateSchema } }, required: true } }, + responses: { + 201: { description: "Tag created", content: { "application/json": { schema: TagResponseSchema } } }, + 401: errRes("Authentication required"), + 409: errRes("Slug already in use"), + }, + }), + async (c) => { + const user = c.get("user"); + const tag = await createTag(c.env, user, c.req.valid("json")); + return c.json({ tag }, 201); + }, + ); - app.get("/api/attachment-tags", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - return c.json({ tags: await listTags(c.env, user) }); - }); + app.openapi( + createRoute({ + method: "get", + path: "/api/attachment-tags", + responses: { 200: { description: "Tags", content: { "application/json": { schema: TagsResponseSchema } } }, 401: errRes("Authentication required") }, + }), + async (c) => { + const user = c.get("user"); + return c.json({ tags: await listTags(c.env, user) }, 200); + }, + ); - app.patch("/api/attachment-tags/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - const body = await readJsonObject(c.req.raw); - return c.json({ tag: await updateTag(c.env, user, c.req.param("id"), body) }); - }); + app.openapi( + createRoute({ + method: "patch", + path: "/api/attachment-tags/{id}", + request: { params: IdParams, body: { content: { "application/json": { schema: TagUpdateSchema } }, required: true } }, + responses: { + 200: { description: "Tag updated", content: { "application/json": { schema: TagResponseSchema } } }, + 401: errRes("Authentication required"), + 404: errRes("Tag not found"), + 409: errRes("Slug already in use"), + }, + }), + async (c) => { + const user = c.get("user"); + const tag = await updateTag(c.env, user, c.req.param("id"), c.req.valid("json")); + return c.json({ tag }, 200); + }, + ); - app.delete("/api/attachment-tags/:id", async (c) => { - const user = await resolveCurrentUser(c.req.raw, c.env); - await deleteTagOf(c.env, user, c.req.param("id")); - return c.body(null, 204); - }); + app.openapi( + createRoute({ + method: "delete", + path: "/api/attachment-tags/{id}", + request: { params: IdParams }, + responses: { 204: { description: "Tag deleted" }, 401: errRes("Authentication required"), 404: errRes("Tag not found") }, + }), + async (c) => { + const user = c.get("user"); + await deleteTagOf(c.env, user, c.req.param("id")); + return c.body(null, 204); + }, + ); + + app.doc31("/openapi.json", (c) => ({ + openapi: "3.1.0", + info: { + title: "cfw-attachment", + version: "0.1.0", + description: "Attachment management service (metadata, single-shot & multipart upload, public files).", + }, + servers: [{ url: c.env.PUBLIC_BASE_URL ?? "https://cfw-attachment.bowong.cc", description: "Production" }], + })); return app; } diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..ba63c10 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,140 @@ +import { z } from "@hono/zod-openapi"; + +// 复用枚举 +const visibility = z.enum(["private", "shared", "public"]); +const attachmentStatus = z.enum(["upload_pending", "uploaded", "available", "deleted"]); + +export const VisibilitySchema = visibility.openapi("Visibility"); +export const AttachmentStatusSchema = attachmentStatus.openapi("AttachmentStatus"); + +export const ProblemSchema = z + .object({ + type: z.string(), + title: z.string(), + status: z.number().int(), + detail: z.record(z.string(), z.unknown()), + code: z.string(), + }) + .openapi("Problem"); + +export const AttachmentSchema = z + .object({ + id: z.string(), + user_id: z.string(), + status: attachmentStatus, + filename: z.string(), + content_type: z.string(), + byte_size: z.number().int().nullable(), + checksum_sha256: z.string().nullable(), + visibility: z.string(), + category_id: z.string().nullable(), + description: z.string().nullable(), + object_key: z.string().nullable(), + etag: z.string().nullable(), + created_by_user_id: z.string(), + updated_by_user_id: z.string(), + deleted_by_user_id: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + deleted_at: z.string().nullable(), + public_url: z.string().url().nullable(), + }) + .openapi("Attachment"); + +export const CreateAttachmentSchema = z + .object({ + filename: z.string().min(1), + content_type: z.string().min(1), + visibility: visibility.optional(), + description: z.string().optional(), + categoryId: z.string().optional(), + category_id: z.string().optional(), + }) + .openapi("CreateAttachment"); + +export const UpdateAttachmentSchema = z + .object({ + filename: z.string().min(1).optional(), + visibility: visibility.optional(), + description: z.string().nullable().optional(), + categoryId: z.string().nullable().optional(), + tagIds: z.array(z.string()).optional(), + }) + .openapi("UpdateAttachment"); + +export const AttachmentResponseSchema = z + .object({ attachment: AttachmentSchema }) + .openapi("AttachmentResponse"); + +export const PaginatedAttachmentsSchema = z + .object({ + items: z.array(AttachmentSchema), + page: z.number().int(), + pageSize: z.number().int(), + total: z.number().int(), + }) + .openapi("PaginatedAttachments"); + +export const CategorySchema = z + .object({ + id: z.string(), + user_id: z.string(), + name: z.string(), + slug: z.string(), + created_at: z.string(), + updated_at: z.string(), + }) + .openapi("Category"); + +export const CategoryCreateSchema = z + .object({ name: z.string().min(1), slug: z.string().optional() }) + .openapi("CategoryCreate"); + +export const CategoryUpdateSchema = z + .object({ name: z.string().min(1).optional(), slug: z.string().optional() }) + .openapi("CategoryUpdate"); + +export const CategoryResponseSchema = z.object({ category: CategorySchema }).openapi("CategoryResponse"); +export const CategoriesResponseSchema = z + .object({ categories: z.array(CategorySchema) }) + .openapi("CategoriesResponse"); + +export const TagSchema = z + .object({ + id: z.string(), + user_id: z.string(), + name: z.string(), + slug: z.string(), + created_at: z.string(), + updated_at: z.string(), + }) + .openapi("Tag"); + +export const TagCreateSchema = z + .object({ name: z.string().min(1), slug: z.string().optional() }) + .openapi("TagCreate"); + +export const TagUpdateSchema = z + .object({ name: z.string().min(1).optional(), slug: z.string().optional() }) + .openapi("TagUpdate"); + +export const TagResponseSchema = z.object({ tag: TagSchema }).openapi("TagResponse"); +export const TagsResponseSchema = z.object({ tags: z.array(TagSchema) }).openapi("TagsResponse"); + +// 分片上传相关 +export const MultipartInitResponseSchema = z + .object({ + upload_id: z.string(), + object_key: z.string(), + part_size_min: z.number().int(), + max_parts: z.number().int(), + }) + .openapi("MultipartInitResponse"); + +export const UploadPartResponseSchema = z + .object({ + part_number: z.number().int(), + etag: z.string(), + byte_size: z.number().int(), + }) + .openapi("UploadPartResponse"); diff --git a/tests/attachment-worker.test.ts b/tests/attachment-worker.test.ts index aca4c30..20aac4c 100644 --- a/tests/attachment-worker.test.ts +++ b/tests/attachment-worker.test.ts @@ -10,10 +10,16 @@ async function call( env: unknown, init: { body?: BodyInit; headers?: Record } = {}, ): Promise { + const headers: Record = { ...init.headers }; + // JSON body 校验要求 content-type: application/json(hono 的 json validator 按 content-type 取体)。 + // 没有 body 的请求不带;有 body 但调用方显式给了 content-type 则尊重原值。 + if (init.body !== undefined && !headers["content-type"] && !headers["Content-Type"]) { + headers["content-type"] = "application/json"; + } const request = new Request(`${BASE}${path}`, { method, body: init.body, - headers: init.headers, + headers, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any return app.fetch(request, env as any); @@ -127,12 +133,16 @@ describe("cfw-attachment worker (core flow)", () => { expect(response.status).toBe(401); }); - it("returns 422 when required fields are missing", async () => { + it("returns 422 problem+json when required fields are missing", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ content_type: "text/plain" }) }); expect(response.status).toBe(422); + expect(response.headers.get("content-type")).toBe("application/problem+json"); + const problem = (await response.json()) as { code: string; detail: { issues: Array<{ path: string }> } }; + expect(problem.code).toBe("validation-failed"); + expect(problem.detail.issues.some((issue) => issue.path === "filename")).toBe(true); }); it("serves an OpenAPI document at /openapi.json without a session", async () => { @@ -141,9 +151,17 @@ describe("cfw-attachment worker (core flow)", () => { const response = await call("GET", "/openapi.json", env); expect(response.status).toBe(200); - const document = (await response.json()) as { openapi: string; paths: Record }; + const document = (await response.json()) as { + openapi: string; + paths: Record; + components?: { schemas?: Record }; + }; expect(document.openapi).toBe("3.1.0"); expect(document.paths["/api/attachments"]).toBeTruthy(); + expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy(); + expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy(); + expect(document.paths["/files/{id}"]).toBeTruthy(); + expect(document.components?.schemas?.Attachment).toBeTruthy(); }); }); @@ -290,3 +308,171 @@ describe("cfw-attachment worker (search, categories, tags)", () => { }); }); +async function initMultipart(env: unknown, id: string): Promise<{ upload_id: string }> { + const response = await call("POST", `/api/attachments/${id}/multipart`, env); + expect(response.status).toBe(200); + return (await response.json()) as { upload_id: string }; +} + +async function uploadPartOk( + env: unknown, + id: string, + partNumber: number, + body: BodyInit, +): Promise<{ part_number: number; etag: string; byte_size: number }> { + const response = await call("PUT", `/api/attachments/${id}/multipart/parts/${partNumber}`, env, { body }); + expect(response.status).toBe(200); + return (await response.json()) as { part_number: number; etag: string; byte_size: number }; +} + +async function finalizeOk(env: unknown, id: string): Promise<{ attachment: { status: string; byte_size: number; etag: string | null; public_url: string | null } }> { + const response = await call("POST", `/api/attachments/${id}/finalize`, env); + expect(response.status).toBe(200); + return response.json(); +} + +describe("cfw-attachment worker (multipart & public files)", () => { + it("uploads parts, finalizes, and the assembled object matches the concatenated bytes", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + + const created = await createAttachment(env, { filename: "big.txt", content_type: "text/plain" }); + const id = created.attachment.id; + + const minPart = 5 * 1024 * 1024; + const bigPart = "a".repeat(minPart); // 非末片必须 >= 5MiB(R2 约束) + const tail = "-END"; + + const init = await initMultipart(env, id); + expect(init.upload_id).toBeTruthy(); + await uploadPartOk(env, id, 1, bigPart); + await uploadPartOk(env, id, 2, tail); + + const finalized = await finalizeOk(env, id); + expect(finalized.attachment.status).toBe("available"); + expect(finalized.attachment.byte_size).toBe(minPart + tail.length); + expect(finalized.attachment.etag).toBeTruthy(); + // 默认 private → 不返回 public_url + expect(finalized.attachment.public_url).toBeNull(); + + const download = await call("GET", `/api/attachments/${id}/download`, env); + expect(download.headers.get("content-type")).toBe("text/plain"); + const text = await download.text(); + expect(text.length).toBe(minPart + tail.length); + expect(text.endsWith(tail)).toBe(true); + expect(text.slice(0, minPart)).toBe(bigPart); + }); + + it("re-uploading the same part number keeps the latest bytes", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; + + await initMultipart(env, id); + await uploadPartOk(env, id, 1, "first"); + await uploadPartOk(env, id, 1, "second"); + + await finalizeOk(env, id); + const download = await call("GET", `/api/attachments/${id}/download`, env); + expect(await download.text()).toBe("second"); + }); + + it("rejects finalize when a non-final part is below the minimum part size", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; + + await initMultipart(env, id); + await uploadPartOk(env, id, 1, "tiny"); // 非末片且 < 5MiB + await uploadPartOk(env, id, 2, "x"); + + const finalize = await call("POST", `/api/attachments/${id}/finalize`, env); + expect(finalize.status).toBe(422); + const problem = (await finalize.json()) as { code: string; detail: { part_number: number } }; + expect(problem.code).toBe("multipart-part-too-small"); + expect(problem.detail.part_number).toBe(1); + }); + + it("aborts a multipart upload and lets a single-shot upload take over", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; + + await initMultipart(env, id); + await uploadPartOk(env, id, 1, "part-data"); + + const abort = await call("DELETE", `/api/attachments/${id}/multipart`, env); + expect(abort.status).toBe(204); + + // abort 后再传片 → 409(无活动会话) + const partAfter = await call("PUT", `/api/attachments/${id}/multipart/parts/1`, env, { body: "x" }); + expect(partAfter.status).toBe(409); + + // 改走单次上传 → finalize 成功 + await call("PUT", `/api/attachments/${id}/content`, env, { body: "single-shot" }); + const finalized = await finalizeOk(env, id); + expect(finalized.attachment.status).toBe("available"); + const download = await call("GET", `/api/attachments/${id}/download`, env); + expect(await download.text()).toBe("single-shot"); + }); + + it("GET /files/:id serves public/shared files with the right content-type and 404s otherwise", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + + const id = (await createAttachment(env, { filename: "img.png", content_type: "image/png", visibility: "public" })).attachment.id; + await call("PUT", `/api/attachments/${id}/content`, env, { body: "PNGDATA" }); + await finalizeOk(env, id); + + const publicFile = await call("GET", `/files/${id}`, env); + expect(publicFile.status).toBe(200); + expect(publicFile.headers.get("content-type")).toBe("image/png"); + expect(publicFile.headers.get("content-disposition")).toContain("inline"); + expect(await publicFile.text()).toBe("PNGDATA"); + + // 转 private → 404 + await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "private" }) }); + expect((await call("GET", `/files/${id}`, env)).status).toBe(404); + + // 转回 shared → 200 + await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "shared" }) }); + expect((await call("GET", `/files/${id}`, env)).status).toBe(200); + + // 未 finalize → 404 + const pending = (await createAttachment(env, { filename: "u.txt", content_type: "text/plain", visibility: "public" })).attachment.id; + expect((await call("GET", `/files/${pending}`, env)).status).toBe(404); + }); + + it("exposes public_url for available public/shared attachments and omits it for private", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const expected = (id: string) => `https://attachment.local/files/${id}`; + + const id = (await createAttachment(env, { filename: "doc.pdf", content_type: "application/pdf", visibility: "public" })).attachment.id; + await call("PUT", `/api/attachments/${id}/content`, env, { body: "PDF" }); + + const finalized = await finalizeOk(env, id); + expect(finalized.attachment.public_url).toBe(expected(id)); + + const privateView = await jsonBody(await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "private" }) })); + expect(privateView.attachment.public_url).toBeNull(); + + const sharedView = await jsonBody(await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "shared" }) })); + expect(sharedView.attachment.public_url).toBe(expected(id)); + + const search = await jsonBody(await call("GET", "/api/attachments/search", env)); + expect(search.items[0].public_url).toBe(expected(id)); + }); + + it("init refuses a second active multipart session", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; + + await initMultipart(env, id); + const second = await call("POST", `/api/attachments/${id}/multipart`, env); + expect(second.status).toBe(409); + expect(((await second.json()) as { code: string }).code).toBe("multipart-active"); + }); +}); + diff --git a/tests/fakes.ts b/tests/fakes.ts index e93959e..7f242f9 100644 --- a/tests/fakes.ts +++ b/tests/fakes.ts @@ -1,11 +1,12 @@ import { DatabaseSync } from "node:sqlite"; import migrationSql from "../migrations/0001_initial.sql?raw"; +import migration2Sql from "../migrations/0002_upload_sessions.sql?raw"; import { vi } from "vitest"; import type { Env, FetcherLike } from "../src/env"; /** * 内存版 D1,基于 node:sqlite 执行真实 SQL。 - * 直接复用 migrations/0001_initial.sql 建表,保证 store 层 SQL 被真实跑过。 + * 直接复用 migrations/0001 + 0002 建表,保证 store 层 SQL 被真实跑过。 */ export class FakeD1 { private db: DatabaseSync; @@ -13,6 +14,7 @@ export class FakeD1 { constructor() { this.db = new DatabaseSync(":memory:"); this.db.exec(migrationSql); + this.db.exec(migration2Sql); } prepare(sql: string) { @@ -62,36 +64,110 @@ function toR2Object(key: string, stored: StoredObject): R2Object { } as unknown as R2Object; } +async function materializeBody( + body: ReadableStream | ArrayBuffer | ArrayBufferView | string | null, +): Promise { + if (body === null) { + return new Uint8Array(); + } + if (typeof body === "string") { + return new TextEncoder().encode(body); + } + if (body instanceof ArrayBuffer) { + return new Uint8Array(body); + } + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + } + const buffer = await new Response(body as ReadableStream).arrayBuffer(); + return new Uint8Array(buffer); +} + +interface MultipartSession { + key: string; + parts: Map; + httpMetadata: R2HTTPMetadata; +} + /** * 内存版 R2 bucket,用 Map 保存对象字节,覆盖 put/get/head/delete。 */ export class FakeR2 { private store = new Map(); + private sessions = new Map(); + private sessionCounter = 0; async put( key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | string | null, options: { httpMetadata?: R2HTTPMetadata } = {}, ): Promise { - let bytes: Uint8Array; - if (body === null) { - bytes = new Uint8Array(); - } else if (typeof body === "string") { - bytes = new TextEncoder().encode(body); - } else if (body instanceof ArrayBuffer) { - bytes = new Uint8Array(body); - } else if (ArrayBuffer.isView(body)) { - bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); - } else { - const buffer = await new Response(body as ReadableStream).arrayBuffer(); - bytes = new Uint8Array(buffer); - } - + const bytes = await materializeBody(body); const stored: StoredObject = { body: bytes, httpMetadata: options.httpMetadata ?? {}, uploaded: Date.now() }; this.store.set(key, stored); return toR2Object(key, stored); } + // --- 分片上传(内存版,镜像 R2 multipart 行为) --- + + async createMultipartUpload( + key: string, + options: { httpMetadata?: R2HTTPMetadata; customMetadata?: Record } = {}, + ): Promise { + const uploadId = `mp-${++this.sessionCounter}`; + this.sessions.set(uploadId, { key, parts: new Map(), httpMetadata: options.httpMetadata ?? {} }); + return this.multipartHandle(uploadId); + } + + resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload { + const session = this.sessions.get(uploadId); + if (!session || session.key !== key) { + throw new Error(`FakeR2: unknown multipart upload ${uploadId}`); + } + return this.multipartHandle(uploadId); + } + + private multipartHandle(uploadId: string): R2MultipartUpload { + const session = this.sessions.get(uploadId)!; + const handle = { + key: session.key, + uploadId, + uploadPart: async ( + partNumber: number, + value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, + ): Promise => { + const bytes = await materializeBody(value as ReadableStream); + const etag = fakeEtag(bytes); + session.parts.set(partNumber, { bytes, etag }); + return { partNumber, etag } as R2UploadedPart; + }, + complete: async (uploadedParts: R2UploadedPart[]): Promise => { + const total = uploadedParts + .slice() + .sort((a, b) => a.partNumber - b.partNumber) + .flatMap((part) => session.parts.get(part.partNumber)?.bytes ?? new Uint8Array()); + const merged = new Uint8Array(total.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of total) { + merged.set(part, offset); + offset += part.length; + } + const stored: StoredObject = { + body: merged, + httpMetadata: session.httpMetadata, + uploaded: Date.now(), + }; + this.store.set(session.key, stored); + this.sessions.delete(uploadId); + return toR2Object(session.key, stored); + }, + abort: async (): Promise => { + this.sessions.delete(uploadId); + }, + }; + return handle as unknown as R2MultipartUpload; + } + async get(key: string): Promise { const stored = this.store.get(key); if (!stored) return null; @@ -145,11 +221,17 @@ export function sharedStores(): SharedStores { return { db: new FakeD1(), r2: new FakeR2() }; } -export function makeEnv(stores: SharedStores, userId: string | null, maxUploadBytes = "52428800"): Env { +export function makeEnv( + stores: SharedStores, + userId: string | null, + maxUploadBytes = "52428800", + publicBaseUrl = "https://attachment.local", +): Env { return { AUTH: fakeAuth(userId), DB: stores.db as unknown as D1Database, ATTACHMENTS: stores.r2 as unknown as R2Bucket, MAX_UPLOAD_BYTES: maxUploadBytes, + PUBLIC_BASE_URL: publicBaseUrl, }; }