Files
flux-srl/src/app/api/assets/route.ts
T
davidherran 148aefc68f feat(team): public Team page + HQ CMS panel
New "Team" section — a LinkedIn-style minimal profile page for the FLUX
team, fully editable from the HQ Command Center.

Data model:
- New TeamMember model (name, role, bio, photoUrl, optional social links:
  email/linkedin/x/website, order, isActive, translationsJson).
- Additive migration 20260602120000_add_team_member (IF NOT EXISTS guards).
- Name stays as written; role + bio are translatable via the AI engine.

HQ panel (/hq-command/dashboard/team):
- Drag-to-reorder (same HTML5 pattern as the Hero panel).
- Inline auto-save for name/role/visibility; expandable editor for photo
  upload, bio, social links, and AI auto-translate to IT/VEC/ES/DE.
- Photo upload reuses /api/assets with a new flat "team" scope -> /public/team/.
- Dashboard tile added.

Public page (/[locale]/team):
- Responsive card grid (framer-motion stagger), portrait + name + role +
  bio + social icons (only the links that exist render).
- Per-member Person JSON-LD + breadcrumb for SEO.
- Localized via getLocalizedData; new TeamPage namespace in all 5 locales.
- NavBar item "Team" inserted before "Spare Parts" (translated 5 locales).
- Added to sitemap.

Infra:
- "team" scope registered in /api/assets (SCOPE_ROOTS + FLAT_SCOPES +
  buildPublicUrl) and revalidate.ts (RevalidateScope + path).
- Nginx serves /team/ from disk; docker-compose mounts public/team in both
  app and nginx (rw + ro).

Verified: production build compiles, all 5 /[locale]/team routes + the HQ
panel render; TypeScript clean; 5 message files valid JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:17:09 -05:00

405 lines
16 KiB
TypeScript

// ─────────────────────────────────────────────────────────────────────────────
// 🔥 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<string, string> = {
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<string, string[]> = {
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 });
}
}