/** * Filesystem-backend download proxy. * * The `FilesystemBackend.presignDownload(...)` returns a CRM-internal URL of * the form `/api/storage/`. This route verifies the HMAC, * checks expiry, enforces single-use via a short Redis cache, then streams * the file out with explicit `Content-Type` + `Content-Disposition`. * * §14.9a mitigations exercised here: * - HMAC verification (timingSafeEqual via filesystem.verifyProxyToken) * - expiry check (token includes `e` epoch seconds) * - single-use replay protection via short Redis SET-NX * - Node runtime only (no edge); explicit headers so Next.js doesn't try to * process the bytes (no image optimization, no streaming transforms) */ import { createReadStream } from 'node:fs'; import * as fs from 'node:fs/promises'; import { Readable } from 'node:stream'; import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { redis } from '@/lib/redis'; import { FilesystemBackend, getStorageBackend } from '@/lib/storage'; import { verifyProxyToken } from '@/lib/storage/filesystem'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; // Replay-protection TTL must outlive the token itself, otherwise the // dedup key expires and the same token can be redeemed twice. We pin it // to the token's own expiry (clamped to a 25-day ceiling so a forged // far-future token can't pollute Redis indefinitely). Send-out emails // mint 24-hour tokens so the typical TTL is 24h + a small buffer. const REPLAY_TTL_FLOOR_SECONDS = 60; // never below 60s (post-expiry tail). const REPLAY_TTL_CEILING_SECONDS = 25 * 24 * 60 * 60; // 25 days. export async function GET( _req: NextRequest, ctx: { params: Promise<{ token: string }> }, ): Promise { const { token } = await ctx.params; const backend = await getStorageBackend(); if (!(backend instanceof FilesystemBackend)) { return NextResponse.json( { error: 'Storage proxy is only available in filesystem mode' }, { status: 404 }, ); } const result = verifyProxyToken(token, backend.getHmacSecret()); if (!result.ok) { logger.warn({ reason: result.reason }, 'Storage proxy token rejected'); return NextResponse.json({ error: 'Invalid or expired token' }, { status: 403 }); } const { payload } = result; // Single-use enforcement. SET NX with a TTL pinned to the token's own // expiry so the dedup window never closes before the token does. Using // the body half of the token as the dedup key (signature included // would also work but body is enough — a reused token has the same body). const replayKey = `storage:proxy:seen:${token.split('.')[0]}`; const remainingSeconds = Math.max( REPLAY_TTL_FLOOR_SECONDS, Math.min(REPLAY_TTL_CEILING_SECONDS, payload.e - Math.floor(Date.now() / 1000) + 60), ); const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX'); if (setOk !== 'OK') { logger.warn({ key: payload.k }, 'Storage proxy token replay rejected'); return NextResponse.json({ error: 'Token already used' }, { status: 403 }); } let absolutePath: string; try { absolutePath = backend.resolveKeyForProxy(payload.k); } catch (err) { logger.warn({ err, key: payload.k }, 'Storage proxy key resolution failed'); return NextResponse.json({ error: 'Invalid key' }, { status: 400 }); } let size: number; try { const stat = await fs.stat(absolutePath); if (!stat.isFile()) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } size = stat.size; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } throw err; } // Convert the Node Readable into a Web ReadableStream for NextResponse. const nodeStream = createReadStream(absolutePath); const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream; const headers = new Headers(); headers.set('Content-Type', payload.c ?? 'application/octet-stream'); headers.set('Content-Length', String(size)); if (payload.f) { // RFC 5987 — quote the filename and provide a UTF-8 fallback. const safe = payload.f.replace(/"/g, ''); headers.set( 'Content-Disposition', `attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(payload.f)}`, ); } headers.set('Cache-Control', 'private, no-store'); headers.set('X-Content-Type-Options', 'nosniff'); return new NextResponse(webStream, { status: 200, headers }); }