fix: nextjs primary group + auto-create asset folders on entity create
Deploy to VPS / deploy (push) Has been cancelled
Deploy to VPS / deploy (push) Has been cancelled
THREE INTERLOCKING FIXES so editors stop hitting permission walls.
1) DOCKERFILE — gid 65533 (nogroup) on uploaded files
The container was creating files as 1001:65533 because Alpine's
`adduser --system --uid 1001 nextjs` doesn't set a primary group.
Files written through /api/assets ended up with `nogroup` ownership,
which surprised host sysadmins and made `chown -R 1001:1001` revert
on each fresh container start.
Fix: `adduser --system --uid 1001 --ingroup nodejs nextjs`. Now
every file written by the container is 1001:1001 (nextjs:nodejs),
matching the host conventions and the existing chown automation.
2) ENTRYPOINT — recursively normalise existing files
The recursive chown in scripts/docker-entrypoint.sh now sweeps every
subfolder of /app/public/branding|footage|applications|cases|news|
parts|operations-inbox|heritage on each container start, fixing any
files that previously slipped through with the wrong group. Single
fast pass, idempotent. Adds /app/public/heritage to the list (was
missing).
3) AUTO-CREATE ASSET BUCKETS on entity create
The big editor UX win: when an admin creates a Case (GlobalNode), an
Application or a News article in HQ Command, the server now also
mkdir's the well-known asset subfolders for that entity. So after
creating "Acme Industries" as a case, the editor immediately gets
/public/cases/acme-industries/{videos,renders,gallery,datasheet,models}
ready — no more "EACCES because the dir wasn't created" gotcha
when they upload their first video.
Implementation:
- src/lib/assetFolders.ts: typed helper with per-scope bucket lists
+ a titleToSlug helper that mirrors the front-end's slugger so the
folder name matches what ApplicationClient expects when rendering
/cases/<slug>/videos/<file>.
- network/actions.ts: createNode -> ensureAssetFolders("cases", slug).
Plus a new server action ensureNodeAssetFolders(id) so the editor
can fix existing nodes without recreating them (one-click "Repair").
- news/actions.ts: createNewsArticle -> ensureAssetFolders("news",slug)
- applications/actions.ts: createApplication -> ensureAssetFolders(...)
DEPLOY (David)
cd /opt/flux-srl
git pull
docker compose up -d --build app
# The entrypoint will fix existing 1001:65533 files automatically
# as the container boots — no manual chown needed.
This commit is contained in:
+5
-2
@@ -59,9 +59,12 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# su-exec — drops privileges from root to nextjs in the entrypoint
|
# su-exec — drops privileges from root to nextjs in the entrypoint
|
||||||
RUN apk add --no-cache vips su-exec
|
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 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)
|
# Public assets (logos, brand SVGs, model files)
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|||||||
@@ -3,18 +3,20 @@
|
|||||||
# FLUX container entrypoint.
|
# FLUX container entrypoint.
|
||||||
#
|
#
|
||||||
# Runs as root briefly so we can:
|
# Runs as root briefly so we can:
|
||||||
# 1. Make sure all mounted upload dirs are writable by uid 1001 (nextjs).
|
# 1. Make sure every mounted upload dir AND every file inside is owned by
|
||||||
# The host folders may have been mkdir'd by another user (debian) and
|
# uid 1001 / gid 1001 (nextjs:nodejs). Without this, files written
|
||||||
# docker-compose mounts preserve those permissions, which would lock
|
# previously when nextjs had nogroup (gid 65533) stay 1001:65533 and
|
||||||
# the container out. This single chown fixes it on every start.
|
# sysadmins on the host see a wrong group.
|
||||||
# 2. Apply pending Prisma migrations idempotently.
|
# 2. Apply pending Prisma migrations idempotently.
|
||||||
# 3. Hand off to the Next.js server, dropping privileges to nextjs.
|
# 3. Hand off to the Next.js server, dropping privileges to nextjs.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Fix ownership on every mounted public/* folder so the container can write.
|
# Recursively normalise ownership on every mounted public/* folder. Recursive
|
||||||
# Skips silently if a folder doesn't exist or chown isn't permitted.
|
# 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 \
|
for dir in \
|
||||||
/app/public/branding \
|
/app/public/branding \
|
||||||
/app/public/footage \
|
/app/public/footage \
|
||||||
@@ -22,7 +24,8 @@ for dir in \
|
|||||||
/app/public/cases \
|
/app/public/cases \
|
||||||
/app/public/news \
|
/app/public/news \
|
||||||
/app/public/parts \
|
/app/public/parts \
|
||||||
/app/public/operations-inbox; do
|
/app/public/operations-inbox \
|
||||||
|
/app/public/heritage; do
|
||||||
if [ -d "$dir" ]; then
|
if [ -d "$dir" ]; then
|
||||||
chown -R 1001:1001 "$dir" 2>/dev/null || true
|
chown -R 1001:1001 "$dir" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ
|
import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ
|
||||||
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
|
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
|
||||||
import { translateContentForCMS } from "@/lib/aiTranslator";
|
import { translateContentForCMS } from "@/lib/aiTranslator";
|
||||||
|
import { ensureAssetFolders } from "@/lib/assetFolders";
|
||||||
|
|
||||||
const generateSlug = (title: string) => {
|
const generateSlug = (title: string) => {
|
||||||
return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
|
return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
@@ -70,12 +71,16 @@ export async function createApplication(formData: FormData) {
|
|||||||
translationsJson
|
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("/hq-command/dashboard/applications");
|
||||||
revalidatePath("/[locale]", "layout");
|
revalidatePath("/[locale]", "layout");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: "Failed to create application. Title might already exist." };
|
return { error: "Failed to create application. Title might already exist." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
|
|
||||||
// 🔥 Importamos el motor de traducción robusto
|
// 🔥 Importamos el motor de traducción robusto
|
||||||
import { translateContentForCMS } from "@/lib/aiTranslator";
|
import { translateContentForCMS } from "@/lib/aiTranslator";
|
||||||
|
import { ensureAssetFolders, titleToSlug } from "@/lib/assetFolders";
|
||||||
|
|
||||||
// 1. OBTENER TODOS LOS NODOS
|
// 1. OBTENER TODOS LOS NODOS
|
||||||
export async function getNodes() {
|
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("/hq-command/dashboard/network");
|
||||||
revalidatePath("/[locale]", "layout");
|
revalidatePath("/[locale]", "layout");
|
||||||
return { success: true };
|
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
|
// 3. ELIMINAR UN NODO
|
||||||
export async function deleteNode(id: string) {
|
export async function deleteNode(id: string) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
// 🔥 Importamos nuestra nueva IA traductora
|
// 🔥 Importamos nuestra nueva IA traductora
|
||||||
import { translateContentForCMS } from "@/lib/aiTranslator";
|
import { translateContentForCMS } from "@/lib/aiTranslator";
|
||||||
|
import { ensureAssetFolders } from "@/lib/assetFolders";
|
||||||
|
|
||||||
export async function getNewsArticles() {
|
export async function getNewsArticles() {
|
||||||
try {
|
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("/news");
|
||||||
revalidatePath("/[locale]/news", "layout");
|
revalidatePath("/[locale]/news", "layout");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -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<AssetScope, string> = {
|
||||||
|
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<AssetScope, string[]> = {
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user