diff --git a/App/pelagia-portal/app/api/files/dev/[...key]/route.ts b/App/pelagia-portal/app/api/files/dev/[...key]/route.ts new file mode 100644 index 0000000..ee3f10e --- /dev/null +++ b/App/pelagia-portal/app/api/files/dev/[...key]/route.ts @@ -0,0 +1,88 @@ +import { auth } from "@/auth"; +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; + +const UPLOADS_DIR = path.join(process.cwd(), ".dev-uploads"); + +function resolveFilePath(keySegments: string[]): string { + const relative = path.join(...keySegments); + const resolved = path.resolve(UPLOADS_DIR, relative); + // Prevent path traversal outside the uploads directory + if (!resolved.startsWith(path.resolve(UPLOADS_DIR))) { + throw new Error("Invalid path"); + } + return resolved; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ key: string[] }> } +) { + if (process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { key } = await params; + let filePath: string; + try { + filePath = resolveFilePath(key); + } catch { + return NextResponse.json({ error: "Invalid path" }, { status: 400 }); + } + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const buffer = await request.arrayBuffer(); + await fs.writeFile(filePath, Buffer.from(buffer)); + + return new NextResponse(null, { status: 200 }); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ key: string[] }> } +) { + if (process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { key } = await params; + let filePath: string; + try { + filePath = resolveFilePath(key); + } catch { + return NextResponse.json({ error: "Invalid path" }, { status: 400 }); + } + + let fileBuffer: Buffer; + try { + fileBuffer = await fs.readFile(filePath) as Buffer; + } catch { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + + const ext = path.extname(filePath).toLowerCase(); + const contentTypeMap: Record = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + }; + const contentType = contentTypeMap[ext] ?? "application/octet-stream"; + + return new NextResponse(new Uint8Array(fileBuffer), { + headers: { "Content-Type": contentType }, + }); +} diff --git a/App/pelagia-portal/app/api/files/sign/route.ts b/App/pelagia-portal/app/api/files/sign/route.ts new file mode 100644 index 0000000..b67dddd --- /dev/null +++ b/App/pelagia-portal/app/api/files/sign/route.ts @@ -0,0 +1,34 @@ +import { auth } from "@/auth"; +import { generateUploadUrl, buildStorageKey } from "@/lib/storage"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const signSchema = z.object({ + fileName: z.string().min(1), + mimeType: z.string().min(1), + poId: z.string().min(1), + type: z.enum(["po-document", "receipt"]), +}); + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const parsed = signSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const { fileName, mimeType, poId, type } = parsed.data; + const key = buildStorageKey(type, poId, fileName); + + try { + const uploadUrl = await generateUploadUrl(key, mimeType); + return NextResponse.json({ uploadUrl, key }); + } catch { + return NextResponse.json({ error: "Failed to generate upload URL" }, { status: 500 }); + } +} diff --git a/App/pelagia-portal/lib/storage.ts b/App/pelagia-portal/lib/storage.ts new file mode 100644 index 0000000..095f4f8 --- /dev/null +++ b/App/pelagia-portal/lib/storage.ts @@ -0,0 +1,54 @@ +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +const isDev = process.env.NODE_ENV === "development"; + +function getR2Client() { + return new S3Client({ + region: "auto", + endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +const DEV_BASE_URL = process.env.NEXTAUTH_URL ?? "http://localhost:3000"; + +export async function generateUploadUrl( + key: string, + contentType: string, + expiresIn = 300 +): Promise { + if (isDev) { + return `${DEV_BASE_URL}/api/files/dev/${key}`; + } + const command = new PutObjectCommand({ + Bucket: process.env.R2_BUCKET_NAME!, + Key: key, + ContentType: contentType, + }); + return getSignedUrl(getR2Client(), command, { expiresIn }); +} + +export async function generateDownloadUrl( + key: string, + expiresIn = 3600 +): Promise { + if (isDev) { + return `${DEV_BASE_URL}/api/files/dev/${key}`; + } + const command = new GetObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key }); + return getSignedUrl(getR2Client(), command, { expiresIn }); +} + +export function buildStorageKey( + type: "po-document" | "receipt", + poId: string, + fileName: string +): string { + const timestamp = Date.now(); + const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); + return `${type}/${poId}/${timestamp}-${safe}`; +} diff --git a/App/pelagia-portal/lib/upload-files.ts b/App/pelagia-portal/lib/upload-files.ts new file mode 100644 index 0000000..2c64ddf --- /dev/null +++ b/App/pelagia-portal/lib/upload-files.ts @@ -0,0 +1,34 @@ +import { linkDocument } from "@/app/actions/link-document"; + +export async function uploadAndLinkFiles( + poId: string, + files: File[], + type: "po-document" | "receipt" = "po-document" +): Promise<{ error: string } | null> { + for (const file of files) { + const signRes = await fetch("/api/files/sign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileName: file.name, mimeType: file.type || "application/octet-stream", poId, type }), + }); + if (!signRes.ok) return { error: `Failed to get upload URL for ${file.name}` }; + const { uploadUrl, key } = await signRes.json(); + + const putRes = await fetch(uploadUrl, { + method: "PUT", + headers: { "Content-Type": file.type || "application/octet-stream" }, + body: file, + }); + if (!putRes.ok) return { error: `Failed to upload ${file.name}` }; + + const result = await linkDocument({ + poId, + storageKey: key, + fileName: file.name, + fileSize: file.size, + mimeType: file.type || "application/octet-stream", + }); + if ("error" in result) return { error: result.error }; + } + return null; +}