fix: Admin-Uploads auf dem Live-Server (Berechtigung, Persistenz, Body-Size-Limit)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
Uploads (Bilder + Hero-Video) schlugen live fehl, lokal aber nicht: der Container laeuft als non-root "nextjs", /app/uploads existierte nirgends vorbereitet und gehoert root -> mkdir() scheiterte mit EACCES, unbehandelt als HTML-500 statt JSON, der Client zeigte nur einen haengenden "wird hochgeladen"-Zustand ohne Fehlermeldung. - Dockerfile legt /app/uploads jetzt vor dem User-Wechsel an und uebereignet es dem nextjs-Nutzer - docker-compose.yml persistiert Uploads jetzt ueber ein eigenes Volume (vorher gingen sie bei jedem Redeploy verloren) und setzt UPLOAD_DIR explizit - Beide Upload-Routen fangen Dateisystemfehler jetzt ab und liefern eine echte JSON-Fehlermeldung statt eine unbehandelte Exception - k8s-Manifeste vorsorglich mitgefixt (fsGroup fuer den Volume-Mount, Ingress-Body-Size-Limit) fuer den Fall eines spaeteren Kubernetes-Wechsels Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
286387d5ec
commit
d0c1d0840c
7 files changed
+71
-5
No files matched your search
+5
-1
@@ -169,7 +169,11 @@ ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
|
|||||||
# Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per
|
# Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per
|
||||||
# .gitignore ausgeschlossen — beim ersten Upload automatisch angelegt).
|
# .gitignore ausgeschlossen — beim ersten Upload automatisch angelegt).
|
||||||
#
|
#
|
||||||
# In Produktion (Kubernetes, siehe k8s/05-app-deployment.yaml) zeigt
|
# In docker-compose.yml ist UPLOAD_DIR bereits fest auf /app/uploads gesetzt
|
||||||
|
# und ueber ein benanntes Docker-Volume persistiert (siehe dort) — hier
|
||||||
|
# nichts weiter noetig, dieser Wert wirkt nur fuer lokales "npm run dev".
|
||||||
|
#
|
||||||
|
# In Produktion per Kubernetes (siehe k8s/05-app-deployment.yaml) zeigt
|
||||||
# UPLOAD_DIR stattdessen auf einen Volume-Mount, damit hochgeladene Bilder
|
# UPLOAD_DIR stattdessen auf einen Volume-Mount, damit hochgeladene Bilder
|
||||||
# einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret
|
# einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret
|
||||||
# ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des
|
# ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des
|
||||||
|
|||||||
+10
@@ -79,6 +79,16 @@ COPY --from=builder /app/src/generated ./src/generated
|
|||||||
COPY --chown=nextjs:nodejs --from=migrator /app/node_modules ./node_modules
|
COPY --chown=nextjs:nodejs --from=migrator /app/node_modules ./node_modules
|
||||||
ENV PATH="/app/node_modules/.bin:${PATH}"
|
ENV PATH="/app/node_modules/.bin:${PATH}"
|
||||||
|
|
||||||
|
# UPLOAD_DIR (Standard "./uploads", siehe .env.example) faellt ohne diesen
|
||||||
|
# Schritt unter das root-eigene /app-Arbeitsverzeichnis: der Container laeuft
|
||||||
|
# als non-root "nextjs" (s.u.), dessen mkdir()-Aufruf in
|
||||||
|
# app/api/admin/upload*/route.ts dort ohne Schreibrecht auf /app selbst mit
|
||||||
|
# EACCES fehlschlaegt — Admin-Uploads (Bilder UND Hero-Video) scheitern dann
|
||||||
|
# ausnahmslos. Verzeichnis hier bereits VOR "USER nextjs" anlegen und
|
||||||
|
# uebereignen; docker-compose.yml mountet ausserdem ein Volume genau hierher,
|
||||||
|
# damit Uploads auch einen Container-Neustart ueberleben (siehe dort).
|
||||||
|
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -79,7 +79,16 @@ services:
|
|||||||
required: false
|
required: false
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
volumes:
|
||||||
|
# Persistiert hochgeladene Admin-Bilder/-Videos (Dockerfile legt
|
||||||
|
# /app/uploads bereits mit passendem Besitzer fuer den non-root
|
||||||
|
# "nextjs"-User an) ueber Container-Neustarts/-Redeploys hinweg — ohne
|
||||||
|
# dieses Volume waere jeder Upload nach dem naechsten "docker compose
|
||||||
|
# up" wieder weg.
|
||||||
|
- wabenhain_uploads_data:/app/uploads
|
||||||
environment:
|
environment:
|
||||||
|
# Muss zum Volume-Mount oben passen — siehe .env.example.
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
# DATABASE_URL kommt komplett aus .env — je nach DATABASE_PROVIDER
|
# DATABASE_URL kommt komplett aus .env — je nach DATABASE_PROVIDER
|
||||||
# entweder die Postgres- ("db"-Service, Port 5432) oder die
|
# entweder die Postgres- ("db"-Service, Port 5432) oder die
|
||||||
# MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306).
|
# MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306).
|
||||||
@@ -110,3 +119,4 @@ volumes:
|
|||||||
wabenhain_db_data:
|
wabenhain_db_data:
|
||||||
wabenhain_db_mysql_data:
|
wabenhain_db_mysql_data:
|
||||||
wabenhain_caddy_data:
|
wabenhain_caddy_data:
|
||||||
|
wabenhain_uploads_data:
|
||||||
@@ -27,6 +27,17 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: wabenhain-app
|
app: wabenhain-app
|
||||||
spec:
|
spec:
|
||||||
|
# fsGroup=1001 muss zur "nodejs"-Gruppe (gid 1001) passen, die das
|
||||||
|
# Dockerfile fuer den non-root "nextjs"-Laufzeitbenutzer anlegt: ohne
|
||||||
|
# dieses Feld mountet Kubernetes das "uploads"-Volume (egal ob emptyDir
|
||||||
|
# oder ein echtes PVC) mit root:root-Besitz — das ueberschreibt den im
|
||||||
|
# Image bereits per chown gesetzten Besitzer von /app/uploads
|
||||||
|
# vollstaendig, und der nicht-root laufende Container koennte trotzdem
|
||||||
|
# keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup
|
||||||
|
# sorgt dafuer, dass Kubernetes JEDES gemountete Volume zusaetzlich der
|
||||||
|
# Gruppe 1001 zuordnet und gruppen-schreibbar macht.
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: git.encry.de/nick.rosenloecher/wabenhain:latest
|
image: git.encry.de/nick.rosenloecher/wabenhain:latest
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ metadata:
|
|||||||
# oder TLS auf andere Weise terminieren.
|
# oder TLS auf andere Weise terminieren.
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
# ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro
|
||||||
|
# Request durch — weit unter dem Bild- (8 MB, lib/uploads.ts
|
||||||
|
# MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (40 MB, lib/uploads.ts
|
||||||
|
# MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht
|
||||||
|
# nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch
|
||||||
|
# ohne verwertbare Fehlermeldung im Browser) — 45m deckt das groessere der
|
||||||
|
# beiden Limits (40 MB) plus etwas Spielraum fuer den multipart/form-data-
|
||||||
|
# Overhead ab.
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "45m"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
tls:
|
||||||
|
|||||||
@@ -73,9 +73,18 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
||||||
const filename = generateUploadFilename(extension);
|
const filename = generateUploadFilename(extension);
|
||||||
await writeFile(path.join(UPLOAD_DIR, filename), buffer);
|
try {
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
await writeFile(path.join(UPLOAD_DIR, filename), buffer);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
|
||||||
|
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "media.upload",
|
action: "media.upload",
|
||||||
|
|||||||
@@ -91,9 +91,22 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Das Bild konnte nicht verarbeitet werden." }, { status: 400 });
|
return NextResponse.json({ error: "Das Bild konnte nicht verarbeitet werden." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
||||||
const filename = generateUploadFilename(extension);
|
const filename = generateUploadFilename(extension);
|
||||||
await writeFile(path.join(UPLOAD_DIR, filename), output);
|
try {
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
await writeFile(path.join(UPLOAD_DIR, filename), output);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Ohne dieses Catch wuerde ein Dateisystemfehler (z.B. fehlende
|
||||||
|
// Schreibrechte auf UPLOAD_DIR) als unbehandelte Exception durchschlagen
|
||||||
|
// -> Next.js liefert dafuer eine HTML-Fehlerseite statt JSON zurueck,
|
||||||
|
// response.json() im Client (admin-image-field.tsx) wirft dann einen
|
||||||
|
// kryptischen Parse-Fehler statt einer verstaendlichen Meldung.
|
||||||
|
console.error("Bild-Upload: Datei konnte nicht gespeichert werden.", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "media.upload",
|
action: "media.upload",
|
||||||
|
|||||||
Reference in new issue
Block a user