feat: add attachment upload and download flow

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-02 07:44:30 -07:00
parent c4a1b167a1
commit fd66c1ac31
10 changed files with 754 additions and 0 deletions

199
src/attachment-service.ts Normal file
View File

@@ -0,0 +1,199 @@
import type { Env } from "./env";
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 {
findAttachmentForUser,
insertAttachment,
insertAuditEvent,
markAvailable,
markUploaded,
softDeleteAttachment,
type AttachmentRecord,
} from "./attachment-store";
const DEFAULT_MAX_UPLOAD_BYTES = 52_428_800;
function nowIso(): string {
return new Date().toISOString();
}
function attachmentNotFound(): HttpProblem {
return new HttpProblem(404, "attachment-not-found", "Attachment was not found");
}
function parseMaxUploadBytes(env: Env): number {
const parsed = Number.parseInt(env.MAX_UPLOAD_BYTES ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES;
}
async function recordAudit(
env: Env,
user: CurrentUser,
attachmentId: string,
action: string,
detail: Record<string, unknown>,
): Promise<void> {
await insertAuditEvent(env.DB, {
id: newId("aud"),
user_id: user.id,
attachment_id: attachmentId,
actor_user_id: user.id,
action,
detail_json: JSON.stringify(detail),
created_at: nowIso(),
});
}
export async function createAttachment(
env: Env,
user: CurrentUser,
body: Record<string, unknown>,
): Promise<AttachmentRecord> {
const filename = requiredString(body, "filename");
const content_type = requiredString(body, "content_type");
const visibility = optionalString(body, "visibility") ?? "private";
const description = optionalString(body, "description") ?? null;
const category_id = optionalString(body, "categoryId") ?? optionalString(body, "category_id") ?? null;
const now = nowIso();
const id = newId("att");
const record: AttachmentRecord = {
id,
user_id: user.id,
status: "upload_pending",
filename,
content_type,
byte_size: null,
checksum_sha256: null,
visibility,
category_id,
description,
object_key: null,
etag: null,
created_by_user_id: user.id,
updated_by_user_id: user.id,
deleted_by_user_id: null,
created_at: now,
updated_at: now,
deleted_at: null,
};
await insertAttachment(env.DB, record);
await recordAudit(env, user, id, "attachment.created", { filename, content_type });
return record;
}
export async function uploadAttachmentContent(
env: Env,
user: CurrentUser,
id: string,
request: Request,
): Promise<AttachmentRecord> {
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 maxBytes = parseMaxUploadBytes(env);
const declaredLength = Number.parseInt(request.headers.get("content-length") ?? "", 10);
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new HttpProblem(413, "payload-too-large", "Upload exceeds the maximum allowed size", {
max_bytes: maxBytes,
});
}
const objectKey = objectKeyFor(user.id, id);
const r2Object = await putObject(env.ATTACHMENTS, objectKey, request, record.content_type);
const etag = (r2Object.etag ?? "").replace(/^"|"$/g, "");
await markUploaded(env.DB, user.id, id, objectKey, etag, r2Object.size);
await recordAudit(env, user, id, "attachment.uploaded", { object_key: objectKey, byte_size: r2Object.size });
const refreshed = await findAttachmentForUser(env.DB, user.id, id);
return refreshed ?? record;
}
export async function finalizeAttachment(
env: Env,
user: CurrentUser,
id: string,
): Promise<AttachmentRecord> {
const record = await findAttachmentForUser(env.DB, user.id, id);
if (!record) {
throw attachmentNotFound();
}
if (record.status !== "uploaded") {
throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", {
status: record.status,
});
}
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;
}
export async function getAttachment(
env: Env,
user: CurrentUser,
id: string,
): Promise<AttachmentRecord> {
const record = await findAttachmentForUser(env.DB, user.id, id);
if (!record) {
throw attachmentNotFound();
}
return record;
}
export async function deleteAttachment(env: Env, user: CurrentUser, id: string): Promise<void> {
const record = await findAttachmentForUser(env.DB, user.id, id);
if (!record) {
throw attachmentNotFound();
}
await softDeleteAttachment(env.DB, user.id, id, user.id);
if (record.object_key) {
await env.ATTACHMENTS.delete(record.object_key).catch(() => undefined);
}
await recordAudit(env, user, id, "attachment.deleted", {});
}
export async function downloadAttachment(env: Env, user: CurrentUser, id: string): Promise<Response> {
const record = await findAttachmentForUser(env.DB, user.id, id);
if (!record) {
throw attachmentNotFound();
}
if (record.status !== "available") {
throw new HttpProblem(409, "object-not-available", "Attachment is not available for download", {
status: record.status,
});
}
if (!record.object_key) {
throw new HttpProblem(410, "object-gone", "Attachment object is missing");
}
const object = await getObjectOrNull(env.ATTACHMENTS, record.object_key);
if (!object) {
throw new HttpProblem(410, "object-gone", "Attachment object is missing");
}
const headers = new Headers({
"content-type": record.content_type,
"content-length": String(object.size),
"content-disposition": `attachment; filename="${sanitizeFilename(record.filename)}"`,
});
return new Response(object.body, { headers });
}
function sanitizeFilename(name: string): string {
return name.replace(/["\\]/g, "_");
}

127
src/attachment-store.ts Normal file
View File

@@ -0,0 +1,127 @@
export type AttachmentStatus = "upload_pending" | "uploaded" | "available" | "deleted";
export interface AttachmentRecord {
id: string;
user_id: string;
status: AttachmentStatus;
filename: string;
content_type: string;
byte_size: number | null;
checksum_sha256: string | null;
visibility: string;
category_id: string | null;
description: string | null;
object_key: string | null;
etag: string | null;
created_by_user_id: string;
updated_by_user_id: string;
deleted_by_user_id: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
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";
export async function insertAttachment(db: D1Database, record: AttachmentRecord): Promise<void> {
await db
.prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.bind(
record.id,
record.user_id,
record.status,
record.filename,
record.content_type,
record.byte_size,
record.checksum_sha256,
record.visibility,
record.category_id,
record.description,
record.object_key,
record.etag,
record.created_by_user_id,
record.updated_by_user_id,
record.deleted_by_user_id,
record.created_at,
record.updated_at,
record.deleted_at,
)
.run();
}
export async function findAttachmentForUser(
db: D1Database,
userId: string,
id: string,
): Promise<AttachmentRecord | null> {
return db
.prepare(`SELECT ${ATTACHMENT_COLUMNS} FROM attachments WHERE id = ? AND user_id = ? AND deleted_at IS NULL`)
.bind(id, userId)
.first<AttachmentRecord>();
}
export async function markUploaded(
db: D1Database,
userId: string,
id: string,
objectKey: string,
etag: string | null,
byteSize: number | null,
): Promise<void> {
await db
.prepare(
`UPDATE attachments
SET status = 'uploaded', object_key = ?, etag = ?, byte_size = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
)
.bind(objectKey, etag, byteSize, new Date().toISOString(), id, userId)
.run();
}
export async function markAvailable(db: D1Database, userId: string, id: string): Promise<void> {
await db
.prepare(
`UPDATE attachments SET status = 'available', updated_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
)
.bind(new Date().toISOString(), id, userId)
.run();
}
export async function softDeleteAttachment(
db: D1Database,
userId: string,
id: string,
deletedByUserId: string,
): Promise<void> {
const now = new Date().toISOString();
await db
.prepare(
`UPDATE attachments
SET status = 'deleted', deleted_at = ?, deleted_by_user_id = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
)
.bind(now, deletedByUserId, now, id, userId)
.run();
}
export interface AuditEvent {
id: string;
user_id: string;
attachment_id: string | null;
actor_user_id: string;
action: string;
detail_json: string;
created_at: string;
}
export async function insertAuditEvent(db: D1Database, event: AuditEvent): Promise<void> {
await db
.prepare(
`INSERT INTO attachment_audit_events (id, user_id, attachment_id, actor_user_id, action, detail_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(event.id, event.user_id, event.attachment_id, event.actor_user_id, event.action, event.detail_json, event.created_at)
.run();
}

5
src/ids.ts Normal file
View File

@@ -0,0 +1,5 @@
export function newId(prefix: string): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
return `${prefix}_${hex}`;
}

3
src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createRoutes } from "./routes";
export default createRoutes();

26
src/json.ts Normal file
View File

@@ -0,0 +1,26 @@
import { HttpProblem } from "./problem";
export async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
const body = await request.json().catch(() => undefined);
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new HttpProblem(422, "validation-failed", "Request body must be a JSON object");
}
return body as Record<string, unknown>;
}
export function requiredString(body: Record<string, unknown>, key: string): string {
const value = body[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new HttpProblem(422, "validation-failed", `${key} is required`, { field: key });
}
return value.trim();
}
export function optionalString(body: Record<string, unknown>, key: string): string | undefined {
const value = body[key];
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") {
throw new HttpProblem(422, "validation-failed", `${key} must be a string`, { field: key });
}
return value.trim();
}

