diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index a1c7075..7d6ee1f 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -144,6 +144,16 @@ export default function SettingsPage() { const [pwMsg, setPwMsg] = useState(null); const [pwErr, setPwErr] = useState(null); + const [trayApiUrl, setTrayApiUrl] = useState(""); + const [trayHasSecret, setTrayHasSecret] = useState(false); + const [trayFromEnv, setTrayFromEnv] = useState(false); + const [trayEnabled, setTrayEnabled] = useState(true); + const [traySecretOnce, setTraySecretOnce] = useState(null); + const [trayManualSecret, setTrayManualSecret] = useState(""); + const [trayBusy, setTrayBusy] = useState(false); + const [trayMsg, setTrayMsg] = useState(null); + const [trayErr, setTrayErr] = useState(null); + const [gpProjectId, setGpProjectId] = useState(""); const [gpSaEmail, setGpSaEmail] = useState(""); const [gpPrivateKey, setGpPrivateKey] = useState(""); @@ -282,6 +292,20 @@ export default function SettingsPage() { .catch(() => undefined); }, []); + const loadTokenTray = useCallback(() => { + fetch("/api/v1/admin/settings/token-tray", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { + const s = d.settings; + if (!s) return; + setTrayApiUrl(s.apiUrl ?? ""); + setTrayHasSecret(!!s.hasSecret); + setTrayFromEnv(!!s.fromEnvFallback); + setTrayEnabled(s.enabled !== false); + }) + .catch(() => undefined); + }, []); + useEffect(() => { loadShelves(); loadDs(); @@ -289,6 +313,7 @@ export default function SettingsPage() { loadOmdb(); loadGooglePlay(); loadTvSettings(); + loadTokenTray(); fetch("/api/v1/auth/me", { credentials: "include" }) .then((r) => r.json()) .then((d) => setAdminEmail(d.user?.email ?? "")) @@ -301,7 +326,7 @@ export default function SettingsPage() { } }) .catch(() => undefined); - }, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings]); + }, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings, loadTokenTray]); async function saveInstallPageEnabled(enabled: boolean) { setInstallPageBusy(true); @@ -373,6 +398,47 @@ export default function SettingsPage() { } } + async function saveTokenTray(body: { + generate?: boolean; + secret?: string; + enabled?: boolean; + clear?: boolean; + }) { + setTrayBusy(true); + setTrayMsg(null); + setTrayErr(null); + try { + const res = await fetch("/api/v1/admin/settings/token-tray", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setTrayErr(data.error?.message ?? "Opslaan mislukt"); + return; + } + const s = data.settings; + setTrayApiUrl(s?.apiUrl ?? trayApiUrl); + setTrayHasSecret(!!s?.hasSecret); + setTrayFromEnv(!!s?.fromEnvFallback); + setTrayEnabled(s?.enabled !== false); + if (s?.secretOnce) { + setTraySecretOnce(s.secretOnce); + setTrayMsg("Nieuwe key aangemaakt — kopieer hem nu naar de Token Tray (wordt niet opnieuw getoond)."); + } else if (body.clear) { + setTraySecretOnce(null); + setTrayMsg("Tray-key gewist"); + } else { + setTrayMsg("Opgeslagen"); + } + setTrayManualSecret(""); + } finally { + setTrayBusy(false); + } + } + async function createShelf() { if (!newShelfName.trim()) return; const res = await fetch("/api/v1/admin/library/shelves", { @@ -715,6 +781,112 @@ export default function SettingsPage() { +
+

Token Tray

+

+ Key voor de Windows Token Tray-app (apps/token-tray). Plak API-URL + + key in de tray-instellingen. +

+
+
+ {trayErr &&
{trayErr}
} + {trayMsg && ( +

+ {trayMsg} +

+ )} +
+ + +

+ Kopieer dit veld naar API URL in de tray. +

+
+

+ Status:{" "} + {trayHasSecret ? ( + + key actief{trayFromEnv ? " (via .env)" : ""} + + ) : ( + nog geen key + )} +

+ {traySecretOnce ? ( +
+ + e.currentTarget.select()} + /> + +
+ ) : null} +
+ + setTrayManualSecret(e.target.value)} + placeholder="leeg laten = genereer" + autoComplete="off" + /> +
+ +
+ + + +
+
+

Publieke install-pagina

diff --git a/apps/master-api/src/agent/routes.ts b/apps/master-api/src/agent/routes.ts index a9f810e..1f10e40 100644 --- a/apps/master-api/src/agent/routes.ts +++ b/apps/master-api/src/agent/routes.ts @@ -1,13 +1,17 @@ import type { FastifyInstance, FastifyRequest } from "fastify"; import type { Config } from "../config"; import { AppError } from "../security/errors"; +import { resolveTokenTraySecret } from "../settings/token-tray"; -function requireTrayKey(request: FastifyRequest, config: Config) { - const secret = config.TOKEN_TRAY_SECRET?.trim(); +async function requireTrayKey(request: FastifyRequest, config: Config) { + const secret = await resolveTokenTraySecret( + config.SESSION_SECRET, + config.TOKEN_TRAY_SECRET + ); if (!secret) { throw new AppError( "FORBIDDEN", - "Token-tray is niet geconfigureerd (TOKEN_TRAY_SECRET ontbreekt)", + "Token-tray is niet geconfigureerd — maak een key onder Admin → Instellingen", 403 ); } @@ -19,7 +23,7 @@ function requireTrayKey(request: FastifyRequest, config: Config) { export function registerAgentRoutes(app: FastifyInstance, config: Config) { app.get("/api/v1/agent/status", async (request) => { - requireTrayKey(request, config); + await requireTrayKey(request, config); return { ok: true, trayEnabled: true, @@ -28,7 +32,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) { }); app.post("/api/v1/agent/viaplay/tokens", async (request) => { - requireTrayKey(request, config); + await requireTrayKey(request, config); const body = request.body as { email?: string; label?: string; diff --git a/apps/master-api/src/settings/routes.ts b/apps/master-api/src/settings/routes.ts index 17a6b25..a57bfa5 100644 --- a/apps/master-api/src/settings/routes.ts +++ b/apps/master-api/src/settings/routes.ts @@ -15,6 +15,10 @@ import { upsertGooglePlaySettings, } from "./google-play"; import { getIptvSettings, upsertIptvSettings } from "./iptv"; +import { + getTokenTraySettingsPublic, + upsertTokenTraySettings, +} from "./token-tray"; import { listIptvCategoriesForAdmin, refreshIptvEpgNow } from "../viewer/iptv-client"; import type { GooglePlayAccessService } from "../google-play/service"; @@ -133,4 +137,19 @@ export function registerSettingsRoutes( const categories = await listIptvCategoriesForAdmin(config); return { ok: true, ...result, categories }; }); + + app.get("/api/v1/admin/settings/token-tray", { preHandler: requireAdmin }, async () => { + return { settings: await getTokenTraySettingsPublic(config) }; + }); + + app.put("/api/v1/admin/settings/token-tray", { preHandler: requireAdmin }, async (request) => { + const body = request.body as { + secret?: string; + generate?: boolean; + enabled?: boolean; + clear?: boolean; + }; + const settings = await upsertTokenTraySettings(config.SESSION_SECRET, config, body); + return { settings }; + }); } diff --git a/apps/master-api/src/settings/token-tray.ts b/apps/master-api/src/settings/token-tray.ts new file mode 100644 index 0000000..76f2fa8 --- /dev/null +++ b/apps/master-api/src/settings/token-tray.ts @@ -0,0 +1,108 @@ +import { randomBytes } from "node:crypto"; +import { prisma } from "../database/client"; +import { decryptSecret, encryptSecret } from "../security/crypto"; +import { AppError } from "../security/errors"; +import type { Config } from "../config"; + +const TOKEN_TRAY_ID = "token-tray"; + +export type TokenTraySettingsPublic = { + enabled: boolean; + hasSecret: boolean; + /** Alleen gezet direct na generate/save — plak dit in de tray. */ + secretOnce?: string; + apiUrl: string; + fromEnvFallback: boolean; +}; + +export async function getTokenTraySettingsPublic( + config: Config +): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: TOKEN_TRAY_ID } }); + const hasDb = !!row?.apiKeyEnc; + const hasEnv = !!config.TOKEN_TRAY_SECRET?.trim(); + return { + enabled: row?.enabled ?? true, + hasSecret: hasDb || hasEnv, + apiUrl: config.PUBLIC_URL.replace(/\/$/, ""), + fromEnvFallback: !hasDb && hasEnv, + }; +} + +/** Secret die de agent-API accepteert: DB → anders env. */ +export async function resolveTokenTraySecret( + sessionSecret: string, + envFallback?: string | null +): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: TOKEN_TRAY_ID } }); + if (row && row.enabled === false) return null; + if (row?.apiKeyEnc) { + try { + const key = decryptSecret(row.apiKeyEnc, sessionSecret).trim(); + if (key) return key; + } catch { + /* fall through */ + } + } + const env = envFallback?.trim(); + return env || null; +} + +export function generateTokenTraySecret(): string { + return randomBytes(24).toString("base64url"); +} + +export async function upsertTokenTraySettings( + sessionSecret: string, + config: Config, + input: { + secret?: string; + generate?: boolean; + enabled?: boolean; + clear?: boolean; + } +): Promise { + const existing = await prisma.integrationSetting.findUnique({ where: { id: TOKEN_TRAY_ID } }); + const enabled = input.enabled ?? existing?.enabled ?? true; + + let apiKeyEnc = existing?.apiKeyEnc ?? null; + let secretOnce: string | undefined; + + if (input.clear) { + apiKeyEnc = null; + } else if (input.generate) { + secretOnce = generateTokenTraySecret(); + apiKeyEnc = encryptSecret(secretOnce, sessionSecret); + } else if (input.secret?.trim()) { + const trimmed = input.secret.trim(); + if (trimmed.length < 16) { + throw new AppError("INVALID_REQUEST", "Tray-key minstens 16 tekens", 400); + } + secretOnce = trimmed; + apiKeyEnc = encryptSecret(trimmed, sessionSecret); + } + + if (enabled && !apiKeyEnc && !config.TOKEN_TRAY_SECRET?.trim()) { + throw new AppError( + "INVALID_REQUEST", + "Genereer of plak een tray-key (of zet TOKEN_TRAY_SECRET in .env)", + 400 + ); + } + + await prisma.integrationSetting.upsert({ + where: { id: TOKEN_TRAY_ID }, + create: { + id: TOKEN_TRAY_ID, + apiKeyEnc, + enabled, + }, + update: { + apiKeyEnc, + enabled, + }, + }); + + const pub = await getTokenTraySettingsPublic(config); + return secretOnce ? { ...pub, secretOnce } : pub; +} diff --git a/apps/token-tray/README.md b/apps/token-tray/README.md index 0a9b3d0..d2e5c1b 100644 --- a/apps/token-tray/README.md +++ b/apps/token-tray/README.md @@ -2,13 +2,9 @@ Windows system-tray helper die Viaplay-sessie-tokens vernieuwt en naar de master-api pusht. -## Server - -Zet in master-api `.env`: - -```env -TOKEN_TRAY_SECRET=minstens-16-willekeurige-tekens -``` +1. Open **Admin → Instellingen → Admin** → sectie **Token Tray** +2. Klik **Genereer nieuwe key** en kopieer de key (+ API URL) naar de tray-app +3. Of zet optioneel `TOKEN_TRAY_SECRET` in `.env` (fallback als er geen DB-key is) Endpoint: @@ -17,12 +13,23 @@ Endpoint: ## Lokaal starten -```bash -cd apps/token-tray -pnpm install -pnpm dev +PowerShell blokkeert soms `pnpm.ps1`. Gebruik dan **`pnpm.cmd`** (of CMD): + +```powershell +cd apps\token-tray +pnpm.cmd install +pnpm.cmd run build +pnpm.cmd run start ``` +Of eenmalig voor je user-account: + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Daarna werkt gewoon `pnpm install` / `pnpm dev` weer. + 1. Vul **API URL** + **Tray key** (= `TOKEN_TRAY_SECRET`) 2. Vul het **zelfde e-mailadres** als het Scripts → Viaplay account 3. Tray → **Viaplay inloggen / vernieuwen** (CAPTCHA in het venster)