src/lib/cloudflare-stream.ts

function·app·4.7 KB · 143 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

9 exports

createUploadUrlgetVideoStatuslistVideosdeleteVideoverifyWebhookSignatureCFStreamUploadResponseCFStreamVideoCF_ACCOUNT_IDCF_API_TOKEN

Code source· typescript

/**
 * Cloudflare Stream client - upload, status, playback, delete.
 *
 * Stack vidéo wari.pro V1 :
 *   Mobile (expo-video) → CF Stream (TUS upload + transcoding HLS + CDN) → Webhook → DB
 *
 * Coût : $5/mois minimum (1k min stocké + 1k min livré), scaling pay-per-use.
 * Doc : https://developers.cloudflare.com/stream/
 */

const CF_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID;
const CF_API_TOKEN = process.env.CLOUDFLARE_STREAM_API_TOKEN;
const CF_WEBHOOK_SECRET = process.env.CLOUDFLARE_STREAM_WEBHOOK_SECRET;

const CF_API_BASE = "https://api.cloudflare.com/client/v4";

if (!CF_ACCOUNT_ID || !CF_API_TOKEN) {
  console.warn(
    "[cloudflare-stream] CF_ACCOUNT_ID ou CF_API_TOKEN absent — vidéos désactivées",
  );
}

export type CFStreamUploadResponse = {
  uploadURL: string; // URL TUS pour upload mobile direct
  uid: string; // ID unique de la vidéo CF Stream
};

export type CFStreamVideo = {
  uid: string;
  thumbnail: string;
  playback: { hls: string; dash: string };
  duration: number;
  status: { state: "queued" | "inprogress" | "ready" | "error" };
  meta: Record<string, string>;
  input?: { width: number; height: number };
};

async function cfFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
  if (!CF_ACCOUNT_ID || !CF_API_TOKEN) {
    throw new Error(
      "Cloudflare Stream non configuré (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_STREAM_API_TOKEN requis)",
    );
  }
  const res = await fetch(`${CF_API_BASE}/accounts/${CF_ACCOUNT_ID}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${CF_API_TOKEN}`,
      "Content-Type": "application/json",
      ...(init.headers ?? {}),
    },
  });
  if (!res.ok) {
    const txt = await res.text().catch(() => "");
    throw new Error(`CF Stream ${res.status}: ${txt.slice(0, 300)}`);
  }
  const data = (await res.json()) as {
    result: T;
    success: boolean;
    errors?: unknown[];
  };
  if (!data.success) {
    throw new Error(`CF Stream error: ${JSON.stringify(data.errors)}`);
  }
  return data.result;
}

/**
 * Crée une URL d'upload direct depuis le mobile.
 * Le mobile fait ensuite un upload vers cette URL avec le binaire vidéo.
 *
 * @param maxDurationSeconds - durée max enforcée par CF (rejette si plus long)
 * @param meta - métadonnées optionnelles (tenantId, contexte, etc.)
 */
export async function createUploadUrl(options: {
  maxDurationSeconds?: number;
  meta?: Record<string, string>;
  requireSignedURLs?: boolean;
}): Promise<CFStreamUploadResponse> {
  const body: Record<string, unknown> = {};
  if (options.maxDurationSeconds) body.maxDurationSeconds = options.maxDurationSeconds;
  if (options.meta) body.meta = options.meta;
  if (options.requireSignedURLs) body.requireSignedURLs = options.requireSignedURLs;

  // CF Stream direct upload : on utilise le mode "direct creator upload"
  // qui retourne uploadURL + uid pour upload depuis mobile.
  return cfFetch<CFStreamUploadResponse>("/stream/direct_upload", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

/** Récupère le status + URLs playback d'une vidéo. */
export async function getVideoStatus(uid: string): Promise<CFStreamVideo> {
  return cfFetch<CFStreamVideo>(`/stream/${uid}`);
}

/** Liste vidéos (pour cleanup batch). */
export async function listVideos(
  options: { limit?: number; before?: string } = {},
): Promise<CFStreamVideo[]> {
  const params = new URLSearchParams();
  if (options.limit) params.set("limit", String(options.limit));
  if (options.before) params.set("before", options.before);
  return cfFetch<CFStreamVideo[]>(`/stream?${params.toString()}`);
}

/** Supprime une vidéo CF Stream (cleanup quand Media supprimé Prisma). */
export async function deleteVideo(uid: string): Promise<void> {
  await cfFetch(`/stream/${uid}`, { method: "DELETE" }).catch(() => undefined);
}

/** Vérifie signature HMAC webhook CF Stream. */
export async function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string | null,
): Promise<boolean> {
  if (!CF_WEBHOOK_SECRET) {
    console.warn(
      "[cf-stream] CLOUDFLARE_STREAM_WEBHOOK_SECRET absent — webhook signatures non vérifiées",
    );
    return true; // V1 dev mode : accept all
  }
  if (!signatureHeader) return false;
  // CF Stream webhook signature format : "time={epoch},sig1={hmac}"
  const parts = Object.fromEntries(
    signatureHeader
      .split(",")
      .map((p) => p.split("=").slice(0, 2) as [string, string]),
  );
  const time = parts.time;
  const expectedSig = parts.sig1;
  if (!time || !expectedSig) return false;
  const payload = `${time}.${rawBody}`;
  const crypto = await import("crypto");
  const computedSig = crypto
    .createHmac("sha256", CF_WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");
  return computedSig === expectedSig;
}

export { CF_ACCOUNT_ID, CF_API_TOKEN };