From 6b2eaf89c658ef8c779397e7c9ad9984c566455a Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 28 Sep 2026 21:15:39 +0200 Subject: [PATCH] Stop proxying Viaplay video through the API. Clients fetch the CDN directly; Don't go stays a User-Agent problem on the device, not a server relay. --- apps/master-api/src/viaplay/cdn-proxy.ts | 184 ------------------ apps/master-api/src/viewer/routes.ts | 8 +- apps/master-api/src/viewer/schedule-events.ts | 7 +- 3 files changed, 2 insertions(+), 197 deletions(-) delete mode 100644 apps/master-api/src/viaplay/cdn-proxy.ts diff --git a/apps/master-api/src/viaplay/cdn-proxy.ts b/apps/master-api/src/viaplay/cdn-proxy.ts deleted file mode 100644 index 42f1a55..0000000 --- a/apps/master-api/src/viaplay/cdn-proxy.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; -import { Readable } from "node:stream"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import type { Config } from "../config"; -import { VIAPLAY_USER_AGENT } from "./client"; - -const TTL_SEC = 8 * 3600; - -export function isAllowedViaplayOrigin(url: string): boolean { - try { - const u = new URL(url); - if (u.protocol !== "https:") return false; - const h = u.hostname.toLowerCase(); - return ( - h.includes("viaplay") || - h.includes("viasat") || - /live-dash|vod-dash/.test(h) - ); - } catch { - return false; - } -} - -function sign(secret: string, exp: number, host: string): string { - return createHmac("sha256", secret) - .update(`viaplay-cdn:${exp}:${host.toLowerCase()}`) - .digest("base64url"); -} - -function safeEq(a: string, b: string): boolean { - const left = Buffer.from(a); - const right = Buffer.from(b); - if (left.length !== right.length) return false; - return timingSafeEqual(left, right); -} - -/** Speel-URL voor apps: MPD + segmenten via onze API (Chrome/122), niet via de app-UA. */ -export function wrapViaplayCdnUrl(opts: { - publicBase: string; - secret: string; - originUrl: string; -}): string { - const raw = String(opts.originUrl || "").trim(); - if (!raw || /\/api\/v1\/client\/viaplay\/cdn\//i.test(raw)) return raw; - if (!isAllowedViaplayOrigin(raw)) return raw; - const origin = new URL(raw); - const exp = Math.floor(Date.now() / 1000) + TTL_SEC; - const sig = sign(opts.secret, exp, origin.hostname); - const base = opts.publicBase.replace(/\/+$/, ""); - const rest = `${origin.hostname}${origin.pathname}${origin.search}`; - return `${base}/api/v1/client/viaplay/cdn/s/${sig}/${exp}/${rest}`; -} - -function originFromSplat( - rest: string, - query: Record | undefined -): string { - let origin = `https://${rest.replace(/^\/+/, "")}`; - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(query || {})) { - if (value == null) continue; - if (Array.isArray(value)) { - for (const item of value) params.append(key, String(item)); - } else { - params.set(key, String(value)); - } - } - const q = params.toString(); - if (q) origin += (origin.includes("?") ? "&" : "?") + q; - return origin; -} - -function proxyPrefix(publicBase: string, sig: string, exp: string): string { - return `${publicBase.replace(/\/+$/, "")}/api/v1/client/viaplay/cdn/s/${sig}/${exp}`; -} - -function toProxyUrl(originUrl: string, prefix: string): string { - const u = new URL(originUrl); - return `${prefix}/${u.hostname}${u.pathname}${u.search}`; -} - -function rewriteMpdThroughProxy(xml: string, prefix: string, originMpd: string): string { - let out = xml; - out = out.replace(/https:\/\/[^\s"'<>]+/gi, (url) => { - if (!isAllowedViaplayOrigin(url)) return url; - return toProxyUrl(url, prefix); - }); - out = out.replace(/([\s\S]*?)<\/BaseURL>/gi, (full, inner: string) => { - const raw = String(inner || "").trim(); - if (!raw || raw.includes("/api/v1/client/viaplay/cdn/")) return full; - try { - const abs = new URL(raw, originMpd).toString(); - if (isAllowedViaplayOrigin(abs)) { - return `${toProxyUrl(abs, prefix)}`; - } - } catch { - /* fallback hieronder */ - } - const dir = originMpd.replace(/\/[^/]*$/, "/"); - return `${toProxyUrl(dir, prefix)}`; - }); - if (!/]*>/i, - (open) => `${open}\n ${toProxyUrl(dir, prefix)}` - ); - } - return out; -} - -export function registerViaplayCdnProxy(app: FastifyInstance, config: Config): void { - app.get( - "/api/v1/client/viaplay/cdn/s/:sig/:exp/*", - async (request: FastifyRequest, reply: FastifyReply) => { - const params = request.params as { sig: string; exp: string; "*": string }; - const rest = String(params["*"] || "").trim(); - const exp = Number(params.exp); - if (!rest || !Number.isFinite(exp) || exp < Date.now() / 1000 - 30) { - return reply.status(410).send("Viaplay-proxy verlopen"); - } - const host = rest.split("/")[0]?.toLowerCase() || ""; - const expected = sign(config.SESSION_SECRET, exp, host); - if (!safeEq(expected, params.sig)) { - return reply.status(403).send("Viaplay-proxy ongeldig"); - } - const origin = originFromSplat(rest, request.query as Record); - if (!isAllowedViaplayOrigin(origin)) { - return reply.status(400).send("Viaplay-proxy host geweigerd"); - } - - const range = request.headers.range; - const headers: Record = { - Accept: "*/*", - "Accept-Language": "nl,en;q=0.9", - "User-Agent": VIAPLAY_USER_AGENT, - Referer: "https://viaplay.com/", - Origin: "https://viaplay.com", - }; - if (typeof range === "string" && range) headers.Range = range; - - const upstream = await fetch(origin, { - headers, - redirect: "follow", - signal: AbortSignal.timeout(30_000), - }); - const ct = upstream.headers.get("content-type") || ""; - const urlLooksMpd = /\.mpd$/i.test(new URL(origin).pathname); - const maybeMpd = urlLooksMpd || /xml|mpd|dash\+xml/i.test(ct); - - if (!maybeMpd) { - reply.status(upstream.status); - if (ct) reply.header("content-type", ct); - const cr = upstream.headers.get("content-range"); - if (cr) reply.header("content-range", cr); - const ar = upstream.headers.get("accept-ranges"); - if (ar) reply.header("accept-ranges", ar); - reply.header("cache-control", "no-store"); - if (!upstream.body) return reply.send(); - return reply.send(Readable.fromWeb(upstream.body as never)); - } - - const buf = Buffer.from(await upstream.arrayBuffer()); - const head = buf.subarray(0, Math.min(buf.length, 400)).toString("utf8"); - const isMpd = /]/i.test(head); - - reply.status(upstream.status); - if (isMpd && upstream.ok) { - const xml = rewriteMpdThroughProxy( - buf.toString("utf8"), - proxyPrefix(config.PUBLIC_URL, params.sig, params.exp), - origin - ); - reply.header("content-type", "application/dash+xml; charset=utf-8"); - reply.header("cache-control", "no-store"); - return reply.send(xml); - } - - if (ct) reply.header("content-type", ct); - reply.header("cache-control", "no-store"); - return reply.send(buf); - } - ); -} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 79cef97..8e61c5a 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -10,7 +10,6 @@ import type { SubtitleSource } from "../opensubtitles/client"; import { GooglePlayAccessService } from "../google-play/service"; import { subscribeViewersChanged } from "./admin-events"; import { registerAndroidTvClientUpdateRoutes } from "../install/routes"; -import { registerViaplayCdnProxy, wrapViaplayCdnUrl } from "../viaplay/cdn-proxy"; export function registerViewerRoutes( app: FastifyInstance, @@ -21,7 +20,6 @@ export function registerViewerRoutes( const viewers = new ViewerService(config, playback, downloads); const profiles = new ViewerProfileService(); const googlePlay = new GooglePlayAccessService(config); - registerViaplayCdnProxy(app, config); registerAndroidTvClientUpdateRoutes(app, (authorization) => viewers.authFromBearer(authorization) @@ -1092,11 +1090,7 @@ export function registerViewerRoutes( eventId: `viaplay-content:${guid}`, name: displayTitle, logoUrl: null, - streamUrl: wrapViaplayCdnUrl({ - publicBase: config.PUBLIC_URL, - secret: config.SESSION_SECRET, - originUrl: played.streamUrl, - }), + streamUrl: played.streamUrl, format: played.format, fallbackStreamUrl: null, fallbackFormat: null, diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index 24c599b..a0dda30 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -5,7 +5,6 @@ import { prisma } from "../database/client"; import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art"; import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art"; import { applyCategoryFallbacks } from "./event-fallbacks"; -import { wrapViaplayCdnUrl } from "../viaplay/cdn-proxy"; const CACHE_TTL_MS = 60_000; const FETCH_TIMEOUT_MS = 20_000; @@ -1131,11 +1130,7 @@ export async function getScheduleEventPlay( eventId: pub.id, name: pub.name, logoUrl: pub.logoUrl ?? pub.imagePortrait, - streamUrl: wrapViaplayCdnUrl({ - publicBase: config.PUBLIC_URL, - secret: config.SESSION_SECRET, - originUrl: played.streamUrl, - }), + streamUrl: played.streamUrl, format: played.format, fallbackStreamUrl: null, fallbackFormat: null,