24
src/object-store.ts Normal file
View File

@@ -0,0 +1,24 @@
export function objectKeyFor(userId: string, attachmentId: string): string {
const date = new Date();
const yyyy = String(date.getUTCFullYear());
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
return `users/${userId}/attachments/${yyyy}/${mm}/${attachmentId}/original`;
}
export async function putObject(
bucket: R2Bucket,
key: string,
request: Request,
contentType: string,
): Promise<R2Object> {
const uploaded = await bucket.put(key, request.body, {
httpMetadata: {
contentType,
},
});
return uploaded as R2Object;
}
export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise<R2ObjectBody | null> {
return bucket.get(key);
}

58
src/routes.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Hono } from "hono";
import { resolveCurrentUser } from "./auth-session";
import type { Env } from "./env";
import { readJsonObject } from "./json";
import { problemResponse } from "./problem";
import {
createAttachment,
deleteAttachment,
downloadAttachment,
finalizeAttachment,
getAttachment,
uploadAttachmentContent,
} from "./attachment-service";
export function createRoutes(): Hono<{ Bindings: Env }> {
const app = new Hono<{ Bindings: Env }>();
app.onError((error) => problemResponse(error));
app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" }));
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.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.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.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.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.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);
});
return app;
}

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import app from "../src/index";
import { makeEnv, sharedStores } from "./fakes";
const BASE = "https://attachment.local";
async function call(
method: string,
path: string,
env: unknown,
init: { body?: BodyInit; headers?: Record<string, string> } = {},
): Promise<Response> {
const request = new Request(`${BASE}${path}`, {
method,
body: init.body,
headers: init.headers,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return app.fetch(request, env as any);
}
async function createAttachment(
env: unknown,
body: Record<string, unknown>,
): Promise<{ attachment: { id: string; status: string; user_id: string } }> {
const response = await call("POST", "/api/attachments", env, { body: JSON.stringify(body) });
expect(response.status).toBe(201);
return (await response.json()) as { attachment: { id: string; status: string; user_id: string } };
}
describe("cfw-attachment worker (core flow)", () => {
it("creates an attachment for the current user and hides it from another user", async () => {
const stores = sharedStores();
const userA = makeEnv(stores, "user_a");
const userB = makeEnv(stores, "user_b");
const created = await createAttachment(userA, {
filename: "invoice.pdf",
content_type: "application/pdf",
});
expect(created.attachment.status).toBe("upload_pending");
expect(created.attachment.user_id).toBe("user_a");
const asOwner = await call("GET", `/api/attachments/${created.attachment.id}`, userA);
expect(asOwner.status).toBe(200);
const asOther = await call("GET", `/api/attachments/${created.attachment.id}`, userB);
expect(asOther.status).toBe(404);
expect(asOther.headers.get("content-type")).toBe("application/problem+json");
});
it("uploads content, finalizes the attachment, and downloads the same bytes", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const payload = "hello attachment bytes";
const created = await createAttachment(env, {
filename: "note.txt",
content_type: "text/plain",
});
const uploaded = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, {
body: payload,
headers: { "content-length": String(new TextEncoder().encode(payload).byteLength) },
});
expect(uploaded.status).toBe(200);
const uploadedBody = (await uploaded.json()) as { attachment: { status: string; byte_size: number } };
expect(uploadedBody.attachment.status).toBe("uploaded");
expect(uploadedBody.attachment.byte_size).toBe(new TextEncoder().encode(payload).byteLength);
const finalized = await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env);
expect(finalized.status).toBe(200);
expect(((await finalized.json()) as { attachment: { status: string } }).attachment.status).toBe("available");
const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env);
expect(download.status).toBe(200);
expect(download.headers.get("content-type")).toBe("text/plain");
expect(await download.text()).toBe(payload);
});
it("does not download before finalize", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const payload = "not yet ready";
const created = await createAttachment(env, { filename: "draft.txt", content_type: "text/plain" });
await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: payload });
const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env);
expect(download.status).toBe(409);
const problem = (await download.json()) as { code: string };
expect(problem.code).toBe("object-not-available");
});
it("soft deletes an attachment and removes it from detail reads", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a");
const created = await createAttachment(env, { filename: "temp.txt", content_type: "text/plain" });
const deleted = await call("DELETE", `/api/attachments/${created.attachment.id}`, env);
expect(deleted.status).toBe(204);
const detail = await call("GET", `/api/attachments/${created.attachment.id}`, env);
expect(detail.status).toBe(404);
});
it("rejects uploads that exceed the configured size limit", async () => {
const stores = sharedStores();
const env = makeEnv(stores, "user_a", "3");
const created = await createAttachment(env, { filename: "big.bin", content_type: "application/octet-stream" });
const tooLarge = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, {
body: "more than three bytes",
headers: { "content-length": "21" },
});
expect(tooLarge.status).toBe(413);
});
it("returns 401 when there is no session", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const response = await call("POST", "/api/attachments", env, {
body: JSON.stringify({ filename: "x.txt", content_type: "text/plain" }),
});
expect(response.status).toBe(401);
});
it("returns 422 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);
});
});

