diff --git a/Dockerfile b/Dockerfile index c8a9199..15bb9cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,9 +59,12 @@ ENV NEXT_TELEMETRY_DISABLED=1 # su-exec — drops privileges from root to nextjs in the entrypoint RUN apk add --no-cache vips su-exec -# Security: run as non-root user (entrypoint chowns volumes as root, then drops) +# Security: run as non-root user (entrypoint chowns volumes as root, then drops). +# `--ingroup nodejs` makes nodejs the primary group of nextjs — without this +# Alpine assigns gid 65533 (nogroup) and every file the container writes ends +# up as 1001:65533, which is confusing and surprises sudoers on the host. RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN adduser --system --uid 1001 --ingroup nodejs nextjs # Public assets (logos, brand SVGs, model files) COPY --from=builder /app/public ./public diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 0a70d15..b55b4b1 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -3,18 +3,20 @@ # FLUX container entrypoint. # # Runs as root briefly so we can: -# 1. Make sure all mounted upload dirs are writable by uid 1001 (nextjs). -# The host folders may have been mkdir'd by another user (debian) and -# docker-compose mounts preserve those permissions, which would lock -# the container out. This single chown fixes it on every start. +# 1. Make sure every mounted upload dir AND every file inside is owned by +# uid 1001 / gid 1001 (nextjs:nodejs). Without this, files written +# previously when nextjs had nogroup (gid 65533) stay 1001:65533 and +# sysadmins on the host see a wrong group. # 2. Apply pending Prisma migrations idempotently. # 3. Hand off to the Next.js server, dropping privileges to nextjs. # ───────────────────────────────────────────────────────────────────────────── set -e -# Fix ownership on every mounted public/* folder so the container can write. -# Skips silently if a folder doesn't exist or chown isn't permitted. +# Recursively normalise ownership on every mounted public/* folder. Recursive +# is fine because (a) Prisma and Next.js never read /app/public except from +# fs APIs that don't care about ownership, and (b) the chown is fast on local +# disk even with thousands of files — runs once per container start. for dir in \ /app/public/branding \ /app/public/footage \ @@ -22,7 +24,8 @@ for dir in \ /app/public/cases \ /app/public/news \ /app/public/parts \ - /app/public/operations-inbox; do + /app/public/operations-inbox \ + /app/public/heritage; do if [ -d "$dir" ]; then chown -R 1001:1001 "$dir" 2>/dev/null || true fi diff --git a/src/app/hq-command/dashboard/applications/actions.ts b/src/app/hq-command/dashboard/applications/actions.ts index a6c3378..ce910f3 100644 --- a/src/app/hq-command/dashboard/applications/actions.ts +++ b/src/app/hq-command/dashboard/applications/actions.ts @@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache"; import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ // 🔥 IMPORTAMOS EL TRADUCTOR DE IA import { translateContentForCMS } from "@/lib/aiTranslator"; +import { ensureAssetFolders } from "@/lib/assetFolders"; const generateSlug = (title: string) => { return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, ''); @@ -70,12 +71,16 @@ export async function createApplication(formData: FormData) { translationsJson } }); - + + // Pre-create the asset bucket folders so the editor's first upload + // (videos, renders, gallery, datasheet) lands somewhere that exists. + ensureAssetFolders("applications", slug); + revalidatePath("/hq-command/dashboard/applications"); revalidatePath("/[locale]", "layout"); return { success: true }; - } catch (error) { - return { error: "Failed to create application. Title might already exist." }; + } catch (error) { + return { error: "Failed to create application. Title might already exist." }; } } diff --git a/src/app/hq-command/dashboard/network/actions.ts b/src/app/hq-command/dashboard/network/actions.ts index 454af77..7e027f0 100644 --- a/src/app/hq-command/dashboard/network/actions.ts +++ b/src/app/hq-command/dashboard/network/actions.ts @@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache"; // 🔥 Importamos el motor de traducción robusto import { translateContentForCMS } from "@/lib/aiTranslator"; +import { ensureAssetFolders, titleToSlug } from "@/lib/assetFolders"; // 1. OBTENER TODOS LOS NODOS export async function getNodes() { @@ -40,6 +41,11 @@ export async function createNode(formData: FormData) { } }); + // Pre-create the asset bucket folders so the editor's first upload + // (videos, renders, gallery, datasheet, models) lands somewhere that + // already exists — no more "EACCES because the dir wasn't created". + ensureAssetFolders("cases", titleToSlug(title)); + revalidatePath("/hq-command/dashboard/network"); revalidatePath("/[locale]", "layout"); return { success: true }; @@ -48,6 +54,19 @@ export async function createNode(formData: FormData) { } } +// 2b. REPARAR / GARANTIZAR CARPETAS DE ASSETS PARA UN NODO EXISTENTE. +// Útil para nodos creados antes de que ensureAssetFolders existiera. +export async function ensureNodeAssetFolders(id: string) { + try { + const node = await prisma.globalNode.findUnique({ where: { id }, select: { title: true } }); + if (!node) return { error: "Node not found." }; + ensureAssetFolders("cases", titleToSlug(node.title)); + return { success: true }; + } catch (error: any) { + return { error: error.message || "Failed to ensure asset folders." }; + } +} + // 3. ELIMINAR UN NODO export async function deleteNode(id: string) { try { diff --git a/src/app/hq-command/dashboard/news/actions.ts b/src/app/hq-command/dashboard/news/actions.ts index f5abfff..7f1f34e 100644 --- a/src/app/hq-command/dashboard/news/actions.ts +++ b/src/app/hq-command/dashboard/news/actions.ts @@ -3,7 +3,8 @@ import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; // 🔥 Importamos nuestra nueva IA traductora -import { translateContentForCMS } from "@/lib/aiTranslator"; +import { translateContentForCMS } from "@/lib/aiTranslator"; +import { ensureAssetFolders } from "@/lib/assetFolders"; export async function getNewsArticles() { try { @@ -49,6 +50,10 @@ export async function createNewsArticle(formData: FormData) { } }); + // Pre-create the asset bucket folders so the editor's first upload + // lands somewhere that already exists. + ensureAssetFolders("news", slug); + revalidatePath("/news"); revalidatePath("/[locale]/news", "layout"); return { success: true }; diff --git a/src/lib/assetFolders.ts b/src/lib/assetFolders.ts new file mode 100644 index 0000000..9ae4063 --- /dev/null +++ b/src/lib/assetFolders.ts @@ -0,0 +1,72 @@ +// src/lib/assetFolders.ts +// ───────────────────────────────────────────────────────────────────────────── +// Server-side helpers that ensure every asset bucket exists when an editor +// creates a new entity in HQ Command. Without this, the editor's first +// upload error-paths because the target subfolder doesn't exist yet. +// +// Each scope has a known set of "buckets" — the well-known subfolder layout +// the front-end expects (e.g. cases use videos/, renders/, gallery/). We +// pre-create them all so editors can drop files anywhere without needing +// to think about server-side folder structure. +// ───────────────────────────────────────────────────────────────────────────── + +import "server-only"; +import fs from "fs"; +import path from "path"; + +export type AssetScope = "cases" | "applications" | "news" | "parts"; + +const SCOPE_ROOT: Record = { + cases: path.join(process.cwd(), "public", "cases"), + applications: path.join(process.cwd(), "public", "applications"), + news: path.join(process.cwd(), "public", "news"), + parts: path.join(process.cwd(), "public", "parts"), +}; + +// Buckets per scope — the subfolders the front-end reads from. +// Adding a new bucket here is the only place in the codebase you need to +// touch to introduce a new asset type. +const SCOPE_BUCKETS: Record = { + cases: ["videos", "renders", "gallery", "datasheet", "models"], + applications: ["videos", "renders", "gallery", "datasheet"], + news: ["gallery"], + parts: ["renders", "gallery"], +}; + +/** + * Create the slug folder + every bucket subfolder if they don't exist. + * Idempotent — safe to call on every entity create or edit. + */ +export function ensureAssetFolders(scope: AssetScope, slug: string): void { + if (!slug) return; + + try { + const safeSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + if (!safeSlug) return; + + const root = SCOPE_ROOT[scope]; + const baseDir = path.join(root, safeSlug); + + fs.mkdirSync(baseDir, { recursive: true }); + + for (const bucket of SCOPE_BUCKETS[scope]) { + fs.mkdirSync(path.join(baseDir, bucket), { recursive: true }); + } + } catch (error) { + console.error(`[assetFolders] Failed to ensure ${scope}/${slug}:`, error); + } +} + +/** + * Convert a free-text title into the same slug the front-end uses. + * Mirrors `nodeToSlug` in ApplicationClient.tsx so the folder name matches + * the path the page renders for that node's assets. + */ +export function titleToSlug(title: string): string { + return title + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, ""); +}