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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-02 09:08:42 -07:00
parent db5a499e5c
commit 9a0a2b1da7
9 changed files with 1307 additions and 188 deletions

View File

@@ -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<Response | null> {
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<void> {
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<void> {
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<AttachmentRecord> {
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<MultipartInitResult> {
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<UploadPartResult> {
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<void> {
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;

View File

@@ -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<void> {
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<UploadSessionRecord | null> {
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<UploadSessionRecord>();
}
export async function upsertUploadPart(
db: D1Database,
part: Omit<UploadPartRecord, "created_at">,
createdAt: string,
): Promise<void> {
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<UploadPartRecord[]> {
const result = await db
.prepare(`SELECT * FROM attachment_upload_parts WHERE session_id = ? ORDER BY part_number ASC`)
.bind(sessionId)
.all<UploadPartRecord>();
return result.results;
}
export async function markUploadSessionCompleted(
db: D1Database,
sessionId: string,
byteSize: number,
partCount: number,
completedAt: string,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<AttachmentRecord | null> {
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<AttachmentRecord>();
}

View File

@@ -22,3 +22,56 @@ export async function putObject(
export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise<R2ObjectBody | null> {
return bucket.get(key);
}
export interface StartedMultipartUpload {
key: string;
uploadId: string;
}
export async function createMultipartUpload(
bucket: R2Bucket,
key: string,
contentType: string,
): Promise<StartedMultipartUpload> {
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<UploadedPart> {
// 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<R2Object> {
const multipart = bucket.resumeMultipartUpload(key, uploadId);
return multipart.complete(parts);
}
export async function abortMultipartUpload(
bucket: R2Bucket,
key: string,
uploadId: string,
): Promise<void> {
const multipart = bucket.resumeMultipartUpload(key, uploadId);
await multipart.abort();
}

View File

@@ -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;

View File

@@ -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 }),
);
}

View File

@@ -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<AppEnv> {
const app = new OpenAPIHono<AppEnv>({ 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;
}

140
src/schemas.ts Normal file
View File

@@ -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");

View File

@@ -10,10 +10,16 @@ async function call(
env: unknown,
init: { body?: BodyInit; headers?: Record<string, string> } = {},
): Promise<Response> {
const headers: Record<string, string> = { ...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<string, unknown> };
const document = (await response.json()) as {
openapi: string;
paths: Record<string, unknown>;
components?: { schemas?: Record<string, unknown> };
};
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");
});
});

View File

@@ -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<Uint8Array> | ArrayBuffer | ArrayBufferView | string | null,
): Promise<Uint8Array> {
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<Uint8Array>).arrayBuffer();
return new Uint8Array(buffer);
}
interface MultipartSession {
key: string;
parts: Map<number, { bytes: Uint8Array; etag: string }>;
httpMetadata: R2HTTPMetadata;
}
/**
* 内存版 R2 bucket,用 Map 保存对象字节,覆盖 put/get/head/delete。
*/
export class FakeR2 {
private store = new Map<string, StoredObject>();
private sessions = new Map<string, MultipartSession>();
private sessionCounter = 0;
async put(
key: string,
body: ReadableStream<Uint8Array> | ArrayBuffer | ArrayBufferView | string | null,
options: { httpMetadata?: R2HTTPMetadata } = {},
): Promise<R2Object> {
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<Uint8Array>).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<string, string> } = {},
): Promise<R2MultipartUpload> {
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<R2UploadedPart> => {
const bytes = await materializeBody(value as ReadableStream<Uint8Array>);
const etag = fakeEtag(bytes);
session.parts.set(partNumber, { bytes, etag });
return { partNumber, etag } as R2UploadedPart;
},
complete: async (uploadedParts: R2UploadedPart[]): Promise<R2Object> => {
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<void> => {
this.sessions.delete(uploadId);
},
};
return handle as unknown as R2MultipartUpload;
}
async get(key: string): Promise<R2ObjectBody | null> {
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,
};
}