Private Medien

Eine private Datei hat keine öffentliche URL. Wie Sichtbarkeit funktioniert, wie ein Asset-Token das Signieren autorisiert und wie dein Backend einer Leserin eine URL gibt, die in fünf Minuten abläuft.

Eine private Datei liegt in einem anderen Bucket - einem, der an keinen öffentlichen Hostnamen gebunden ist. Keine URL liefert ihre Bytes an die Welt aus. Der einzige Weg, sie zu lesen, ist eine kurzlebige signierte URL, die dein Backend bei cmssy anfordert - für eine Leserin, von der dein Backend entschieden hat, dass sie berechtigt ist.

Diese Trennung ist das ganze Design. cmssy entscheidet, ob der Aufrufer signieren darf; du entscheidest, wer die Leserin ist. cmssy lernt deine Endnutzer nie kennen und muss es auch nicht.

Eine Datei privat machen

Lade sie privat hoch oder stelle eine vorhandene Datei in der Medienbibliothek um. Das Umstellen kopiert das Objekt in den anderen Bucket, prüft, dass die Kopie vollständig angekommen ist, aktualisiert die Bibliothek und entfernt erst dann die alte Kopie - in dieser Reihenfolge, damit ein Fehler an irgendeiner Stelle die Datei lesbar lässt statt gestrandet.

Scheitert nur der letzte Schritt, bekommst du:

The file is now private, but the old public copy could not be removed. Run this again to clear it.

Ein erneuter Lauf ist sicher und ist genau das, was den Rest beseitigt.

Zwei Dinge, bevor du eine Datei umstellst, die schon auf einer Seite liegt:

  • Die öffentliche URL hört auf zu funktionieren. Alles, was sie hält - eine gecachte Seite, ein Lesezeichen, eine fremde Site, die sie einbindet - bricht sofort.
  • Die Auslieferung gibt ab sofort url: null zurück. Ein Block, der sein Bild bedingungslos rendert, rendert ein kaputtes.

Was die Auslieferung zurückgibt

{
  "id": "6a6495e44d1ee7dedcae1f52",
  "url": null,
  "visibility": "private",
  "alt": "Titelbild des Quartalsberichts",
  "width": 1600,
  "height": 900
}

Alles außer den Bytes kommt weiterhin durch: die Id, die Maße, der Alt-Text. Genau das macht ein sanftes Degradieren möglich - du weißt, dass ein Bild da ist, du kennst seine Form, und du weißt, dass du hier nicht berechtigt bist.

const src = mediaUrl(content.src);
if (!src) return null;

Eine Transformation auf einer privaten Referenz bewirkt nichts, weil es keine URL zu transformieren gibt.

Auch die KI-Seitengenerierung bietet dem Modell nie eine private Datei an. Ihre gespeicherte Adresse zeigt in einen Bucket ohne öffentlichen Hostnamen; sie anzubieten würde nur dazu führen, dass das Modell eine tote URL in die Seite schreibt.

Asset-Tokens

Das Signieren autorisiert ein Asset-Token: eine Berechtigung, die dem Workspace gehört und nicht einer Person, und die genau eines kann - signierte URLs für die privaten Dateien dieses Workspace ausstellen.

Es ist bewusst kein API-Token. Ein API-Token authentifiziert eine Person, und alles, was diese Person darf, fließt daraus; es dem Server eines Konsumenten zu geben, würde alles gewähren, um eine Operation zu erlauben.

Lege eines unter Einstellungen → Headless an oder über GraphQL:

mutation {
  assetToken {
    create(name: "storefront", expiresAt: "2027-01-01T00:00:00Z") {
      id
      prefix
      token
    }
  }
}

Der token wird einmal gezeigt und nie wieder - cmssy behält nur einen Hash davon. Danach siehst du den prefix: csa_ plus sieben Zeichen, genug, um zwei Tokens auseinanderzuhalten, wenn du entscheidest, welches du widerrufst. expiresAt ist optional und lohnt sich.

assetToken { list } liefert sie mit lastUsedAt, ein seit Monaten ungenutztes Token fällt also auf. assetToken { delete(id: "...") } widerruft eines sofort. Ausstellen und Widerrufen landen beide im Audit-Log, auf die Person gebucht, die es getan hat.

Nur serverseitig. In einem Browser-Bundle gewährt dieses Token das Signieren jeder privaten Datei im Workspace. Sein Platz ist eine serverseitige Umgebungsvariable - niemals NEXT_PUBLIC_, niemals eine Client-Komponente, niemals eine beim Build eingebackene Konstante.

Eine signierte URL ausstellen

POST https://api.cmssy.io/media/{assetId}/sign
Authorization: Bearer csa_...
{
  "url": "https://...",
  "expiresAt": "2026-08-09T18:35:00.000Z"
}

Die URL gilt fünf Minuten. Behandle sie pro Anfrage: stelle sie aus, wenn jemand die Seite anfordert, gib sie weiter, lass sie ablaufen.

Ein öffentliches Asset beantwortet denselben Aufruf mit seiner CDN-URL und expiresAt: null, ein Konsument muss also nie nach Sichtbarkeit verzweigen, bevor er fragt.

Die Fehler lohnen genaues Lesen:

  • 401 - kein Token, oder das Token ist ungültig oder abgelaufen.
  • 404 - kein solches Asset im Workspace dieses Tokens. Eine fremde Datei antwortet absichtlich 404 statt 403: ein 403 würde bestätigen, dass die Id existiert, und den Endpunkt damit in ein Nachschlageorakel für andere Mandanten verwandeln.
  • 429 - 120 Signierungen pro Minute je Token und 300 pro Minute je aufrufender Adresse. Das Adresslimit wird belastet, bevor das Token überhaupt angesehen wird, denn seine Prüfung kostet einen bcrypt-Vergleich - und den soll ein Fremder nicht auf deine Rechnung ausgeben können.

Jede Signierung wird dem Workspace zugerechnet. Kein Tarif begrenzt sie heute; sie wird gezählt, damit sie sichtbar ist.

Verdrahtung

Lege das Token in dein Backend und eine Route davor. In der Route wohnt die Berechtigung - eine Mitgliedsstufe, ein Kauf, ein Abo - genau wie bei allem anderen, das du absicherst:

// 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);
}

Leite weiter, statt die URL zurückzugeben. Ein signierter Link in deinem HTML sitzt dort die vollen fünf Minuten seiner Gültigkeit - im CDN-Cache, im geteilten Seitenquelltext, in jemandes Quelltextansicht.

Achte auf den Host: CMSSY_API_URL zeigt auf den GraphQL-Endpunkt, das Signieren sitzt eine Ebene darüber. Halte den API-Host in einer eigenen Variable, statt an der anderen String-Chirurgie zu betreiben.

Nächste Schritte