Add searchable DRM key database in admin for reused ClearKeys.
Persist kids/keys with channel/event/MPD metadata, expose /drm search UI, and record keys from Odido sync/play for cache-first reuse.
This commit is contained in:
parent
d8a1c28283
commit
4b1f9eb763
9 changed files with 813 additions and 2 deletions
325
apps/admin-ui/src/app/drm/page.tsx
Normal file
325
apps/admin-ui/src/app/drm/page.tsx
Normal file
|
|
@ -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<DrmKeyRow[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(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 (
|
||||||
|
<div className="page">
|
||||||
|
<Nav />
|
||||||
|
<main className="container">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="page-kicker">Beveiliging</p>
|
||||||
|
<h1>DRM keys</h1>
|
||||||
|
<p className="page-lead">
|
||||||
|
Alle ooit opgehaalde ClearKey/Widevine-keys. Zoek op KID, KEY, MPD, zender of
|
||||||
|
event. Altijd eerst cache — alleen ophalen bij miss.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busyId === "backfill"}
|
||||||
|
onClick={() => void backfill()}
|
||||||
|
>
|
||||||
|
{busyId === "backfill" ? "Bezig…" : "Import uit live-lijsten"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="card" onSubmit={onSearch} style={{ marginBottom: "1rem" }}>
|
||||||
|
<div className="form-row" style={{ gap: "0.75rem", flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Zoek kid, key, mpd, zender, event…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ flex: "1 1 240px" }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
style={{ flex: "0 0 140px" }}
|
||||||
|
>
|
||||||
|
<option value="">Alle bronnen</option>
|
||||||
|
<option value="odido">odido</option>
|
||||||
|
<option value="viaplay">viaplay</option>
|
||||||
|
<option value="manual">manual</option>
|
||||||
|
<option value="live-channel">live-channel</option>
|
||||||
|
<option value="event">event</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn" disabled={loading}>
|
||||||
|
{loading ? "Zoeken…" : "Zoeken"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{msg && <p className="ok-msg">{msg}</p>}
|
||||||
|
{err && <p className="err-msg">{err}</p>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<h2>Keys</h2>
|
||||||
|
<span className="muted">{total} totaal · {keys.length} getoond</span>
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>KID</th>
|
||||||
|
<th>KEY</th>
|
||||||
|
<th>Zender / event</th>
|
||||||
|
<th>MPD</th>
|
||||||
|
<th>Bron</th>
|
||||||
|
<th>Laatst gezien</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="muted">
|
||||||
|
Geen keys. Gebruik “Import uit live-lijsten” of wacht tot sync keys
|
||||||
|
ophaalt.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{keys.map((k) => {
|
||||||
|
const fullKey = revealed[k.id];
|
||||||
|
return (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td>
|
||||||
|
<code
|
||||||
|
title={k.kid}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => void copyText("KID", k.kid)}
|
||||||
|
>
|
||||||
|
{k.kid.length > 20 ? `${k.kid.slice(0, 16)}…` : k.kid}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{fullKey ? (
|
||||||
|
<code
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => void copyText("KEY", fullKey)}
|
||||||
|
>
|
||||||
|
{fullKey.length > 20 ? `${fullKey.slice(0, 16)}…` : fullKey}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="muted">{k.hasKey ? "••••••••" : "—"}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{k.channelName || "—"}</div>
|
||||||
|
{k.eventName && (
|
||||||
|
<div className="muted" style={{ fontSize: "0.85em" }}>
|
||||||
|
{k.eventName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td title={k.mpdUrl ?? undefined}>
|
||||||
|
<span className="muted">{shortUrl(k.mpdUrl)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{k.source}</code>
|
||||||
|
</td>
|
||||||
|
<td className="muted">{fmtWhen(k.lastSeenAt)}</td>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{!fullKey && k.hasKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={busyId === k.id}
|
||||||
|
onClick={() => void reveal(k.id)}
|
||||||
|
>
|
||||||
|
Toon
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{k.mpdUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void copyText("MPD", k.mpdUrl!)}
|
||||||
|
>
|
||||||
|
MPD
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
disabled={busyId === k.id}
|
||||||
|
onClick={() => void remove(k.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ const desktopLinks = [
|
||||||
{ href: "/downloads", label: "Downloads" },
|
{ href: "/downloads", label: "Downloads" },
|
||||||
{ href: "/streams", label: "Streams" },
|
{ href: "/streams", label: "Streams" },
|
||||||
{ href: "/live-lists", label: "Live-lijsten" },
|
{ href: "/live-lists", label: "Live-lijsten" },
|
||||||
|
{ href: "/drm", label: "DRM" },
|
||||||
{ href: "/scripts", label: "Scripts" },
|
{ href: "/scripts", label: "Scripts" },
|
||||||
{ href: "/viewers", label: "Gebruikers" },
|
{ href: "/viewers", label: "Gebruikers" },
|
||||||
{ href: "/settings", label: "Instellingen" },
|
{ href: "/settings", label: "Instellingen" },
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -790,6 +790,37 @@ model LibrarySubtitle {
|
||||||
@@map("library_subtitles")
|
@@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). */
|
/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
|
||||||
model GooglePlayAccessAuditLog {
|
model GooglePlayAccessAuditLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
|
||||||
|
|
@ -1810,6 +1810,95 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return syncF1tvCatalog(config.SESSION_SECRET);
|
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 ---
|
// --- Custom ClearKey/DASH live channel lists ---
|
||||||
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
||||||
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
||||||
|
|
|
||||||
294
apps/master-api/src/drm/keys.ts
Normal file
294
apps/master-api/src/drm/keys.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||||
|
if (source) where.source = source;
|
||||||
|
|
||||||
|
if (q.length >= 2) {
|
||||||
|
const hex = normalizeHex(q);
|
||||||
|
const or: Record<string, unknown>[] = [
|
||||||
|
{ 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<DrmKeyPublic> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
|
|
||||||
export type OdidoChannelPlayState = {
|
export type OdidoChannelPlayState = {
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
externalId: string | null;
|
externalId: string | null;
|
||||||
mediaId: string | null;
|
mediaId: string | null;
|
||||||
contentId: string | null;
|
contentId: string | null;
|
||||||
|
|
@ -74,7 +75,8 @@ async function fetchKeysIfNeeded(
|
||||||
sessionSecret: string,
|
sessionSecret: string,
|
||||||
existingKid: string | null,
|
existingKid: string | null,
|
||||||
existingKey: string | null,
|
existingKey: string | null,
|
||||||
allowFetch: boolean
|
allowFetch: boolean,
|
||||||
|
meta?: { channelName?: string | null; externalId?: string | null }
|
||||||
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
|
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
|
||||||
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
|
if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey };
|
||||||
if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
|
if (!allowFetch || !play.licenseUrl || !play.licenseToken) {
|
||||||
|
|
@ -92,6 +94,19 @@ async function fetchKeysIfNeeded(
|
||||||
});
|
});
|
||||||
const key = keys.map(parseKey).find((item) => item !== null);
|
const key = keys.map(parseKey).find((item) => item !== null);
|
||||||
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
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 {
|
return {
|
||||||
kidEnc: encryptSecret(key.kid, sessionSecret),
|
kidEnc: encryptSecret(key.kid, sessionSecret),
|
||||||
keyEnc: encryptSecret(key.key, sessionSecret),
|
keyEnc: encryptSecret(key.key, sessionSecret),
|
||||||
|
|
@ -159,7 +174,8 @@ export async function refreshChannelMpd(
|
||||||
sessionSecret,
|
sessionSecret,
|
||||||
channel.kidEnc,
|
channel.kidEnc,
|
||||||
channel.keyEnc,
|
channel.keyEnc,
|
||||||
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay
|
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay,
|
||||||
|
{ channelName: channel.name, externalId }
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,24 @@ export async function syncOdidoList(
|
||||||
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
||||||
kidEnc = encryptSecret(key.kid, sessionSecret);
|
kidEnc = encryptSecret(key.kid, sessionSecret);
|
||||||
keyEnc = encryptSecret(key.key, 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 {
|
} catch {
|
||||||
counts.failed += 1;
|
counts.failed += 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,7 @@ export async function getCustomPlayUrl(
|
||||||
const resolved = await resolveOdidoPlay(
|
const resolved = await resolveOdidoPlay(
|
||||||
{
|
{
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
externalId,
|
externalId,
|
||||||
mediaId,
|
mediaId,
|
||||||
contentId: channel.contentId,
|
contentId: channel.contentId,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue