diff --git a/apps/admin-ui/src/app/drm/page.tsx b/apps/admin-ui/src/app/drm/page.tsx new file mode 100644 index 0000000..97cb817 --- /dev/null +++ b/apps/admin-ui/src/app/drm/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { Nav, useAuth } from "@/components/Nav"; + +type DrmKeyRow = { + id: string; + kid: string; + keyMasked: string; + hasKey: boolean; + mpdUrl: string | null; + channelName: string | null; + eventName: string | null; + source: string; + externalId: string | null; + mediaGuid: string | null; + lastSeenAt: string; + createdAt: string; + updatedAt: string; +}; + +function shortUrl(url: string | null, max = 48): string { + if (!url) return "—"; + const s = url.trim(); + if (s.length <= max) return s; + try { + const u = new URL(s); + const path = u.pathname.split("/").filter(Boolean).pop() ?? ""; + const host = u.host.replace(/^www\./, ""); + const tip = path ? `${host}/…/${path}` : host; + return tip.length <= max ? tip : `${tip.slice(0, max - 1)}…`; + } catch { + return `${s.slice(0, max - 1)}…`; + } +} + +function fmtWhen(iso: string): string { + try { + return new Date(iso).toLocaleString("nl-NL", { + dateStyle: "short", + timeStyle: "short", + }); + } catch { + return iso; + } +} + +export default function DrmPage() { + useAuth(); + const [search, setSearch] = useState(""); + const [source, setSource] = useState(""); + const [keys, setKeys] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + const [revealed, setRevealed] = useState>({}); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(async (q: string, src: string) => { + setLoading(true); + setErr(null); + try { + const params = new URLSearchParams(); + if (q.trim()) params.set("search", q.trim()); + if (src.trim()) params.set("source", src.trim()); + params.set("limit", "200"); + const r = await fetch(`/api/v1/admin/drm-keys?${params}`, { + credentials: "include", + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.message || d.error || "Laden mislukt"); + setKeys(d.keys ?? []); + setTotal(d.total ?? 0); + if (d.requiresQuery) { + setMsg("Typ minstens 2 tekens om te zoeken, of laat leeg voor recente keys."); + } else { + setMsg(null); + } + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load("", ""); + }, [load]); + + function onSearch(e: FormEvent) { + e.preventDefault(); + void load(search, source); + } + + async function reveal(id: string) { + setBusyId(id); + setErr(null); + try { + const r = await fetch(`/api/v1/admin/drm-keys/${id}/reveal`, { + credentials: "include", + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.message || "Reveal mislukt"); + setRevealed((prev) => ({ ...prev, [id]: d.key as string })); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + } + + async function copyText(label: string, text: string) { + try { + await navigator.clipboard.writeText(text); + setMsg(`${label} gekopieerd`); + } catch { + setErr("Clipboard niet beschikbaar"); + } + } + + async function backfill() { + setBusyId("backfill"); + setErr(null); + try { + const r = await fetch("/api/v1/admin/drm-keys/backfill", { + method: "POST", + credentials: "include", + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.message || "Backfill mislukt"); + setMsg( + `Backfill: ${d.imported ?? 0} nieuw, ${d.skipped ?? 0} overgeslagen, ${d.failed ?? 0} mislukt` + ); + await load(search, source); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + } + + async function remove(id: string) { + if (!confirm("Deze DRM-key verwijderen uit de database?")) return; + setBusyId(id); + try { + const r = await fetch(`/api/v1/admin/drm-keys/${id}`, { + method: "DELETE", + credentials: "include", + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.message || "Verwijderen mislukt"); + } + setKeys((prev) => prev.filter((k) => k.id !== id)); + setMsg("Key verwijderd"); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + } + + return ( +
+
+ ); +} diff --git a/apps/admin-ui/src/components/Nav.tsx b/apps/admin-ui/src/components/Nav.tsx index 6c571c2..c5fa4fd 100644 --- a/apps/admin-ui/src/components/Nav.tsx +++ b/apps/admin-ui/src/components/Nav.tsx @@ -14,6 +14,7 @@ const desktopLinks = [ { href: "/downloads", label: "Downloads" }, { href: "/streams", label: "Streams" }, { href: "/live-lists", label: "Live-lijsten" }, + { href: "/drm", label: "DRM" }, { href: "/scripts", label: "Scripts" }, { href: "/viewers", label: "Gebruikers" }, { href: "/settings", label: "Instellingen" }, diff --git a/apps/master-api/prisma/migrations/20260922210000_drm_keys/migration.sql b/apps/master-api/prisma/migrations/20260922210000_drm_keys/migration.sql new file mode 100644 index 0000000..d52d5db --- /dev/null +++ b/apps/master-api/prisma/migrations/20260922210000_drm_keys/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "drm_keys" ( + "id" TEXT NOT NULL, + "kid" TEXT NOT NULL, + "key_enc" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "mpd_url" TEXT, + "channel_name" TEXT, + "event_name" TEXT, + "source" TEXT NOT NULL DEFAULT 'manual', + "external_id" TEXT, + "media_guid" TEXT, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "drm_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "drm_keys_kid_key" ON "drm_keys"("kid"); + +-- CreateIndex +CREATE INDEX "drm_keys_key_hash_idx" ON "drm_keys"("key_hash"); + +-- CreateIndex +CREATE INDEX "drm_keys_last_seen_at_idx" ON "drm_keys"("last_seen_at"); + +-- CreateIndex +CREATE INDEX "drm_keys_source_idx" ON "drm_keys"("source"); + +-- CreateIndex +CREATE INDEX "drm_keys_channel_name_idx" ON "drm_keys"("channel_name"); + +-- CreateIndex +CREATE INDEX "drm_keys_event_name_idx" ON "drm_keys"("event_name"); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index c706ee7..c3e555b 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -790,6 +790,37 @@ model LibrarySubtitle { @@map("library_subtitles") } +/** + * Globale ClearKey/Widevine-key cache (admin DRM-pagina). + * KID plaintext (staat vaak in MPD); KEY encrypted. Uniek op genormaliseerde KID. + */ +model DrmKey { + id String @id @default(uuid()) + /** Genormaliseerde KID hex (zonder dashes), uniek */ + kid String @unique + /** AES-GCM ciphertext van de KEY hex */ + keyEnc String @map("key_enc") @db.Text + /** SHA-256 van genormaliseerde KEY hex — exacte key-search zonder decrypt */ + keyHash String @map("key_hash") + mpdUrl String? @map("mpd_url") @db.Text + channelName String? @map("channel_name") + eventName String? @map("event_name") + /** odido | viaplay | manual | event | … */ + source String @default("manual") + externalId String? @map("external_id") + mediaGuid String? @map("media_guid") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([keyHash]) + @@index([lastSeenAt]) + @@index([source]) + @@index([channelName]) + @@index([eventName]) + @@map("drm_keys") +} + /** Audit trail voor Google Play group-lidmaatschap (geen secrets). */ model GooglePlayAccessAuditLog { id String @id @default(uuid()) diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index ff3610e..cbe4277 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1810,6 +1810,95 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return syncF1tvCatalog(config.SESSION_SECRET); }); + // --- DRM key database --- + app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => { + const q = request.query as { search?: string; source?: string; limit?: string }; + const { searchDrmKeys } = await import("../drm/keys"); + return searchDrmKeys(prisma, { + search: q.search, + source: q.source, + limit: q.limit ? Number(q.limit) : undefined, + }); + }); + + app.get("/api/v1/admin/drm-keys/:id/reveal", { preHandler: requireAdmin }, async (request) => { + const { id } = request.params as { id: string }; + const { revealDrmKey } = await import("../drm/keys"); + try { + return await revealDrmKey(prisma, config.SESSION_SECRET, id); + } catch { + throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404); + } + }); + + app.patch("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => { + const { id } = request.params as { id: string }; + const body = request.body as { + channelName?: string | null; + eventName?: string | null; + mpdUrl?: string | null; + source?: string; + }; + const { updateDrmKeyMeta } = await import("../drm/keys"); + try { + return { key: await updateDrmKeyMeta(prisma, id, body) }; + } catch { + throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404); + } + }); + + app.delete("/api/v1/admin/drm-keys/:id", { preHandler: requireAdmin }, async (request) => { + const { id } = request.params as { id: string }; + const { deleteDrmKey } = await import("../drm/keys"); + try { + await deleteDrmKey(prisma, id); + return { ok: true }; + } catch { + throw new AppError("NOT_FOUND", "DRM-key niet gevonden", 404); + } + }); + + app.post("/api/v1/admin/drm-keys/backfill", { preHandler: requireAdmin }, async () => { + const { backfillDrmKeysFromLiveChannels } = await import("../drm/keys"); + return backfillDrmKeysFromLiveChannels(prisma, config.SESSION_SECRET); + }); + + app.post("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => { + const body = request.body as { + kid?: string; + key?: string; + mpdUrl?: string; + channelName?: string; + eventName?: string; + source?: string; + externalId?: string; + mediaGuid?: string; + }; + if (!body.kid?.trim() || !body.key?.trim()) { + throw new AppError("INVALID_REQUEST", "kid en key verplicht", 400); + } + const { upsertDrmKey } = await import("../drm/keys"); + try { + const result = await upsertDrmKey(prisma, config.SESSION_SECRET, { + kid: body.kid, + key: body.key, + mpdUrl: body.mpdUrl, + channelName: body.channelName, + eventName: body.eventName, + source: body.source || "manual", + externalId: body.externalId, + mediaGuid: body.mediaGuid, + }); + return result; + } catch (e) { + throw new AppError( + "INVALID_REQUEST", + e instanceof Error ? e.message : "Ongeldige key", + 400 + ); + } + }); + // --- Custom ClearKey/DASH live channel lists --- app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); diff --git a/apps/master-api/src/drm/keys.ts b/apps/master-api/src/drm/keys.ts new file mode 100644 index 0000000..82257a3 --- /dev/null +++ b/apps/master-api/src/drm/keys.ts @@ -0,0 +1,294 @@ +import { createHash } from "crypto"; +import type { PrismaClient } from "@prisma/client"; +import { decryptSecret, encryptSecret } from "../security/crypto"; + +export type DrmKeyUpsertInput = { + kid: string; + key: string; + mpdUrl?: string | null; + channelName?: string | null; + eventName?: string | null; + source?: string; + externalId?: string | null; + mediaGuid?: string | null; +}; + +export type DrmKeyPublic = { + id: string; + kid: string; + keyMasked: string; + hasKey: boolean; + mpdUrl: string | null; + channelName: string | null; + eventName: string | null; + source: string; + externalId: string | null; + mediaGuid: string | null; + lastSeenAt: string; + createdAt: string; + updatedAt: string; +}; + +function normalizeHex(value: string): string { + return value.trim().toLowerCase().replace(/[^0-9a-f]/g, ""); +} + +export function normalizeKid(kid: string): string { + return normalizeHex(kid); +} + +export function normalizeKey(key: string): string { + return normalizeHex(key); +} + +export function hashKey(key: string): string { + return createHash("sha256").update(normalizeKey(key), "utf8").digest("hex"); +} + +function maskKey(key: string | null | undefined): string { + if (!key) return ""; + if (key.length <= 8) return "********"; + return `${key.slice(0, 4)}…${key.slice(-4)}`; +} + +function toPublic(row: { + id: string; + kid: string; + keyEnc: string; + mpdUrl: string | null; + channelName: string | null; + eventName: string | null; + source: string; + externalId: string | null; + mediaGuid: string | null; + lastSeenAt: Date; + createdAt: Date; + updatedAt: Date; +}): DrmKeyPublic { + return { + id: row.id, + kid: row.kid, + keyMasked: "********", + hasKey: Boolean(row.keyEnc), + mpdUrl: row.mpdUrl, + channelName: row.channelName, + eventName: row.eventName, + source: row.source, + externalId: row.externalId, + mediaGuid: row.mediaGuid, + lastSeenAt: row.lastSeenAt.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** Upsert by KID; refreshes lastSeen + metadata. Never downgrades an existing key. */ +export async function upsertDrmKey( + prisma: PrismaClient, + sessionSecret: string, + input: DrmKeyUpsertInput +): Promise<{ id: string; kid: string; created: boolean }> { + const kid = normalizeKid(input.kid); + const key = normalizeKey(input.key); + if (kid.length < 16 || key.length < 16) { + throw new Error("Ongeldige kid/key"); + } + const keyEnc = encryptSecret(key, sessionSecret); + const keyHash = hashKey(key); + const now = new Date(); + const existing = await prisma.drmKey.findUnique({ where: { kid } }); + if (existing) { + await prisma.drmKey.update({ + where: { kid }, + data: { + keyEnc, + keyHash, + lastSeenAt: now, + ...(input.mpdUrl != null && input.mpdUrl !== "" + ? { mpdUrl: input.mpdUrl } + : {}), + ...(input.channelName != null && input.channelName !== "" + ? { channelName: input.channelName } + : {}), + ...(input.eventName != null && input.eventName !== "" + ? { eventName: input.eventName } + : {}), + ...(input.source ? { source: input.source } : {}), + ...(input.externalId != null ? { externalId: input.externalId } : {}), + ...(input.mediaGuid != null ? { mediaGuid: input.mediaGuid } : {}), + }, + }); + return { id: existing.id, kid, created: false }; + } + const created = await prisma.drmKey.create({ + data: { + kid, + keyEnc, + keyHash, + mpdUrl: input.mpdUrl?.trim() || null, + channelName: input.channelName?.trim() || null, + eventName: input.eventName?.trim() || null, + source: input.source?.trim() || "manual", + externalId: input.externalId?.trim() || null, + mediaGuid: input.mediaGuid?.trim() || null, + lastSeenAt: now, + }, + }); + return { id: created.id, kid, created: true }; +} + +export async function lookupDrmKeyByKid( + prisma: PrismaClient, + sessionSecret: string, + kidRaw: string +): Promise<{ kid: string; key: string; mpdUrl: string | null } | null> { + const kid = normalizeKid(kidRaw); + if (!kid) return null; + const row = await prisma.drmKey.findUnique({ where: { kid } }); + if (!row) return null; + return { + kid: row.kid, + key: decryptSecret(row.keyEnc, sessionSecret), + mpdUrl: row.mpdUrl, + }; +} + +export async function searchDrmKeys( + prisma: PrismaClient, + opts: { search?: string; source?: string; limit?: number } +): Promise<{ keys: DrmKeyPublic[]; total: number; requiresQuery: boolean }> { + const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500); + const q = (opts.search || "").trim(); + const source = opts.source?.trim() || undefined; + + if (q.length > 0 && q.length < 2) { + return { keys: [], total: 0, requiresQuery: true }; + } + + const where: Record = {}; + if (source) where.source = source; + + if (q.length >= 2) { + const hex = normalizeHex(q); + const or: Record[] = [ + { kid: { contains: hex.length >= 2 ? hex : q, mode: "insensitive" } }, + { mpdUrl: { contains: q, mode: "insensitive" } }, + { channelName: { contains: q, mode: "insensitive" } }, + { eventName: { contains: q, mode: "insensitive" } }, + { externalId: { contains: q, mode: "insensitive" } }, + { mediaGuid: { contains: q, mode: "insensitive" } }, + { source: { contains: q, mode: "insensitive" } }, + ]; + // Exact key search via hash when query looks like a key (32+ hex) + if (hex.length >= 32) { + or.push({ keyHash: hashKey(hex) }); + or.push({ kid: hex }); + } + where.OR = or; + } + + const [total, rows] = await Promise.all([ + prisma.drmKey.count({ where }), + prisma.drmKey.findMany({ + where, + orderBy: { lastSeenAt: "desc" }, + take: limit, + }), + ]); + + return { + keys: rows.map(toPublic), + total, + requiresQuery: false, + }; +} + +export async function revealDrmKey( + prisma: PrismaClient, + sessionSecret: string, + id: string +): Promise<{ id: string; kid: string; key: string; mpdUrl: string | null }> { + const row = await prisma.drmKey.findUnique({ where: { id } }); + if (!row) throw new Error("DRM-key niet gevonden"); + return { + id: row.id, + kid: row.kid, + key: decryptSecret(row.keyEnc, sessionSecret), + mpdUrl: row.mpdUrl, + }; +} + +export async function updateDrmKeyMeta( + prisma: PrismaClient, + id: string, + patch: { + channelName?: string | null; + eventName?: string | null; + mpdUrl?: string | null; + source?: string; + } +): Promise { + const row = await prisma.drmKey.update({ + where: { id }, + data: { + ...(patch.channelName !== undefined + ? { channelName: patch.channelName?.trim() || null } + : {}), + ...(patch.eventName !== undefined + ? { eventName: patch.eventName?.trim() || null } + : {}), + ...(patch.mpdUrl !== undefined ? { mpdUrl: patch.mpdUrl?.trim() || null } : {}), + ...(patch.source !== undefined ? { source: patch.source.trim() || "manual" } : {}), + }, + }); + return toPublic(row); +} + +export async function deleteDrmKey(prisma: PrismaClient, id: string): Promise { + await prisma.drmKey.delete({ where: { id } }); +} + +/** One-time / on-demand: import keys from live_channels into drm_keys. */ +export async function backfillDrmKeysFromLiveChannels( + prisma: PrismaClient, + sessionSecret: string +): Promise<{ imported: number; skipped: number; failed: number }> { + const channels = await prisma.liveChannel.findMany({ + where: { kidEnc: { not: null }, keyEnc: { not: null } }, + select: { + name: true, + mpdUrl: true, + streamUrlCache: true, + kidEnc: true, + keyEnc: true, + externalId: true, + list: { select: { provider: true } }, + }, + }); + let imported = 0; + let skipped = 0; + let failed = 0; + for (const ch of channels) { + if (!ch.kidEnc || !ch.keyEnc) { + skipped += 1; + continue; + } + try { + const kid = decryptSecret(ch.kidEnc, sessionSecret); + const key = decryptSecret(ch.keyEnc, sessionSecret); + const result = await upsertDrmKey(prisma, sessionSecret, { + kid, + key, + mpdUrl: ch.streamUrlCache || ch.mpdUrl, + channelName: ch.name, + source: ch.list.provider || "live-channel", + externalId: ch.externalId, + }); + if (result.created) imported += 1; + else skipped += 1; + } catch { + failed += 1; + } + } + return { imported, skipped, failed }; +} diff --git a/apps/master-api/src/odido/play.ts b/apps/master-api/src/odido/play.ts index c7e57c1..2489099 100644 --- a/apps/master-api/src/odido/play.ts +++ b/apps/master-api/src/odido/play.ts @@ -19,6 +19,7 @@ import { export type OdidoChannelPlayState = { id: string; + name?: string | null; externalId: string | null; mediaId: string | null; contentId: string | null; @@ -74,7 +75,8 @@ async function fetchKeysIfNeeded( sessionSecret: string, existingKid: string | null, existingKey: string | null, - allowFetch: boolean + allowFetch: boolean, + meta?: { channelName?: string | null; externalId?: string | null } ): Promise<{ kidEnc: string | null; keyEnc: string | null }> { if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey }; if (!allowFetch || !play.licenseUrl || !play.licenseToken) { @@ -92,6 +94,19 @@ async function fetchKeysIfNeeded( }); const key = keys.map(parseKey).find((item) => item !== null); if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); + try { + const { upsertDrmKey } = await import("../drm/keys"); + await upsertDrmKey(prisma, sessionSecret, { + kid: key.kid, + key: key.key, + mpdUrl: play.playUrl, + channelName: meta?.channelName, + source: "odido", + externalId: meta?.externalId, + }); + } catch { + /* DRM-db mag play niet breken */ + } return { kidEnc: encryptSecret(key.kid, sessionSecret), keyEnc: encryptSecret(key.key, sessionSecret), @@ -159,7 +174,8 @@ export async function refreshChannelMpd( sessionSecret, channel.kidEnc, channel.keyEnc, - opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay + opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay, + { channelName: channel.name, externalId } ); const now = new Date(); diff --git a/apps/master-api/src/odido/sync.ts b/apps/master-api/src/odido/sync.ts index 8878435..8dab8ca 100644 --- a/apps/master-api/src/odido/sync.ts +++ b/apps/master-api/src/odido/sync.ts @@ -314,6 +314,24 @@ export async function syncOdidoList( if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); kidEnc = encryptSecret(key.kid, sessionSecret); keyEnc = encryptSecret(key.key, sessionSecret); + try { + const { upsertDrmKey } = await import("../drm/keys"); + const chName = + entry.channel.name?.trim() || + entry.channel.channelName?.trim() || + physical?.mediaName?.trim() || + externalId; + await upsertDrmKey(prisma, sessionSecret, { + kid: key.kid, + key: key.key, + mpdUrl: entry.playUrl, + channelName: chName, + source: "odido", + externalId, + }); + } catch { + /* ignore drm db errors */ + } } catch { counts.failed += 1; } diff --git a/apps/master-api/src/viewer/live-channels.ts b/apps/master-api/src/viewer/live-channels.ts index 77df15c..52f33d7 100644 --- a/apps/master-api/src/viewer/live-channels.ts +++ b/apps/master-api/src/viewer/live-channels.ts @@ -446,6 +446,7 @@ export async function getCustomPlayUrl( const resolved = await resolveOdidoPlay( { id: channel.id, + name: channel.name, externalId, mediaId, contentId: channel.contentId,