155
tests/fakes.ts Normal file
View File

@@ -0,0 +1,155 @@
import { DatabaseSync } from "node:sqlite";
import migrationSql from "../migrations/0001_initial.sql?raw";
import { vi } from "vitest";
import type { Env, FetcherLike } from "../src/env";
/**
* 内存版 D1,基于 node:sqlite 执行真实 SQL。
* 直接复用 migrations/0001_initial.sql 建表,保证 store 层 SQL 被真实跑过。
*/
export class FakeD1 {
private db: DatabaseSync;
constructor() {
this.db = new DatabaseSync(":memory:");
this.db.exec(migrationSql);
}
prepare(sql: string) {
const exec = (values: unknown[]) => {
const statement = this.db.prepare(sql);
return {
first: async <T = unknown>(): Promise<T | null> => (statement.get(...values) as T | null) ?? null,
all: async <T = unknown>() => ({ results: statement.all(...values) as T[], success: true as const }),
run: async <T = unknown>() => {
statement.run(...values);
return { results: [] as T[], success: true as const };
},
bind: (...vals: unknown[]) => exec(vals),
};
};
return exec([]);
}
}
function fakeEtag(bytes: Uint8Array): string {
let hash = 5381;
for (let i = 0; i < bytes.length; i += 1) {
hash = ((hash << 5) + hash + bytes[i]) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
interface StoredObject {
body: Uint8Array;
httpMetadata: R2HTTPMetadata;
uploaded: number;
}
function toR2Object(key: string, stored: StoredObject): R2Object {
return {
key,
size: stored.body.byteLength,
etag: fakeEtag(stored.body),
version: "fake-version",
httpEtag: `"${fakeEtag(stored.body)}"`,
uploaded: new Date(stored.uploaded),
httpMetadata: stored.httpMetadata,
customMetadata: {},
storageClass: "Standard",
writeHttpMetadata: () => new Headers(),
storageUsage: 0,
} as unknown as R2Object;
}
/**
* 内存版 R2 bucket,用 Map 保存对象字节,覆盖 put/get/head/delete。
*/
export class FakeR2 {
private store = new Map<string, StoredObject>();
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 stored: StoredObject = { body: bytes, httpMetadata: options.httpMetadata ?? {}, uploaded: Date.now() };
this.store.set(key, stored);
return toR2Object(key, stored);
}
async get(key: string): Promise<R2ObjectBody | null> {
const stored = this.store.get(key);
if (!stored) return null;
const head = toR2Object(key, stored);
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(stored.body);
controller.close();
},
});
return {
...head,
body,
bodyUsed: false,
arrayBuffer: async () =>
stored.body.buffer.slice(stored.body.byteOffset, stored.body.byteOffset + stored.body.byteLength),
text: async () => new TextDecoder().decode(stored.body),
json: async <T>() => JSON.parse(new TextDecoder().decode(stored.body)) as T,
blob: async () => new Blob([stored.body as BlobPart]),
} as unknown as R2ObjectBody;
}
async head(key: string): Promise<R2Object | null> {
const stored = this.store.get(key);
return stored ? toR2Object(key, stored) : null;
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
has(key: string): boolean {
return this.store.has(key);
}
}
export function fakeAuth(userId: string | null): FetcherLike {
return {
fetch: vi.fn(async () =>
Response.json(userId ? { user: { id: userId } } : null, { status: userId ? 200 : 401 }),
),
};
}
export interface SharedStores {
db: FakeD1;
r2: FakeR2;
}
export function sharedStores(): SharedStores {
return { db: new FakeD1(), r2: new FakeR2() };
}
export function makeEnv(stores: SharedStores, userId: string | null, maxUploadBytes = "52428800"): Env {
return {
AUTH: fakeAuth(userId),
DB: stores.db as unknown as D1Database,
ATTACHMENTS: stores.r2 as unknown as R2Bucket,
MAX_UPLOAD_BYTES: maxUploadBytes,
};
}

20
tests/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
// 为测试 fake 提供的最小类型声明,避免引入 @types/node 与 workers-types 全局冲突。
declare module "node:sqlite" {
export interface StatementSync {
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
get(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
}
export class DatabaseSync {
constructor(location: string);
exec(sql: string): void;
prepare(sql: string): StatementSync;
close(): void;
}
}
declare module "*.sql?raw" {
const content: string;
export default content;
}