import type { FastifyInstance } from "fastify"; import fs from "fs"; import path from "path"; import type { Config } from "../config"; import { requireAdmin } from "../auth/routes"; import { AppError } from "../security/errors"; import { TOKEN_TRAY_MAX_BYTES, deleteTokenTrayInstaller, getTokenTrayMeta, resolveInstallRoot, saveTokenTrayUpload, tokenTrayPaths, } from "./token-tray-store"; import { ANDROID_TV_APK_MAX_BYTES, androidTvApkPaths, checkAndroidTvUpdate, deleteAndroidTvApk, getAndroidTvApkMeta, saveAndroidTvApkUpload, type AndroidTvApkFlavor, } from "./android-tv-apk-store"; function sendInstallScript( reply: { header: (k: string, v: string) => unknown; status: (c: number) => { send: (b: string) => unknown }; send: (b: string) => unknown; }, dir: string, filename: string, publicUrl: string ) { const file = path.join(dir, filename); if (!fs.existsSync(file)) { return reply .status(404) .send("Install script not found — redeploy Master with install assets."); } let script = fs.readFileSync(file, "utf8"); script = script.replaceAll("__MASTER_PUBLIC_URL__", publicUrl.replace(/\/$/, "")); reply.header("Content-Type", "text/x-shellscript; charset=utf-8"); reply.header("Content-Disposition", `inline; filename="${filename}"`); return reply.send(script); } export async function registerInstallRoutes(app: FastifyInstance, config: Config) { const dir = resolveInstallRoot(); const publicUrl = config.PUBLIC_URL; app.get("/install/unraid.sh", async (_request, reply) => { return sendInstallScript(reply, dir, "unraid.sh", publicUrl); }); app.get("/install/synology.sh", async (_request, reply) => { return sendInstallScript(reply, dir, "synology.sh", publicUrl); }); app.get("/install/upgrade-node.sh", async (_request, reply) => { return sendInstallScript(reply, dir, "upgrade-node.sh", publicUrl); }); for (const bin of ["media-node-linux-amd64", "media-node-linux-arm64"] as const) { app.get(`/install/${bin}`, async (_request, reply) => { const file = path.join(dir, bin); if (!fs.existsSync(file)) { return reply.status(404).send(`${bin} not available on this Master build`); } reply.header("Content-Type", "application/octet-stream"); reply.header("Content-Disposition", `attachment; filename="${bin}"`); return reply.send(fs.createReadStream(file)); }); } app.get("/install/token-tray.json", async (_request, reply) => { reply.header("Content-Type", "application/json; charset=utf-8"); reply.header("Cache-Control", "no-store"); return reply.send(getTokenTrayMeta(publicUrl)); }); app.get("/install/token-tray-setup.exe", async (_request, reply) => { const { exe } = tokenTrayPaths(); if (!fs.existsSync(exe)) { return reply .status(404) .send( "Token Tray installer not found — upload via Admin → /agent (of pack:win + publish)." ); } reply.header("Content-Type", "application/octet-stream"); reply.header( "Content-Disposition", 'attachment; filename="Media-Cluster-Token-Tray-Setup.exe"' ); 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); }); // ——— Android TV / VMC Mediabox OTA APK ——— app.get("/api/v1/admin/install/android-tv", { preHandler: requireAdmin }, async () => { return getAndroidTvApkMeta(); }); app.post( "/api/v1/admin/install/android-tv", { preHandler: requireAdmin, bodyLimit: ANDROID_TV_APK_MAX_BYTES + 1024 * 1024, }, async (request) => { const data = await request.file(); if (!data) { throw new AppError( "INVALID_REQUEST", "Geen bestand — stuur multipart field 'file' (.apk)", 400 ); } return saveAndroidTvApkUpload({ stream: data.file, filename: data.filename, fields: data.fields as Record, }); } ); app.delete("/api/v1/admin/install/android-tv", { preHandler: requireAdmin }, async () => { return deleteAndroidTvApk(); }); } /** Client OTA check + download — registered from viewer routes (needs auth). */ export function registerAndroidTvClientUpdateRoutes( app: FastifyInstance, authFromBearer: (authorization: string | undefined) => Promise ) { app.get("/api/v1/client/app-update", async (request) => { await authFromBearer(request.headers.authorization); const q = request.query as { platform?: string; versionCode?: string; flavor?: string; }; const flavor: AndroidTvApkFlavor = q.flavor === "store" || q.platform === "android_tv_store" ? "store" : "appliance"; const current = Math.max(0, Number(q.versionCode) || 0); return checkAndroidTvUpdate({ flavor, currentVersionCode: current }); }); app.get("/api/v1/client/app-update/download", async (request, reply) => { await authFromBearer(request.headers.authorization); const { apk } = androidTvApkPaths(); const meta = getAndroidTvApkMeta(); if (!meta.available || !fs.existsSync(apk)) { throw new AppError("NOT_FOUND", "Geen update-APK beschikbaar", 404); } const name = meta.versionName != null ? `vmc-appliance-${meta.versionName}.apk` : "vmc-appliance.apk"; reply.header("Content-Type", "application/vnd.android.package-archive"); reply.header("Content-Disposition", `attachment; filename="${name}"`); reply.header("Cache-Control", "no-store"); reply.header("X-VMC-Version-Code", String(meta.versionCode ?? "")); reply.header("X-VMC-Version-Name", String(meta.versionName ?? "")); return reply.send(fs.createReadStream(apk)); }); }