feat(04-02): create POST /api/documents and GET /api/documents/[id]/file routes
- POST handles both form-data (custom upload) and JSON (template copy) paths
- Copies seed PDF or writes uploaded file to uploads/clients/{clientId}/{uuid}.pdf
- Path traversal guard on destPath before writing
- GET streams PDF bytes with Content-Type: application/pdf
- Path traversal guard on filePath before reading
- Both routes return 401 for unauthenticated requests
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { documents } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const UPLOADS_BASE = path.join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const doc = await db.query.documents.findFirst({
|
||||||
|
where: eq(documents.id, id),
|
||||||
|
});
|
||||||
|
if (!doc || !doc.filePath) return new Response('Not found', { status: 404 });
|
||||||
|
|
||||||
|
const filePath = path.join(UPLOADS_BASE, doc.filePath);
|
||||||
|
|
||||||
|
// Path traversal guard — critical security check
|
||||||
|
if (!filePath.startsWith(UPLOADS_BASE)) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: { 'Content-Type': 'application/pdf' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
teressa-copeland-homes/src/app/api/documents/route.ts
Normal file
77
teressa-copeland-homes/src/app/api/documents/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { documents, formTemplates } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const SEEDS_DIR = path.join(process.cwd(), 'seeds', 'forms');
|
||||||
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const contentType = req.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
|
let clientId: string;
|
||||||
|
let name: string;
|
||||||
|
let formTemplateId: string | undefined;
|
||||||
|
let fileBuffer: ArrayBuffer | undefined;
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
// File picker path — custom PDF upload
|
||||||
|
const formData = await req.formData();
|
||||||
|
clientId = formData.get('clientId') as string;
|
||||||
|
name = formData.get('name') as string;
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
fileBuffer = await file.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
// Library path — copy from seed template
|
||||||
|
const body = await req.json();
|
||||||
|
clientId = body.clientId;
|
||||||
|
name = body.name;
|
||||||
|
formTemplateId = body.formTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId || !name) {
|
||||||
|
return Response.json({ error: 'clientId and name are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = crypto.randomUUID();
|
||||||
|
const destDir = path.join(UPLOADS_DIR, 'clients', clientId);
|
||||||
|
const destPath = path.join(destDir, `${docId}.pdf`);
|
||||||
|
const relPath = `clients/${clientId}/${docId}.pdf`;
|
||||||
|
|
||||||
|
// Path traversal guard
|
||||||
|
if (!destPath.startsWith(UPLOADS_DIR)) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(destDir, { recursive: true });
|
||||||
|
|
||||||
|
if (fileBuffer !== undefined) {
|
||||||
|
// Custom upload
|
||||||
|
await writeFile(destPath, Buffer.from(fileBuffer));
|
||||||
|
} else {
|
||||||
|
// Template copy
|
||||||
|
const template = await db.query.formTemplates.findFirst({
|
||||||
|
where: eq(formTemplates.id, formTemplateId!),
|
||||||
|
});
|
||||||
|
if (!template) return Response.json({ error: 'Template not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const srcPath = path.join(SEEDS_DIR, template.filename);
|
||||||
|
await copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [doc] = await db.insert(documents).values({
|
||||||
|
id: docId,
|
||||||
|
clientId,
|
||||||
|
name,
|
||||||
|
formTemplateId: formTemplateId ?? null,
|
||||||
|
filePath: relPath,
|
||||||
|
status: 'Draft',
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return Response.json(doc, { status: 201 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user