Files
ipal-kit/docs/storage.md
T

8.8 KiB
Raw Permalink Blame History

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 + 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 przez custom domenę. To proces w Cloudflare (nie w kodzie), wieloetapowy — poniżej dokładnie.

Dlaczego custom domena, nie „r2.dev"

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:

# .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

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ł

Root subdomeny media zwraca 404 (to normalne)

media.klient.pl/plik.jpg → R2 zwraca plik. Ale media.klient.pl/ (sam root, bez pliku) → 404, bo R2 nie ma obiektu pod rootem. To NORMALNE zachowanie R2, nie błąd.

Audyty SEO (Screaming Frog) czasem zgłaszają to 404 — bo crawler widzi URL-e plików (media.../logo.svg) i próbuje roota. Ale:

  • NIE linkuj do samego roota media.klient.pl/ — tylko do plików. Kod nie powinien nigdzie mieć media.klient.pl/ bez nazwy pliku.
  • Root media 404 nie szkodzi SEO głównej domeny (Google indeksuje klient.pl, nie media.klient.pl). Nikt nie trafia na root media.

Plugin tego nie naprawi — subdomena media to serwis R2/Cloudflare, nie aplikacja Next. Żądania do media.klient.pl nie docierają do Twojego kodu.

Jeśli chcesz „czysto" w Search Console (opcjonalne): Cloudflare → Rules → Redirect Rules → gdy hostname = media.klient.pl i path = / → 301 na klient.pl. Jednorazowo w panelu CF. Ale to kosmetyka — root media 404 jest nieszkodliwe.

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.

Preconnect do domeny mediów (wydajność)

Komponent MediaPreconnect generuje <link rel="preconnect"> + dns-prefetch dla domeny mediów (R2_PUBLIC_URL) — przeglądarka nawiązuje połączenie TLS/DNS z CDN zawczasu, zanim napotka pierwszy <img>. Zysk ~150–300 ms na pierwszym obrazie.

// layout.tsx — w <head> (albo górze <body>, Next hoistuje link tagi)
import { MediaPreconnect } from '@intecion/ipal-kit/rsc'

<head>
  <MediaPreconnect />
</head>

Czyta domenę z R2_PUBLIC_URL (to samo źródło co buildR2Storage) — zero hardkodu, jedno źródło prawdy. Gdy R2_PUBLIC_URL nie ustawione (lokalny dysk, brak CDN) → nie renderuje nic. Zmiana domeny mediów = zmiana jednej zmiennej env, komponent podąża automatycznie.

NIE hardkoduj <link rel="preconnect" href="https://media.klient.pl"> ręcznie w layoutcie — to zaszywa domenę klienta w kodzie. Użyj MediaPreconnect, który bierze ją z env.