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:
parent
15757d98b5
commit
62a7ddec63
9 changed files with 578 additions and 109 deletions
|
|
@ -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'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 }}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
266
apps/master-api/src/install/token-tray-store.ts
Normal file
266
apps/master-api/src/install/token-tray-store.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue