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:
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
511
src/routes.ts
511
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<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
140
src/schemas.ts
Normal 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");
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
114
tests/fakes.ts
114
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<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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user