feat(storage): file storage with local dev server and Cloudflare R2 for production
Sign API returns presigned upload URL + storage key. Dev: files served through auth-gated /api/files/dev route with path-traversal protection. Prod: R2 presigned URLs for upload and time-limited download.
This commit is contained in:
parent
92b80dd278
commit
62c5a52fc0
4 changed files with 210 additions and 0 deletions
88
App/pelagia-portal/app/api/files/dev/[...key]/route.ts
Normal file
88
App/pelagia-portal/app/api/files/dev/[...key]/route.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
".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 },
|
||||
});
|
||||
}
|
||||
34
App/pelagia-portal/app/api/files/sign/route.ts
Normal file
34
App/pelagia-portal/app/api/files/sign/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
54
App/pelagia-portal/lib/storage.ts
Normal file
54
App/pelagia-portal/lib/storage.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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}`;
|
||||
}
|
||||
34
App/pelagia-portal/lib/upload-files.ts
Normal file
34
App/pelagia-portal/lib/upload-files.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue