211 lines
7.8 KiB
Markdown
211 lines
7.8 KiB
Markdown
# 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ść
|
||
|
||
```bash
|
||
pnpm add @payloadcms/storage-s3
|
||
```
|
||
|
||
## Zmienne .env
|
||
|
||
Patrz [R2-ENV-przyklad](../R2-ENV-przyklad.md) po pełną instrukcję skąd wziąć wartości.
|
||
|
||
```bash
|
||
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)
|
||
|
||
```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:
|
||
|
||
```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
|
||
|
||
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
|
||
|
||
```bash
|
||
# 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):
|
||
|
||
```ts
|
||
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.
|
||
|
||
```tsx
|
||
// 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. |