From 0dee178ded940cdd92e4d9a1679f65338212fd97 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Thu, 27 Aug 2026 15:41:47 +0200 Subject: [PATCH] R2 storage from env + filename normalization --- dist/modules/storage/buildR2Storage.js | 16 +++++ dist/modules/storage/buildR2Storage.js.map | 2 +- docs/storage.md | 69 +++++++++++++++++++--- src/modules/storage/buildR2Storage.ts | 15 +++++ 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/dist/modules/storage/buildR2Storage.js b/dist/modules/storage/buildR2Storage.js index 71af9cf..693dd16 100644 --- a/dist/modules/storage/buildR2Storage.js +++ b/dist/modules/storage/buildR2Storage.js @@ -38,9 +38,25 @@ import { s3Storage } from '@payloadcms/storage-s3'; for (const slug of collections){ collectionsConfig[slug] = true; } + // Public URL for served media. R2 is private by default; with a custom domain + // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs + // pointing there instead of the private S3 endpoint (which 403s on the front). + // Without it, uploads work but images don't display publicly. See docs/storage.md. + const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash + ; return s3Storage({ bucket, collections: collectionsConfig, + ...publicUrl ? { + // generateFileURL overrides the stored/returned URL to the CDN domain. + // Params come from Payload's storage plugin; type them explicitly since + // the callback shape isn't inferred here (would be implicit any). + generateFileURL: ({ filename, prefix })=>[ + publicUrl, + prefix, + filename + ].filter(Boolean).join('/') + } : {}, config: { credentials: { accessKeyId, diff --git a/dist/modules/storage/buildR2Storage.js.map b/dist/modules/storage/buildR2Storage.js.map index 2e0e99a..ad89ada 100644 --- a/dist/modules/storage/buildR2Storage.js.map +++ b/dist/modules/storage/buildR2Storage.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // s3Storage wants Record (the literal true, per collection),\n // not Record. Object.fromEntries widens true → boolean, so\n // build the map with an explicitly-typed accumulator to keep the literal.\n const collectionsConfig: Record = {}\n for (const slug of collections) {\n collectionsConfig[slug] = true\n }\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","collectionsConfig","slug","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,2EAA2E;IAC3E,4EAA4E;IAC5E,0EAA0E;IAC1E,MAAMG,oBAA0C,CAAC;IACjD,KAAK,MAAMC,QAAQf,YAAa;QAC9Bc,iBAAiB,CAACC,KAAK,GAAG;IAC5B;IAEA,OAAOjB,UAAU;QACfG;QACAD,aAAac;QACbH,QAAQ;YACNK,aAAa;gBAAET;gBAAaE;YAAgB;YAC5CJ;YACAY,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // s3Storage wants Record (the literal true, per collection),\n // not Record. Object.fromEntries widens true → boolean, so\n // build the map with an explicitly-typed accumulator to keep the literal.\n const collectionsConfig: Record = {}\n for (const slug of collections) {\n collectionsConfig[slug] = true\n }\n\n // Public URL for served media. R2 is private by default; with a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there instead of the private S3 endpoint (which 403s on the front).\n // Without it, uploads work but images don't display publicly. See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n ...(publicUrl\n ? {\n // generateFileURL overrides the stored/returned URL to the CDN domain.\n // Params come from Payload's storage plugin; type them explicitly since\n // the callback shape isn't inferred here (would be implicit any).\n generateFileURL: ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/'),\n }\n : {}),\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","collectionsConfig","slug","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,2EAA2E;IAC3E,4EAA4E;IAC5E,0EAA0E;IAC1E,MAAMG,oBAA0C,CAAC;IACjD,KAAK,MAAMC,QAAQf,YAAa;QAC9Bc,iBAAiB,CAACC,KAAK,GAAG;IAC5B;IAEA,8EAA8E;IAC9E,yEAAyE;IACzE,+EAA+E;IAC/E,mFAAmF;IACnF,MAAMC,YAAYd,QAAQC,GAAG,CAACc,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,OAAOpB,UAAU;QACfG;QACAD,aAAac;QACb,GAAIE,YACA;YACE,uEAAuE;YACvE,wEAAwE;YACxE,kEAAkE;YAClEG,iBAAiB,CAAC,EAAEC,QAAQ,EAAEC,MAAM,EAAyC,GAC3E;oBAACL;oBAAWK;oBAAQD;iBAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC;QACvD,IACA,CAAC,CAAC;QACNb,QAAQ;YACNc,aAAa;gBAAElB;gBAAaE;YAAgB;YAC5CJ;YACAqB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"} \ No newline at end of file diff --git a/docs/storage.md b/docs/storage.md index a606a8d..6333a28 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -52,16 +52,71 @@ masz więcej kolekcji plików: `buildR2Storage(['media', 'documents'])`. To wzorzec „degrade gracefully" — jak mailAdapter, który wraca do SMTP, gdy brak Graph. Projekt działa niezależnie od tego, czy R2 jest skonfigurowany. -## Publiczny dostęp (WAŻNE) +## Publiczny dostęp + custom domena (WAŻNE — krok po kroku) -R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403), dopóki -nie skonfigurujesz publicznego odczytu: +R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403), +dopóki nie skonfigurujesz publicznego odczytu przez custom domenę. To proces +w Cloudflare (nie w kodzie), wieloetapowy — poniżej dokładnie. -1. Cloudflare → R2 → bucket → Settings → **Public access** → podłącz custom domain -2. Albo serwuj przez Cloudflare CDN / własną domenę +### Dlaczego custom domena, nie „r2.dev" -Bez tego media wgrają się do R2, ale front nie pokaże obrazów. Konfiguracja domeny -jest po stronie Cloudflare, nie kodu. +R2 oferuje szybki publiczny URL `*.r2.dev`, ALE: +- jest rate-limitowany (nie do produkcji) +- nie przechodzi przez cache Cloudflare (brak CDN, wolniej, drożej) +- brzydki URL (nie Twoja domena) + +Dla produkcji ZAWSZE custom domena (np. `media.klient.pl`) — daje CDN, cache, +własny URL. r2.dev tylko do szybkiego testu. + +### Warunek wstępny: domena w Cloudflare + +Custom domena dla R2 wymaga, żeby domena (albo subdomena) była zarządzana przez +Cloudflare (nameservery klienta wskazują na Cloudflare). Jeśli domena klienta +jest u innego rejestratora — trzeba ją najpierw dodać do Cloudflare (Add Site) +i przełączyć nameservery. Sama subdomena `media.klient.pl` wystarczy, jeśli +główna domena jest już w Cloudflare. + +### Krok po kroku — podpięcie custom domeny + +1. **Cloudflare Dashboard → R2 → wybierz bucket** +2. Zakładka **Settings** → sekcja **Public access** → **Custom Domains** +3. **Connect Domain** → wpisz subdomenę, np. `media.klient.pl` +4. Cloudflare automatycznie doda rekord CNAME (bo domena jest w Cloudflare) i + wystawi certyfikat SSL. Poczekaj, aż status = **Active** (kilka minut). +5. Od tej chwili pliki są publiczne pod `https://media.klient.pl/`. + +### Krok: ustaw publiczny URL w projekcie + +Payload musi generować URL-e mediów wskazujące na custom domenę, nie na endpoint +S3. Dodaj zmienną i przekaż ją do adaptera: + +```bash +# .env +R2_PUBLIC_URL=https://media.klient.pl +``` + +Adapter `buildR2Storage` czyta ją i ustawia jako bazowy URL mediów (jeśli +ustawiona). Bez niej Payload zwróci URL wskazujący na prywatny endpoint S3 → +403 na froncie. (Patrz aktualizacja buildR2Storage niżej.) + +### Weryfikacja + +1. Wgraj obraz w panelu (Media). +2. Sprawdź URL obrazu w panelu — powinien być `https://media.klient.pl/...`, + NIE `https://.r2.cloudflarestorage.com/...`. +3. Otwórz URL w przeglądarce — obraz się pokazuje (nie 403). +4. Na froncie `` działa. + +### Częsty błąd: 403 mimo custom domeny + +- **URL wskazuje na endpoint S3, nie custom domenę** → brakuje `R2_PUBLIC_URL` + albo adapter jej nie używa. Sprawdź URL w panelu. +- **Custom domena nie Active** → poczekaj na SSL/CNAME w Cloudflare. +- **Public access wyłączony** → w bucket Settings sprawdź, czy custom domena jest + podpięta (nie tylko utworzona). + +Bez tego media wgrają się do R2, ale front pokaże 403. Konfiguracja domeny jest +po stronie Cloudflare, publiczny URL po stronie projektu (.env). ## Migracja istniejących mediów diff --git a/src/modules/storage/buildR2Storage.ts b/src/modules/storage/buildR2Storage.ts index c13964f..642a1e0 100644 --- a/src/modules/storage/buildR2Storage.ts +++ b/src/modules/storage/buildR2Storage.ts @@ -46,9 +46,24 @@ export const buildR2Storage = (collections: string[] = ['media']): Plugin => { collectionsConfig[slug] = true } + // Public URL for served media. R2 is private by default; with a custom domain + // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs + // pointing there instead of the private S3 endpoint (which 403s on the front). + // Without it, uploads work but images don't display publicly. See docs/storage.md. + const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash + return s3Storage({ bucket, collections: collectionsConfig, + ...(publicUrl + ? { + // generateFileURL overrides the stored/returned URL to the CDN domain. + // Params come from Payload's storage plugin; type them explicitly since + // the callback shape isn't inferred here (would be implicit any). + generateFileURL: ({ filename, prefix }: { filename: string; prefix?: string }) => + [publicUrl, prefix, filename].filter(Boolean).join('/'), + } + : {}), config: { credentials: { accessKeyId, secretAccessKey }, endpoint,