feat(documents): path-style download URLs for rep-facing readability
Storage paths stay UUID-flat per the established CRM pattern (every other content type — brochures, berth PDFs, invoices, reports, templates, expense receipts — uses the same shape). The new catch-all /api/v1/documents/[id]/download/[...slug] route serves files keyed on doc id but rebuilds the slug from current state and 404s on mismatch — a hand-edited or stale link can't render the wrong filename or fold a wrong-folder path into a forwarded URL. URLs in shared links / browser tabs read like 'Deals 2026/Q1/contract.pdf' even though storage keys remain UUIDs. listDocuments + getDocumentById now hydrate a `downloadUrl` field per row (null when no file is attached yet) so UI consumers don't reconstruct paths. Filename is batch-fetched via files-table join to keep the query builder shape unchanged. Tests: 5 integration cases — happy-path stream, wrong-folder slug, wrong-filename slug, orphaned doc (no fileId), cross-port (tenancy isolation). Storage backend swapped to a real FilesystemBackend in a tempdir so the byte-streaming path is exercised end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
98
src/app/api/v1/documents/[id]/download/[...slug]/handlers.ts
Normal file
98
src/app/api/v1/documents/[id]/download/[...slug]/handlers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Route handler for `GET /api/v1/documents/[id]/download/[...slug]`.
|
||||
*
|
||||
* Lives in handlers.ts (not route.ts) so integration tests can call it
|
||||
* directly, bypassing auth/permission middleware (per CLAUDE.md "Route
|
||||
* handler exports" convention).
|
||||
*
|
||||
* Lookup is keyed off the doc id; the slug embeds the current folder path +
|
||||
* filename so a forwarded link reads like `Deals 2026/Q1/contract.pdf` even
|
||||
* though the underlying storage key is a UUID. The slug is rebuilt from
|
||||
* current state and compared with the supplied path — a stale or
|
||||
* hand-edited URL 404s rather than silently serving the wrong file.
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { documents, files } from '@/lib/db/schema/documents';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { listTree, type FolderNode } from '@/lib/services/document-folders.service';
|
||||
import { findFolderPath } from '@/lib/services/documents.service';
|
||||
|
||||
type DownloadParams = { id: string; slug: string[] };
|
||||
|
||||
export const downloadHandler: RouteHandler<DownloadParams> = async (_req, ctx, params) => {
|
||||
try {
|
||||
const docId = params.id;
|
||||
if (!docId) throw new NotFoundError('Document');
|
||||
|
||||
const doc = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
folderId: documents.folderId,
|
||||
fileId: documents.fileId,
|
||||
fileStoragePath: files.storagePath,
|
||||
fileMimeType: files.mimeType,
|
||||
fileFilename: files.filename,
|
||||
})
|
||||
.from(documents)
|
||||
.leftJoin(files, eq(files.id, documents.fileId))
|
||||
.where(and(eq(documents.id, docId), eq(documents.portId, ctx.portId)))
|
||||
.limit(1)
|
||||
.then((r) => r[0]);
|
||||
|
||||
if (!doc) throw new NotFoundError('Document');
|
||||
if (!doc.fileStoragePath || !doc.fileFilename) {
|
||||
throw new NotFoundError('Document file');
|
||||
}
|
||||
|
||||
const tree = await listTree(ctx.portId);
|
||||
const expected = buildExpectedSlug(doc.folderId, doc.fileFilename, tree);
|
||||
const supplied = (params.slug ?? []).map(decodeURIComponent).join('/');
|
||||
if (supplied !== expected) throw new NotFoundError('Document at this path');
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
const nodeStream = await backend.get(doc.fileStoragePath);
|
||||
const webStream = Readable.toWeb(nodeStream as Readable) as ReadableStream;
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': doc.fileMimeType ?? 'application/octet-stream',
|
||||
'content-disposition': `inline; filename="${sanitizeFilenameForHeader(doc.fileFilename)}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
function buildExpectedSlug(
|
||||
folderId: string | null,
|
||||
filename: string,
|
||||
tree: FolderNode[],
|
||||
): string {
|
||||
const segments: string[] = [];
|
||||
if (folderId) {
|
||||
const path = findFolderPath(tree, folderId);
|
||||
for (const node of path) segments.push(node.name);
|
||||
}
|
||||
segments.push(filename);
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control + quote characters from a filename before placing it in the
|
||||
* Content-Disposition header value. The legacy `inline; filename="..."` form
|
||||
* is what the rest of the CRM emits today; this is the same minimal
|
||||
* sanitization the other download endpoints rely on.
|
||||
*/
|
||||
function sanitizeFilenameForHeader(filename: string): string {
|
||||
return filename.replace(/["\\\r\n]/g, '_');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { downloadHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('documents', 'view', downloadHandler));
|
||||
Reference in New Issue
Block a user