From 62a7ddec63710b226041eca5430e1fe5f7d7067b Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 23 Sep 2026 00:48:13 +0200 Subject: [PATCH] Add Admin upload for Token Tray installer on /agent. Allows publishing NSIS builds via multipart without SSH/docker cp to the host volume. --- apps/admin-ui/src/app/agent/page.tsx | 187 +++++++++++- apps/admin-ui/src/app/api/[...path]/route.ts | 32 ++- apps/admin-ui/src/components/Nav.tsx | 1 + apps/master-api/package.json | 1 + apps/master-api/src/app.ts | 11 + apps/master-api/src/install/routes.ts | 124 ++++---- .../src/install/token-tray-store.ts | 266 ++++++++++++++++++ apps/token-tray/README.md | 41 ++- pnpm-lock.yaml | 24 ++ 9 files changed, 578 insertions(+), 109 deletions(-) create mode 100644 apps/master-api/src/install/token-tray-store.ts diff --git a/apps/admin-ui/src/app/agent/page.tsx b/apps/admin-ui/src/app/agent/page.tsx index 28c5d85..1135cf0 100644 --- a/apps/admin-ui/src/app/agent/page.tsx +++ b/apps/admin-ui/src/app/agent/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Nav, useAuth } from "@/components/Nav"; @@ -25,20 +25,32 @@ function formatBytes(n: number): string { export default function AgentPage() { useAuth(); + const fileRef = useRef(null); const [meta, setMeta] = useState(null); const [err, setErr] = useState(null); + const [msg, setMsg] = useState(null); const [loading, setLoading] = useState(true); + const [version, setVersion] = useState("0.1.1"); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(null); + const [deleting, setDeleting] = useState(false); const load = useCallback(async () => { setLoading(true); setErr(null); try { - const res = await fetch(`${MASTER_URL}/install/token-tray.json`, { + const res = await fetch("/api/v1/admin/install/token-tray", { + credentials: "include", cache: "no-store", }); - if (!res.ok) throw new Error(`Meta ophalen mislukt (${res.status})`); - const data = (await res.json()) as TrayMeta; + const data = (await res.json()) as TrayMeta & { + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(data.error?.message || `Meta ophalen mislukt (${res.status})`); + } setMeta(data); + if (data.version) setVersion(String(data.version)); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); setMeta(null); @@ -51,6 +63,78 @@ export default function AgentPage() { void load(); }, [load]); + function uploadFile(file: File | null) { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".exe")) { + setErr("Kies een .exe (NSIS-installer)"); + return; + } + setErr(null); + setMsg(null); + setUploading(true); + setProgress(0); + + const fd = new FormData(); + fd.append("file", file, file.name); + fd.append("version", version.trim() || "0.0.0"); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/v1/admin/install/token-tray"); + xhr.withCredentials = true; + xhr.upload.onprogress = (ev) => { + if (!ev.lengthComputable) return; + setProgress(Math.round((ev.loaded / ev.total) * 100)); + }; + xhr.onload = () => { + setUploading(false); + setProgress(null); + if (fileRef.current) fileRef.current.value = ""; + try { + const data = JSON.parse(xhr.responseText) as TrayMeta & { + error?: { message?: string }; + }; + if (xhr.status < 200 || xhr.status >= 300) { + setErr(data.error?.message || `Upload mislukt (${xhr.status})`); + return; + } + setMeta(data); + if (data.version) setVersion(String(data.version)); + setMsg( + `Geüpload: v${data.version || "?"} · ${formatBytes(data.sizeBytes ?? 0)}` + ); + } catch { + setErr(`Upload mislukt (${xhr.status})`); + } + }; + xhr.onerror = () => { + setUploading(false); + setProgress(null); + setErr("Netwerkfout tijdens upload — proxy/timeout?"); + }; + xhr.send(fd); + } + + async function removeInstaller() { + if (!confirm("Installer van Master verwijderen?")) return; + setDeleting(true); + setErr(null); + setMsg(null); + try { + const res = await fetch("/api/v1/admin/install/token-tray", { + method: "DELETE", + credentials: "include", + }); + const data = (await res.json()) as TrayMeta & { error?: { message?: string } }; + if (!res.ok) throw new Error(data.error?.message || "Verwijderen mislukt"); + setMeta(data); + setMsg("Installer verwijderd"); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setDeleting(false); + } + } + const available = Boolean(meta?.available); const downloadHref = meta?.downloadUrl?.trim() || `${MASTER_URL}/install/token-tray-setup.exe`; @@ -68,16 +152,82 @@ export default function AgentPage() { stil via Chrome/Edge — hands-off zolang de PC aan staat.

- {err &&
{err}
} + {msg && ( +

+ {msg} +

+ )}
+

Installer uploaden

+

+ Bouw lokaal met pnpm pack:win → kies{" "} + token-tray-setup.exe hier. Geen Docker-host nodig. +

+
+
+
+ + setVersion(e.target.value)} + placeholder="0.1.1" + disabled={uploading} + style={{ maxWidth: 160 }} + /> +
+
+ + uploadFile(e.target.files?.[0] ?? null)} + /> +
+ {uploading && ( +

+ Uploaden…{progress != null ? ` ${progress}%` : ""} +

+ )} + {progress != null && ( +
+
+
+ )} +
+ +

Download

-

NSIS-installer (x64). SmartScreen kan waarschuwen — lokaal gebouwd, niet Store-gesigned.

+

+ Publieke link voor Windows-PC's. SmartScreen kan waarschuwen — lokaal gebouwd, niet + Store-gesigned. +

{loading && !meta ? ( @@ -93,19 +243,28 @@ export default function AgentPage() {

{formatBytes(meta?.sizeBytes ?? 0)} {meta?.builtAt - ? ` · gebouwd ${new Date(meta.builtAt).toLocaleString("nl-NL")}` + ? ` · geüpload ${new Date(meta.builtAt).toLocaleString("nl-NL")}` : ""}

- - Download installer - +
+ + Download installer + + +
) : ( <> -

Installer nog niet beschikbaar op Master.

+

Nog geen installer op Master.

- {meta?.note || - "Bouw op Windows: apps/token-tray → pack:win → publish:installer, plaats token-tray-setup.exe in het agent-volume (of rebuild image met deploy/agent/), daarna deze pagina vernieuwen."} + {meta?.note || "Upload hierboven token-tray-setup.exe."}

)} @@ -116,7 +275,7 @@ export default function AgentPage() {
    -
  1. Run de installer → tray-icoon in het systeemvak.
  2. +
  3. Run de installer → tray-icoon (MC) in het systeemvak.
  4. Open Instellingen… in het tray-menu.
  5. diff --git a/apps/admin-ui/src/app/api/[...path]/route.ts b/apps/admin-ui/src/app/api/[...path]/route.ts index cde1670..32d92bf 100644 --- a/apps/admin-ui/src/app/api/[...path]/route.ts +++ b/apps/admin-ui/src/app/api/[...path]/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; +/** Lange uploads (~80–120 MB Token Tray installer). */ +export const maxDuration = 600; const SESSION_COOKIE = "mc_admin_session"; const SESSION_MAX_AGE = 7 * 24 * 60 * 60; @@ -29,8 +31,6 @@ function applySessionCookie( request: NextRequest, expiresAt?: string ) { - // Only mark Secure when the browser actually used HTTPS. - // NODE_ENV=production alone must NOT force Secure (breaks http://admin...). const secure = isHttps(request); response.cookies.set({ name: SESSION_COOKIE, @@ -57,6 +57,10 @@ function clearSessionCookie(response: NextResponse, request: NextRequest) { }); } +function isTokenTrayUpload(method: string, targetPath: string): boolean { + return method === "POST" && targetPath === "v1/admin/install/token-tray"; +} + async function proxy(request: NextRequest, pathSegments: string[]) { const targetPath = pathSegments.join("/"); const url = new URL(request.url); @@ -65,18 +69,26 @@ async function proxy(request: NextRequest, pathSegments: string[]) { const isLogout = request.method === "POST" && targetPath === "v1/auth/logout"; const isSse = request.method === "GET" && targetPath === "v1/admin/viewers/events"; + const streamUpload = isTokenTrayUpload(request.method, targetPath); const headers = new Headers(); const contentType = request.headers.get("content-type"); if (contentType) headers.set("content-type", contentType); const cookie = request.headers.get("cookie"); if (cookie) headers.set("cookie", cookie); + const contentLength = request.headers.get("content-length"); + if (contentLength) headers.set("content-length", contentLength); headers.set("accept", isSse ? "text/event-stream" : "application/json"); - const body = - request.method !== "GET" && request.method !== "HEAD" - ? await request.arrayBuffer() - : undefined; + let body: BodyInit | undefined; + if (request.method !== "GET" && request.method !== "HEAD") { + if (streamUpload) { + // Stream ~80–120 MB installer — niet bufferen in memory + body = request.body ?? undefined; + } else { + body = await request.arrayBuffer(); + } + } const errors: string[] = []; @@ -88,13 +100,14 @@ async function proxy(request: NextRequest, pathSegments: string[]) { headers, body, redirect: "manual", - // SSE must not be aborted when the proxy function "returns" the stream ...(isSse ? { cache: "no-store" as RequestCache, signal: request.signal } : {}), + ...(streamUpload && body + ? ({ duplex: "half", signal: request.signal } as RequestInit) + : {}), }); - // Special-case auth so the session cookie is owned by admin.vonas.nl if (isLogin) { const text = await upstream.text(); let data: { @@ -171,7 +184,6 @@ async function proxy(request: NextRequest, pathSegments: string[]) { upstream.headers.get("content-type") ?? "application/json" ); - // Forward any upstream cookies as fallback (rewritten without Domain) const anyHeaders = upstream.headers as Headers & { getSetCookie?: () => string[]; }; @@ -193,6 +205,8 @@ async function proxy(request: NextRequest, pathSegments: string[]) { } catch (err) { const message = err instanceof Error ? err.message : "fetch failed"; errors.push(`${base}: ${message}`); + // Stream body can only be consumed once — stop retrying other bases + if (streamUpload) break; } } diff --git a/apps/admin-ui/src/components/Nav.tsx b/apps/admin-ui/src/components/Nav.tsx index 30ad2a9..b2ca82f 100644 --- a/apps/admin-ui/src/components/Nav.tsx +++ b/apps/admin-ui/src/components/Nav.tsx @@ -16,6 +16,7 @@ const desktopLinks = [ { href: "/live-lists", label: "Live-lijsten" }, { href: "/drm", label: "Licenties" }, { href: "/scripts", label: "Scripts" }, + { href: "/agent", label: "Agent" }, { href: "/viewers", label: "Gebruikers" }, { href: "/settings", label: "Instellingen" }, ]; diff --git a/apps/master-api/package.json b/apps/master-api/package.json index bdb6b0b..da1d11d 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -15,6 +15,7 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^10.0.2", + "@fastify/multipart": "^10.1.1", "@fastify/rate-limit": "^10.2.2", "@fastify/websocket": "^11.0.2", "@media-cluster/protocol": "workspace:*", diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 59bc050..076fdbc 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -3,6 +3,7 @@ import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import websocket from "@fastify/websocket"; +import multipart from "@fastify/multipart"; import { loadConfig } from "./config"; import { disconnectDatabase, prisma } from "./database/client"; import { registerAuthRoutes, ensureAdminUser } from "./auth/routes"; @@ -23,6 +24,7 @@ import { startIptvEpgWarmup } from "./viewer/iptv-client"; import { applyIptvEpgUrlsToRuntime } from "./settings/iptv"; import { startOdidoMpdRefreshWorker } from "./odido/refresh"; import { startViaplayPreLiveWorker } from "./viaplay/refresh"; +import { TOKEN_TRAY_MAX_BYTES } from "./install/token-tray-store"; async function main() { const config = loadConfig(); @@ -34,6 +36,7 @@ async function main() { redact: ["req.headers.cookie", "req.headers.authorization"], }, trustProxy: true, + bodyLimit: TOKEN_TRAY_MAX_BYTES + 1024 * 1024, }); await app.register(cors, { @@ -45,6 +48,14 @@ async function main() { secret: config.SESSION_SECRET, }); + await app.register(multipart, { + limits: { + fileSize: TOKEN_TRAY_MAX_BYTES, + files: 1, + fields: 8, + }, + }); + await app.register(rateLimit, { max: 300, timeWindow: "1 minute", diff --git a/apps/master-api/src/install/routes.ts b/apps/master-api/src/install/routes.ts index eef4ad4..493e2a5 100644 --- a/apps/master-api/src/install/routes.ts +++ b/apps/master-api/src/install/routes.ts @@ -2,43 +2,23 @@ import type { FastifyInstance } from "fastify"; import fs from "fs"; import path from "path"; import type { Config } from "../config"; - -function installDir(): string { - const candidates = [ - path.join(process.cwd(), "install"), - path.join(__dirname, "..", "..", "install"), - "/app/install", - ]; - for (const dir of candidates) { - if ( - fs.existsSync(path.join(dir, "unraid.sh")) || - fs.existsSync(path.join(dir, "synology.sh")) || - fs.existsSync(path.join(dir, "upgrade-node.sh")) || - fs.existsSync(path.join(dir, "token-tray-setup.exe")) || - fs.existsSync(path.join(dir, "agent", "token-tray-setup.exe")) - ) { - return dir; - } - } - return candidates[0]; -} - -function tokenTrayPaths(dir: string): { exe: string; meta: string } { - const nested = path.join(dir, "agent"); - if (fs.existsSync(path.join(nested, "token-tray-setup.exe"))) { - return { - exe: path.join(nested, "token-tray-setup.exe"), - meta: path.join(nested, "token-tray.json"), - }; - } - return { - exe: path.join(dir, "token-tray-setup.exe"), - meta: path.join(dir, "token-tray.json"), - }; -} +import { requireAdmin } from "../auth/routes"; +import { AppError } from "../security/errors"; +import { + TOKEN_TRAY_MAX_BYTES, + deleteTokenTrayInstaller, + getTokenTrayMeta, + resolveInstallRoot, + saveTokenTrayUpload, + tokenTrayPaths, +} from "./token-tray-store"; function sendInstallScript( - reply: { header: (k: string, v: string) => unknown; status: (c: number) => { send: (b: string) => unknown }; send: (b: string) => unknown }, + reply: { + header: (k: string, v: string) => unknown; + status: (c: number) => { send: (b: string) => unknown }; + send: (b: string) => unknown; + }, dir: string, filename: string, publicUrl: string @@ -57,7 +37,7 @@ function sendInstallScript( } export async function registerInstallRoutes(app: FastifyInstance, config: Config) { - const dir = installDir(); + const dir = resolveInstallRoot(); const publicUrl = config.PUBLIC_URL; app.get("/install/unraid.sh", async (_request, reply) => { @@ -85,43 +65,18 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config } app.get("/install/token-tray.json", async (_request, reply) => { - const { exe, meta } = tokenTrayPaths(dir); - const available = fs.existsSync(exe); - let body: Record = { - available, - version: null, - filename: "token-tray-setup.exe", - sizeBytes: 0, - downloadUrl: `${publicUrl.replace(/\/$/, "")}/install/token-tray-setup.exe`, - productName: "Media Cluster Token Tray", - }; - if (fs.existsSync(meta)) { - try { - const parsed = JSON.parse(fs.readFileSync(meta, "utf8")) as Record; - body = { ...body, ...parsed, available }; - } catch { - /* ignore */ - } - } - if (available) { - body.sizeBytes = fs.statSync(exe).size; - body.available = true; - } else { - body.available = false; - body.sizeBytes = 0; - } reply.header("Content-Type", "application/json; charset=utf-8"); reply.header("Cache-Control", "no-store"); - return reply.send(body); + return reply.send(getTokenTrayMeta(publicUrl)); }); app.get("/install/token-tray-setup.exe", async (_request, reply) => { - const { exe } = tokenTrayPaths(dir); + const { exe } = tokenTrayPaths(); if (!fs.existsSync(exe)) { return reply .status(404) .send( - "Token Tray installer not found — build with apps/token-tray pack:win + publish:installer, then redeploy." + "Token Tray installer not found — upload via Admin → /agent (of pack:win + publish)." ); } reply.header("Content-Type", "application/octet-stream"); @@ -132,4 +87,45 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config reply.header("Cache-Control", "no-store"); return reply.send(fs.createReadStream(exe)); }); + + app.get("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => { + return getTokenTrayMeta(publicUrl); + }); + + app.post( + "/api/v1/admin/install/token-tray", + { + preHandler: requireAdmin, + bodyLimit: TOKEN_TRAY_MAX_BYTES + 1024 * 1024, + }, + async (request) => { + const data = await request.file(); + if (!data) { + throw new AppError( + "INVALID_REQUEST", + "Geen bestand — stuur multipart field 'file' (.exe)", + 400 + ); + } + + let version: string | null = null; + const versionField = data.fields?.version; + if (versionField && !Array.isArray(versionField) && "value" in versionField) { + version = String((versionField as { value: unknown }).value ?? "").trim() || null; + } + const q = (request.query as { version?: string }).version; + if (!version && typeof q === "string" && q.trim()) version = q.trim(); + + return saveTokenTrayUpload({ + stream: data.file, + filename: data.filename, + version, + publicUrl, + }); + } + ); + + app.delete("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => { + return deleteTokenTrayInstaller(publicUrl); + }); } diff --git a/apps/master-api/src/install/token-tray-store.ts b/apps/master-api/src/install/token-tray-store.ts new file mode 100644 index 0000000..e8795d3 --- /dev/null +++ b/apps/master-api/src/install/token-tray-store.ts @@ -0,0 +1,266 @@ +import fs from "fs"; +import path from "path"; +import { pipeline } from "stream/promises"; +import type { Readable } from "stream"; +import { AppError } from "../security/errors"; + +export const TOKEN_TRAY_FILENAME = "token-tray-setup.exe"; +export const TOKEN_TRAY_META_FILENAME = "token-tray.json"; +/** Max ~200 MB — Electron NSIS builds are typically 70–120 MB. */ +export const TOKEN_TRAY_MAX_BYTES = 200 * 1024 * 1024; +export const TOKEN_TRAY_MIN_BYTES = 1 * 1024 * 1024; + +export type TokenTrayMeta = { + available: boolean; + version: string | null; + filename: string; + sizeBytes: number; + builtAt: string | null; + productName: string; + downloadUrl: string; + note?: string; +}; + +function installRootCandidates(): string[] { + return [ + path.join(process.cwd(), "install"), + path.join(__dirname, "..", "..", "install"), + "/app/install", + ]; +} + +/** Root that contains unraid.sh / agent / binaries (same logic as public install routes). */ +export function resolveInstallRoot(): string { + for (const dir of installRootCandidates()) { + if ( + fs.existsSync(path.join(dir, "unraid.sh")) || + fs.existsSync(path.join(dir, "synology.sh")) || + fs.existsSync(path.join(dir, "upgrade-node.sh")) || + fs.existsSync(path.join(dir, TOKEN_TRAY_FILENAME)) || + fs.existsSync(path.join(dir, "agent", TOKEN_TRAY_FILENAME)) || + fs.existsSync(path.join(dir, "agent")) + ) { + return dir; + } + } + return installRootCandidates()[0]; +} + +/** Writable dir for Token Tray installer (Dokploy volume: /app/install/agent). */ +export function resolveAgentDir(): string { + const preferred = [ + path.join("/app/install", "agent"), + path.join(resolveInstallRoot(), "agent"), + path.join(process.cwd(), "install", "agent"), + ]; + for (const dir of preferred) { + try { + fs.mkdirSync(dir, { recursive: true }); + fs.accessSync(dir, fs.constants.W_OK); + return dir; + } catch { + /* try next */ + } + } + const fallback = preferred[preferred.length - 1]; + fs.mkdirSync(fallback, { recursive: true }); + return fallback; +} + +export function tokenTrayPaths(): { exe: string; meta: string; dir: string } { + const root = resolveInstallRoot(); + const nested = path.join(root, "agent"); + if (fs.existsSync(path.join(nested, TOKEN_TRAY_FILENAME))) { + return { + dir: nested, + exe: path.join(nested, TOKEN_TRAY_FILENAME), + meta: path.join(nested, TOKEN_TRAY_META_FILENAME), + }; + } + if (fs.existsSync(path.join(root, TOKEN_TRAY_FILENAME))) { + return { + dir: root, + exe: path.join(root, TOKEN_TRAY_FILENAME), + meta: path.join(root, TOKEN_TRAY_META_FILENAME), + }; + } + const dir = resolveAgentDir(); + return { + dir, + exe: path.join(dir, TOKEN_TRAY_FILENAME), + meta: path.join(dir, TOKEN_TRAY_META_FILENAME), + }; +} + +export function getTokenTrayMeta(publicUrl: string): TokenTrayMeta { + const { exe, meta } = tokenTrayPaths(); + const available = fs.existsSync(exe); + const base: TokenTrayMeta = { + available, + version: null, + filename: TOKEN_TRAY_FILENAME, + sizeBytes: 0, + builtAt: null, + productName: "Media Cluster Token Tray", + downloadUrl: `${publicUrl.replace(/\/$/, "")}/install/token-tray-setup.exe`, + }; + if (fs.existsSync(meta)) { + try { + const parsed = JSON.parse(fs.readFileSync(meta, "utf8")) as Partial; + Object.assign(base, parsed); + } catch { + /* ignore */ + } + } + if (available) { + base.sizeBytes = fs.statSync(exe).size; + base.available = true; + delete base.note; + } else { + base.available = false; + base.sizeBytes = 0; + if (!base.note) { + base.note = + "Nog geen installer — upload token-tray-setup.exe via Admin → Token Tray (/agent)."; + } + } + base.downloadUrl = `${publicUrl.replace(/\/$/, "")}/install/token-tray-setup.exe`; + base.filename = TOKEN_TRAY_FILENAME; + return base; +} + +function assertPeExecutable(buf: Buffer) { + if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) { + throw new AppError( + "INVALID_REQUEST", + "Bestand is geen Windows .exe (MZ-header ontbreekt)", + 400 + ); + } +} + +export async function saveTokenTrayUpload(opts: { + stream: Readable; + filename?: string; + version?: string | null; + publicUrl: string; +}): Promise { + const name = (opts.filename || "").toLowerCase(); + if (name && !name.endsWith(".exe")) { + throw new AppError("INVALID_REQUEST", "Alleen .exe-bestanden toegestaan", 400); + } + + const dir = resolveAgentDir(); + const exePath = path.join(dir, TOKEN_TRAY_FILENAME); + const metaPath = path.join(dir, TOKEN_TRAY_META_FILENAME); + const tmpPath = path.join(dir, `${TOKEN_TRAY_FILENAME}.uploading`); + + try { + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); + await pipeline(opts.stream, fs.createWriteStream(tmpPath)); + } catch (err) { + try { + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + const msg = err instanceof Error ? err.message : String(err); + if (/Limit|fileSize|truncated/i.test(msg)) { + throw new AppError( + "INVALID_REQUEST", + `Bestand te groot (max ${Math.round(TOKEN_TRAY_MAX_BYTES / (1024 * 1024))} MB)`, + 400 + ); + } + throw err; + } + + const stat = fs.statSync(tmpPath); + if (stat.size < TOKEN_TRAY_MIN_BYTES) { + fs.unlinkSync(tmpPath); + throw new AppError( + "INVALID_REQUEST", + `Bestand te klein (${stat.size} bytes) — verwacht een NSIS-installer`, + 400 + ); + } + if (stat.size > TOKEN_TRAY_MAX_BYTES) { + fs.unlinkSync(tmpPath); + throw new AppError( + "INVALID_REQUEST", + `Bestand te groot (max ${Math.round(TOKEN_TRAY_MAX_BYTES / (1024 * 1024))} MB)`, + 400 + ); + } + + const fd = fs.openSync(tmpPath, "r"); + const head = Buffer.alloc(2); + fs.readSync(fd, head, 0, 2, 0); + fs.closeSync(fd); + try { + assertPeExecutable(head); + } catch (e) { + fs.unlinkSync(tmpPath); + throw e; + } + + fs.renameSync(tmpPath, exePath); + + const version = + (opts.version || "").trim().replace(/^v/i, "") || + guessVersionFromFilename(opts.filename) || + null; + + const meta = { + version, + filename: TOKEN_TRAY_FILENAME, + sizeBytes: stat.size, + builtAt: new Date().toISOString(), + productName: "Media Cluster Token Tray", + available: true, + }; + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8"); + return getTokenTrayMeta(opts.publicUrl); +} + +function guessVersionFromFilename(filename?: string): string | null { + if (!filename) return null; + const m = /(?:^|[^\d])(\d+\.\d+\.\d+)/.exec(filename); + return m?.[1] ?? null; +} + +export function deleteTokenTrayInstaller(publicUrl: string): TokenTrayMeta { + const dir = resolveAgentDir(); + const exePath = path.join(dir, TOKEN_TRAY_FILENAME); + const metaPath = path.join(dir, TOKEN_TRAY_META_FILENAME); + // Also clear flat layout if present + const root = resolveInstallRoot(); + for (const p of [ + exePath, + metaPath, + path.join(root, TOKEN_TRAY_FILENAME), + path.join(root, TOKEN_TRAY_META_FILENAME), + ]) { + try { + if (fs.existsSync(p)) fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + const placeholder = { + version: null, + filename: TOKEN_TRAY_FILENAME, + sizeBytes: 0, + builtAt: null, + productName: "Media Cluster Token Tray", + available: false, + note: "Installer verwijderd — upload een nieuwe build via Admin → /agent.", + }; + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(metaPath, JSON.stringify(placeholder, null, 2), "utf8"); + } catch { + /* ignore */ + } + return getTokenTrayMeta(publicUrl); +} diff --git a/apps/token-tray/README.md b/apps/token-tray/README.md index 7e5aa88..eb5c8c4 100644 --- a/apps/token-tray/README.md +++ b/apps/token-tray/README.md @@ -4,11 +4,29 @@ Windows system-tray helper: **stille browser-UI login** voor Viaplay + F1TV (gee ## Gebruikers (productie) -1. Open **https://admin.vonas.nl/agent** → download NSIS-installer +1. Open **https://admin.vonas.nl/agent** → download NSIS-installer (of upload een nieuwe build) 2. Installeer → tray start mee met Windows 3. **Instellingen → Token Tray** → kopieer API URL + key naar de tray 4. **Scripts → Viaplay / F1TV** → accounts met e-mail + wachtwoord +## Installer bouwen + uploaden (Admin) + +```powershell +cd apps\token-tray +pnpm.cmd install +pnpm.cmd run pack:win +``` + +Ga naar **Admin → Agent** (`/agent`), vul het versie-label in, kies `release\token-tray-setup.exe`. +Master bewaart het op `/app/install/agent/` (volume) — geen Docker-host/`docker cp` nodig. + +API: + +- `GET /api/v1/admin/install/token-tray` — status +- `POST /api/v1/admin/install/token-tray` — multipart `file` + `version` +- `DELETE /api/v1/admin/install/token-tray` — verwijderen +- Publiek: `GET /install/token-tray-setup.exe` + `token-tray.json` + ## Dev (zonder installer) ```powershell @@ -18,27 +36,6 @@ pnpm.cmd run build pnpm.cmd run start ``` -## Installer bouwen + publiceren - -```powershell -cd apps\token-tray -pnpm.cmd install -pnpm.cmd run pack:win -pnpm.cmd run publish:installer -``` - -Dat schrijft: - -- `deploy/agent/token-tray-setup.exe` -- `deploy/agent/token-tray.json` - -Master serveert: - -- `GET /install/token-tray-setup.exe` -- `GET /install/token-tray.json` - -De `.exe` staat in `.gitignore`. Op Dokploy: mount/upload naar volume `agent_install` → `/app/install/agent/`, of force-add + rebuild image. - ## Auto-modus (hands-off) Zolang de tray op de Windows-PC draait: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 563c9a2..18b28ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@fastify/cors': specifier: ^10.0.2 version: 10.1.0 + '@fastify/multipart': + specifier: ^10.1.1 + version: 10.1.1 '@fastify/rate-limit': specifier: ^10.2.2 version: 10.3.0 @@ -333,12 +336,18 @@ packages: '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + '@fastify/busboy@3.2.2': + resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==} + '@fastify/cookie@11.1.2': resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} '@fastify/cors@10.1.0': resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -351,6 +360,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@10.1.1': + resolution: {integrity: sha512-jyRHgnFVdchZRKjJRf6kGPEiDp3Bg4MLo4d6/krt8Z4RutLrqL5IYWihx8a4tnv7Tu7JfuHcyC+dU9zPzTxiSg==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -2924,6 +2936,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 4.1.3 + '@fastify/busboy@3.2.2': {} + '@fastify/cookie@11.1.2': dependencies: cookie: 2.0.1 @@ -2934,6 +2948,8 @@ snapshots: fastify-plugin: 5.1.0 mnemonist: 0.40.0 + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.1.0': @@ -2946,6 +2962,14 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@10.1.1': + dependencies: + '@fastify/busboy': 3.2.2 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 6.0.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.2