Manage Token Tray key from Admin settings.
Generate or paste a secret in the UI; agent API prefers DB key over TOKEN_TRAY_SECRET env.
This commit is contained in:
parent
22d46ffac7
commit
c425b2b839
5 changed files with 327 additions and 17 deletions
|
|
@ -144,6 +144,16 @@ export default function SettingsPage() {
|
|||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||
const [pwErr, setPwErr] = useState<string | null>(null);
|
||||
|
||||
const [trayApiUrl, setTrayApiUrl] = useState("");
|
||||
const [trayHasSecret, setTrayHasSecret] = useState(false);
|
||||
const [trayFromEnv, setTrayFromEnv] = useState(false);
|
||||
const [trayEnabled, setTrayEnabled] = useState(true);
|
||||
const [traySecretOnce, setTraySecretOnce] = useState<string | null>(null);
|
||||
const [trayManualSecret, setTrayManualSecret] = useState("");
|
||||
const [trayBusy, setTrayBusy] = useState(false);
|
||||
const [trayMsg, setTrayMsg] = useState<string | null>(null);
|
||||
const [trayErr, setTrayErr] = useState<string | null>(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() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="section-head" style={{ marginTop: "1.5rem" }}>
|
||||
<h2 className="section-title">Token Tray</h2>
|
||||
<p className="muted">
|
||||
Key voor de Windows Token Tray-app (<code>apps/token-tray</code>). Plak API-URL +
|
||||
key in de tray-instellingen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
{trayErr && <div className="error">{trayErr}</div>}
|
||||
{trayMsg && (
|
||||
<p className="muted" style={{ color: "var(--ok)", marginBottom: "0.75rem" }}>
|
||||
{trayMsg}
|
||||
</p>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>API URL (voor de tray)</label>
|
||||
<input type="text" value={trayApiUrl} readOnly />
|
||||
<p className="muted" style={{ fontSize: "0.85rem", marginTop: 4 }}>
|
||||
Kopieer dit veld naar <strong>API URL</strong> in de tray.
|
||||
</p>
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: "0.9rem" }}>
|
||||
Status:{" "}
|
||||
{trayHasSecret ? (
|
||||
<span style={{ color: "var(--ok)" }}>
|
||||
key actief{trayFromEnv ? " (via .env)" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span>nog geen key</span>
|
||||
)}
|
||||
</p>
|
||||
{traySecretOnce ? (
|
||||
<div className="form-group" style={{ marginTop: "0.75rem" }}>
|
||||
<label>Tray key (eenmalig zichtbaar)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={traySecretOnce}
|
||||
readOnly
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(traySecretOnce);
|
||||
setTrayMsg("Key gekopieerd naar klembord");
|
||||
}}
|
||||
>
|
||||
Kopieer key
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-group" style={{ marginTop: "0.75rem" }}>
|
||||
<label>Eigen key plakken (optioneel, min. 16 tekens)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={trayManualSecret}
|
||||
onChange={(e) => setTrayManualSecret(e.target.value)}
|
||||
placeholder="leeg laten = genereer"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="checkbox-row"
|
||||
style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginBottom: 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={trayEnabled}
|
||||
disabled={trayBusy}
|
||||
onChange={(e) => void saveTokenTray({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span>Token Tray API inschakelen</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={trayBusy}
|
||||
onClick={() => void saveTokenTray({ generate: true, enabled: true })}
|
||||
>
|
||||
{trayBusy ? "Bezig…" : "Genereer nieuwe key"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={trayBusy || trayManualSecret.trim().length < 16}
|
||||
onClick={() =>
|
||||
void saveTokenTray({ secret: trayManualSecret.trim(), enabled: true })
|
||||
}
|
||||
>
|
||||
Key opslaan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={trayBusy || !trayHasSecret}
|
||||
onClick={() => {
|
||||
if (confirm("Tray-key wissen? De tray kan daarna niet meer pushen.")) {
|
||||
void saveTokenTray({ clear: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Wissen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-head" style={{ marginTop: "1.5rem" }}>
|
||||
<h2 className="section-title">Publieke install-pagina</h2>
|
||||
<p className="muted">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
108
apps/master-api/src/settings/token-tray.ts
Normal file
108
apps/master-api/src/settings/token-tray.ts
Normal file
|
|
@ -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<TokenTraySettingsPublic> {
|
||||
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<string | null> {
|
||||
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<TokenTraySettingsPublic> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue