From a276da0da1a8c0f55a67d50d120e087f1c618d93 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 20 Mar 2026 11:41:18 -0600 Subject: [PATCH] feat(06-05): download token utilities + download API route - Add createDownloadToken and verifyDownloadToken to token.ts (15-min TTL, purpose:'download' claim) - Create GET /api/sign/[token]/download route: validates dt query param JWT, streams signedFilePath as PDF - Path traversal guard: signedFilePath must start with UPLOADS_DIR - Auto-fix: Buffer cast to Uint8Array for Response BodyInit compatibility (Next.js 16 / TypeScript strict) --- .../app/api/sign/[token]/download/route.ts | 84 +++++++++++++++++++ .../src/lib/signing/token.ts | 16 ++++ 2 files changed, 100 insertions(+) create mode 100644 teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts diff --git a/teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts b/teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts new file mode 100644 index 0000000..cba3111 --- /dev/null +++ b/teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts @@ -0,0 +1,84 @@ +import { NextRequest } from 'next/server'; +import { verifyDownloadToken } from '@/lib/signing/token'; +import { db } from '@/lib/db'; +import { documents } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); + +// GET /api/sign/[token]/download?dt=[downloadToken] +// Authorization: short-lived download JWT (dt query param), not the signing token +// No agent session required — download token is the authorization mechanism +export async function GET(req: NextRequest) { + // 1. Get download token from query params + const url = new URL(req.url); + const dt = url.searchParams.get('dt'); + + if (!dt) { + return new Response(JSON.stringify({ error: 'Missing download token' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 2. Verify download token + let documentId: string; + try { + const verified = await verifyDownloadToken(dt); + documentId = verified.documentId; + } catch { + return new Response(JSON.stringify({ error: 'Download link expired or invalid' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 3. Fetch document + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, documentId), + columns: { + id: true, + name: true, + signedFilePath: true, + }, + }); + + // 4. Guard: signedFilePath must be set + if (!doc || !doc.signedFilePath) { + return new Response(JSON.stringify({ error: 'Signed PDF not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 5. Path traversal guard — signedFilePath must be within uploads/ + const absPath = path.join(UPLOADS_DIR, doc.signedFilePath); + if (!absPath.startsWith(UPLOADS_DIR)) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 6. Read the signed PDF file + let fileBuffer: Buffer; + try { + fileBuffer = await readFile(absPath); + } catch { + return new Response(JSON.stringify({ error: 'Signed PDF not found on disk' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // 7. Stream as PDF download + const safeName = doc.name.replace(/[^a-zA-Z0-9-_ ]/g, ''); + return new Response(new Uint8Array(fileBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${safeName}_signed.pdf"`, + }, + }); +} diff --git a/teressa-copeland-homes/src/lib/signing/token.ts b/teressa-copeland-homes/src/lib/signing/token.ts index f4a323e..35cb104 100644 --- a/teressa-copeland-homes/src/lib/signing/token.ts +++ b/teressa-copeland-homes/src/lib/signing/token.ts @@ -30,3 +30,19 @@ export async function verifySigningToken(token: string): Promise<{ documentId: s const { payload } = await jwtVerify(token, getSecret()); return payload as { documentId: string; jti: string; exp: number }; } + +// Short-lived download token for client copy download (15-min TTL, no DB record) +// purpose: 'download' claim distinguishes from signing tokens +export async function createDownloadToken(documentId: string): Promise { + return await new SignJWT({ documentId, purpose: 'download' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('15m') + .sign(getSecret()); +} + +export async function verifyDownloadToken(token: string): Promise<{ documentId: string }> { + const { payload } = await jwtVerify(token, getSecret()); + if (payload['purpose'] !== 'download') throw new Error('Not a download token'); + return { documentId: payload['documentId'] as string }; +}