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