18d5ed87c8
Deploy to VPS / deploy (push) Has been cancelled
Acts on the verified findings from the 2026-06 audit (docs/AUDIT_2026-06_ VERIFIED.md). The audit's #1 "middleware never runs" was a false positive (verified in prod: /hq-command redirects to login). These are the genuine gaps: - SEC-04 (HIGH): /api/assets (GET/POST/PUT/DELETE/PATCH) and /api/branding/favicon (POST) had NO auth. The middleware matcher excludes /api, so they were world-reachable — anyone could list/upload/rename/ delete CMS files or regenerate the favicon. Added a new getAdminSession() helper (src/lib/session.ts) and a requireAdmin() guard on every handler. - DB-01 (HIGH): the ClientUser table (B2B client portal) was defined in the schema but NEVER created by any migration, and OperationsSignal.clientId + its FK were missing too. B2B register/login failed at runtime; the dashboard silently showed 0 clients. New additive migration 20260609120000_add_client_user creates the table, the unique email index, the clientId column (IF NOT EXISTS), and the FK (duplicate-object guarded). - SEC-05 (MED-HIGH): operations.ts generateRichEmailHtml() interpolated item.title/sku/quantity, clientName/Company/Email/Phone and the free-text message straight into HTML — stored XSS into the team's internal inbox. Now escaped via escapeHtml/escapeAttr/safeMailto; file links validated to internal paths only. - SEC-01 (MED): removed the hardcoded SESSION_SECRET fallback in src/proxy.ts; it now validates lazily and throws if the secret is missing (mirrors session.ts), so a runtime env failure can't fall back to a public key. Verified: next build compiles with SESSION_SECRET unset (Docker parity), TypeScript clean, prisma schema valid, golden tests 17/17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
419 lines
17 KiB
TypeScript
419 lines
17 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";
|
|
import { getAdminSession } from "@/lib/session";
|
|
|
|
// All asset operations are admin-only. The middleware (src/proxy.ts) does NOT
|
|
// cover /api, so each handler must verify the admin session itself.
|
|
async function requireAdmin(): Promise<NextResponse | null> {
|
|
const session = await getAdminSession();
|
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
return null;
|
|
}
|
|
|
|
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) {
|
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
|
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) {
|
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
|
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) {
|
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
|
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) {
|
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
|
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) {
|
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
|
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 });
|
|
}
|
|
} |