Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded |
+19
-4
@@ -31,12 +31,27 @@ import { s3Storage } from '@payloadcms/storage-s3';
|
||||
return config;
|
||||
};
|
||||
}
|
||||
// s3Storage wants Record<string, true> (the literal true, per collection),
|
||||
// not Record<string, boolean>. Object.fromEntries widens true → boolean, so
|
||||
// build the map with an explicitly-typed accumulator to keep the literal.
|
||||
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||
// accepts uploads and won't serve files (403). With a custom domain
|
||||
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||
// pointing there. 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
|
||||
;
|
||||
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||
const generateFileURL = publicUrl ? ({ filename, prefix })=>[
|
||||
publicUrl,
|
||||
prefix,
|
||||
filename
|
||||
].filter(Boolean).join('/') : undefined;
|
||||
const collectionsConfig = {};
|
||||
for (const slug of collections){
|
||||
collectionsConfig[slug] = true;
|
||||
collectionsConfig[slug] = generateFileURL ? {
|
||||
generateFileURL
|
||||
} : true;
|
||||
}
|
||||
return s3Storage({
|
||||
bucket,
|
||||
|
||||
+1
-1
@@ -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<string, true> (the literal true, per collection),\n // not Record<string, boolean>. Object.fromEntries widens true → boolean, so\n // build the map with an explicitly-typed accumulator to keep the literal.\n const collectionsConfig: Record<string, true> = {}\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"}
|
||||
{"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 // Public URL for served media. R2 is private by default; its S3 endpoint only\n // accepts uploads and won't serve files (403). With a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there. Without it, uploads work but images don't display publicly.\n // See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n // generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a\n // top-level one) — R2 needs it to point served URLs at the custom domain\n // instead of the private S3 endpoint. Each collection gets either `true`\n // (plain offload) or an object carrying generateFileURL when a public URL is set.\n const generateFileURL = publicUrl\n ? ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/')\n : undefined\n\n const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = generateFileURL ? { generateFileURL } : 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","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","undefined","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,8EAA8E;IAC9E,oEAAoE;IACpE,yEAAyE;IACzE,8EAA8E;IAC9E,uBAAuB;IACvB,MAAMG,YAAYZ,QAAQC,GAAG,CAACY,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,kFAAkF;IAClF,MAAMC,kBAAkBH,YACpB,CAAC,EAAEI,QAAQ,EAAEC,MAAM,EAAyC,GAC1D;YAACL;YAAWK;YAAQD;SAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC,OACrDC;IAEJ,MAAMC,oBAAwF,CAAC;IAC/F,KAAK,MAAMC,QAAQzB,YAAa;QAC9BwB,iBAAiB,CAACC,KAAK,GAAGR,kBAAkB;YAAEA;QAAgB,IAAI;IACpE;IAEA,OAAOnB,UAAU;QACfG;QACAD,aAAawB;QACbb,QAAQ;YACNe,aAAa;gBAAEnB;gBAAaE;YAAgB;YAC5CJ;YACAsB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
||||
+62
-7
@@ -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/<klucz-pliku>`.
|
||||
|
||||
### 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://<account>.r2.cloudflarestorage.com/...`.
|
||||
3. Otwórz URL w przeglądarce — obraz się pokazuje (nie 403).
|
||||
4. Na froncie `<img src>` 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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@intecion/ipal-kit",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.22",
|
||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -38,12 +38,25 @@ export const buildR2Storage = (collections: string[] = ['media']): Plugin => {
|
||||
}
|
||||
}
|
||||
|
||||
// s3Storage wants Record<string, true> (the literal true, per collection),
|
||||
// not Record<string, boolean>. Object.fromEntries widens true → boolean, so
|
||||
// build the map with an explicitly-typed accumulator to keep the literal.
|
||||
const collectionsConfig: Record<string, true> = {}
|
||||
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||
// accepts uploads and won't serve files (403). With a custom domain
|
||||
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||
// pointing there. 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
|
||||
|
||||
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||
const generateFileURL = publicUrl
|
||||
? ({ filename, prefix }: { filename: string; prefix?: string }) =>
|
||||
[publicUrl, prefix, filename].filter(Boolean).join('/')
|
||||
: undefined
|
||||
|
||||
const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}
|
||||
for (const slug of collections) {
|
||||
collectionsConfig[slug] = true
|
||||
collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true
|
||||
}
|
||||
|
||||
return s3Storage({
|
||||
|
||||
Reference in New Issue
Block a user