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:
@@ -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"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,3 +30,19 @@ export async function verifySigningToken(token: string): Promise<{ documentId: s
|
|||||||
const { payload } = await jwtVerify(token, getSecret());
|
const { payload } = await jwtVerify(token, getSecret());
|
||||||
return payload as { documentId: string; jti: string; exp: number };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user