From 6d0f58d5591c6dc74076a2445a5df57501587172 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 10:33:10 -0600 Subject: [PATCH] fix: storage S3 stream conversion, API client FormData support, and attachment API Fix S3 provider to use async generator for web stream conversion instead of Readable.fromWeb, add postForm helper and attachment API methods to the UI client, and add local disk storage provider tests. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/storage-local-provider.test.ts | 78 +++++++++++++++++++ server/src/storage/s3-provider.ts | 10 ++- ui/src/api/client.ts | 10 ++- ui/src/api/issues.ts | 17 +++- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 server/src/__tests__/storage-local-provider.test.ts diff --git a/server/src/__tests__/storage-local-provider.test.ts b/server/src/__tests__/storage-local-provider.test.ts new file mode 100644 index 00000000..9e9a55d1 --- /dev/null +++ b/server/src/__tests__/storage-local-provider.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js"; +import { createStorageService } from "../storage/service.js"; + +async function readStreamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +describe("local disk storage provider", () => { + const tempRoots: string[] = []; + + afterEach(async () => { + await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); + tempRoots.length = 0; + }); + + it("round-trips bytes through storage service", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); + tempRoots.push(root); + + const service = createStorageService(createLocalDiskStorageProvider(root)); + const content = Buffer.from("hello image bytes", "utf8"); + const stored = await service.putFile({ + companyId: "company-1", + namespace: "issues/issue-1", + originalFilename: "demo.png", + contentType: "image/png", + body: content, + }); + + const fetched = await service.getObject("company-1", stored.objectKey); + const fetchedBody = await readStreamToBuffer(fetched.stream); + + expect(fetchedBody.toString("utf8")).toBe("hello image bytes"); + expect(stored.sha256).toHaveLength(64); + }); + + it("blocks cross-company object access", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); + tempRoots.push(root); + + const service = createStorageService(createLocalDiskStorageProvider(root)); + const stored = await service.putFile({ + companyId: "company-a", + namespace: "issues/issue-1", + originalFilename: "demo.png", + contentType: "image/png", + body: Buffer.from("hello", "utf8"), + }); + + await expect(service.getObject("company-b", stored.objectKey)).rejects.toMatchObject({ status: 403 }); + }); + + it("delete is idempotent", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); + tempRoots.push(root); + + const service = createStorageService(createLocalDiskStorageProvider(root)); + const stored = await service.putFile({ + companyId: "company-1", + namespace: "issues/issue-1", + originalFilename: "demo.png", + contentType: "image/png", + body: Buffer.from("hello", "utf8"), + }); + + await service.deleteObject("company-1", stored.objectKey); + await service.deleteObject("company-1", stored.objectKey); + await expect(service.getObject("company-1", stored.objectKey)).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/server/src/storage/s3-provider.ts b/server/src/storage/s3-provider.ts index 525953d4..3549289d 100644 --- a/server/src/storage/s3-provider.ts +++ b/server/src/storage/s3-provider.ts @@ -40,7 +40,15 @@ async function toReadableStream(body: unknown): Promise { }; if (typeof candidate.transformToWebStream === "function") { - return Readable.fromWeb(candidate.transformToWebStream() as globalThis.ReadableStream); + const webStream = candidate.transformToWebStream(); + const reader = webStream.getReader(); + return Readable.from((async function* () { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) yield value; + } + })()); } if (typeof candidate.arrayBuffer === "function") { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 7fc4942e..2841e7e5 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,8 +1,14 @@ const BASE = "/api"; async function request(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers ?? undefined); + const body = init?.body; + if (!(body instanceof FormData) && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const res = await fetch(`${BASE}${path}`, { - headers: { "Content-Type": "application/json" }, + headers, ...init, }); if (!res.ok) { @@ -16,6 +22,8 @@ export const api = { get: (path: string) => request(path), post: (path: string, body: unknown) => request(path, { method: "POST", body: JSON.stringify(body) }), + postForm: (path: string, body: FormData) => + request(path, { method: "POST", body }), patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: "DELETE" }), diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 7030d6d5..e0439aec 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,4 +1,4 @@ -import type { Approval, Issue, IssueComment } from "@paperclip/shared"; +import type { Approval, Issue, IssueAttachment, IssueComment } from "@paperclip/shared"; import { api } from "./client"; export const issuesApi = { @@ -17,6 +17,21 @@ export const issuesApi = { listComments: (id: string) => api.get(`/issues/${id}/comments`), addComment: (id: string, body: string, reopen?: boolean) => api.post(`/issues/${id}/comments`, reopen === undefined ? { body } : { body, reopen }), + listAttachments: (id: string) => api.get(`/issues/${id}/attachments`), + uploadAttachment: ( + companyId: string, + issueId: string, + file: File, + issueCommentId?: string | null, + ) => { + const form = new FormData(); + form.append("file", file); + if (issueCommentId) { + form.append("issueCommentId", issueCommentId); + } + return api.postForm(`/companies/${companyId}/issues/${issueId}/attachments`, form); + }, + deleteAttachment: (id: string) => api.delete<{ ok: true }>(`/attachments/${id}`), listApprovals: (id: string) => api.get(`/issues/${id}/approvals`), linkApproval: (id: string, approvalId: string) => api.post(`/issues/${id}/approvals`, { approvalId }),