// ───────────────────────────────────────────────────────────────────────────── // 🔥 ASSET MANAGER API — File Browser & Upload for Applications CMS // ───────────────────────────────────────────────────────────────────────────── // Place this file at: /src/app/api/assets/route.ts (or /app/api/assets/route.ts) // // Endpoints: // GET /api/assets?slug=textile-drying&path=images // → Lists all files and folders inside /public/applications/{slug}/{path} // // POST /api/assets (multipart FormData with: slug, path, file) // → Uploads a file to /public/applications/{slug}/{path}/ // // PUT /api/assets (JSON body: { slug, folderName, parentPath }) // → Creates a new folder at /public/applications/{slug}/{parentPath}/{folderName} // // DELETE /api/assets (JSON body: { slug, filePath }) // → Deletes a file at /public/applications/{slug}/{filePath} // // Security: // - All paths are sanitized and clamped to /public/applications/{slug}/ // - No traversal (../) allowed // - Only known media extensions are served // ───────────────────────────────────────────────────────────────────────────── // /src/app/api/assets/route.ts — ASSET MANAGER API v3 // Supports scope=applications (/public/applications/{slug}/) // scope=cases (/public/cases/{slug}/) // scope=news (/public/news/{slug}/) import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { revalidateContent, type RevalidateScope } from "@/lib/revalidate"; import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer"; const SCOPE_ROOTS: Record = { applications: path.join(process.cwd(), "public", "applications"), cases: path.join(process.cwd(), "public", "cases"), news: path.join(process.cwd(), "public", "news"), // 🔥 NUEVO: Scope para el Component Matrix parts: path.join(process.cwd(), "public", "parts"), // 🔥 NUEVO: Hero carousel media (flat folder, slug ignored) footage: path.join(process.cwd(), "public", "footage", "main"), // 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image) branding: path.join(process.cwd(), "public", "branding"), // 🔥 NUEVO: Team member portraits (flat folder, slug ignored) team: path.join(process.cwd(), "public", "team"), }; // Scopes that ignore the `slug` parameter and write directly under their root. const FLAT_SCOPES = new Set(["footage", "branding", "team"]); const MEDIA_TYPES: Record = { image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], video: [".mp4", ".webm", ".mov"], model: [".glb", ".gltf", ".usdz"], document: [".pdf"], }; const ALL_EXTENSIONS = Object.values(MEDIA_TYPES).flat(); function getFileType(filename: string): string { const ext = path.extname(filename).toLowerCase(); for (const [type, exts] of Object.entries(MEDIA_TYPES)) { if (exts.includes(ext)) return type; } return "unknown"; } function getFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function sanitizePath(input: string): string { return input .replace(/\.\./g, "") .replace(/[<>:"|?*]/g, "") .replace(/\/+/g, "/") .replace(/^\/+|\/+$/g, ""); } function buildSafePath(scope: string, slug: string, subPath?: string): string | null { const root = SCOPE_ROOTS[scope]; if (!root) return null; // Flat scopes (footage, branding) ignore slug and operate directly on root. if (FLAT_SCOPES.has(scope)) { if (!subPath || subPath === "" || subPath === "/") return root; const cleaned = sanitizePath(subPath); const fullPath = path.join(root, cleaned); if (!path.resolve(fullPath).startsWith(path.resolve(root))) return null; return fullPath; } if (!slug) return null; const appDir = path.join(root, slug); if (!subPath || subPath === "" || subPath === "/") return appDir; const cleaned = sanitizePath(subPath); const fullPath = path.join(appDir, cleaned); if (!path.resolve(fullPath).startsWith(path.resolve(appDir))) return null; return fullPath; } function buildPublicUrl(scope: string, slug: string, rel: string): string { if (scope === "footage") return `/footage/main/${rel}`; if (scope === "branding") return `/branding/${rel}`; if (scope === "team") return `/team/${rel}`; return `/${scope}/${slug}/${rel}`; } function buildBreadcrumbs(subPath: string) { const parts = subPath ? subPath.split("/").filter(Boolean) : []; const crumbs = [{ name: "Root", path: "" }]; let acc = ""; for (const p of parts) { acc += (acc ? "/" : "") + p; crumbs.push({ name: p, path: acc }); } return crumbs; } // GET — List files and folders export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const scope = searchParams.get("scope") || "applications"; const slug = searchParams.get("slug") || ""; const subPath = searchParams.get("path") || ""; if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: `Invalid scope "${scope}"` }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); const dirPath = buildSafePath(scope, slug, subPath); if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); if (!fs.existsSync(dirPath)) { return NextResponse.json({ success: true, scope, slug, currentPath: subPath || "/", items: [], breadcrumbs: buildBreadcrumbs(subPath), }); } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const items = entries .filter(e => !e.name.startsWith(".") && e.name !== "Thumbs.db") .map(entry => { const entryPath = path.join(dirPath, entry.name); const rel = subPath ? `${subPath}/${entry.name}` : entry.name; if (entry.isDirectory()) { let childCount = 0; try { childCount = fs.readdirSync(entryPath).filter(f => !f.startsWith(".")).length; } catch {} return { name: entry.name, type: "folder" as const, path: rel, childCount }; } const stats = fs.statSync(entryPath); return { name: entry.name, type: "file" as const, mediaType: getFileType(entry.name), extension: path.extname(entry.name).toLowerCase(), path: rel, publicUrl: buildPublicUrl(scope, slug, rel), size: getFileSize(stats.size), sizeBytes: stats.size, modifiedAt: stats.mtime.toISOString(), }; }) .sort((a, b) => { if (a.type === "folder" && b.type !== "folder") return -1; if (a.type !== "folder" && b.type === "folder") return 1; return a.name.localeCompare(b.name); }); return NextResponse.json({ success: true, scope, slug, currentPath: subPath || "/", items, breadcrumbs: buildBreadcrumbs(subPath), }); } catch (error) { console.error("Asset GET error:", error); return NextResponse.json({ error: "Failed to list directory" }, { status: 500 }); } } // POST — Upload a file // // Optional query / form param `optimize=true` (or `optimize=1`) routes the // upload through the sharp pipeline: auto-orient, cap at 2560px, encode to // WebP, and save under a content-hashed filename. The same image always // produces the same hash, so re-uploading is idempotent. Different content // produces a different hash, so the browser cache invalidates instantly // without any header trickery. export async function POST(request: NextRequest) { try { const formData = await request.formData(); const scope = (formData.get("scope") as string) || "applications"; const slug = (formData.get("slug") as string) || ""; const subPath = formData.get("path") as string || ""; const file = formData.get("file") as File; // Two ways to opt into optimization: ?optimize=1 query or form field "optimize". const optFlag = formData.get("optimize") ?? new URL(request.url).searchParams.get("optimize"); const shouldOptimize = optFlag === "true" || optFlag === "1" || optFlag === "on"; if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); const ext = path.extname(file.name).toLowerCase(); if (!ALL_EXTENSIONS.includes(ext)) { return NextResponse.json({ error: `Type "${ext}" not allowed. Accepted: ${ALL_EXTENSIONS.join(", ")}` }, { status: 400 }); } if (file.size > 500 * 1024 * 1024) { return NextResponse.json({ error: "File exceeds 500MB limit" }, { status: 400 }); } const dirPath = buildSafePath(scope, slug, subPath); if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); fs.mkdirSync(dirPath, { recursive: true }); const inputBuffer: Buffer = Buffer.from(await file.arrayBuffer()); // Optimization branch: replace filename with a content-hashed WebP one. let saveName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, ""); let outputBuffer: Buffer | Uint8Array = inputBuffer; let optimizedMeta: { width: number | null; height: number | null; bytes: number } | null = null; if (shouldOptimize && isOptimizable(file.name)) { const opt = await optimizeImage(inputBuffer, file.name); saveName = opt.filename; outputBuffer = opt.buffer; optimizedMeta = { width: opt.width, height: opt.height, bytes: opt.bytes }; } const filePath = path.join(dirPath, saveName); const existed = fs.existsSync(filePath); fs.writeFileSync(filePath, outputBuffer); const rel = subPath ? `${subPath}/${saveName}` : saveName; // 🔥 Invalida caché para que la imagen aparezca sin recompilar revalidateContent({ scope: scope as RevalidateScope, slug }); return NextResponse.json({ success: true, file: { name: saveName, publicUrl: buildPublicUrl(scope, slug, rel), path: rel, mediaType: getFileType(saveName), size: getFileSize(outputBuffer.byteLength), overwritten: existed, optimized: optimizedMeta !== null, ...(optimizedMeta ? { width: optimizedMeta.width, height: optimizedMeta.height, originalBytes: file.size, savedBytes: file.size - optimizedMeta.bytes, } : {}), } }); } catch (error) { console.error("Asset POST error:", error); return NextResponse.json({ error: "Upload failed" }, { status: 500 }); } } // PUT — Create a new folder export async function PUT(request: NextRequest) { try { const body = await request.json(); const { scope = "applications", slug = "", folderName, parentPath = "" } = body; if (!folderName) return NextResponse.json({ error: "Missing folderName" }, { status: 400 }); if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); const safe = folderName.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, ""); if (!safe) return NextResponse.json({ error: "Invalid folder name" }, { status: 400 }); const targetPath = buildSafePath(scope, slug, parentPath ? `${parentPath}/${safe}` : safe); if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); if (fs.existsSync(targetPath)) return NextResponse.json({ error: "Folder already exists" }, { status: 409 }); fs.mkdirSync(targetPath, { recursive: true }); revalidateContent({ scope: scope as RevalidateScope, slug }); return NextResponse.json({ success: true, folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe } }); } catch (error) { console.error("Asset PUT error:", error); return NextResponse.json({ error: "Failed to create folder" }, { status: 500 }); } } // DELETE — Remove a file (or many in one call). // Body shape: // { scope, slug, filePath: "..." } single delete // { scope, slug, filePaths: ["a", "b", ...] } bulk delete export async function DELETE(request: NextRequest) { try { const body = await request.json(); const { scope = "applications", slug = "", filePath, filePaths } = body; if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); const targets: string[] = Array.isArray(filePaths) ? filePaths : (filePath ? [filePath] : []); if (targets.length === 0) return NextResponse.json({ error: "Missing filePath(s)" }, { status: 400 }); const deleted: string[] = []; const failed: { path: string; reason: string }[] = []; for (const rel of targets) { const targetPath = buildSafePath(scope, slug, rel); if (!targetPath) { failed.push({ path: rel, reason: "Invalid path" }); continue; } if (!fs.existsSync(targetPath)) { failed.push({ path: rel, reason: "Not found" }); continue; } if (fs.statSync(targetPath).isDirectory()) { failed.push({ path: rel, reason: "Refusing to delete folder via API" }); continue; } try { fs.unlinkSync(targetPath); deleted.push(rel); } catch (err: any) { failed.push({ path: rel, reason: err.message || "unlink failed" }); } } revalidateContent({ scope: scope as RevalidateScope, slug }); return NextResponse.json({ success: deleted.length > 0, deleted, failed, }); } catch (error) { console.error("Asset DELETE error:", error); return NextResponse.json({ error: "Delete failed" }, { status: 500 }); } } // PATCH — Move or rename a file. // Body shape: { scope, slug, fromPath, toPath } // - rename in same bucket: fromPath="videos/a.mp4", toPath="videos/intro.mp4" // - move between buckets: fromPath="videos/a.mp4", toPath="renders/a.mp4" // - move to root: fromPath="videos/a.mp4", toPath="a.mp4" // Cannot overwrite an existing file (returns 409). Sanitises target name // the same way upload does, and creates intermediate folders if needed. export async function PATCH(request: NextRequest) { try { const body = await request.json(); const { scope = "applications", slug = "", fromPath, toPath } = body; if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); if (!fromPath || !toPath) return NextResponse.json({ error: "Missing fromPath or toPath" }, { status: 400 }); const sourceAbs = buildSafePath(scope, slug, fromPath); const destAbs = buildSafePath(scope, slug, toPath); if (!sourceAbs || !destAbs) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); if (!fs.existsSync(sourceAbs)) return NextResponse.json({ error: "Source file not found" }, { status: 404 }); if (fs.statSync(sourceAbs).isDirectory()) return NextResponse.json({ error: "Source is a folder" }, { status: 400 }); if (fs.existsSync(destAbs)) return NextResponse.json({ error: "A file with that name already exists at the destination" }, { status: 409 }); fs.mkdirSync(path.dirname(destAbs), { recursive: true }); fs.renameSync(sourceAbs, destAbs); revalidateContent({ scope: scope as RevalidateScope, slug }); return NextResponse.json({ success: true, from: fromPath, to: toPath, publicUrl: buildPublicUrl(scope, slug, toPath), }); } catch (error: any) { console.error("Asset PATCH error:", error); return NextResponse.json({ error: error.message || "Move/rename failed" }, { status: 500 }); } }