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:
Hardik 2026-05-05 23:24:41 +05:30
parent 92b80dd278
commit 62c5a52fc0
4 changed files with 210 additions and 0 deletions

View 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 },
});
}

View 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 });
}
}

View 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}`;
}

View 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;
}