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