Add Admin upload for Token Tray installer on /agent.

Allows publishing NSIS builds via multipart without SSH/docker cp to the host volume.
This commit is contained in:
Jos Vooges | STH 2026-09-23 00:48:13 +02:00
parent 15757d98b5
commit 62a7ddec63
9 changed files with 578 additions and 109 deletions

View file

@ -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<HTMLInputElement>(null);
const [meta, setMeta] = useState<TrayMeta | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [version, setVersion] = useState("0.1.1");
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<number | null>(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.
</p>
</div>
<button type="button" onClick={() => void load()} disabled={loading}>
<button type="button" onClick={() => void load()} disabled={loading || uploading}>
{loading ? "Laden…" : "Vernieuwen"}
</button>
</header>
{err && <div className="error">{err}</div>}
{msg && (
<p className="muted" style={{ color: "var(--ok)", marginBottom: "1rem" }}>
{msg}
</p>
)}
<div className="section-head">
<h2 className="section-title">Installer uploaden</h2>
<p className="muted">
Bouw lokaal met <code>pnpm pack:win</code> → kies{" "}
<code>token-tray-setup.exe</code> hier. Geen Docker-host nodig.
</p>
</div>
<div className="card">
<div className="form-group">
<label htmlFor="tray-version">Versie-label</label>
<input
id="tray-version"
type="text"
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder="0.1.1"
disabled={uploading}
style={{ maxWidth: 160 }}
/>
</div>
<div className="form-group" style={{ marginTop: "0.75rem" }}>
<label htmlFor="tray-file">NSIS installer (.exe, max ~200 MB)</label>
<input
id="tray-file"
ref={fileRef}
type="file"
accept=".exe,application/vnd.microsoft.portable-executable,application/octet-stream"
disabled={uploading}
onChange={(e) => uploadFile(e.target.files?.[0] ?? null)}
/>
</div>
{uploading && (
<p className="muted" style={{ marginTop: "0.75rem" }}>
Uploaden…{progress != null ? ` ${progress}%` : ""}
</p>
)}
{progress != null && (
<div
style={{
marginTop: 8,
height: 6,
borderRadius: 3,
background: "var(--border, #333)",
overflow: "hidden",
}}
>
<div
style={{
width: `${progress}%`,
height: "100%",
background: "var(--accent, #3b82f6)",
transition: "width 0.15s ease",
}}
/>
</div>
)}
</div>
<div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Download</h2>
<p className="muted">NSIS-installer (x64). SmartScreen kan waarschuwen — lokaal gebouwd, niet Store-gesigned.</p>
<p className="muted">
Publieke link voor Windows-PC&apos;s. SmartScreen kan waarschuwen — lokaal gebouwd, niet
Store-gesigned.
</p>
</div>
<div className="card">
{loading && !meta ? (
@ -93,19 +243,28 @@ export default function AgentPage() {
<p className="muted" style={{ fontSize: "0.9rem" }}>
{formatBytes(meta?.sizeBytes ?? 0)}
{meta?.builtAt
? ` · gebouwd ${new Date(meta.builtAt).toLocaleString("nl-NL")}`
? ` · geüpload ${new Date(meta.builtAt).toLocaleString("nl-NL")}`
: ""}
</p>
<a className="btn" href={downloadHref} style={{ display: "inline-block", marginTop: 8 }}>
Download installer
</a>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
<a className="btn" href={downloadHref} style={{ display: "inline-block" }}>
Download installer
</a>
<button
type="button"
className="btn-logout"
disabled={deleting || uploading}
onClick={() => void removeInstaller()}
>
{deleting ? "Verwijderen…" : "Verwijderen"}
</button>
</div>
</>
) : (
<>
<p style={{ marginTop: 0 }}>Installer nog niet beschikbaar op Master.</p>
<p style={{ marginTop: 0 }}>Nog geen installer op Master.</p>
<p className="muted" style={{ fontSize: "0.9rem" }}>
{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."}
</p>
</>
)}
@ -116,7 +275,7 @@ export default function AgentPage() {
</div>
<div className="card">
<ol className="install-ol" style={{ margin: 0, paddingLeft: "1.25rem" }}>
<li>Run de installer → tray-icoon in het systeemvak.</li>
<li>Run de installer → tray-icoon (MC) in het systeemvak.</li>
<li>
Open <strong>Instellingen…</strong> in het tray-menu.
</li>

View file

@ -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;
}
}

View file

@ -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" },
];

View file

@ -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:*",

View file

@ -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",

View file

@ -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<string, unknown> = {
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<string, unknown>;
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);
});
}

View file

@ -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<TokenTrayMeta>;
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<TokenTrayMeta> {
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);
}

View file

@ -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:

View file

@ -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