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)
This commit is contained in:
Chandler Copeland
2026-03-20 11:41:18 -06:00
parent 5c1ea3568e
commit a276da0da1
2 changed files with 100 additions and 0 deletions

View File

@@ -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"`,
},
});
}

View File

@@ -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<string> {
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 };
}