feat: Settings → Diagnostics, a consented log level and the tab's own log

A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.

- users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which
  computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force.
- The server logs an elevated user's requests at that level and stores every line of them
  (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation
  is active). Teljes adds the redacted query string to the request line.
- POST /api/diagnostics/events stores the browser's log as source=web rows: account from the
  session, body capped while streaming, events/attrs capped, re-redacted, events under the
  consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed.
- lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in
  batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its
  own timer), on withdrawal and on 409.
- Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user
  text, input values or a link's query), visibility/bfcache/resize, filter changes
  (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are
  warnings at every level). Offline/online at the base level.
- "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy
  the filtered events as JSON lines, clear.
- Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to
  the same sections); the policy now describes the server logs and diagnostic logging, self-service
  deletion and password sign-in.

Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
  handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
  a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
This commit is contained in:
2026-09-17 02:42:12 +02:00
parent a210992bfc
commit 5883ea509d
49 changed files with 2790 additions and 122 deletions
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
//
// E22 S5: a clicked control's label is logged only when it is the app's own copy — as its English
// wording — so nothing a user wrote (a title, a tag, a channel name) can come out of the click log.
import { describe, expect, it } from "vitest";
import { appCopyOf } from "./appCopy";
describe("appCopyOf", () => {
it("finds app copy in either language and reads it in English, whatever the whitespace", () => {
expect(appCopyOf("Turn on for 24 hours")).toBe("Turn on for 24 hours");
expect(appCopyOf(" Bekapcsolom\n 24 órára ")).toBe("Turn on for 24 hours");
});
it("a wording several keys share still names the wording, not one arbitrary key", () => {
// "Diagnosztika" is both the Settings tab and the Configuration group.
expect(appCopyOf("Diagnosztika")).toBe("Diagnostics");
});
it("finds nothing in what a user wrote, and never matches an interpolated string", () => {
expect(appCopyOf("My holiday video 2024")).toBeNull();
expect(appCopyOf("")).toBeNull();
expect(appCopyOf(null)).toBeNull();
expect(appCopyOf("{{count}} events · newest first")).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import i18n from "./index";
// The translation KEY a piece of on-screen text is, or null (E22 S5 click capture).
//
// A click is logged by what the control IS, and its visible label is the most telling part — but a
// label can be the user's own content (a video title, a tag, a channel name), which the log must not
// carry. Text that is exactly one of the app's own translated strings is app copy, not user content,
// so it can be logged — as its ENGLISH wording, whatever the UI language: the same click reads the
// same in every log, and nothing a user typed comes out of it (a user's tag that happens to equal a
// string of ours is logged as our string, which says nothing about the user). Not as a key: one
// wording is often several keys ("Diagnostics" is a settings tab AND a config group), so a key would
// name a control the user may never have touched. Interpolated strings (`{{n}} items`) never match.
let index: Map<string, string> | null = null;
const normalize = (text: string) => text.replace(/\s+/g, " ").trim();
/** Every app string (any language) → its English wording. */
function build(): Map<string, string> {
const map = new Map<string, string>();
const resources = i18n.options.resources ?? {};
const english = (path: string[]): string | null => {
let node: unknown = resources.en?.translation;
for (const key of path) node = (node as Record<string, unknown> | undefined)?.[key];
return typeof node === "string" ? normalize(node) : null;
};
const walk = (node: unknown, path: string[]) => {
if (typeof node === "string") {
const text = normalize(node);
const en = english(path);
if (en && text.length >= 2 && !text.includes("{{") && !map.has(text)) map.set(text, en);
} else if (node && typeof node === "object") {
for (const [k, v] of Object.entries(node)) walk(v, [...path, k]);
}
};
for (const lang of Object.keys(resources)) walk(resources[lang]?.translation, []);
return map;
}
export function appCopyOf(text: string | null | undefined): string | null {
if (!text) return null;
index ??= build();
return index.get(normalize(text)) ?? null;
}
+7
View File
@@ -0,0 +1,7 @@
{
"lastUpdated": "Last updated: {{date}}",
"home": "Home",
"contact": "Contact",
"operatorFallback": "your instance's administrator",
"languageLabel": "Language"
}
+58 -1
View File
@@ -3,7 +3,8 @@
"tabs": {
"appearance": "Appearance",
"notifications": "Notifications",
"account": "Account"
"account": "Account",
"diagnostics": "Diagnostics"
},
"plexSync": {
"title": "Plex watch sync",
@@ -190,5 +191,61 @@
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your inbox instead, and fix it under Configuration → Email / SMTP.",
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so removing this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
},
"diagnostics": {
"title": "Logging level",
"level": "Level",
"levelHint": "At Basic, the log lives only in this browser tab — errors, warnings, page changes and requests — and nothing is sent to the server. Detailed and Full record more and send it to the server for 24 hours, only with your consent.",
"levels": {
"INFO": "Basic",
"DEBUG": "Detailed",
"TRACE": "Full"
},
"consent": {
"title": {
"DEBUG": "Turn on detailed logging for 24 hours",
"TRACE": "Turn on full logging for 24 hours"
},
"what": {
"DEBUG": "We record which buttons, filters and player controls you use, and the values in the address bar.",
"TRACE": "We record which buttons, filters and player controls you use, the values in the address bar, and the text you type into search."
},
"server_one": "The Siftlode server receives this too and keeps it for {{count}} day, so the problem can be tracked down.",
"server_other": "The Siftlode server receives this too and keeps it for {{count}} days, so the problem can be tracked down.",
"never": "Passwords, tokens, email addresses and the content of your messages are never included.",
"lapse": "After 24 hours it goes back to Basic by itself.",
"policy": "Privacy policy",
"cancel": "Cancel",
"confirm": "Turn on for 24 hours"
},
"status": "{{level}} logging · back to Basic in {{time}}",
"hoursMinutes": "{{h}} h {{m}} min",
"minutes": "{{m}} min",
"revert": "Back to Basic now",
"demo": "The demo account is shared by several people, so detailed logging can't be turned on here.",
"failed": "Couldn't change the logging level"
},
"myLog": {
"title": "My log",
"hint": "What this browser tab has recorded. It is gone when the page reloads.",
"search": "Search the events…",
"filterLabel": "Filter by level",
"filters": {
"all": "All",
"error": "Errors",
"warning": "Warnings",
"info": "Info",
"detail": "Detailed"
},
"count_one": "{{count}} event · newest first",
"count_other": "{{count}} events · newest first",
"capped": "showing the newest {{shown}} of {{count}}",
"copy": "Copy",
"copied_one": "Copied {{count}} event",
"copied_other": "Copied {{count}} events",
"copyFailed": "Couldn't copy to the clipboard",
"clear": "Clear",
"empty": "No event matches the filter.",
"emptyAll": "No events yet."
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"lastUpdated": "Utoljára frissítve: {{date}}",
"home": "Kezdőlap",
"contact": "Kapcsolat",
"operatorFallback": "a példány adminisztrátora",
"languageLabel": "Nyelv"
}
+58 -1
View File
@@ -3,7 +3,8 @@
"tabs": {
"appearance": "Megjelenés",
"notifications": "Értesítések",
"account": "Fiók"
"account": "Fiók",
"diagnostics": "Diagnosztika"
},
"plexSync": {
"title": "Plex nézettség-szinkron",
@@ -190,5 +191,61 @@
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az értesítéseidet figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik."
},
"diagnostics": {
"title": "Naplózási szint",
"level": "Szint",
"levelHint": "Alap szinten a napló csak ebben a böngészőlapban él (hibák, figyelmeztetések, oldalváltások, kérések), és semmi nem megy a szerverre. A Részletes és a Teljes többet rögzít, és 24 óráig a szervernek is elküldi, csak a beleegyezéseddel.",
"levels": {
"INFO": "Alap",
"DEBUG": "Részletes",
"TRACE": "Teljes"
},
"consent": {
"title": {
"DEBUG": "Részletes naplózás bekapcsolása 24 órára",
"TRACE": "Teljes naplózás bekapcsolása 24 órára"
},
"what": {
"DEBUG": "Rögzítjük, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a címsor paramétereinek értékét.",
"TRACE": "Rögzítjük, melyik gombot, szűrőt és lejátszóvezérlőt használod, a címsor paramétereinek értékét, és a keresőbe beírt szöveget is."
},
"server_one": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"server_other": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"never": "Jelszó, token, e-mail-cím és az üzeneteid tartalma soha nem kerül bele.",
"lapse": "24 óra múlva magától visszaáll Alapra.",
"policy": "Adatvédelmi irányelvek",
"cancel": "Mégse",
"confirm": "Bekapcsolom 24 órára"
},
"status": "{{level}} naplózás · visszaáll Alapra {{time}} múlva",
"hoursMinutes": "{{h}} ó {{m}} p",
"minutes": "{{m}} p",
"revert": "Visszaállítás most",
"demo": "A demo fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.",
"failed": "Nem sikerült módosítani a naplózási szintet"
},
"myLog": {
"title": "Saját napló",
"hint": "Amit ez a böngészőlap rögzített. Az oldal újratöltésekor törlődik.",
"search": "Keresés az eseményekben…",
"filterLabel": "Szűrés szintre",
"filters": {
"all": "Mind",
"error": "Hibák",
"warning": "Figyelmeztetések",
"info": "Info",
"detail": "Részletes"
},
"count_one": "{{count}} esemény · a legújabb felül",
"count_other": "{{count}} esemény · a legújabb felül",
"capped": "a legújabb {{shown}} látszik a(z) {{count}} közül",
"copy": "Másolás",
"copied_one": "Kimásolva: {{count}} esemény",
"copied_other": "Kimásolva: {{count}} esemény",
"copyFailed": "Nem sikerült a vágólapra másolni",
"clear": "Ürítés",
"empty": "Nincs a szűrésnek megfelelő esemény.",
"emptyAll": "Még nincs esemény."
}
}