This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔥 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";
|
||||
|
||||
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"),
|
||||
};
|
||||
|
||||
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 || !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 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 (!slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: `Invalid scope "${scope}"` }, { 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: `/${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
|
||||
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;
|
||||
|
||||
if (!slug || !file) return NextResponse.json({ error: "Missing slug or file" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { 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 > 50 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File exceeds 50MB 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 safeName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
|
||||
const filePath = path.join(dirPath, safeName);
|
||||
const existed = fs.existsSync(filePath);
|
||||
|
||||
fs.writeFileSync(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
|
||||
const rel = subPath ? `${subPath}/${safeName}` : safeName;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: {
|
||||
name: safeName,
|
||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
||||
path: rel,
|
||||
mediaType: getFileType(safeName),
|
||||
size: getFileSize(file.size),
|
||||
overwritten: existed,
|
||||
}
|
||||
});
|
||||
} 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 (!slug || !folderName) return NextResponse.json({ error: "Missing slug or folderName" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { 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 });
|
||||
|
||||
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
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { scope = "applications", slug, filePath } = body;
|
||||
|
||||
if (!slug || !filePath) return NextResponse.json({ error: "Missing params" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
|
||||
const targetPath = buildSafePath(scope, slug, filePath);
|
||||
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
|
||||
if (!fs.existsSync(targetPath)) return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
if (fs.statSync(targetPath).isDirectory()) return NextResponse.json({ error: "Cannot delete folders via API" }, { status: 400 });
|
||||
|
||||
fs.unlinkSync(targetPath);
|
||||
|
||||
return NextResponse.json({ success: true, deleted: filePath });
|
||||
} catch (error) {
|
||||
console.error("Asset DELETE error:", error);
|
||||
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user