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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
|
|
@ -25,20 +25,32 @@ function formatBytes(n: number): string {
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [meta, setMeta] = useState<TrayMeta | null>(null);
|
const [meta, setMeta] = useState<TrayMeta | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
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",
|
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);
|
setMeta(data);
|
||||||
|
if (data.version) setVersion(String(data.version));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e));
|
setErr(e instanceof Error ? e.message : String(e));
|
||||||
setMeta(null);
|
setMeta(null);
|
||||||
|
|
@ -51,6 +63,78 @@ export default function AgentPage() {
|
||||||
void load();
|
void load();
|
||||||
}, [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 available = Boolean(meta?.available);
|
||||||
const downloadHref =
|
const downloadHref =
|
||||||
meta?.downloadUrl?.trim() || `${MASTER_URL}/install/token-tray-setup.exe`;
|
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.
|
stil via Chrome/Edge — hands-off zolang de PC aan staat.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => void load()} disabled={loading}>
|
<button type="button" onClick={() => void load()} disabled={loading || uploading}>
|
||||||
{loading ? "Laden…" : "Vernieuwen"}
|
{loading ? "Laden…" : "Vernieuwen"}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
|
{msg && (
|
||||||
|
<p className="muted" style={{ color: "var(--ok)", marginBottom: "1rem" }}>
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="section-head">
|
<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>
|
<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>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{loading && !meta ? (
|
{loading && !meta ? (
|
||||||
|
|
@ -93,19 +243,28 @@ export default function AgentPage() {
|
||||||
<p className="muted" style={{ fontSize: "0.9rem" }}>
|
<p className="muted" style={{ fontSize: "0.9rem" }}>
|
||||||
{formatBytes(meta?.sizeBytes ?? 0)}
|
{formatBytes(meta?.sizeBytes ?? 0)}
|
||||||
{meta?.builtAt
|
{meta?.builtAt
|
||||||
? ` · gebouwd ${new Date(meta.builtAt).toLocaleString("nl-NL")}`
|
? ` · geüpload ${new Date(meta.builtAt).toLocaleString("nl-NL")}`
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
<a className="btn" href={downloadHref} style={{ display: "inline-block", marginTop: 8 }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
||||||
Download installer
|
<a className="btn" href={downloadHref} style={{ display: "inline-block" }}>
|
||||||
</a>
|
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" }}>
|
<p className="muted" style={{ fontSize: "0.9rem" }}>
|
||||||
{meta?.note ||
|
{meta?.note || "Upload hierboven token-tray-setup.exe."}
|
||||||
"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."}
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -116,7 +275,7 @@ export default function AgentPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<ol className="install-ol" style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
<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>
|
<li>
|
||||||
Open <strong>Instellingen…</strong> in het tray-menu.
|
Open <strong>Instellingen…</strong> in het tray-menu.
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
/** Lange uploads (~80–120 MB Token Tray installer). */
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
const SESSION_COOKIE = "mc_admin_session";
|
const SESSION_COOKIE = "mc_admin_session";
|
||||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60;
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60;
|
||||||
|
|
@ -29,8 +31,6 @@ function applySessionCookie(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
expiresAt?: string
|
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);
|
const secure = isHttps(request);
|
||||||
response.cookies.set({
|
response.cookies.set({
|
||||||
name: SESSION_COOKIE,
|
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[]) {
|
async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
const targetPath = pathSegments.join("/");
|
const targetPath = pathSegments.join("/");
|
||||||
const url = new URL(request.url);
|
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 isLogout = request.method === "POST" && targetPath === "v1/auth/logout";
|
||||||
const isSse =
|
const isSse =
|
||||||
request.method === "GET" && targetPath === "v1/admin/viewers/events";
|
request.method === "GET" && targetPath === "v1/admin/viewers/events";
|
||||||
|
const streamUpload = isTokenTrayUpload(request.method, targetPath);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const contentType = request.headers.get("content-type");
|
const contentType = request.headers.get("content-type");
|
||||||
if (contentType) headers.set("content-type", contentType);
|
if (contentType) headers.set("content-type", contentType);
|
||||||
const cookie = request.headers.get("cookie");
|
const cookie = request.headers.get("cookie");
|
||||||
if (cookie) headers.set("cookie", 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");
|
headers.set("accept", isSse ? "text/event-stream" : "application/json");
|
||||||
|
|
||||||
const body =
|
let body: BodyInit | undefined;
|
||||||
request.method !== "GET" && request.method !== "HEAD"
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
? await request.arrayBuffer()
|
if (streamUpload) {
|
||||||
: undefined;
|
// Stream ~80–120 MB installer — niet bufferen in memory
|
||||||
|
body = request.body ?? undefined;
|
||||||
|
} else {
|
||||||
|
body = await request.arrayBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
|
@ -88,13 +100,14 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
// SSE must not be aborted when the proxy function "returns" the stream
|
|
||||||
...(isSse
|
...(isSse
|
||||||
? { cache: "no-store" as RequestCache, signal: request.signal }
|
? { 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) {
|
if (isLogin) {
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
let data: {
|
let data: {
|
||||||
|
|
@ -171,7 +184,6 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
upstream.headers.get("content-type") ?? "application/json"
|
upstream.headers.get("content-type") ?? "application/json"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Forward any upstream cookies as fallback (rewritten without Domain)
|
|
||||||
const anyHeaders = upstream.headers as Headers & {
|
const anyHeaders = upstream.headers as Headers & {
|
||||||
getSetCookie?: () => string[];
|
getSetCookie?: () => string[];
|
||||||
};
|
};
|
||||||
|
|
@ -193,6 +205,8 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "fetch failed";
|
const message = err instanceof Error ? err.message : "fetch failed";
|
||||||
errors.push(`${base}: ${message}`);
|
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: "/live-lists", label: "Live-lijsten" },
|
||||||
{ href: "/drm", label: "Licenties" },
|
{ href: "/drm", label: "Licenties" },
|
||||||
{ href: "/scripts", label: "Scripts" },
|
{ href: "/scripts", label: "Scripts" },
|
||||||
|
{ href: "/agent", label: "Agent" },
|
||||||
{ href: "/viewers", label: "Gebruikers" },
|
{ href: "/viewers", label: "Gebruikers" },
|
||||||
{ href: "/settings", label: "Instellingen" },
|
{ href: "/settings", label: "Instellingen" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
|
"@fastify/multipart": "^10.1.1",
|
||||||
"@fastify/rate-limit": "^10.2.2",
|
"@fastify/rate-limit": "^10.2.2",
|
||||||
"@fastify/websocket": "^11.0.2",
|
"@fastify/websocket": "^11.0.2",
|
||||||
"@media-cluster/protocol": "workspace:*",
|
"@media-cluster/protocol": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import cookie from "@fastify/cookie";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
import { disconnectDatabase, prisma } from "./database/client";
|
import { disconnectDatabase, prisma } from "./database/client";
|
||||||
import { registerAuthRoutes, ensureAdminUser } from "./auth/routes";
|
import { registerAuthRoutes, ensureAdminUser } from "./auth/routes";
|
||||||
|
|
@ -23,6 +24,7 @@ import { startIptvEpgWarmup } from "./viewer/iptv-client";
|
||||||
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
||||||
import { startOdidoMpdRefreshWorker } from "./odido/refresh";
|
import { startOdidoMpdRefreshWorker } from "./odido/refresh";
|
||||||
import { startViaplayPreLiveWorker } from "./viaplay/refresh";
|
import { startViaplayPreLiveWorker } from "./viaplay/refresh";
|
||||||
|
import { TOKEN_TRAY_MAX_BYTES } from "./install/token-tray-store";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -34,6 +36,7 @@ async function main() {
|
||||||
redact: ["req.headers.cookie", "req.headers.authorization"],
|
redact: ["req.headers.cookie", "req.headers.authorization"],
|
||||||
},
|
},
|
||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
|
bodyLimit: TOKEN_TRAY_MAX_BYTES + 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
|
|
@ -45,6 +48,14 @@ async function main() {
|
||||||
secret: config.SESSION_SECRET,
|
secret: config.SESSION_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: TOKEN_TRAY_MAX_BYTES,
|
||||||
|
files: 1,
|
||||||
|
fields: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
max: 300,
|
max: 300,
|
||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,23 @@ import type { FastifyInstance } from "fastify";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
|
import { requireAdmin } from "../auth/routes";
|
||||||
function installDir(): string {
|
import { AppError } from "../security/errors";
|
||||||
const candidates = [
|
import {
|
||||||
path.join(process.cwd(), "install"),
|
TOKEN_TRAY_MAX_BYTES,
|
||||||
path.join(__dirname, "..", "..", "install"),
|
deleteTokenTrayInstaller,
|
||||||
"/app/install",
|
getTokenTrayMeta,
|
||||||
];
|
resolveInstallRoot,
|
||||||
for (const dir of candidates) {
|
saveTokenTrayUpload,
|
||||||
if (
|
tokenTrayPaths,
|
||||||
fs.existsSync(path.join(dir, "unraid.sh")) ||
|
} from "./token-tray-store";
|
||||||
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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendInstallScript(
|
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,
|
dir: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
publicUrl: string
|
publicUrl: string
|
||||||
|
|
@ -57,7 +37,7 @@ function sendInstallScript(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerInstallRoutes(app: FastifyInstance, config: Config) {
|
export async function registerInstallRoutes(app: FastifyInstance, config: Config) {
|
||||||
const dir = installDir();
|
const dir = resolveInstallRoot();
|
||||||
const publicUrl = config.PUBLIC_URL;
|
const publicUrl = config.PUBLIC_URL;
|
||||||
|
|
||||||
app.get("/install/unraid.sh", async (_request, reply) => {
|
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) => {
|
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("Content-Type", "application/json; charset=utf-8");
|
||||||
reply.header("Cache-Control", "no-store");
|
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) => {
|
app.get("/install/token-tray-setup.exe", async (_request, reply) => {
|
||||||
const { exe } = tokenTrayPaths(dir);
|
const { exe } = tokenTrayPaths();
|
||||||
if (!fs.existsSync(exe)) {
|
if (!fs.existsSync(exe)) {
|
||||||
return reply
|
return reply
|
||||||
.status(404)
|
.status(404)
|
||||||
.send(
|
.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");
|
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");
|
reply.header("Cache-Control", "no-store");
|
||||||
return reply.send(fs.createReadStream(exe));
|
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)
|
## 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
|
2. Installeer → tray start mee met Windows
|
||||||
3. **Instellingen → Token Tray** → kopieer API URL + key naar de tray
|
3. **Instellingen → Token Tray** → kopieer API URL + key naar de tray
|
||||||
4. **Scripts → Viaplay / F1TV** → accounts met e-mail + wachtwoord
|
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)
|
## Dev (zonder installer)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
@ -18,27 +36,6 @@ pnpm.cmd run build
|
||||||
pnpm.cmd run start
|
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)
|
## Auto-modus (hands-off)
|
||||||
|
|
||||||
Zolang de tray op de Windows-PC draait:
|
Zolang de tray op de Windows-PC draait:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ importers:
|
||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^10.0.2
|
specifier: ^10.0.2
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
|
'@fastify/multipart':
|
||||||
|
specifier: ^10.1.1
|
||||||
|
version: 10.1.1
|
||||||
'@fastify/rate-limit':
|
'@fastify/rate-limit':
|
||||||
specifier: ^10.2.2
|
specifier: ^10.2.2
|
||||||
version: 10.3.0
|
version: 10.3.0
|
||||||
|
|
@ -333,12 +336,18 @@ packages:
|
||||||
'@fastify/ajv-compiler@4.0.6':
|
'@fastify/ajv-compiler@4.0.6':
|
||||||
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.2':
|
||||||
|
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
|
||||||
|
|
||||||
'@fastify/cookie@11.1.2':
|
'@fastify/cookie@11.1.2':
|
||||||
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
|
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
|
||||||
|
|
||||||
'@fastify/cors@10.1.0':
|
'@fastify/cors@10.1.0':
|
||||||
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
|
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1':
|
||||||
|
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
|
|
@ -351,6 +360,9 @@ packages:
|
||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
|
'@fastify/multipart@10.1.1':
|
||||||
|
resolution: {integrity: sha512-jyRHgnFVdchZRKjJRf6kGPEiDp3Bg4MLo4d6/krt8Z4RutLrqL5IYWihx8a4tnv7Tu7JfuHcyC+dU9zPzTxiSg==}
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
|
|
@ -2924,6 +2936,8 @@ snapshots:
|
||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 4.1.3
|
fast-uri: 4.1.3
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.2': {}
|
||||||
|
|
||||||
'@fastify/cookie@11.1.2':
|
'@fastify/cookie@11.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 2.0.1
|
cookie: 2.0.1
|
||||||
|
|
@ -2934,6 +2948,8 @@ snapshots:
|
||||||
fastify-plugin: 5.1.0
|
fastify-plugin: 5.1.0
|
||||||
mnemonist: 0.40.0
|
mnemonist: 0.40.0
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1': {}
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.1.0':
|
'@fastify/fast-json-stringify-compiler@5.1.0':
|
||||||
|
|
@ -2946,6 +2962,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
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':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/forwarded': 3.0.2
|
'@fastify/forwarded': 3.0.2
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue