Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
641 changes: 638 additions & 3 deletions backend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@google-cloud/storage": "^7.19.0",
"@google/genai": "^1.50.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.49.4",
Expand Down
66 changes: 66 additions & 0 deletions backend/src/core/storagePaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export function normalizeDownloadFilename(name: string): string {
const trimmed = name.trim();
const base = trimmed || "download";
return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_");
}

export function sanitizeDispositionFilename(name: string): string {
return normalizeDownloadFilename(name)
.replace(/["\\]/g, "_")
.replace(/[^\x20-\x7E]/g, "_");
}

export function encodeRFC5987(str: string): string {
return encodeURIComponent(str).replace(
/['()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}

export function buildContentDisposition(
kind: "inline" | "attachment",
filename: string,
): string {
const normalized = normalizeDownloadFilename(filename);
return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`;
}

export function storageKey(
userId: string,
docId: string,
filename: string,
): string {
return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`;
}

export function pdfStorageKey(
userId: string,
docId: string,
stem: string,
): string {
return `documents/${userId}/${docId}/${stem}.pdf`;
}

export function generatedDocKey(
userId: string,
docId: string,
filename: string,
): string {
return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`;
}

export function versionStorageKey(
userId: string,
docId: string,
versionSlug: string,
filename: string,
): string {
return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`;
}

function storageExtension(filename: string, fallback: string): string {
const lastDot = filename.lastIndexOf(".");
if (lastDot < 0) return fallback;
const ext = filename.slice(lastDot).toLowerCase();
return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback;
}
149 changes: 149 additions & 0 deletions backend/src/lib/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";

import {
normalizeDownloadFilename,
sanitizeDispositionFilename,
encodeRFC5987,
buildContentDisposition,
storageKey,
pdfStorageKey,
generatedDocKey,
versionStorageKey,
} from "../storage";

describe("normalizeDownloadFilename", () => {
it("trims surrounding whitespace", () => {
expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf");
});

it("falls back to 'download' for empty string", () => {
expect(normalizeDownloadFilename("")).toBe("download");
expect(normalizeDownloadFilename(" ")).toBe("download");
});

it("replaces control characters with underscore", () => {
expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf");
expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf");
});

it("replaces forward and backward slashes with underscore", () => {
expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf");
expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf");
});

it("preserves normal filenames unchanged", () => {
expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe(
"Contract v2 (Final).pdf",
);
});
});

describe("sanitizeDispositionFilename", () => {
it("strips double-quote characters", () => {
expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf");
});

it("strips backslash characters", () => {
expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf");
});

it("strips non-ASCII characters", () => {
expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf");
});

it("still applies normalizeDownloadFilename rules first", () => {
expect(sanitizeDispositionFilename(" ")).toBe("download");
});
});

describe("encodeRFC5987", () => {
it("encodes spaces as %20", () => {
expect(encodeRFC5987("hello world")).toBe("hello%20world");
});

it("encodes single-quote as %27", () => {
expect(encodeRFC5987("it's")).toContain("%27");
});

it("encodes ( and ) as %28 and %29", () => {
const result = encodeRFC5987("a(b)c");
expect(result).toContain("%28");
expect(result).toContain("%29");
});

it("encodes * as %2A", () => {
expect(encodeRFC5987("a*b")).toContain("%2A");
});

it("leaves safe ASCII characters unencoded", () => {
expect(encodeRFC5987("file.pdf")).toBe("file.pdf");
});
});

describe("buildContentDisposition", () => {
it("produces an attachment header with ASCII filename", () => {
const header = buildContentDisposition("attachment", "contract.pdf");
expect(header).toMatch(/^attachment;/);
expect(header).toContain('filename="contract.pdf"');
expect(header).toContain("filename*=UTF-8''contract.pdf");
});

it("produces an inline header", () => {
const header = buildContentDisposition("inline", "preview.pdf");
expect(header).toMatch(/^inline;/);
});

it("encodes unicode filename in filename* param", () => {
const header = buildContentDisposition("attachment", "Ünïcödé.pdf");
expect(header).toContain("filename*=UTF-8''");
expect(header).not.toContain("Ü");
});
});

describe("storageKey", () => {
it("includes userId, docId, and correct extension", () => {
const key = storageKey("user1", "doc1", "contract.pdf");
expect(key).toBe("documents/user1/doc1/source.pdf");
});

it("falls back to .bin for extensions longer than 16 chars", () => {
const key = storageKey("user1", "doc1", "file.toolongextension1234");
expect(key).toBe("documents/user1/doc1/source.bin");
});

it("falls back to .bin when no extension", () => {
const key = storageKey("user1", "doc1", "noextension");
expect(key).toBe("documents/user1/doc1/source.bin");
});
});

describe("pdfStorageKey", () => {
it("places PDF in the correct path with stem", () => {
const key = pdfStorageKey("user1", "doc1", "contract");
expect(key).toBe("documents/user1/doc1/contract.pdf");
});
});

describe("generatedDocKey", () => {
it("uses generated/ prefix and .docx extension for docx files", () => {
const key = generatedDocKey("user1", "doc1", "output.docx");
expect(key).toBe("generated/user1/doc1/generated.docx");
});

it("falls back to .docx for extensions longer than 16 chars", () => {
const key = generatedDocKey("user1", "doc1", "output.toolongextension1234");
expect(key).toBe("generated/user1/doc1/generated.docx");
});
});

describe("versionStorageKey", () => {
it("includes userId, docId, versionSlug, and extension", () => {
const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf");
expect(key).toBe("documents/user1/doc1/versions/v2.pdf");
});

it("falls back to .bin for unknown extensions", () => {
const key = versionStorageKey("user1", "doc1", "v2", "file");
expect(key).toBe("documents/user1/doc1/versions/v2.bin");
});
});
Loading