4.5 KiB
storage — media na Cloudflare R2
Offload mediów (obrazy, pliki) do Cloudflare R2 zamiast lokalnego dysku. R2 jest
S3-kompatybilny; plugin dostarcza buildR2Storage, który czyta dane z .env
i konfiguruje adapter.
Storage to infrastruktura, nie treść. Dane R2 (klucze, bucket) idą do
.env— jak DATABASE_URI, PAYLOAD_SECRET, GRAPH_*. NIE do panelu (to sekrety agencyjne, wiążą się przy starcie, nie zmienia ich redaktor).
Zależność
pnpm add @payloadcms/storage-s3
Zmienne .env
Patrz R2-ENV-przyklad po pełną instrukcję skąd wziąć wartości.
R2_BUCKET=nazwa-bucketa
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=<access-key-id>
R2_SECRET_ACCESS_KEY=<secret-access-key>
Wpięcie (payload.config.ts)
import { buildR2Storage } from '@intecion/ipal-kit'
export default buildConfig({
// ...
plugins: [
ipalKit({ /* ... */ }),
buildR2Storage(['media']), // slugi kolekcji upload do offloadu
],
})
buildR2Storage przyjmuje listę kolekcji upload (domyślnie ['media']). Jeśli
masz więcej kolekcji plików: buildR2Storage(['media', 'documents']).
Zachowanie (fallback)
- Wszystkie 4 zmienne → media w R2.
- Brak zmiennych → fallback na lokalny dysk (dev działa bez R2, zero konfiguracji).
- Część zmiennych → ostrzeżenie w logu + fallback (częściowa konfiguracja = pewnie pomyłka).
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)
R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403), dopóki nie skonfigurujesz publicznego odczytu:
- Cloudflare → R2 → bucket → Settings → Public access → podłącz custom domain
- Albo serwuj przez Cloudflare CDN / własną domenę
Bez tego media wgrają się do R2, ale front nie pokaże obrazów. Konfiguracja domeny jest po stronie Cloudflare, nie kodu.
Migracja istniejących mediów
Jeśli projekt miał media lokalnie i przełączasz na R2 — nowe uploady idą do R2,
ale STARE zostają na dysku (i znikną przy redeployu bez wolumenu). Przed
przełączeniem na produkcji przenieś istniejące pliki do bucketa (np. rclone
albo ręcznie przez R2 dashboard), inaczej stare obrazy znikną.
Weryfikacja
# po wpięciu i ustawieniu .env:
pnpm dev
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
Dev na lokalnym I na R2 (seedowanie podczas developmentu)
Fallback (brak zmiennych → lokalny dysk) oznacza, że dev działa w obu trybach:
- Dev bez R2 w .env → media na lokalnym dysku. Szybki start, zero konfiguracji.
- Dev z R2 w .env → media w R2 już podczas developmentu. Przydatne, gdy seedujesz treść w devie i chcesz, żeby od razu lądowała w buckecie (np. wspólny bucket dev, albo test realnego flow przed produkcją).
Przełączasz trybem po prostu obecnością zmiennych R2 w .env. Ten sam kod,
buildR2Storage sam wykrywa. Nie musisz nic zmieniać w configu między trybami.
Jeśli seedujesz w devie do R2 — pamiętaj, że to realny bucket. Używaj osobnego bucketa dev (nie produkcyjnego), żeby nie mieszać danych testowych z realnymi.
Normalizacja nazw plików (automatyczna)
Plik normalizeFilenameHook czyści nazwy wgrywanych plików — slugifikuje nazwę,
zachowuje rozszerzenie:
"Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
"Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
Wpięcie w kolekcję Media (projekt):
import { normalizeFilenameHook } from '@intecion/ipal-kit'
export const Media: CollectionConfig = {
slug: 'media',
upload: { staticDir: 'media' /* ... */ },
hooks: {
beforeOperation: [normalizeFilenameHook], // czyści nazwę przed zapisem
},
fields: [ /* alt itd. */ ],
}
Działa z lokalnym dyskiem i z R2/S3 (hook biegnie PRZED warstwą storage, więc czysta nazwa trafia i do bazy, i do bucketa). Dlaczego to ważne:
- URL-e mediów są czyste —
/media/zdjecie-nad-morzem.jpg, nie/media/Zdjęcie%20jeden%20nad%20morzem.jpg(spacje/diakrytyki w URL = problemy). - Przenośność — nazwa bez polskich znaków/spacji działa wszędzie (CDN, S3, systemy plików).
- Bez kolizji kodowania — spacje i
#,()w nazwach plików potrafią psuć ścieżki i cache.
Sama funkcja normalizeFilename(name) też jest wyeksportowana, gdybyś potrzebował
jej poza hookiem.