Compare commits

..

3 Commits

Author SHA1 Message Date
45f4175929 v2026.04.15.2 — queue pump, strict parseId, settings secret masking
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:15:45 +02:00
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
b0d06a1d8c execute: drain newly-approved jobs mid-run, use strict shared parseId
Queue previously processed a snapshot of pending jobs — anything approved
after Run-all clicked sat idle until the user clicked again. Now, when
the local queue drains, re-poll the DB once for newly-approved jobs
before exiting.

Also swap the looser local parseId (Number.parseInt accepted '42abc')
for the strict shared parseId in server/lib/validate.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:12:59 +02:00
4 changed files with 126 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.15.1",
"version": "2026.04.15.2",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -3,6 +3,7 @@ import { Hono } from "hono";
import { stream } from "hono/streaming";
import { getDb } from "../db/index";
import { log, error as logError, warn } from "../lib/log";
import { parseId } from "../lib/validate";
import { predictExtractedFiles } from "../services/ffmpeg";
import {
getScheduleConfig,
@@ -31,12 +32,17 @@ function emitQueueStatus(
for (const l of jobListeners) l(line);
}
async function runSequential(jobs: Job[]): Promise<void> {
async function runSequential(initial: Job[]): Promise<void> {
if (queueRunning) return;
queueRunning = true;
try {
let first = true;
for (const job of jobs) {
const queue: Job[] = [...initial];
const seen = new Set<number>(queue.map((j) => j.id));
while (queue.length > 0) {
const job = queue.shift() as Job;
// Pause outside the processing window
if (!isInProcessWindow()) {
emitQueueStatus("paused", {
@@ -70,6 +76,19 @@ async function runSequential(jobs: Job[]): Promise<void> {
} catch (err) {
logError(`Job ${job.id} failed:`, err);
}
// When the local queue drains, re-check the DB for jobs that were
// approved mid-run. Without this they'd sit pending until the user
// manually clicks "Run all" again.
if (queue.length === 0) {
const more = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
for (const m of more) {
if (!seen.has(m.id)) {
queue.push(m);
seen.add(m.id);
}
}
}
}
} finally {
queueRunning = false;
@@ -137,14 +156,6 @@ function loadJobRow(jobId: number) {
return { job: row as unknown as Job, item };
}
// ─── Param helpers ────────────────────────────────────────────────────────────
function parseId(raw: string | undefined): number | null {
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
// ─── Start all pending ────────────────────────────────────────────────────────
app.post("/start", (c) => {

View File

@@ -8,16 +8,37 @@ 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 = body.api_key;
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);
@@ -54,7 +75,7 @@ app.post("/jellyfin", async (c) => {
app.post("/radarr", async (c) => {
const body = await c.req.json<{ url?: string; api_key?: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = body.api_key;
const apiKey = resolveSecret(body.api_key, "radarr_api_key");
if (!url || !apiKey) {
setConfig("radarr_enabled", "0");
@@ -72,7 +93,7 @@ app.post("/radarr", async (c) => {
app.post("/sonarr", async (c) => {
const body = await c.req.json<{ url?: string; api_key?: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = body.api_key;
const apiKey = resolveSecret(body.api_key, "sonarr_api_key");
if (!url || !apiKey) {
setConfig("sonarr_enabled", "0");
@@ -127,9 +148,10 @@ app.post("/mqtt", async (c) => {
setConfig("mqtt_url", url);
setConfig("mqtt_topic", topic || "jellyfin/events");
setConfig("mqtt_username", username);
// Only overwrite password when a non-empty value is sent, so the UI can
// leave the field blank to indicate "keep the existing one".
if (password) setConfig("mqtt_password", password);
// 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(() => {});

View File

@@ -58,6 +58,79 @@ function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTML
// (LockedInput) already signals when a value is env-controlled, the badge
// was duplicate noise.
// ─── Secret input (password-masked with eye-icon reveal) ──────────────────────
/**
* Input for API keys / passwords. Shows "***" masked when the server returns
* a secret value (the raw key never reaches this component by default). Eye
* icon fetches the real value via /api/settings/reveal and shows it. Users
* can also type a new value directly — any edit clears the masked state.
*/
function SecretInput({
configKey,
locked,
value,
onChange,
placeholder,
className,
}: {
configKey: string;
locked: boolean;
value: string;
onChange: (next: string) => void;
placeholder?: string;
className?: string;
}) {
const [revealed, setRevealed] = useState(false);
const isMasked = value === "***";
const toggle = async () => {
if (revealed) {
setRevealed(false);
return;
}
if (isMasked) {
try {
const res = await api.get<{ value: string }>(`/api/settings/reveal?key=${encodeURIComponent(configKey)}`);
onChange(res.value);
} catch {
/* ignore — keep masked */
}
}
setRevealed(true);
};
return (
<div className="relative">
<Input
type={revealed || !isMasked ? "text" : "password"}
value={value}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`pr-16 ${className ?? ""}`}
/>
<button
type="button"
onClick={toggle}
disabled={locked}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-60 hover:opacity-100 disabled:opacity-30"
title={revealed ? "Hide" : "Reveal"}
>
{revealed ? "🙈" : "👁"}
</button>
{locked && (
<span
className="absolute right-9 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
title="Set via environment variable — edit your .env file to change this value"
>
🔒
</span>
)}
</div>
);
}
// ─── Section card ──────────────────────────────────────────────────────────────
function SectionCard({
@@ -232,10 +305,11 @@ function ConnSection({
</label>
<label className="block text-sm text-gray-700 mb-1 mt-3">
API Key
<LockedInput
<SecretInput
configKey={apiKeyProp}
locked={locked.has(apiKeyProp)}
value={key}
onChange={(e) => setKey(e.target.value)}
onChange={setKey}
placeholder="your-api-key"
className="mt-0.5 max-w-xs"
/>