Files
netfelix-audio-fix/server/api/settings.ts
Felix Förtsch e040c9a234 settings: mask API keys in GET /api/settings, add eye-icon reveal
GET /api/settings now returns jellyfin_api_key, radarr_api_key,
sonarr_api_key, mqtt_password as "***" when set (empty string when
unset). Real values only reach the client via an explicit
GET /api/settings/reveal?key=<key> call, wired to an eye icon on
each secret input in the Settings page.

Save endpoints treat an incoming "***" as a sentinel meaning "user
didn't touch this field, keep stored value", so saving without
revealing preserves the existing secret.

Addresses audit finding #3 (settings endpoint leaks secrets).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:15:08 +02:00

243 lines
9.2 KiB
TypeScript

import { Hono } from "hono";
import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
import { getMqttStatus, startMqttClient, testMqttConnection } from "../services/mqtt";
import { testConnection as testRadarr } from "../services/radarr";
import { getScheduleConfig, type ScheduleConfig, updateScheduleConfig } from "../services/scheduler";
import { testConnection as testSonarr } from "../services/sonarr";
const app = new Hono();
// Config keys that hold credentials. `GET /` returns these as "***" when set,
// "" when unset. Real values only reach the client via the explicit
// GET /reveal?key=<key> endpoint (eye-icon toggle in the settings UI).
const SECRET_KEYS = new Set(["jellyfin_api_key", "radarr_api_key", "sonarr_api_key", "mqtt_password"]);
app.get("/", (c) => {
const config = getAllConfig();
for (const key of SECRET_KEYS) {
if (config[key]) config[key] = "***";
}
const envLocked = Array.from(getEnvLockedKeys());
return c.json({ config, envLocked });
});
app.get("/reveal", (c) => {
const key = c.req.query("key") ?? "";
if (!SECRET_KEYS.has(key)) return c.json({ error: "not a secret key" }, 400);
return c.json({ value: getConfig(key) ?? "" });
});
// The UI sends "***" as a sentinel meaning "user didn't touch this field,
// keep the stored value". Save endpoints call this before writing a secret.
function resolveSecret(incoming: string | undefined, storedKey: string): string {
if (incoming === "***") return getConfig(storedKey) ?? "";
return incoming ?? "";
}
app.post("/jellyfin", async (c) => {
const body = await c.req.json<{ url: string; api_key: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = resolveSecret(body.api_key, "jellyfin_api_key");
if (!url || !apiKey) return c.json({ ok: false, error: "URL and API key are required" }, 400);
// Save first so the user's input is never silently dropped on a test
// failure (matches the Radarr/Sonarr pattern). The frontend reads the
// { ok, saved, testError } shape to decide what message to show.
setConfig("jellyfin_url", url);
setConfig("jellyfin_api_key", apiKey);
const result = await testJellyfin({ url, apiKey });
// Only mark setup complete when the connection actually works. Setting
// setup_complete=1 on a failing test would let the user click past the
// wizard into an app that then dies on the first Jellyfin call.
if (result.ok) {
setConfig("setup_complete", "1");
// Best-effort admin discovery only when the connection works; ignore failures.
try {
const users = await getUsers({ url, apiKey });
const admin = users.find((u) => u.Name === "admin") ?? users[0];
if (admin?.Id) setConfig("jellyfin_user_id", admin.Id);
} catch {
/* ignore */
}
}
return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error });
});
// Persist values BEFORE testing the connection. The previous behaviour
// silently dropped what the user typed when the test failed (e.g. Sonarr
// not yet reachable), making the field appear to "forget" the input on
// reload. Save first, surface the test result as a warning the UI can show.
app.post("/radarr", async (c) => {
const body = await c.req.json<{ url?: string; api_key?: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = resolveSecret(body.api_key, "radarr_api_key");
if (!url || !apiKey) {
setConfig("radarr_enabled", "0");
return c.json({ ok: false, error: "URL and API key are required" }, 400);
}
setConfig("radarr_url", url);
setConfig("radarr_api_key", apiKey);
setConfig("radarr_enabled", "1");
const result = await testRadarr({ url, apiKey });
return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error });
});
app.post("/sonarr", async (c) => {
const body = await c.req.json<{ url?: string; api_key?: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = resolveSecret(body.api_key, "sonarr_api_key");
if (!url || !apiKey) {
setConfig("sonarr_enabled", "0");
return c.json({ ok: false, error: "URL and API key are required" }, 400);
}
setConfig("sonarr_url", url);
setConfig("sonarr_api_key", apiKey);
setConfig("sonarr_enabled", "1");
const result = await testSonarr({ url, apiKey });
return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error });
});
app.post("/audio-languages", async (c) => {
const body = await c.req.json<{ langs: string[] }>();
setConfig("audio_languages", JSON.stringify(body.langs ?? []));
return c.json({ ok: true });
});
app.get("/schedule", (c) => {
return c.json(getScheduleConfig());
});
app.patch("/schedule", async (c) => {
const body = await c.req.json<Partial<ScheduleConfig>>();
try {
updateScheduleConfig(body);
} catch (e) {
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
}
return c.json(getScheduleConfig());
});
// ─── MQTT ────────────────────────────────────────────────────────────────────
app.post("/mqtt", async (c) => {
const body = await c.req.json<{
enabled?: boolean;
url?: string;
topic?: string;
username?: string;
password?: string;
}>();
const enabled = body.enabled === true;
const url = (body.url ?? "").trim();
const topic = (body.topic ?? "jellyfin/events").trim();
const username = (body.username ?? "").trim();
const password = body.password ?? "";
setConfig("mqtt_enabled", enabled ? "1" : "0");
setConfig("mqtt_url", url);
setConfig("mqtt_topic", topic || "jellyfin/events");
setConfig("mqtt_username", username);
// Only overwrite password when a real value is sent. The UI leaves the
// field blank or sends "***" (masked placeholder) when the user didn't
// touch it — both mean "keep the existing one".
if (password && password !== "***") setConfig("mqtt_password", password);
// Reconnect with the new config. Best-effort; failures surface in status.
startMqttClient().catch(() => {});
return c.json({ ok: true, saved: true });
});
app.get("/mqtt/status", (c) => {
return c.json(getMqttStatus());
});
app.post("/mqtt/test", async (c) => {
const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>();
const url = (body.url ?? "").trim();
if (!url) return c.json({ ok: false, error: "Broker URL required" }, 400);
const topic = (body.topic ?? "jellyfin/events").trim() || "jellyfin/events";
const password = body.password || getConfig("mqtt_password") || "";
// The user triggers real activity in Jellyfin (start playback / add an
// item) while the test runs — a blind metadata refresh from here often
// doesn't fire any webhook (the plugin only emits Item Added on actual
// additions, which a no-op refresh isn't).
const result = await testMqttConnection(
{ url, topic, username: (body.username ?? "").trim(), password },
async () => null,
30_000,
);
return c.json(result);
});
/**
* Returns whether Jellyfin has the Webhook plugin installed. The Settings
* panel uses this to decide between "setup steps" vs "install this plugin".
*/
app.get("/jellyfin/webhook-plugin", async (c) => {
const url = getConfig("jellyfin_url");
const apiKey = getConfig("jellyfin_api_key");
if (!url || !apiKey) return c.json({ ok: false, error: "Jellyfin not configured" }, 400);
try {
const res = await fetch(`${url}/Plugins`, { headers: { "X-Emby-Token": apiKey } });
if (!res.ok) return c.json({ ok: false, error: `HTTP ${res.status}` }, 502);
const plugins = (await res.json()) as { Name?: string; Id?: string; Version?: string }[];
const hit = plugins.find((p) => typeof p.Name === "string" && p.Name.toLowerCase().includes("webhook"));
return c.json({ ok: true, installed: !!hit, plugin: hit ?? null });
} catch (err) {
return c.json({ ok: false, error: String(err) }, 502);
}
});
app.post("/clear-scan", (c) => {
const db = getDb();
// Delete children first to avoid slow cascade deletes
db.transaction(() => {
db.prepare("DELETE FROM stream_decisions").run();
db.prepare("DELETE FROM jobs").run();
db.prepare("DELETE FROM subtitle_files").run();
db.prepare("DELETE FROM review_plans").run();
db.prepare("DELETE FROM media_streams").run();
db.prepare("DELETE FROM media_items").run();
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
})();
return c.json({ ok: true });
});
/**
* Full factory reset. Truncates every table including config, re-seeds the
* defaults so the setup wizard reappears, and returns. Env-backed config
* keys (JELLYFIN_URL, etc.) continue to resolve via getConfig's env fallback
* — they don't live in the DB to begin with.
*/
app.post("/reset", (c) => {
const db = getDb();
db.transaction(() => {
// Order matters when ON DELETE CASCADE isn't consistent across versions.
db.prepare("DELETE FROM stream_decisions").run();
db.prepare("DELETE FROM jobs").run();
db.prepare("DELETE FROM subtitle_files").run();
db.prepare("DELETE FROM review_plans").run();
db.prepare("DELETE FROM media_streams").run();
db.prepare("DELETE FROM media_items").run();
db.prepare("DELETE FROM config").run();
})();
reseedDefaults();
return c.json({ ok: true });
});
export default app;