diff --git a/.env.example b/.env.example index bb9e886..aaf6403 100644 --- a/.env.example +++ b/.env.example @@ -169,7 +169,11 @@ ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback # Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per # .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 # einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret # ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des diff --git a/Dockerfile b/Dockerfile index b36de0c..76593fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,6 +79,16 @@ COPY --from=builder /app/src/generated ./src/generated COPY --chown=nextjs:nodejs --from=migrator /app/node_modules ./node_modules 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 EXPOSE 3000 ENV PORT=3000 diff --git a/docker-compose.yml b/docker-compose.yml index 964e0d1..318066d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,16 @@ services: required: false expose: - "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: + # Muss zum Volume-Mount oben passen — siehe .env.example. + UPLOAD_DIR: /app/uploads # DATABASE_URL kommt komplett aus .env — je nach DATABASE_PROVIDER # entweder die Postgres- ("db"-Service, Port 5432) oder die # MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306). @@ -110,3 +119,4 @@ volumes: wabenhain_db_data: wabenhain_db_mysql_data: wabenhain_caddy_data: + wabenhain_uploads_data: diff --git a/k8s/05-app-deployment.yaml b/k8s/05-app-deployment.yaml index 42a54ce..226df83 100644 --- a/k8s/05-app-deployment.yaml +++ b/k8s/05-app-deployment.yaml @@ -27,6 +27,17 @@ spec: labels: app: wabenhain-app 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: - name: app image: git.encry.de/nick.rosenloecher/wabenhain:latest diff --git a/k8s/06-ingress.yaml b/k8s/06-ingress.yaml index b5b3d39..c50f56e 100644 --- a/k8s/06-ingress.yaml +++ b/k8s/06-ingress.yaml @@ -18,6 +18,15 @@ metadata: # oder TLS auf andere Weise terminieren. cert-manager.io/cluster-issuer: letsencrypt-prod 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: ingressClassName: nginx tls: diff --git a/src/app/api/admin/upload-video/route.ts b/src/app/api/admin/upload-video/route.ts index 704eef3..928a313 100644 --- a/src/app/api/admin/upload-video/route.ts +++ b/src/app/api/admin/upload-video/route.ts @@ -73,9 +73,18 @@ export async function POST(request: Request) { ); } - await mkdir(UPLOAD_DIR, { recursive: true }); 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, { action: "media.upload", diff --git a/src/app/api/admin/upload/route.ts b/src/app/api/admin/upload/route.ts index ed407fd..df1820f 100644 --- a/src/app/api/admin/upload/route.ts +++ b/src/app/api/admin/upload/route.ts @@ -91,9 +91,22 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Das Bild konnte nicht verarbeitet werden." }, { status: 400 }); } - await mkdir(UPLOAD_DIR, { recursive: true }); 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, { action: "media.upload",