diff --git a/Dockerfile b/Dockerfile index 76593fe..1b6d095 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,13 +27,25 @@ ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholde # Datenbanken wechseln kann, siehe lib/prisma.ts. RUN pnpm build -# --- Migrator-Tools ----------------------------------------------------- +# --- Migrator-Tools + Laufzeit-Zusatzpakete -------------------------------- # Eigenstaendiger, schlanker node_modules-Baum nur mit Prisma-CLI, tsx und # den paar Laufzeit-Paketen, die prisma/seed.ts importiert — NICHT # next/react/sharp/stripe/alle devDependencies, die der volle builder-Stage # oben mitbringt. Wird unten in die runner-Stage kopiert, damit die App sich # beim Start selbst migrieren/seeden kann (kein separates Migrations-Image/ # kein Kubernetes-Job mehr noetig). +# +# ffmpeg-static (Video-Transcoding, siehe lib/video-transcode.ts) landet +# bewusst HIER statt per Next.js' automatischer Standalone-Output-Tracing +# aus der builder-Stage: dessen heruntergeladene ffmpeg-Binary liegt im +# pnpm-Store hinter einem Symlink, den Next.js' Tracer (anders als bei +# sharp, dafuer gibt es eine Sonderbehandlung) nicht zuverlaessig erkennt, +# UND ein direktes COPY einzelner Pakete aus dem pnpm-Store der +# builder-Stage wuerde denselben Symlink lediglich unaufgeloest (und damit +# kaputt) in die runner-Stage kopieren. Diese Stage installiert stattdessen +# jedes benoetigte Laufzeitpaket ueber ein eigenes, in sich geschlossenes +# "pnpm add" neu — genau das Muster, das fuer Prisma/tsx hier bereits +# funktioniert. FROM base AS migrator # pnpm-workspace.yaml zuerst kopieren: pnpm v11 blockt Postinstall-Skripte # (u.a. @prisma/engines, laedt die Schema-Engine-Binary fuer "migrate @@ -50,7 +62,8 @@ COPY pnpm-workspace.yaml ./ RUN pnpm add --save-prod \ prisma@^7.9.1 tsx@^4 bcryptjs@^3.0.3 dotenv@^17.4.2 \ @prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \ - @prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3 + @prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3 \ + ffmpeg-static@^5.3.0 # --- Runtime -------------------------------------------------------------- FROM base AS runner diff --git a/package.json b/package.json index 9857977..ce7f570 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", "bcryptjs": "^3.0.3", + "ffmpeg-static": "^5.3.0", "jose": "^6.2.8", "mariadb": "^3.5.3", "motion": "^13.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 965e934..13e81bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: bcryptjs: specifier: ^3.0.3 version: 3.0.3 + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0(supports-color@7.2.0) jose: specifier: ^6.2.8 version: 6.2.8 @@ -262,6 +265,10 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@derhuerst/http-basic@8.2.4': + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -1229,6 +1236,9 @@ packages: '@types/lodash@4.17.25': resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} @@ -1518,6 +1528,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1656,6 +1670,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -1687,6 +1704,9 @@ packages: caniuse-lite@1.0.30001807: resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1722,6 +1742,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -1920,6 +1944,10 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2145,6 +2173,10 @@ packages: picomatch: optional: true + ffmpeg-static@5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2321,6 +2353,13 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-response-object@3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -2345,6 +2384,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2927,6 +2969,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-cache-control@1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3072,6 +3117,10 @@ packages: typescript: optional: true + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3111,6 +3160,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@5.1.1: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} @@ -3183,6 +3236,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3330,6 +3386,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3460,6 +3519,9 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.66.0: resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3501,6 +3563,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: @@ -3860,6 +3925,13 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@derhuerst/http-basic@8.2.4': + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -4614,6 +4686,8 @@ snapshots: '@types/lodash@4.17.25': {} + '@types/node@10.17.60': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 @@ -4934,6 +5008,12 @@ snapshots: acorn@8.18.0: {} + agent-base@6.0.2(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5094,6 +5174,8 @@ snapshots: node-releases: 2.0.53 update-browserslist-db: 1.3.0(browserslist@4.28.7) + buffer-from@1.1.2: {} + c12@3.3.4: dependencies: chokidar: 5.0.0 @@ -5132,6 +5214,8 @@ snapshots: caniuse-lite@1.0.30001807: {} + caseless@0.12.0: {} + chai@6.2.2: {} chalk@4.1.2: @@ -5163,6 +5247,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.2.4: {} convert-source-map@2.0.0: {} @@ -5343,6 +5434,8 @@ snapshots: entities@8.0.0: {} + env-paths@2.2.1: {} + env-paths@3.0.0: {} es-abstract-get@1.0.0: @@ -5738,6 +5831,15 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + ffmpeg-static@5.3.0(supports-color@7.2.0): + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5922,6 +6024,17 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + http-response-object@3.0.2: + dependencies: + '@types/node': 10.17.60 + + https-proxy-agent@5.0.1(supports-color@7.2.0): + dependencies: + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -5939,6 +6052,8 @@ snapshots: indent-string@4.0.0: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6491,6 +6606,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-cache-control@1.0.1: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -6628,6 +6745,8 @@ snapshots: - react - react-dom + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -6668,6 +6787,12 @@ snapshots: react@19.2.8: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@5.1.1: {} redent@3.0.0: @@ -6758,6 +6883,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -6960,6 +7087,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -7082,6 +7213,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) @@ -7153,6 +7286,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f77f77c..bb2c2f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ allowBuilds: '@prisma/engines': true esbuild: true + ffmpeg-static: true prisma: true sharp: true unrs-resolver: false diff --git a/src/app/api/admin/upload-video/route.ts b/src/app/api/admin/upload-video/route.ts index 928a313..0152f48 100644 --- a/src/app/api/admin/upload-video/route.ts +++ b/src/app/api/admin/upload-video/route.ts @@ -4,12 +4,8 @@ import path from "node:path"; import { isTrustedOrigin } from "@/lib/csrf"; import { rateLimit } from "@/lib/rate-limit"; import { logAdminAction } from "@/lib/audit-log"; -import { - UPLOAD_DIR, - MAX_UPLOAD_VIDEO_BYTES, - detectVideoExtension, - generateUploadFilename, -} from "@/lib/uploads"; +import { UPLOAD_DIR, MAX_UPLOAD_VIDEO_BYTES, detectVideoExtension, generateUploadFilename } from "@/lib/uploads"; +import { transcodeVideoForWeb } from "@/lib/video-transcode"; import { requireAdminForApi } from "../_shared"; // node:fs braucht die Node-Runtime (nicht Edge). @@ -19,16 +15,21 @@ export const runtime = "nodejs"; * Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe * lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video). * Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil - * Videos anders validiert werden: sharp kann nur Bilder dekodieren, ein - * Video wird deshalb NICHT neu enkodiert (kein Pixel-fuer-Pixel-Rebuild wie - * bei Bildern moeglich), sondern nur anhand seiner Magic-Bytes als MP4/WebM - * verifiziert und unveraendert gespeichert. Das ist vertretbar, weil (1) nur - * eingeloggte 2FA-Admins diesen Endpunkt erreichen (requireAdminForApi), - * (2) ausgelieferte Dateien einen festen, aus der Endung abgeleiteten - * Content-Type + "X-Content-Type-Options: nosniff" bekommen (siehe - * app/uploads/[...path]/route.ts) und daher nie als HTML/Skript interpretiert - * werden koennen, und (3) der zufaellige Dateiname + die feste Endungs- - * Allowlist jede andere Nutzung des Pfads verhindern. + * Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier + * uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die + * Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur + * Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit), + * (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton, + * begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart + * ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange + * Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3) + * NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei — + * ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die + * Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte- + * Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). Zugriff + * ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi), Auslieferung + * mit festem Content-Type + "X-Content-Type-Options: nosniff" (siehe + * app/uploads/[...path]/route.ts) und zufaelligem Dateinamen. */ export async function POST(request: Request) { if (!isTrustedOrigin(request)) { @@ -65,18 +66,31 @@ export async function POST(request: Request) { const buffer = Buffer.from(await file.arrayBuffer()); - const extension = detectVideoExtension(buffer); - if (!extension) { + if (!detectVideoExtension(buffer)) { return NextResponse.json( { error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." }, { status: 400 } ); } - const filename = generateUploadFilename(extension); + let transcoded: Buffer; + try { + transcoded = await transcodeVideoForWeb(buffer); + } catch (error: unknown) { + console.error("Video-Upload: Transcoding fehlgeschlagen.", error); + return NextResponse.json( + { error: "Das Video konnte nicht verarbeitet werden." }, + { status: 400 } + ); + } + + // Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom + // Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der + // urspruenglich erkannten Eingabe-Endung. + const filename = generateUploadFilename("mp4"); try { await mkdir(UPLOAD_DIR, { recursive: true }); - await writeFile(path.join(UPLOAD_DIR, filename), buffer); + await writeFile(path.join(UPLOAD_DIR, filename), transcoded); } catch (error: unknown) { // Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster). console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error); @@ -90,7 +104,7 @@ export async function POST(request: Request) { action: "media.upload", entityType: "Upload", entityId: filename, - detail: `${extension}, ${Math.ceil(buffer.length / 1024)} KB`, + detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`, }); return NextResponse.json({ path: `/uploads/${filename}` }); diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts index 6b752a1..ebebea3 100644 --- a/src/lib/uploads.ts +++ b/src/lib/uploads.ts @@ -36,11 +36,12 @@ export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; * 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video * (z.B. direkt von einer Kamera/einem Handy exportiert, oft 40-80 MB) ab. * Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil Video-Dateien inhaerent - * groesser sind — Videos werden aber (anders als Bilder) NICHT neu enkodiert - * (dafuer gibt es hier keine sharp-Entsprechung ohne eine ffmpeg- - * Abhaengigkeit), die Groessengrenze ist daher die einzige Bremse gegen - * uebermaessig grosse Uploads. Muss zum "proxy-body-size" in - * k8s/06-ingress.yaml passen (dort grosszuegiger bemessen). + * groesser sind. Diese Grenze gilt fuer die ROHDATEI vor dem Transcoding + * (siehe lib/video-transcode.ts transcodeVideoForWeb, aufgerufen in + * app/api/admin/upload-video/route.ts) — das tatsaechlich gespeicherte, + * web-optimierte Ergebnis ist danach in aller Regel deutlich kleiner. Muss + * zum "proxy-body-size" in k8s/06-ingress.yaml passen (dort grosszuegiger + * bemessen). */ export const MAX_UPLOAD_VIDEO_BYTES = 100 * 1024 * 1024; diff --git a/src/lib/video-transcode.ts b/src/lib/video-transcode.ts new file mode 100644 index 0000000..133aa65 --- /dev/null +++ b/src/lib/video-transcode.ts @@ -0,0 +1,108 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import ffmpegPath from "ffmpeg-static"; + +/** + * Harte Obergrenze fuer einen einzelnen ffmpeg-Lauf: verhindert, dass ein + * (versehentlich oder absichtlich) pathologischer Upload einen + * Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten + * (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze + * Hintergrundvideo bei "medium"-Preset deutlich. + */ +const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Enkodiert ein hochgeladenes Video (MP4 ODER WebM, siehe + * detectVideoExtension in lib/uploads.ts) zu einem fuer die Web-Auslieferung + * optimierten MP4 (H.264/yuv420p) neu. Das ist der Grund, warum ein 57-MB- + * Handy-/Kamera-Export vorher spuerbar langsam lud: + * + * 1. Kein Ton: das Hero-Video spielt immer stumm (siehe hero.tsx `muted`) — + * die Audiospur wird komplett entfernt (-an), spart oft bereits deutlich + * Groesse ohne jeden sichtbaren Qualitaetsverlust. + * 2. Passende Aufloesung statt Rohmaterial: auf max. 1920px Breite skaliert + * (Handy-/Kamera-Exporte sind haeufig hoeher aufgeloest, als eine + * Hintergrundflaeche je zeigen kann). + * 3. Web-taugliche Bitrate statt Kamera-Bitrate (CRF 26 + Bitratendeckel) + * statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy. + * 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei) + * an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die + * GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann — + * das allein erklaerte einen grossen Teil der langsamen ersten + * Wiedergabe, unabhaengig von der Dateigroesse. + * + * Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei + * tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die + * gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon + * abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der + * Magic-Bytes am Dateianfang). + */ +export async function transcodeVideoForWeb(buffer: Buffer): Promise { + if (!ffmpegPath) { + throw new Error("ffmpeg-Binary (ffmpeg-static) nicht gefunden."); + } + + const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-")); + const inputPath = path.join(dir, "input"); + const outputPath = path.join(dir, "output.mp4"); + + try { + await writeFile(inputPath, buffer); + await runFfmpeg(ffmpegPath, [ + "-y", + "-i", + inputPath, + "-an", + "-vf", + "scale='min(1920,iw)':-2", + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "26", + "-maxrate", + "3M", + "-bufsize", + "6M", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + outputPath, + ]); + return await readFile(outputPath); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function runFfmpeg(binary: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(binary, args); + let stderr = ""; + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + reject(new Error("ffmpeg-Zeitlimit ueberschritten.")); + }, TRANSCODE_TIMEOUT_MS); + + proc.stderr.on("data", (chunk: Buffer) => { + // ffmpeg schreibt Fortschritt/Diagnose nach stderr auch bei Erfolg — + // hier nur fuer eine hilfreiche Fehlermeldung im Misserfolgsfall + // gesammelt (auf die letzten paar KB begrenzt), nicht geloggt. + stderr += chunk.toString(); + if (stderr.length > 4000) stderr = stderr.slice(-4000); + }); + proc.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + proc.on("close", (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(`ffmpeg beendete sich mit Code ${code}: ${stderr}`)); + }); + }); +}