Media prywatne
Prywatny plik nie ma publicznego URL-a. Jak działa widoczność, jak token assetu autoryzuje podpisywanie i jak Twój backend podaje czytelnikowi URL ważny pięć minut.
Prywatny plik leży w innym buckecie - takim, który nie ma żadnego publicznego hosta. Żaden URL nie wydaje jego bajtów światu. Jedyny sposób, by go odczytać, to krótkotrwały podpisany URL, o który Twój backend prosi cmssy - dla czytelnika, o którym Twój backend zdecydował, że mu się należy.
Ten podział jest całym projektem. cmssy decyduje, czy wołający może podpisywać; Ty decydujesz, kim jest czytelnik. cmssy nigdy nie poznaje Twoich użytkowników końcowych i nie musi.
Jak zrobić plik prywatnym
Wgraj go jako prywatny albo przełącz istniejący w bibliotece mediów. Przełączenie kopiuje obiekt do drugiego bucketa, sprawdza, że kopia doszła w całości, aktualizuje bibliotekę i dopiero potem usuwa starą kopię - w tej kolejności, więc awaria w dowolnym miejscu zostawia plik czytelnym, a nie osieroconym.
Jeśli zawiedzie tylko ostatni krok, dostaniesz:
The file is now private, but the old public copy could not be removed. Run this again to clear it.Powtórzenie operacji jest bezpieczne i to właśnie ono sprząta resztkę.
Dwie rzeczy, zanim przełączysz plik, który już jest na stronie:
- Publiczny URL przestaje działać. Wszystko, co go trzyma - zcache'owana strona, zakładka, obcy serwis podlinkowany na twardo - psuje się natychmiast.
- Dostarczanie zaczyna zwracać
url: null. Blok renderujący obrazek bezwarunkowo wyrenderuje zepsuty.
Co zwraca dostarczanie
{
"id": "6a6495e44d1ee7dedcae1f52",
"url": null,
"visibility": "private",
"alt": "Okładka raportu kwartalnego",
"width": 1600,
"height": 900
}Wszystko poza bajtami wciąż przychodzi: id, wymiary, alt. To właśnie umożliwia łagodną degradację - wiesz, że obrazek jest, znasz jego kształt i wiesz, że tutaj Ci się nie należy.
const src = mediaUrl(content.src);
if (!src) return null;Transformacja na prywatnej referencji nic nie robi, bo nie ma czego transformować.
Generowanie stron przez AI też nigdy nie podsuwa modelowi prywatnego pliku. Jego zapisany adres celuje w bucket bez publicznego hosta, więc podanie go skończyłoby się tylko tym, że model wpisałby do strony martwy URL.
Tokeny assetów
Podpisywanie autoryzuje token assetu: poświadczenie, które należy do workspace'u, a nie do osoby, i potrafi dokładnie jedno - wystawiać podpisane URL-e do prywatnych plików tego workspace'u.
Celowo nie jest to token API. Token API uwierzytelnia użytkownika i wszystko, co ten użytkownik może, wynika z niego; oddanie go serwerowi konsumenta dałoby całość po to, by pozwolić na jedną operację.
Utwórz go w Ustawienia → Headless albo przez GraphQL:
mutation {
assetToken {
create(name: "storefront", expiresAt: "2027-01-01T00:00:00Z") {
id
prefix
token
}
}
}Wartość token pokazuje się raz i nigdy więcej - cmssy trzyma tylko jej hash. Później widzisz prefix: csa_ plus siedem znaków, tyle wystarczy, by odróżnić dwa tokeny, gdy decydujesz, który unieważnić. expiresAt jest opcjonalne i warto je ustawić.
assetToken { list } zwraca je z lastUsedAt, więc token, którego nic nie używało od miesięcy, widać od razu. assetToken { delete(id: "...") } unieważnia go natychmiast. Wystawienie i unieważnienie trafiają do audit logu, na konto osoby, która to zrobiła.
Tylko po stronie serwera. W bundlu przeglądarkowym ten token daje podpisywanie każdego prywatnego pliku w workspace'ie. Jego miejsce to serwerowa zmienna środowiskowa - nigdy NEXT_PUBLIC_, nigdy komponent kliencki, nigdy stała wstrzyknięta przy buildzie.
Wystawianie podpisanego URL-a
POST https://api.cmssy.io/media/{assetId}/sign
Authorization: Bearer csa_...{
"url": "https://...",
"expiresAt": "2026-08-09T18:35:00.000Z"
}URL żyje pięć minut. Traktuj go per żądanie: wystaw, gdy czytelnik prosi o stronę, podaj mu go i pozwól wygasnąć.
Zasób publiczny odpowiada na to samo wywołanie swoim URL-em z CDN-a i expiresAt: null, więc konsument nigdy nie musi rozgałęziać się po widoczności, zanim zapyta.
Błędy warto czytać dokładnie:
401- brak tokenu albo token nieważny lub wygasły.404- nie ma takiego zasobu w workspace'ie tego tokenu. Plik należący do kogoś innego odpowiada 404, a nie 403, i to celowo: 403 potwierdziłoby, że id istnieje, czyli zamieniłoby endpoint w wyrocznię do sprawdzania cudzych identyfikatorów.429- 120 podpisów na minutę na token i 300 na minutę na adres wołającego. Limit adresowy pobiera się, zanim token zostanie w ogóle obejrzany, bo walidacja kosztuje porównanie bcryptem, a tego obcy nie powinien móc wydawać na Twój rachunek.
Każde podpisanie jest mierzone na workspace. Żaden plan tego dziś nie ogranicza; liczy się je po to, żeby było widać.
Jak to spiąć
Token trzymaj w backendzie, a przed nim postaw route. To właśnie w route stoi uprawnienie - poziom członkostwa, zakup, subskrypcja - dokładnie tak jak przy wszystkim innym, co bramkujesz:
// app/api/asset/[id]/route.ts
export async function GET(request: Request, { params }) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const signed = await fetch(`${process.env.CMSSY_API_HOST}/media/${id}/sign`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.CMSSY_ASSET_TOKEN}` },
});
if (!signed.ok) return new Response("Not found", { status: 404 });
const { url } = await signed.json();
return Response.redirect(url, 307);
}Przekierowuj, zamiast zwracać URL. Podpisany link w Twoim HTML-u siedzi tam przez całe pięć minut swojej ważności - w cache'u CDN-a, we współdzielonym źródle strony, w czyimś podglądzie źródła.
Uwaga na host: CMSSY_API_URL celuje w endpoint GraphQL, a podpisywanie siedzi poziom wyżej. Trzymaj host API w osobnej zmiennej, zamiast robić chirurgię na stringu tamtej.
Następne kroki
- Media - biblioteka, pole media, transformacje i usuwanie.
- Schemat bloku i typy pól -
fields.mediapośród reszty.