Compare commits

...
45 Commits
Author SHA1 Message Date
peter 8c10e57208 release: 0.88.0 (feature/e22-activity-trail)
gate / frontend (push) Failing after 3m31s
gate / backend (push) Successful in 4m44s
2026-09-18 00:05:50 +02:00
peter dd040adc96 chore: 0.88.0 release notes
E22 (activity trail): a consented, self-expiring diagnostics level with the user's own log visible
before anything is sent, last sign-in, bilingual privacy policy and terms, an admin diagnostics
section, and pickers for every closed-set setting. Plus what the epic's measurements turned up on
the way: doubled chat delivery, Settings on a phone, locale-ignoring dates and numbers, and the
feed poll narrowed to followed channels.
2026-09-18 00:05:46 +02:00
peter 4ab128ccab Merge branch 'main' into feature/e22-activity-trail 2026-09-18 00:02:12 +02:00
peter a28193a00c test: pin the focus restore, the choice labels and the phone layout to their fix
Three round-1 fixes whose tests passed against the broken version too.

The focus restore keeps watching until the element that closed the box is GONE, rather than spending
itself on the first render after the change — the distinction Chrome exposed and jsdom did not,
because jsdom commits the save in one render where Chrome takes two. Driven now through the general
case instead: the user moved the focus elsewhere while the request was out, renders keep coming
while that element is still there, and the restore happens only once it disappears.

The choice test's i18n mock returned an option's bare value, which is exactly what dropping the
`config.choices.*` lookup renders — a Hungarian admin would have seen `on`/`off` instead of
`GPU`/`Szoftver` with the gate green. Marked labels now, still short enough not to move the
segmented-bar budget the same file measures.

The admin config row's stacking had no test at all, only its sibling fix did — and stacking is the
only fix for a text or number key, whose fixed-width input is the wide control. Class contract, as
SearchBar.dom.test.tsx already does for the narrow-header rules, since jsdom does no layout.

Also: the time-zone cleanup deleted TZ, and Node re-reads it on assignment only, so the process kept
the last zone the file set. Harmless until someone appends a test below it.
2026-09-17 23:45:41 +02:00
peter 986bc8d01e fix: a batch the user themselves cancelled is not called a lapse
Picking "Alap" revokes the consent, and a batch still in flight is then answered by the server with
the same 409 a real expiry gets. The 409 arm only checked that the consent session had not changed,
not that this tab was still streaming — so the user's own log gained a "diagnostics level lapsed"
line for a level they had just turned off themselves, plus a pointless `me` refetch. The same guard
also stops a lapse this tab has already announced being announced a second time.

Measured in Chrome by holding the POST at the network layer, switching to Alap and only then
answering 409: the line appears without the guard and not with it.

Three tests that named the session counter never reached it — they settled the refusal while the
stream was already stopped, where `unsubscribe` does the work, so the counter could be deleted from
both arms with the suite green. The refusal now settles after the re-raise, which is the ordering it
exists for. Same for the queue trim on the 429 path: the old test unshifted a batch into an empty
queue, so the trim was a no-op and only the subscribe path was ever measured.
2026-09-17 23:45:29 +02:00
peter 07e3ea3737 docs: the deep-backfill card counts the backlog, not the next pass
Two reviewers in a row read the admin card's selection against `run_deep_backfill`'s, saw it was
missing the job's `recent_synced_at` filter, and called it a card that counts channels the job will
never touch. It is the opposite: the card is the same backlog figure /api/sync/my-status shows the
user, and a channel still missing its recent pull is one the same job tick fetches first. Narrowing
it would hide genuinely queued work. Written down where the next reader will look.
2026-09-17 23:45:29 +02:00
peter b2eaa9e00b fix: a credential, an erased account and a stale level stay out of the log
Round 2 of the E22 review, on what round 1 changed.

`redact_query` decodes a parameter before the text rules see it, which is what lets `%40` be
recognised as an address — but `_SECRET_PARAM_RE` was written for the raw, encoded form, where a
value holds no space, `&` or quote. On decoded text its value class stops at the first one, so
`?state=a%20b` kept its tail and `?code=%20abc` was not matched at all. The parameter-NAME rule now
decides, on the key, before any text rule runs.

`forget_user` dropped the account's level elevation without recomputing the root level, so a process
whose only elevated account had just deleted itself kept building TRACE records to throw away, until
some later request happened to recompute it. Every other path that drops an elevation recomputes.

The sink's writer took the erasure lock and THEN waited for a pooled connection, while every request
path already holds a connection when it reaches that lock — so under pool pressure the writer held a
process-global lock against the very requests that could hand a connection back. Same order on both
sides now; the lock still spans the check, the insert and the commit.

The storage-budget notice said the rest of the hour was not kept, and then smaller batches kept
landing underneath it: the window slides and a refused batch is not charged to it. Says what it does.

Tests: the route's half of the erasure rule had no test — only the sink's did — and a source-level
contract test now covers the third insert nobody has written yet. `logs._erased` is process-global
with an hour's memory while the DB fixture hands serial ids back out from 1, so it is swept before
each test like the rate limiters are.
2026-09-17 23:45:16 +02:00
peter 791ba5f507 fix: a calendar date is the same day everywhere; legal pages use the shared language picker
formatDate read a date-only value as UTC midnight and showed it in the viewer's zone, so the legal pages' last update read a day early west of UTC. Date-only values are shown in UTC. The legal pages' hand-rolled language buttons are the landing page's LanguageSwitcher. formatDateTime and formatDate get the two-language test formatNumber already had.
2026-09-17 03:53:40 +02:00
peter 7789db8240 fix: admin config pickers fit a phone
A segmented bar cannot shrink or wrap, and the five log levels (~300 px) are wider than a phone leaves beside the navigation rail. A choice is a bar only when its labels are short; the rows stack below sm.
2026-09-17 03:53:40 +02:00
peter f92cd9017f fix: the diagnostics tab keeps its focus, its level and its consent
Deep-review round 1 of E22.

- After a 429 the stream sent one POST per new event while a full batch
  waited; it now stays quiet for 15 s. Past the queue cap the oldest
  events go, as the comment always said, a refused batch included.
- A batch refused after the level was lowered came back into the queue
  of a later consent in the same tab; each consent is its own session.
- A `me` fetch that left before a level or preference change could put the
  old value back for up to a minute. `updateMeCache` writes the cache and
  cancels that fetch; usePref goes through it too.
- The consent box's buttons and "revert now" took the focus with them to
  <body>; it returns to the level picker. Watched until the button is
  gone — in Chrome the saved level arrives in two renders, and a one-shot
  check spent itself on the first (jsdom never showed it).
- The countdown line is no longer a live region re-read every minute.
- "Saját napló" rows were keyed by index on a newest-first list, so every
  new event remounted every row and dropped the focus off a row button.
2026-09-17 03:53:40 +02:00
peter 4bfba4aa86 refactor: one definition of a followed channel and of a choice spelling
The explore purge rebuilt followed_channel's two EXISTS clauses inline, so what is polled and what may be deleted could drift; it now uses the predicate, which moved to sync/explore.py. The env validator and sysconfig each folded a choice value on their own; both call config.canonical_choice. The deep-backfill comment no longer calls recent backfill unconditional.
2026-09-17 03:53:29 +02:00
peter 86bc008a81 fix: a crashed job's crash line carries its job id
The bind sat inside the try, so it was already reset when the crash line and the cleanup were logged — the one line most worth finding by job id had none.
2026-09-17 03:53:29 +02:00
peter fac5c4465a fix: stored logs keep bad rows, erased accounts and unconsented queries out
Deep-review round 1 of E22.

- A NUL character (`GET /assets/%00` logs the decoded path) made Postgres
  refuse the row and with it the sink's whole batch, every user's rows
  included. `to_row` replaces NUL in every text column and in attrs.
- An erased account's lines kept arriving after purge_user's DELETE: the
  erasing request's own access line, a queued row, a background task.
  `logs.forget_user` runs first; later rows are stored without the account,
  and the check and the insert share a lock with the erasure.
- An admin setting the whole process to TRACE logged every user's query
  string. The query is now logged only under the user's own Teljes consent,
  and decoded before redaction, so `%40` no longer hides an address.
- The browser log ingest has an hourly storage budget per account (5 MiB,
  one notice when spent) and reads only a JSON Content-Type, so a
  same-site text/plain form post cannot write into a user's log.
  RateLimiter takes a per-attempt cost for the byte budget.
- The root level is applied under the elevation lock: two concurrent
  changes could leave it at the base while an elevation was active.
- Tests for the running sink (level gate, drain on stop) and for the
  admin log level applied at runtime and at boot.
2026-09-17 03:53:29 +02:00
peter 9c9b1cf6d5 test: the gate's output carries no noise to hide a real warning in
- Home tests mocked the api without downloadIndex, so every card's download button logged a
  missing-queryFn error (64 stderr blocks per run).
- QueryState rendered without an i18n instance; Modal.fullscreen awaited a synchronous act,
  which React reports as an act environment it does not support.
- pytest: Starlette 1.x's TestClient is built on httpx2 and warned on every run without it
  (pinned in requirements-dev); alembic.ini declares its path separator.
2026-09-17 03:03:37 +02:00
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00
peter 569190480d refactor: hook dependencies name everything the hooks read
The 12 react-hooks/exhaustive-deps warnings the gate let through:
- ShareDialog and YearFilter built a fresh [] per render while their query loaded, so their
  memos recomputed on every render; they share one empty list now.
- VideoEditor's drag effect called a seek/moveBoundary recreated each render; both are stable
  callbacks in its deps.
- WizardProvider re-checks onboarding on exactly the fields it reads (not the me object, whose
  60 s refetch would reopen a closed wizard).
- Channels, Feed and PlexPlayer list the stable values they already used.
- The player's keydown effect is bound once on purpose (its helpers read refs): said so, with a
  disable, as the codebase's other deliberate sites do.
- dialogFocus and the YouTube player's cleanup read refs React may already have let go of; they
  use what they captured when the effect ran.
2026-09-17 02:45:19 +02:00
peter 5883ea509d 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.
2026-09-17 02:42:12 +02:00
peter a210992bfc feat: client log with request ids, joined to the server's
The browser now keeps its half of the one event shape logs.py defines:
lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring
with the server's level names and severity numbers. Nothing leaves the
browser yet; sending is the consented diagnostics level (S5).

- lib/http.ts `send` is the one fetch: every request carries an
  X-Request-Id and becomes one http.request event. The server echoes the
  matched route template in X-Route (only for a matched route, so a raw
  path with a value in it is never sent back); without one the client
  templates the path itself. req, beacon, the upload XHR, the public
  watch page and the legal pages all go through it.
- Hooks: app start snapshot, uncaught errors and rejections, History
  push/replace/popstate (a scroll-only replace is not an event; parameter
  values only at DEBUG, typed search only at TRACE), notifications, the
  error dialog, the crash boundary, failed queries and mutations (key
  head only), and the messages socket (lifecycle, frame types at DEBUG).
- Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json
  runs against both redact() implementations.
- Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging
  module, console to none; a contract test proves the rules fire.

Fixes a socket takeover: the close of a socket released by the last
subscriber cleared the newer socket a quick re-subscribe had opened and
scheduled a third, leaving two live sockets that delivered every message
twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
2026-09-17 01:44:29 +02:00
peter 0517de1f59 fix: poll and backfill only channels somebody follows
The scheduled RSS poll and recent backfill covered every channel row,
but rows also come from search results, playlist sync and dropped
subscriptions, and nothing deletes them. On prod 582 of 979 polled
channels had no follower, three of them dead feeds answering 404 on
every poll. Both jobs now take subscribed or explored channels only,
and the scheduler card counts the same set. As a deliberate trade-off,
the shared Library no longer receives new uploads from channels nobody
follows.
2026-09-17 00:38:46 +02:00
peter b59fd38a9c feat: store warnings and request lines in log_events
The log now has a stored, queryable copy for the diagnostics console:
every WARNING and above plus the request line of every request, kept
for log_retention_days (default 7, no "forever") and pruned hourly by
the new log_gc job.

Rows are built on the logging thread from the same redacted fields as
the stdout line, queued without blocking and written in batches by one
background thread on its own connection. A full queue or a failed write
drops and reports instead of retrying. Flood control keeps 30 of any
one message per minute plus a count of the rest; anonymous request
lines are capped at 60 a minute per client, and the client address is
never stored. Deleting an account deletes its stored events.

Measured on prod first: a YouTube RSS outage wrote 11k warnings in four
hours, one per channel per poll. The poll now writes one warning with
per-reason counts and a few examples, and a failure on our side gets
one traceback per poll instead of one per channel.
2026-09-17 00:20:14 +02:00
peter 88a166e4aa fix: call the audit log "Auditnapló" in Hungarian
"Naplózás" (logging) named the audit log in the nav, its page and its
Configuration tab, right beside the new "Naplózási szint" (log level)
under Diagnosztika — two different things under one word. The scheduler
hint also pointed at a "Beállítások → Naplózás" path that does not
exist; it is Konfiguráció → Auditnapló, as the English already says.
2026-09-17 00:04:01 +02:00
peter 07766c43e8 feat: closed-set admin knobs are pickers, not free text
log_level, search_source, download_layout and the transcode hw/preset
were typed by hand, and every consumer carried its own guess at what a
typo meant. The value set is now a Literal in config.py: pydantic
rejects a bad env value at boot (any letter case accepted), sysconfig
refuses a bad override and derives the admin picker's options from the
same Literal, and the consumers read the value as-is.

The Configuration page renders a choice key as a segmented bar, or a
dropdown past six options, with translated option names. Its tabs now
follow the registry's order instead of a hand-kept copy that had
already dropped the explore group.

A single-choice FilterSelect now opens on the option in use: in the
browser it could land on the first row instead.
2026-09-17 00:03:56 +02:00
peter bb3c6dec45 release: 0.87.1 (bug/youtube-cookie-host)
gate / frontend (push) Failing after 3m13s
gate / backend (push) Successful in 4m22s
2026-09-16 04:41:40 +02:00
peter 3cf8f04d73 chore: 0.87.1 release notes 2026-09-16 04:41:10 +02:00
peter 2b56c5c2eb fix: name the error yt-dlp actually raises for a cookie-emptied client set
A review round read yt-dlp's source and found the comment behind the previous commit's safety-net
marker did not match it. Measured in the worker with a real account cookie and
`player_client=android_vr`: yt-dlp logs `Skipping client "android_vr" since it does not support
cookies`, drops it, and the request left with no client fails with "Requested format is not
available" — not "No player clients have been requested". That raise sits EARLIER in yt-dlp, before
the cookie-capability removal, so the cookie path can never reach it; and an unknown or renamed
client is skipped with a warning and replaced by yt-dlp's defaults, so the other scenario the
comment named cannot reach it either.

So the marker bought nothing: the symptom that does occur was already retriable here before this
branch. It is dropped, the comment now states what was measured, and the test asserts the real
symptom instead of a message the code cannot produce. The cookie-capability filter itself is
vindicated by the same measurement — without it every signed-in download would burn its first
attempt on a client yt-dlp discards.

Also from the round: an enqueue test for a `source_kind="youtube"` job, the one join nothing else
made (a YouTube job stores a bare 11-char ref and the URL the cookie gate keys on is rebuilt by the
adapter — both ends were pinned apart, the join was not, and a break there would stop every YouTube
cookie silently while signed-out downloads kept working). And copy corrections: the group's only
unhedged claim is hedged, "described the video" loses the pipeline vocabulary, and four Hungarian
phrasings that read as translated English are rewritten.
2026-09-16 04:29:13 +02:00
peter c06a456a9a fix: a YouTube stream refused after extraction is the gate, not a network fault
Measured on the isolated test stack the same night, on the very same video ~90 minutes after it was
answering with the bot message: yt-dlp got the player response and picked its formats, and the CDN
then refused the stream itself — "unable to download video data: HTTP Error 403: Forbidden".
Signed out it did that on every attempt; with the owner's cookie attached the video downloaded in
full. So the wall moves between the player response and the stream, and the text it wears there was
costing the download twice.

retriable: the format URLs belong to the client that minted them, so the next client set is a real
second chance — but this text classifies as `network` and nothing in the YouTube marker list matched
it, so the ladder ended the job on attempt 1 and never tried the fallback.

catalog: `youtube.media_forbidden` for the cookie-less case, so the UI stops calling it a network
error when nothing was wrong with the network, and names what actually gets past it. Scoped exactly
as narrowly as the measurement: another host keeps the generic `network` category, and so does a
download that already carried a cookie — telling a signed-in user to sign in is the failure the
catalog exists to avoid. EN + HU copy.
2026-09-16 03:43:29 +02:00
peter 864f673ef5 fix: a YouTube video behind the bot wall is downloadable with cookies
A YouTube link can fail with "Sign in to confirm you're not a bot" while every
other YouTube download works — YouTube gates INDIVIDUAL videos behind a
signed-in session. Measured on prod job 273: all twelve of yt-dlp's player
clients fail on that one video while its neighbours download fine, the bgutil
PO-token sidecar is live and answering, and the newest yt-dlp fails identically.
So the download is not broken by our stack or a stale yt-dlp, and no retry,
client switch or upgrade gets past it. A cookie is the only way in.

The failure copy already sends the user to Settings -> Account -> Download
authentication, but YouTube was not in the cookie host registry, so that
dropdown could not help them: the app asked for something it did not offer.

credentials: YouTube joins SUPPORTED_HOSTS (youtube.com, youtu.be,
youtube-nocookie.com). The Settings dropdown, the SEC-12 host scope and the
private-asset cache salt all derive from the row, so that is the whole wiring.

adapters: a signed-in YouTube download runs only cookie-capable player clients.
Measured against yt-dlp's own INNERTUBE_CLIENTS table: with account cookies
attached yt-dlp REMOVES every client whose SUPPORTS_COOKIES is False, and a set
it empties that way raises "No player clients have been requested" — which is
not a per-client flake it retries past. Our primary set is android_vr, one of
those, so without this the cookie would have failed the download on attempt 1
and never reached the POT-backed web fallback it exists for. A test pins the
client list against the installed yt-dlp so a version bump turns the gate red
instead of silently un-authing every YouTube download, and the empty-set message
joins the retriable markers as the second line of defence.

errors: catalog entries for the wall with and without a cookie, so a user who
has already added cookies is told they were rejected (YouTube rotates exported
ones) rather than to add some. The marker is quote-agnostic: YouTube writes the
apostrophe as U+2019, so "you're not a bot" with a straight quote never matches
the real text. EN + HU copy.
2026-09-16 02:03:00 +02:00
peter 6c63df6704 feat: one structured log for every process, with a request id
A deployment printed four different log formats and none of them could be
joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in
main.py for anything not started by uvicorn, logging.basicConfig() in the worker
and in the download child (no timestamp at all), and alembic's fileConfig.
app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log
data model, no SDK): ts, level, severity, source, logger, event code, message,
attributes, plus the correlation the context carries — request id, account,
download job.

Redaction is applied at the HANDLER, not at the call sites: every message, every
attribute and every traceback is scrubbed on the way out, so a secret cannot
leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies,
secret config values and email addresses never reach a line.

The access log is ours now (`http.request`): the route template instead of the
concrete path, the status, the duration and the account — and never the query
string, which uvicorn's access log printed on every single request (`?account=`,
`?source=`, the `?token=` of a share link). A well-formed client request id is
accepted and echoed in X-Request-Id, so a browser line and a server line join up.
The account comes from `request.state`, filled by the auth dependency: the
middleware is the outermost one, so it runs outside SessionMiddleware and in a
different task context.

`log_level` becomes an admin knob (sysconfig), applied without a restart and
re-applied at startup — best-effort on purpose, since a logging knob must never
be what stops the app from booting. Every compose service gets bounded json-file
logs (3×50 MB): Docker keeps every line forever by default, and the API now
writes one line per request.

Verified on the localdev stack: JSON everywhere including alembic and the
worker, the query strings gone, a supplied X-Request-Id echoed and logged, and
three /healthz hits of which only the one made while the level was DEBUG
produced a line.
2026-09-16 01:37:02 +02:00
peter a5bcd9fca2 fix: numbers follow the UI language, not the browser's
`n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a
Hungarian browser printed "1 234 567" while a Hungarian UI in an English
browser printed "1,234,567" — the number half of the date defect fixed in
618ef812. One `formatNumber` in lib/format.ts (a per-language cached
Intl.NumberFormat, like the relative-time formatters beside it) now serves
every grouped figure: Stats, Scheduler, the channel columns and the stats
popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours.

ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the
previous sweep missed; it moves onto formatDate.

The new cases assert both languages and that the two differ, instead of
comparing against the runtime's own default: that default is Hungarian on the
dev box and English on a CI image, so a one-language assertion would pass
under the old bug on half the machines. Verified in Chromium on :5173 with the
browser forced to hu-HU and the UI in English — Stats, Scheduler and the
channel table all keep comma grouping.
2026-09-16 01:05:53 +02:00
peter 618ef812e6 fix: dates follow the UI language, not the browser's
Seven sites formatted with a bare toLocaleString()/toLocaleDateString(), so an
English UI on a Hungarian machine printed Hungarian-shaped dates (and the reverse).
They now go through formatDateTime/formatDate, which take the i18n language.
2026-09-16 00:49:23 +02:00
peter e299c66121 feat: show each account's last sign-in in Users
A right-aligned column at the end of every Users & roles row says how long ago the
account signed in, with the exact moment as the hint and as a line in the account
panel; a dash where nothing is recorded, and the demo account says it is not recorded.
formatDateTime formats that moment in the UI language rather than the browser's.
2026-09-16 00:45:03 +02:00
peter 11aef4ab7a feat: record when an account last signed in
users.last_login_at (migration 0085, no backfill) is stamped by _establish_session,
which the Google callback now shares with password login instead of repeating its
session setup inline. The demo entry does not stamp its shared account. The admin
user list carries the field.
2026-09-16 00:34:50 +02:00
peter 4e52bdd4ca fix: a signed-out tab no longer asks for saved views
The providers mount for the public landing too, and the saved-views query had no
enabled gate, so every signed-out load logged a 401 for /api/saved-views. The query
now waits for a signed-in account in BootGate's me cache, like WizardProvider.
2026-09-15 23:45:13 +02:00
peter 595490cb98 release: 0.87.0 (improvement/watch-background-audio)
gate / frontend (push) Failing after 3m19s
gate / backend (push) Successful in 4m25s
2026-09-15 03:02:19 +02:00
peter 7537882235 chore(release): 0.87.0 2026-09-15 03:01:59 +02:00
peter 47d968eeea fix: review round 4 — an account switch unloads as the old account, a share link's lockout costs five networks
Deep-review round 4, targeted at round 3's changes (f569b19c..e7f38396).

Accounts
- Switching, adding or signing out an account moved the tab's pin before
  the page unloaded, so the players' resume beacons fired on the way out
  under the NEW account: one account's playback position saved into the
  other. The pin is now handed to the next page (pinOnNextPage) and taken
  over at import; clearActiveAccount is gone.
- Switching to a suspended account was refused but left it in the
  switcher, refused on every click; a deactivated one was not refused at
  all, and made the default it signed every account in the browser out on
  the next request. Every account _session_user would refuse — gone,
  deactivated, suspended, revoked — is refused and dropped from the list.

Share page (/watch)
- An IPv6 client is limited by its /64 (client_ip becomes client_key, for
  every limiter): keyed on the full address, one subscriber could rotate
  through endless identities. The comments now state what the per-link
  ceiling costs: guessing from five networks can keep a link shut for its
  window, and guesses already running when it fills are still checked.
- The link is looked up before the limiter records the attempt, so made-up
  tokens no longer grow it by an entry each.

Forms
- Failing the same way twice is announced again: the sign-in, passphrase
  and setup errors are keyed on the attempt (a clear and a set in one
  handler left the alert node untouched).
- The install wizard's own validation showed the generic error — it threw
  plain objects its catch did not read. It sets the step's message.

Tests: a re-pin leaving the previous account's notices behind; the pin
hand-over; switching to an ended account; the /64 viewer; made-up tokens;
the repeated alerts. Each mutation-checked RED for its own reason.
2026-09-15 02:53:22 +02:00
peter e7f38396a2 fix: review round 3 — a new tab keeps its account's notices, a revoked tab account lands on the default
Deep-review round 3 over main..improvement/watch-background-audio.

Account boot
- The notification history and settings, and the hints toggle, are read
  when their modules load, which in a new tab (bookmark, typed URL) is
  before BootGate pins the account: they read as nobody's, and the tab's
  first notice replaced the account's stored history (up to 100 entries)
  with a one-item list. They now read again when the tab is pinned
  (onAccountPinned, run by setActiveAccount); a notice raised before the
  pin stays, after the history.
- A tab pinned to an account still in this browser's wallet but whose
  session ended (suspended, deleted, or revoked by "log out everywhere")
  got "signed out" from /api/me while the default account was still
  signed in, and signing in again as anyone else left it there. A refused
  per-tab account now leaves the wallet, and the probe answers as the
  default, so the tab reloads onto it.

Share page (/watch)
- The password unlock is limited per viewer (link + client IP), under a
  per-link ceiling that counts only wrong passwords. Keyed on the link
  alone, ten wrong guesses every five minutes locked out every viewer,
  the ones with the password included. client_ip moves to app.ratelimit
  so the public routes can key on it.
- Its failure messages and the load-failure screen are announced
  (role=alert, the password input described by its error). The sign-in,
  passphrase, password-change and setup form errors get role=alert too.

Comments that still described removed machinery are corrected: App's
collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig.

Tests: the account's history and hints across the pin; a revoked and a
suspended pinned account; the per-viewer limit and the ceiling; the
limiter's exhausted(); the share page's announced failures; a share
link's feed filters persisted. Each mutation-checked RED for its own
reason.
2026-09-15 02:25:00 +02:00
peter f569b19c49 fix: review round 2 — a signed-out tab acts as nobody, honest share-page failures
Deep-review round 2 over main..improvement/watch-background-audio.

Account resolution (server)
- A request naming an account that is no longer signed in to this browser
  (X-Siftlode-Account or ?account=) now acts as nobody instead of the
  session's default account. A tab still pinned to an account signed out in
  another tab went on acting as the default: its player checkpoints and
  preference saves landed in that account, the messages socket and Plex
  playback ran as it, and its Sign out signed THAT account out. A hidden tab
  never polls /api/me, so a tab playing in the background kept doing it.
  The /api/me bootstrap probe alone keeps the fallback, which is how such a
  tab learns who it now is: the app's 401 handler reloads, and BootGate
  re-pins the tab.

BootGate
- On an identity change the tab reloads without moving its pin first. The
  tree being torn down still runs its unmount saves before the page goes,
  and with the pin already moved they wrote into the new account's local
  keys (a player volume still pending). The reloaded page corrects the pin.

Share page (/watch)
- Only a 404 says the link is gone. A 5xx, a dropped connection, and the
  unlock's own rate limit all rendered "no longer available ... expired or
  revoked" to a viewer with a valid link, with no way to try again. A load
  failure now offers Retry; too many password tries says so and keeps the
  form; an unlock that gets no answer says so instead of doing nothing.
  Strings in en/hu/de.

Downloads
- A cookie-authed asset is recognised under the current signature version
  and the one before it, so a job queued before a deploy that bumps the
  version still downloads with its cookie instead of failing on the login
  wall with an error that says no cookie was used.

The app chunk is preloaded through lazyWithPreload, so a failed fetch no
longer leaves an unhandled rejection beside the ErrorBoundary. The panel
layout seed is one helper (seedLayout).

Tests: the stale-tab refusal, its harmless Sign out and the probe fallback;
the pre-bump authed asset; BootGate keeping the pin on reload and staying
mounted through a same-account change; the reveal clock's later-commit
hand-off; the prefs, panel layout and demo-scope seeds from the me cache;
the share page's failure states. Each mutation-checked RED for its own
reason.
2026-09-15 01:35:25 +02:00
peter e795d191c5 fix(boot): review round 1 — reconcile the tab pin, one loading clock, honest probe failures
Deep-review round 1 over main..improvement/watch-background-audio.

BootGate
- The tab's account pin is corrected to the account the server answered as.
  The server honours the pin only for an account still signed in to this
  browser and otherwise answers as the default, so a tab pinned to an account
  signed out in another tab ran as the default on the server while reading
  and writing the gone account's local state.
- A mounted tab whose identity changes (its session ended, or the case above)
  reloads instead of rebuilding the tree in place, like every account switch;
  that also drops the previous account's query cache. The account key on the
  tree goes: the readyFor gate already unmounted it on every change.
- An unanswered setup probe shows the error with Retry, like an unanswered
  account probe, instead of the signed-out landing to a signed-in user.
- Comments: /api/me never 401s; the ended-session path is identity → nobody.

Loading states
- LoadingState's reveal delay is one clock across loading states that hand
  over to each other (boot screen → page-chunk fallback → the page's own
  fetching state), instead of a CSS delay restarted by each, which blanked a
  visible spinner for another 300 ms at every hand-off. BootScreen is now the
  shared loading visual, full screen, counting from navigation start.

Plex shared link
- Persists only the dimensions the link carries again. Writing the default
  filters for an omitted `pf` created the stored-filters key, which is what
  tells SavedViewsProvider the account picked filters, so the account's
  default Plex saved view was never applied again.

Watch page
- The Repeat toggle uses the shared pressed-pill paint (a solid fill when on)
  instead of Button variants that differ only in colour, and its flag goes
  through storage.ts readJSON/writeJSON.
- The actions wrap under the title when the row is too narrow, instead of
  squeezing the title into a column a word wide on a phone; the title breaks
  long words.

Comments that still justified code by the removed adopt effects (usePref,
PrefsProvider, FeedViewProvider) are rewritten against the seed-once model.

Tests: BootGate error/retry for both probes, setup mode, a failed background
refetch keeping the app, the pin correction and the reload on an identity
change; the shared reveal clock; the Plex link persist; the audio signature
actually moved off version 4; a video download (av and v) still remuxed to
an mp4. Each mutation-checked RED for its own reason.
2026-09-15 00:53:15 +02:00
peter 4ad7183102 feat(players): keep a video playing in the background on Brave iOS; repeat on the watch page
On iPad Brave, a shared video stopped the moment the browser left the
foreground, even with Brave's "Enable Background Audio" on, while an mp3
kept playing. Brave iOS keeps a web <video> going by re-playing one the OS
paused (brave-core ios/browser/web/media/resources/media_backgrounding.ts),
but it only hooks a <video> present at document start or added as a
MutationObserver addedNode that IS a video. React inserts a player's
<video> inside its freshly mounted wrapper in one go, so Brave never saw it.

lib/browserBackgroundPlay re-inserts a mounted <video> at its own position,
once per element: it becomes an added node, the DOM React manages does not
change, and playback is not interrupted. The watch page, the downloads
library player and the Plex player hand their <video> to it. Measured on the
owner's iPad (Brave) with a prototype of the same idea: the video kept
playing in another app and with the screen locked. The YouTube modal is not
ours to change: its <video> lives in YouTube's cross-origin iframe, and
nothing of ours pauses it on backgrounding.

The prototype's two audio-element variants (a muted video following an
<audio>, and an <audio> taking over on hide) went silent in the background on
the iPad and are removed.

The watch page gets a Repeat toggle, on by default and remembered per
browser (LS.watchRepeat; the page has no account): it sets `loop` on the
<video> or the audio-only <audio>.
2026-09-14 23:58:30 +02:00
peter 2c3aaec62d feat(watch): prototype background sound for a shared video on iPad (dev only)
On iPad Brave a shared mp3 keeps playing when the browser leaves the
foreground, but a shared h264 video stops at once, even with Brave's own
"Enable Background Audio" on. Three variants behind a `?bg=` param on the
/watch page, for a real-iPad comparison; without the param the page is
unchanged.

  a: the sound comes from a hidden <audio> on the same file, the <video> runs
     muted and follows it (drift correction, user seeks and mute moved onto
     the audio; our own catch-up seeks are not copied back, which re-seeked
     the sound on every correction and sent both back seconds in WebKit).
  b: the <video> plays with sound; an <audio> unlocked by the start tap takes
     over while the page is hidden and hands back on return.
  c: no audio trick: the <video> is inserted as a node of its own into an
     already-mounted wrapper. Brave iOS's background play only hooks a video
     present at document start or added as a MutationObserver addedNode that
     IS a video (brave-core ios/browser/web/media/resources/
     media_backgrounding.ts); the page inserted the video inside a new wrapper
     in one go, so Brave never saw it. A re-implementation of that detection
     rule reports the default page's video unhooked and mode c's hooked.

Modes a and b were driven in Chromium and WebKit with a simulated backgrounding
(document.hidden forced plus the OS's pause): sound continues, the picture
catches up on return, a user pause stops both. iPadOS's own media policy can
only be judged on the iPad.
2026-09-14 23:40:51 +02:00
peter 70598d47a3 fix(dev): proxy the messages WebSocket through Vite, which stops a reset killing the dev server
The Vite dev proxy had no `ws: true` on /api, so the in-app messages socket
(/api/messages/ws) was an upgrade nobody handled: it never connected on :5173,
and the first reset on it (a closed tab, a dropped connection) threw an
unhandled ECONNRESET that took the whole dev server down. Reproduced with a raw
upgrade + RST (dev server dead); with `ws: true` the backend answers the
upgrade, the dev server survives the reset and a closed tab, and the signed-in
socket is accepted through the proxy.
2026-09-14 23:08:41 +02:00
peter 5858e7b513 test(e2e): run the net on WebKit and an iPad profile; opt the account out of Home
The Playwright net now runs every flow in three projects: chromium (Desktop
Chrome), webkit (Desktop Safari) and ipad (iPad gen 7 landscape: touch, mobile
viewport). WebKit catches the engine-level differences every iOS browser
inherits; the config and the README say plainly what it does NOT catch: iPad
media refusals. Playwright's WebKit decodes through the host OS, and on Windows
it played an mp3-in-mp4 and an AV1 mp4 that a real iPad refuses, so media
formats are checked on a real iPad against the dev stack over the LAN.

global.setup opts the e2e account out of starting on Home (the Startup setting),
which the feed, channel and player specs need since E24. It only takes effect
with the new-tab boot fix: before it, all twelve of those specs landed on Home
in every project. 49/49 green on :8080 across the three projects.
2026-09-14 22:29:33 +02:00
peter 5346efcd10 fix(boot): a new tab knows its account before anything renders
With Settings -> Appearance -> Startup turned off, every NEW tab or window still
opened on Home; only a same-tab reload honoured it. The provider tree mounted
before /api/me answered, so NavigationProvider's initializer could not read the
per-account opt-out, landed on Home and mirrored ?page=home into the address bar.
When App pinned the account a moment later, the re-hydrate re-read that mirror
and stayed on Home. The same mount-then-patch shape made a new tab start on
default filters, the default theme and the browser language, and correct each
after the first paint.

BootGate (main.tsx, above the providers) now owns the setup-status and /api/me
queries and mounts the tree only once the identity is settled: it pins the tab's
account and adopts the account's language in a layout effect, so the first frame
of the app is already right. The tree is keyed on the account id, which makes
"per-account state is read once, for this account" a structural guarantee.

With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the
reactive active-account store, the markDirty guards, the pin-deferred saved-view
seed and the present-dimension bookkeeping of the shared Channels/Plex links.
PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the
me cache instead of adopting it in an effect; the theme is applied in layout
effects. The app chunk is fetched in parallel with the probe, and the boot screen
only fades its label in after 400 ms, on one clock across the gate and the
Suspense fallback.

Module switches no longer flash a placeholder either. Measured frame by frame
in Chromium and WebKit, every first visit to a module showed "Loading..." for
17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's
LoadingState. Pages are now lazyWithPreload components, and App fetches the
chunks of every module the account can reach (plus the channel page) once the
first page is idle; a preloaded page renders synchronously and never suspends.
LoadingState, the one shared loading visual, fades in only after 300 ms, so a
fast load shows nothing in between and a slow one still gets its spinner.
2026-09-14 22:29:33 +02:00
peter 6169e32ca5 fix(downloads): a signed-in Facebook reel keeps its title, and an mp3 stays an mp3
Since a Facebook cookie was added on prod, every reel came down titled
"Video": measured on dev, a signed-in read gives yt-dlp the placeholder
"Video" (an 18+ reel read as Chrome gives "Facebook") while the post's text
is still in the description. A new SourceAdapter.title hook takes the whole
first line of the description in that case, and both title writes (the early
fill and the finished download) go through it, so the row, the file name and
the share preview carry the post's text again. The line is not cut with an
ellipsis: normalize_title's NFKC turns "…" into "..." and collapses it to
".", which read as a finished sentence (seen in dev UAT).

The "Audio (MP3)" profile's extracted .mp3 was remuxed into an .mp4 by the
browser-safe step (the audio spec is clamped to container mp4 too) and served
as video/mp4, which iPad's WebKit will not play (prod job 268). Audio mode now
skips that step, so an .mp3 is served as audio/mpeg and an .m4a as audio/mp4.
Audio downloads version their cache identity on their own (v5), so a cached
remuxed audio re-derives without re-downloading every cached video.
2026-09-14 17:46:46 +02:00
329 changed files with 11965 additions and 2741 deletions
+8
View File
@@ -28,6 +28,14 @@ ALLOWED_EMAILS=
# Admin emails (subset of the above) get the admin role.
ADMIN_EMAILS=
# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
# the worker/download child read; the API's running level is the admin Configuration page's
# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in
# any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
# line — what a log collector ingests) or "text" (a readable line for a local terminal).
LOG_LEVEL=INFO
LOG_FORMAT=json
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
FRONTEND_ORIGIN=
+70
View File
@@ -7,6 +7,76 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.88.0 — 2026-09-18
When something goes wrong, you can now turn on detailed logging for yourself for 24 hours — and read your own log on the spot, before any of it is sent anywhere. Siftlode also has a privacy policy and terms at last, in both languages, saying exactly what is recorded and for how long.
### Features
- Settings → Diagnostics: choosing Detailed or Full changes nothing until you confirm a box that tells you what will be recorded, that the server keeps it, for how long, and that it switches itself off after 24 hours. Going back to Basic needs no confirmation and takes effect at once.
- “My log”, on the same tab, shows what your browser has recorded — newest first, any line openable. Nothing leaves the browser before you have consented.
- What you search for and filter by is recorded only at Full, and only under your own consent — never an administrator's. Passwords, tokens, cookies and email addresses are recorded at no level, by anyone.
- Your last sign-in is shown in your account panel, and in the administrator's user list.
- A privacy policy and terms of service, in Hungarian and English, covering what the server logs and what each diagnostics level adds.
- Administrators get a Diagnostics section in Configuration: the log level takes effect without a restart, and stored logs are cleaned up on a schedule (7 days by default).
- Every setting with a fixed set of values is now picked from those values instead of typed in, so a wrong one can no longer be saved.
### Fixes
- Every message arrived twice if you left a conversation and came back quickly: two chat connections stayed open where there should have been one.
- Settings was unusable on a phone — the tab rail took the width and left almost nothing for the panel beside it.
- Dates and numbers follow the language you picked in Siftlode, not the one your computer happens to be set to. An English Siftlode on a Hungarian machine no longer shows Hungarian dates.
- An administrator setting shown as a dropdown opened on its first option instead of the one actually in force.
- In Hungarian, the audit log and the new log settings had names you could not tell apart.
- The scheduled feed poll now covers only channels somebody actually follows — on this instance 582 of 979 polled channels had no follower at all. The trade-off is deliberate: new uploads from a channel nobody follows no longer appear in the shared Library.
### Internal
- One logging mechanism replaced four. The API, the worker, the downloader and the migration tool now write the same structured line; secrets are scrubbed where the line is written rather than at each of ~125 places that write one; and every request carries an id that joins a line from your browser to the server line of the same request. The browser's log uses the same shape and the same redaction rules, checked from both sides against one shared set of cases.
- Two adversarial review rounds over the epic: 56 findings raised, 32 survived three independent attempts to refute each, all fixed. Every regression test was proven to fail with its own fix removed — which is how a test that passed only on leftover state from an earlier test was caught, and how a focus bug that jsdom could not reproduce at all was settled in a real browser.
## 0.87.1 — 2026-09-16
A YouTube video that YouTube will only show to a signed-in viewer can now be downloaded: paste your YouTube cookies in Settings the way the failure message already told you to — until now that list had no YouTube in it.
### Features
- YouTube joins the sites you can store download cookies for (Settings → Account → Download authentication). A signed-in YouTube download automatically uses the players that accept your login.
### Fixes
- A YouTube download that stops at “Sign in to confirm you’re not a bot” says so in your language and points at a setting that now exists. If you had already added cookies, it tells you they were refused instead of asking for them again.
- A video YouTube describes but then refuses to hand over is tried again with another player instead of failing at once, and it is no longer called a network error — nothing was wrong with the network.
### Internal
- The gated video was measured end to end on an isolated stack before and after the fix: every player client, two yt-dlp versions, and finally the real download (519 MB) with cookies attached. One deep-review round — 25 findings raised, none survived adversarial verification, and the one real defect came out of the verification itself: a comment of mine named an error yt-dlp does not raise there. Running it settled what reading it could not.
## 0.87.0 — 2026-09-15
A shared or library video keeps playing when you switch apps on an iPad, and a new tab opens as the account it belongs to without flashing another page first. Facebook reels keep their titles and an mp3 stays an mp3.
### Features
- Background play on iPad (Brave): the shared-video page, the library player and the Plex player keep playing in another app and on the locked screen.
- The shared-video page has a Repeat toggle, on by default.
- Switching modules no longer flashes a loading screen: the pages load quietly in the background, and a slow answer shows its spinner only after a moment.
### Fixes
- A new tab honours your Startup choice and opens as its own account — no Home page flash, no other account's language or settings on the first frame.
- A signed-in Facebook reel keeps its real title instead of “Video” or “Facebook”, and an mp3 download stays an mp3 (it used to be wrapped into an mp4 an iPad would not play).
- A tab whose account was signed out, suspended or signed out everywhere acts as nobody and comes back as the account still signed in, instead of writing into another account or sticking on the sign-in page.
- Switching or adding an account no longer saves the video you were watching into the other account's resume position.
- A new tab keeps the account's notification history instead of replacing it with its own first notice.
- The shared-video page tells a failed load, a wrong password, too many attempts and an unreachable server apart, with Retry where it helps. Its password limit counts per viewer, so one person guessing cannot lock everyone else out.
- Form errors on sign-in, the message passphrase, password change and setup are read out by screen readers — again on a second identical failure — and the setup wizard names what is wrong instead of a generic error.
### Internal
- WebKit and iPad projects in the Playwright suite; the Vite dev proxy forwards websockets.
- Four deep-review rounds (12 → 8 → 8 → 8, the last one targeted), every regression test mutation-checked; half of the later rounds' findings were older defects, fixed here.
## 0.86.0 — 2026-09-14
A failed download now tells you why it failed and what you can do about it, instead of repeating yt-dlp's raw error. Facebook comes first: downloads that used to die on “Cannot parse data” go through, and a Facebook group post downloads its own video instead of a different one from the page.
+3 -1
View File
@@ -58,7 +58,7 @@ how it gets built. Concretely:
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
@@ -95,6 +95,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
## House rules that bite (learned from real bugs)
+1 -1
View File
@@ -1 +1 @@
0.86.0
0.88.0
+2
View File
@@ -1,6 +1,8 @@
[alembic]
script_location = alembic
prepend_sys_path = .
# Split the path list the way the OS does (alembic's legacy space/comma/colon split is deprecated).
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
+4 -4
View File
@@ -1,17 +1,17 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import settings
from app.db import Base
from app.logs import configure_logging
import app.models # noqa: F401 (import so models register on Base.metadata)
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Migrations run at container start (entrypoint.sh), so their output is part of the API's log
# stream — one format with everything else, not alembic.ini's own fileConfig shape.
configure_logging("alembic")
target_metadata = Base.metadata
@@ -0,0 +1,26 @@
"""add users.last_login_at — when the account last signed in (E22 S1)
Revision ID: 0085_users_last_login_at
Revises: 0084_dl_job_error_detail
Create Date: 2026-09-16
Stamped by the one sign-in path (`routes/auth._establish_session`), which the Google callback and
password login share. No backfill is possible: the signal exists only at sign-in, so existing rows
read NULL until each account signs in again, and the admin UI says so rather than inventing a date.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0085_users_last_login_at"
down_revision: str | None = "0084_dl_job_error_detail"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("users", "last_login_at")
@@ -0,0 +1,48 @@
"""add log_events — stored log events for diagnostics (E22 S3)
Revision ID: 0086_log_events
Revises: 0085_users_last_login_at
Create Date: 2026-09-17
Written by app/logsink.py's background writer (WARNING and above, request lines), pruned by the log_gc
job after `log_retention_days`. Sized against prod on 2026-09-16: ~2k rows on a normal day, ~14k on a
YouTube-RSS-outage day before flood control — a few MB over the 7-day window. `user_id` carries no
foreign key on purpose (see models.LogEvent).
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0086_log_events"
down_revision: str | None = "0085_users_last_login_at"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"log_events",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
sa.Column("level", sa.String(10), nullable=False),
sa.Column("sev", sa.SmallInteger(), nullable=False),
sa.Column("source", sa.String(16), nullable=False),
sa.Column("logger", sa.String(128), nullable=False),
sa.Column("event", sa.String(64), nullable=True),
sa.Column("msg", sa.Text(), nullable=False),
sa.Column("attrs", postgresql.JSONB(), nullable=True),
sa.Column("exc", sa.Text(), nullable=True),
sa.Column("request_id", sa.String(64), nullable=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("debug_session_id", sa.String(64), nullable=True),
sa.Column("job_id", sa.Integer(), nullable=True),
sa.Column("version", sa.String(64), nullable=True),
)
op.create_index("ix_log_events_ts", "log_events", ["ts"])
op.create_index("ix_log_events_request_id", "log_events", ["request_id"])
op.create_index("ix_log_events_user_ts", "log_events", ["user_id", "ts"])
def downgrade() -> None:
op.drop_table("log_events")
@@ -0,0 +1,31 @@
"""add users.diagnostics_level + diagnostics_until — a user's consented diagnostics level (E22 S5)
Revision ID: 0087_users_diagnostics_level
Revises: 0086_log_events
Create Date: 2026-09-17
Both NULL = the base level (Alap). A raised level is ALWAYS paired with its expiry, computed by the
server at consent time (`app/diagnostics.py`), so no client can ask for a level that never ends.
Columns rather than `users.preferences`: that JSON is written wholesale by the client, and a consent
with a lifetime must not be something a client can write.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0087_users_diagnostics_level"
down_revision: str | None = "0086_log_events"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("diagnostics_level", sa.String(8), nullable=True))
op.add_column(
"users", sa.Column("diagnostics_until", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column("users", "diagnostics_until")
op.drop_column("users", "diagnostics_level")
+4 -3
View File
@@ -20,9 +20,10 @@ Three layers, by *who* runs the code — not by feature:
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, … are single-file primitives many
layers import. They depend on nothing above them (a core module must never import a `routes/…`
module).
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
configuration), `logsink` (its `log_events` table) and `diagnostics` (a user's consented log
level), … are single-file primitives many layers import. They depend on nothing above them (a core
module must never import a `routes/…` module).
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
*second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down
+67 -17
View File
@@ -6,12 +6,14 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection
from app import diagnostics
from app import email as email_mod
from app import logs
from app import mailtext
from app import sysconfig
from app.config import settings
from app.db import get_db
from app.models import Invite, User
from app.models import Invite, LogEvent, User
from app.security import decrypt_optional
from app.utils import canonical_email
@@ -245,6 +247,12 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
)
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id))
# Stored log events name the account by id with no foreign key to cascade from (models.LogEvent),
# so they go explicitly — they are request history, i.e. personal data. Forgotten FIRST: this very
# request's access line, a queued row, a later background task would otherwise put rows back
# under the id after this DELETE (logs.forget_user).
logs.forget_user(user_id)
db.execute(delete(LogEvent).where(LogEvent.user_id == user_id))
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
# account was just erased — and prod really does carry approved pairs for one mailbox.
@@ -269,21 +277,29 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]:
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
"""Which account this connection acts as, and whether that's the session's default account.
Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this
exact per-tab resolution instead of re-implementing it.
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
plus a default `user_id`. A caller may override the default with the X-Siftlode-Account header,
but ONLY for an account already in the wallet — so a tab can't impersonate an account that never
authenticated here. Everything else (WebSocket, plain navigations) keeps using the cookie default.
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
`?account=`), but ONLY an account in the wallet — so a tab can't impersonate an account that
never authenticated here. A connection that names no account acts as the cookie default.
A connection naming an account NOT in the wallet acts as nobody (`None`) — typically a tab still
pinned to an account that was signed out in another tab. It must not fall back to the default:
that tab would go on acting as an account it never showed, its player checkpoints and pref saves
landing there and its Sign out signing THAT account out. Only the bootstrap /api/me probe passes
`strict=False` and gets the default, which is how such a tab learns who it now is (the frontend
re-pins it and reloads). An account whose session ended while it stayed in the wallet (deleted,
suspended, revoked) leaves the wallet when it is refused (`_session_user`), so a tab pinned to it
takes the same way back.
"""
default_id = conn.session.get("user_id")
wallet = conn.session.get("account_ids") or []
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
# plain <a> file download). Both are wallet-gated below, so neither can impersonate an
# account that never signed into this browser.
# plain <a> file download). Both are wallet-gated below.
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
if hdr:
try:
@@ -292,28 +308,46 @@ def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]:
hid = None
if hid is not None and hid in wallet:
return hid, hid == default_id
if strict:
return None, False
return default_id, True
def drop_from_wallet(session: dict, user_id: int) -> None:
"""Take one account out of this browser's wallet (`account_ids`); the session default is untouched."""
session["account_ids"] = [a for a in (session.get("account_ids") or []) if a != user_id]
def _end_account_session(request: Request, user_id: int, is_default: bool) -> None:
"""An account whose session here is no longer valid. Only nuke the whole session when it is the
SESSION DEFAULT; a per-tab account just leaves the wallet (other tabs' default may still be valid).
Leaving is what lets a tab pinned to it recover: its next /api/me names an account no longer signed
in here and gets the default. Left in, it answered "signed out" — the landing page, with the default
still signed in, and no way off it but closing the tab."""
if is_default:
request.session.clear()
else:
drop_from_wallet(request.session, user_id)
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id, is_default = resolved_user_id(request)
return _session_user(request, db, *resolved_user_id(request))
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id)
if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
# But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale
# per-tab header account just 401s that one tab (other tabs' default may still be valid).
if is_default:
request.session.clear()
# Suspended/deactivated mid-session → end it so the block takes effect at once.
_end_account_session(request, user_id, is_default)
raise HTTPException(status_code=401, detail="Not authenticated")
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
epochs = request.session.get("epochs") or {}
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
if is_default:
request.session.clear()
_end_account_session(request, user_id, is_default)
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the session default in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
@@ -322,15 +356,31 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
if default_id and default_id not in accounts:
accounts.append(default_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
# Attribute this request's log lines to the account (E22). Here, not in the request middleware:
# this is the one place a session becomes an account, and the middleware runs outside both the
# session middleware and this task's context. `request.state` is shared across middleware layers
# (it lives on the ASGI scope), so the access-log line can read it back. The user's consented
# diagnostics level rides along: their requests are logged — and stored — in that detail (S5).
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
request.state.user_id = user.id
return user
def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
the browser console (a non-2xx fetch is a console error no matter how the app handles it).
The one lenient resolution (`strict=False`): a tab naming an account no longer signed in here gets
the default account, which is how it learns who it now is — see `resolved_user_id`. That includes an
account refused right here, which `_session_user` has just taken out of the wallet."""
user_id, is_default = resolved_user_id(request, strict=False)
try:
return current_user(request, db)
return _session_user(request, db, user_id, is_default)
except HTTPException:
if is_default:
return None
try:
return _session_user(request, db, *resolved_user_id(request, strict=False))
except HTTPException:
return None
+65 -6
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from typing import Literal, get_args, get_origin
from pydantic import model_validator
from pydantic import ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below.
@@ -20,6 +21,33 @@ def _version_from_file() -> str:
return "dev"
# Closed value sets. A setting typed with one of these is a CHOICE: pydantic rejects anything else at
# boot, and sysconfig derives the admin picker's options from the same Literal — so the list lives in
# exactly one place and a consumer never has to guess what a typo should fall back to.
SearchSource = Literal["scrape", "api"]
DownloadLayout = Literal["plex", "flat"]
LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
LogFormat = Literal["json", "text"]
TranscodeHw = Literal["auto", "on", "off"]
# libx264's presets, fastest first (`placebo` left out on purpose: no measurable gain for its cost).
TranscodePreset = Literal[
"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"
]
def choices_of(annotation: object) -> tuple[str, ...]:
"""The allowed values of a choice-typed setting, or () for any other type."""
return tuple(get_args(annotation)) if get_origin(annotation) is Literal else ()
def canonical_choice(options: tuple[str, ...], raw: object) -> str | None:
"""The option `raw` names, in its canonical spelling (any letter case, surrounding space
ignored), or None. The ONE matching rule for a choice value from the env and from the admin
API, so a spelling one accepts the other cannot refuse."""
folded = str(raw).strip().casefold()
return next((option for option in options if option.casefold() == folded), None)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -57,7 +85,7 @@ class Settings(BaseSettings):
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
# deploy. Comma-separated. See routes.auth._client_ip + the deploy docs.
# deploy. Comma-separated. See ratelimit.client_key + the deploy docs.
trusted_proxy_ips: str = ""
# --- Outbound email (onboarding: access-request + approval notices) ---
@@ -85,7 +113,7 @@ class Settings(BaseSettings):
search_daily_limit_per_user: int = 70
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list,
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
search_source: str = "scrape"
search_source: SearchSource = "scrape"
# Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365
@@ -155,6 +183,9 @@ class Settings(BaseSettings):
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
download_player_clients: str = "android_vr"
download_player_clients_fallback: str = "web_safari,web,android"
@@ -221,10 +252,27 @@ class Settings(BaseSettings):
audit_retention_days: int = 180
audit_gc_minutes: int = 1440
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
download_layout: str = "plex"
download_layout: DownloadLayout = "plex"
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
sponsorblock_default: bool = False
# --- Diagnostics / logging (E22) ---
# BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at
# startup and on change without a restart. This is what a process logs at before it can reach
# the database — and the only knob the worker/runner children read, since they are short-lived.
# TRACE (below DEBUG) is the "Teljes" diagnostics level; see app/logs.py.
log_level: LogLevel = "INFO"
# "json" (one object per line — what Loki and the log_events sink ingest) or "text" (a readable
# line for a local terminal). Redaction applies to both.
log_format: LogFormat = "json"
# Also write events to the `log_events` table (app/logsink.py): WARNING and above, the request
# lines, and — once they exist — client batches. Off only where no table is wanted (the tests).
log_db_sink: bool = True
# Days a `log_events` row is kept (the privacy policy promises this), and how often the log_gc job
# enforces it — hourly, so a row never outlives the promise by more than an hour.
log_retention_days: int = 7
log_gc_minutes: int = 60
# --- Plex integration (optional) ---
# The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve);
# Plex is used only for metadata + optional watch-state sync. Requires the Plex server to be
@@ -266,7 +314,7 @@ class Settings(BaseSettings):
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
plex_transcode_hw: str = "auto"
plex_transcode_hw: TranscodeHw = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
@@ -276,7 +324,7 @@ class Settings(BaseSettings):
# call, not a fix. Only affects files that go through remux/transcode — a direct-played file is
# served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for.
plex_audio_loudnorm: bool = False
plex_transcode_preset: str = "veryfast"
plex_transcode_preset: TranscodePreset = "veryfast"
plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
@@ -325,6 +373,17 @@ class Settings(BaseSettings):
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://")
@field_validator("*", mode="before")
@classmethod
def _canonical_choice(cls, value: object, info: ValidationInfo) -> object:
"""A choice setting accepts its value in any letter case (`LOG_LEVEL=debug`) and stores the
canonical spelling; an unknown value still fails the boot with pydantic's own message."""
field = cls.model_fields.get(info.field_name or "")
options = choices_of(field.annotation) if field else ()
if options and isinstance(value, str):
return canonical_choice(options, value) or value
return value
@model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder
+61
View File
@@ -0,0 +1,61 @@
"""A user's diagnostics level (E22 S5): what they consented to, and for how long.
Three levels, named in the UI Alap / Részletes / Teljes:
* INFO — the base, for everyone, no consent. The browser keeps its log in memory; nothing extra
is sent or stored.
* DEBUG — consented. The server logs this user's requests in detail and stores every line of
them, and the browser streams its own log to `POST /api/diagnostics/events`.
* TRACE — consented. As DEBUG, plus what the user typed into a search.
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
never taken from the client, and an expired level reads as INFO without anything having to clear it.
The shared demo account cannot raise its level — its log would mix every visitor's session.
Core layer: imports `models` and `sysconfig`.
"""
from datetime import datetime, timedelta, timezone
from typing import Literal, get_args
from sqlalchemy.orm import Session
from app import sysconfig
from app.models import User
DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
BASE: DiagnosticsLevel = "INFO"
ELEVATION_HOURS = 24
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
"""The level in force for `user` right now: the consented one until it expires, else BASE."""
level, until = user.diagnostics_level, user.diagnostics_until
if level not in LEVELS or level == BASE or until is None:
return BASE
if until <= (now or datetime.now(timezone.utc)):
return BASE
return level # type: ignore[return-value] — narrowed by the LEVELS check above
def state(db: Session, user: User) -> dict:
"""The `/api/me` view of it: the level in force, when a raised one lapses, and how long stored
log lines are kept — the consent text states that number, so it must be the instance's own."""
level = effective_level(user)
return {
"level": level,
"until": user.diagnostics_until.isoformat() if level != BASE else None,
"retention_days": sysconfig.get_int(db, "log_retention_days"),
}
def set_level(user: User, level: DiagnosticsLevel) -> None:
"""Record a consent (or its withdrawal). The caller commits. Raising the level again restarts
the clock — each consent is for the next ELEVATION_HOURS, not an extension of an older one."""
if level == BASE:
user.diagnostics_level = None
user.diagnostics_until = None
return
user.diagnostics_level = level
user.diagnostics_until = datetime.now(timezone.utc) + timedelta(hours=ELEVATION_HOURS)
+58 -3
View File
@@ -61,6 +61,10 @@ class SourceAdapter:
return False
# --- metadata -----------------------------------------------------------------------------
def title(self, info: dict) -> str | None:
"""The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's."""
return info.get("title")
def uploader_url(self, info: dict) -> str | None:
"""The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives
`channel_url`), else None (reddit / a raw media URL have no derivable channel page)."""
@@ -89,12 +93,46 @@ class YouTubeAdapter(SourceAdapter):
"Requested format is not available",
"Only images are available",
"Sign in to confirm",
# Measured 2026-09-16: a gated video whose EXTRACTION succeeds and whose media fetch is then
# refused by the CDN ("unable to download video data: HTTP Error 403"). The wall moves
# between the player response and the stream, so this is the same per-client flakiness one
# step later — and it used to end the job on attempt 1, because the text classifies as
# `network` and nothing here matched it. The format URLs belong to the client that minted
# them, so the next client set is a real second chance.
"unable to download video data",
)
# Measured in the worker against a REAL account cookie (2026-09-16, yt-dlp 2026.07.04): with
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
# rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every
# signed-in download would burn its first attempt that way before reaching the POT-backed web
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
# that moves the table turns the gate red instead of silently un-authing every YouTube download.
_COOKIE_CAPABLE = frozenset(
{
"mweb",
"tv",
"tv_downgraded",
"web",
"web_creator",
"web_embedded",
"web_music",
"web_safari",
}
)
# Where a signed-in download lands when the admin's knobs hold no cookie-capable client at all.
# Both clients in ONE set: yt-dlp queries each and merges their formats, which is the cover for
# the per-client flakiness that the two-tier ladder gives a signed-out download.
_AUTHED_DEFAULT = ("web_safari", "web")
def client_sets(self, authed: bool = False) -> list:
return [
c for c in (settings.player_client_list, settings.player_client_fallback_list) if c
] or [None]
sets = [c for c in (settings.player_client_list, settings.player_client_fallback_list) if c]
if authed:
kept = [[c for c in s if c in self._COOKIE_CAPABLE] for s in sets]
return [s for s in kept if s] or [list(self._AUTHED_DEFAULT)]
return sets or [None]
def ydl_opts_extra(self, clients) -> dict:
# == --extractor-args "youtube:player_client=…;youtubepot-bgutilhttp:base_url=…" (list values).
@@ -158,6 +196,23 @@ class FacebookAdapter(SourceAdapter):
def retriable(self, error_text: str) -> bool:
return FACEBOOK_PARSE_WALL in error_text.lower()
# Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an
# 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A
# signed-out read titles the page with that same text behind the engagement counts, cut short.
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
def title(self, info: dict) -> str | None:
raw = info.get("title")
if (raw or "").strip().lower() not in self._PLACEHOLDER_TITLES:
return raw
line = next(
(ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), ""
)
# The whole first line, never cut here with an ellipsis: normalize_title's NFKC turns "…" into
# "..." and then collapses it to ".", which read as a finished sentence ("… live at."). The
# stored title is unbounded; display_name and the file name clip it themselves.
return line or raw # no description either (the 18+ reel): nothing better on the page
def uploader_url(self, info: dict) -> str | None:
# Facebook gives neither channel_url nor uploader_url, but exposes a numeric `uploader_id`
# (the page id) that resolves at facebook.com/<id>.
+16 -7
View File
@@ -11,7 +11,7 @@ in prose):
is downloading into is that private, salted identity — so a cookie can never poison a shared
asset (the load-bearing cross-user privacy fix).
Both go through `cookie_salt` / `authed_format_sig` / `host_key_for_url` here, so they can't drift.
Both key the private identity on `cookie_salt` / `host_key_for_url` here, so they can't drift.
Security controls enforced here:
* **host scope (SEC-12)** — `host_key_for_url` matches the download URL's netloc against a curated
@@ -55,6 +55,11 @@ class HostSpec:
# all derive from this, so there is nothing else to wire up.
SUPPORTED_HOSTS: tuple[HostSpec, ...] = (
HostSpec("facebook", "Facebook", ("facebook.com", "fb.watch")),
# YouTube gates INDIVIDUAL videos behind "Sign in to confirm you're not a bot" (measured on prod
# job 273: every player client fails on that one video while its neighbours download fine, and
# the newest yt-dlp fails the same way — a signed-in session is the only way through). Without a
# row here the `login_required` copy sent the user to a dropdown that could not help them.
HostSpec("youtube", "YouTube", ("youtube.com", "youtu.be", "youtube-nocookie.com")),
HostSpec("instagram", "Instagram", ("instagram.com",)),
HostSpec("twitter", "X (Twitter)", ("twitter.com", "x.com")),
HostSpec("tiktok", "TikTok", ("tiktok.com",)),
@@ -101,8 +106,9 @@ def cookie_salt(user_id: int, host_key: str) -> str:
def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
"""The salted `format_sig` an authed download for (user, host) resolves to — the private asset
identity. Both enqueue (to create it) and the worker (to recognise it) compute it HERE."""
"""The salted `format_sig` an authed download for (user, host) resolves to under the current
signature version — the private asset enqueue creates, and one of the identities `is_authed_asset`
recognises. Neither calls it; the tests use it to hold the two to each other."""
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
@@ -118,14 +124,17 @@ def is_authed_asset(asset: MediaAsset, spec: dict, user_id: int, url: str | None
"""Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one
kind of asset a cookie is ever attached to: the one enqueue creates (`authed_format_sig`), or a
Re-download's rebuild of it (`rebuild_salt`). The worker's cookie gate asks it before a download,
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie)."""
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie).
Either signature version counts (`formats.format_sigs`): a job queued before a deploy that bumped
the version still downloads into the asset it was given."""
host_key = host_key_for_url(url)
if host_key is None:
return False
if asset.format_sig == authed_format_sig(spec, user_id, host_key):
salt = cookie_salt(user_id, host_key)
if asset.format_sig in formats.format_sigs(spec, salt=salt):
return True
return asset.id is not None and asset.format_sig == formats.format_sig(
spec, salt=rebuild_salt(asset.id, cookie_salt(user_id, host_key))
return asset.id is not None and asset.format_sig in formats.format_sigs(
spec, salt=rebuild_salt(asset.id, salt)
)
+37
View File
@@ -212,6 +212,43 @@ _CATALOG: tuple[_Entry, ...] = (
cookie=False,
link=facebook_group_post_id,
),
# YouTube gates INDIVIDUAL videos behind "Sign in to confirm you're not a bot" (measured on prod
# job 273, a festival set: all 12 of yt-dlp's player clients fail on that one video while its
# neighbours download fine, the POT sidecar is live and answering, and the newest yt-dlp fails
# identically — so this is the video's own signed-out gate, not a broken stack or a stale yt-dlp).
# The marker is quote-agnostic on purpose: YouTube writes the apostrophe as U+2019, so
# "you're not a bot" with a straight quote never matches the real text.
_Entry(
host="youtube",
markers=("[youtube]", "not a bot"),
code="login_required",
detail="youtube.bot_wall",
cookie=False,
),
# The same wall WITH a cookie. Not yet reproduced with a real YouTube cookie (this user's first
# one is being added now) — but "add cookies" is provably the wrong advice for someone who has,
# and yt-dlp names the likeliest cause itself: "The provided YouTube account cookies are no
# longer valid. They have likely been rotated in the browser as a security measure."
_Entry(
host="youtube",
markers=("[youtube]", "not a bot"),
code="login_required",
detail="youtube.cookie_rejected",
cookie=True,
),
# The same gate one step later: the player response comes back, and then the CDN refuses the
# stream itself ("unable to download video data: HTTP Error 403"). Measured 2026-09-16 on the
# very same video, ~90 minutes after it was answering with the bot message — signed out it
# 403'd on every attempt; with the owner's cookie attached it downloaded in full (519 MB). So
# the category this text carries generically, `network`, is a lie here: nothing was wrong with
# the network. Scoped to a cookie-less YouTube download, where that is what we measured.
_Entry(
host="youtube",
markers=("unable to download video data", "403"),
code="login_required",
detail="youtube.media_forbidden",
cookie=False,
),
)
+21 -3
View File
@@ -57,6 +57,10 @@ _DEFAULTS = {
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
# the v3 net — must re-derive through the current browser-safe guarantee.
_SIG_VERSION = 4
# Audio-only downloads version on their own, so a change to the audio bytes doesn't re-download every
# cached video. v5: an extracted .mp3/.m4a is stored as itself instead of remuxed into an .mp4 that
# iPad's WebKit refuses to play, so a cached remuxed audio asset must re-derive.
_AUDIO_SIG_VERSION = 5
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
@@ -96,20 +100,34 @@ def normalize(spec: dict) -> dict:
return s
def format_sig(spec: dict, salt: str | None = None) -> str:
def _sig_version(s: dict) -> int:
return _AUDIO_SIG_VERSION if s["mode"] == "a" else _SIG_VERSION
def format_sig(spec: dict, salt: str | None = None, *, version: int | None = None) -> str:
"""Stable short hash of the output-affecting spec — the MediaAsset cache key component.
`salt` forks a distinct, non-dedup identity for the same spec (used by force-redownload so it
always re-fetches into its own asset instead of sharing a cached file with other jobs)."""
always re-fetches into its own asset instead of sharing a cached file with other jobs).
`version` hashes under a signature version other than the current one (see `format_sigs`)."""
s = normalize(spec)
data = {k: s[k] for k in _SIG_KEYS}
data["_v"] = _SIG_VERSION
data["_v"] = _sig_version(s) if version is None else version
if salt is not None:
data["_salt"] = salt
payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode()).hexdigest()[:24]
def format_sigs(spec: dict, salt: str | None = None) -> tuple[str, str]:
"""`format_sig` under the current signature version and under the one before it. An asset keeps
the signature it was created with, so a job queued before a deploy that bumped the version and
run after it downloads into an asset carrying the previous one — which must still be recognised
as that user's private asset (`credentials.is_authed_asset`), or it downloads without its cookie."""
previous = _sig_version(normalize(spec)) - 1
return format_sig(spec, salt), format_sig(spec, salt, version=previous)
def build_ydl_opts(
spec: dict,
outtmpl: str,
+10 -5
View File
@@ -113,9 +113,10 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
# (e.g. a title that was only a social engagement prefix); the completion path later sets
# the final title with its own source_ref fallback.
asset.title = normalize_title(info.get("title")) or info.get("id")
adapter = adapters.resolve_adapter(None, None, info)
asset.title = normalize_title(adapter.title(info)) or info.get("id")
asset.uploader = info.get("uploader") or info.get("channel")
asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info)
asset.uploader_url = adapter.uploader_url(info)
asset.thumbnail_url = info.get("thumbnail")
asset.duration_s = int(info["duration"]) if info.get("duration") else None
asset.upload_date = _parse_upload_date(info.get("upload_date"))
@@ -403,8 +404,12 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
if spec.get("container") != "mp4" or spec.get("vcodec"):
file to store (converted, or the original) and corrects `info`'s codec fields to match disk.
An audio download (mode "a") is never touched: yt-dlp already extracted it to .mp3 / .m4a, which
every browser plays as audio/mpeg / audio/mp4. Remuxing it into .mp4 served an mp3 as video/mp4,
which iPad's WebKit refuses to play (prod job 268)."""
if spec.get("mode") == "a" or spec.get("container") != "mp4" or spec.get("vcodec"):
return produced
vcodec, acodec = editmod.probe_codecs(produced)
is_mp4 = produced.suffix.lower() == ".mp4"
@@ -509,7 +514,7 @@ def _download(
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
meta = storage.MediaMeta(
video_id=info.get("id") or source_ref,
title=normalize_title(info.get("title")) or source_ref,
title=normalize_title(adapter.title(info)) or source_ref,
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
upload_date=_parse_upload_date(info.get("upload_date")),
uniqueid_type=adapter.nfo_uniqueid_type(),
+7 -1
View File
@@ -17,6 +17,8 @@ import shutil
import subprocess
import sys
from app import logs
from app import logsink
from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
@@ -52,12 +54,16 @@ def _deprioritize() -> None:
def main() -> None:
logging.basicConfig(level=logging.INFO)
logs.configure_logging("runner")
_deprioritize()
if len(sys.argv) != 2:
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
raise SystemExit(2)
job_id = int(sys.argv[1])
# This whole process exists to run ONE job — tag every line it prints with it, so the child's
# output joins up with the worker's and the API's view of the same download.
logs.set_job(job_id)
logsink.start() # flushed at exit (atexit) — this process has no shutdown hook of its own
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
+685
View File
@@ -0,0 +1,685 @@
"""Structured logging for every Siftlode process — the ONE place logging is configured.
Before this module a single deployment printed FOUR different log formats: uvicorn's
`log_config.json` for the API, a hand-rolled StreamHandler in `main.py` for everything that did not
start under uvicorn (tests, scripts), `logging.basicConfig()` in the worker and in the download
child (no timestamp at all), and alembic's `fileConfig`. None of them carried a correlation id, and
uvicorn's access log printed the query string of every request (`?account=1`, `?source=feed`) — so
the logs were both unjoinable and leaky. `configure_logging()` replaces all four.
Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
ts · level · sev · source · logger · event · msg · attrs
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
`job_id`) and the app version. That shape is what E22.6's admin console filters on and what a Gitea
issue attachment will carry, so it is deliberately flat and stable.
Call sites stay ordinary stdlib logging:
log = logging.getLogger("siftlode.downloads")
log.info("job finished", extra=logs.event("download.job.done", job_id=7, bytes=n))
`event()` is optional — a bare `log.info("…")` still comes out well-formed — but the dotted code is
what makes a line queryable later, so add one wherever the line matters.
REDACTION IS APPLIED AT THE HANDLER, not at the call site: every message, every attr value and
every traceback passes `redact()` on the way out. That is the whole point — a secret cannot be
leaked by forgetting to redact it at one of ~125 call sites, and a new call site is safe by
default. Never logged, at any level: passwords, tokens (session, reset/verify, setup, share,
OAuth), cookie blobs, secret config values, and email addresses.
"""
import contextlib
import contextvars
import json
import logging
import logging.config
import re
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
from typing import Any
from urllib.parse import parse_qsl
from app.config import settings
from app.ratelimit import RateLimiter, client_key
# One level below DEBUG for the "Teljes / TRACE" diagnostics level (search strings, redacted request
# bodies). Registered on import so `logging.getLogger(...).log(logs.TRACE, …)` and a `LOG_LEVEL=TRACE`
# both work in every process, including one that never calls configure_logging (a test).
TRACE = 5
logging.addLevelName(TRACE, "TRACE")
# OpenTelemetry severity numbers for our level names. Kept beside the level because the number is
# what a log backend sorts and filters on; the name is for humans reading a terminal.
SEVERITY: dict[str, int] = {
"TRACE": 1,
"DEBUG": 5,
"INFO": 9,
"WARNING": 13,
"ERROR": 17,
"CRITICAL": 21,
}
# Which process this is. Set by configure_logging; every line carries it so one Loki/DB query can
# separate the API's view of a job from the worker's.
_source = "api"
# --- correlation context -------------------------------------------------------------------
# contextvars, like quota's actor attribution (app/quota.py): set at an entry point, read by the
# formatter, never threaded through call signatures. A child task copies the context at creation,
# so anything set before `await call_next(...)` is visible to the whole request.
_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"log_request_id", default=None
)
_user_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_user_id", default=None)
_debug_session: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"log_debug_session", default=None
)
_job_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_job_id", default=None)
_CONTEXT_VARS = {
"request_id": _request_id,
"user_id": _user_id,
"debug_session_id": _debug_session,
"job_id": _job_id,
}
class _RequestScope:
"""What the auth dependency learns about a request, for every line logged after it.
A MUTABLE object behind a contextvar, not plain contextvars, because of how a request actually
runs: FastAPI resolves a sync dependency (`auth.current_user`) in a threadpool on a COPY of the
context, so a `ContextVar.set()` there is gone by the time the handler runs — measured: that is
how the account never reached a handler's own lines. `log_requests` installs one scope per
request; every copied context shares the same object, so what the dependency writes is seen by
the handler and by the request line itself."""
__slots__ = ("user_id", "level")
def __init__(self) -> None:
self.user_id: int | None = None
# The user's consented diagnostics level as a logging number; None at the base level.
self.level: int | None = None
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
"log_request_scope", default=None
)
def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None:
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
auth dependency — the one place the session is actually resolved into an account — rather than
from the request middleware, which runs OUTSIDE the session middleware."""
_user_id.set(user_id)
scope = _scope.get()
if scope is None: # outside log_requests (a script, a unit test): this context only
scope = _RequestScope()
_scope.set(scope)
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
scope.user_id = user_id
scope.level = resolve_level(level) if elevated else None
if user_id is not None:
_track_elevation(user_id, scope.level, until)
def set_job(job_id: int | None) -> None:
"""Attribute this process's log lines to a download job — for the download CHILD, whose whole
life is one job (the worker uses `bind()` per job instead, since it juggles several)."""
_job_id.set(job_id)
@contextlib.contextmanager
def bind(**fields: Any):
"""Bind correlation fields for the duration of a block (a worker job, a scheduler run):
with logs.bind(job_id=job.id):
...
Unknown field names are ignored rather than raising — a caller binding a field this module does
not know about is a typo, not a reason to take down a download."""
tokens = [(_CONTEXT_VARS[k].set(v)) for k, v in fields.items() if k in _CONTEXT_VARS]
keys = [k for k in fields if k in _CONTEXT_VARS]
try:
yield
finally:
for key, token in zip(keys, tokens):
_CONTEXT_VARS[key].reset(token)
def event(code: str, **attrs: Any) -> dict[str, Any]:
"""`extra=` payload for a log call: a stable dotted event code plus structured attributes.
Returns a plain dict so it composes with stdlib logging and costs nothing when the level is off
(the dict is built either way; keep the attrs cheap — no DB reads inside the call)."""
return {"event": code, "attrs": attrs}
# --- redaction -----------------------------------------------------------------------------
# Substring matching, not exact keys: the same secret travels as `password`, `current_password`,
# `new_password`, `smtp_password`, `plex_server_token`, `access_token`, … Matching on the PART is
# what makes a key we have never seen safe by default.
_SECRET_KEY_PARTS = (
"password",
"passphrase",
"token",
"secret",
"cookie",
"authorization",
"auth_header",
"ciphertext",
"private_key",
"api_key",
"apikey",
"credential",
"session_key",
)
# Query/fragment parameters that carry a credential in a URL we log (OAuth `code`/`state`, our own
# `?token=` setup and share links, `#verify=` / `#reset=` fragments).
_SECRET_PARAM_NAMES = (
"access_token",
"refresh_token",
"id_token",
"token",
"code",
"state",
"key",
"secret",
"password",
"pwd",
"sig",
"signature",
"verify",
"reset",
"setup",
)
_SECRET_PARAM_RE = re.compile(
r"\b((?:" + "|".join(_SECRET_PARAM_NAMES) + r")=)([^&\s\"']+)",
re.IGNORECASE,
)
# Deliberately loose: over-matching redacts a non-address that looks like one (harmless), while
# under-matching puts a real address in a log we attach to an issue (not harmless).
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
REDACTED = "[redacted]"
# `extra={PERSIST: False}` keeps a line out of the `log_events` table while stdout still gets it —
# how `log_requests` caps anonymous traffic per client (app/logsink.py reads it).
PERSIST = "persist"
REDACTED_EMAIL = "[email]"
def redact_text(text: str) -> str:
"""Scrub a free-text string: credential-bearing URL params and email addresses."""
scrubbed = _SECRET_PARAM_RE.sub(lambda m: f"{m.group(1)}{REDACTED}", text)
return _EMAIL_RE.sub(REDACTED_EMAIL, scrubbed)
def redact_query(query: str) -> str:
"""Scrub a raw query string. DECODED first: the text rules match what was typed, and a browser
sends it percent-encoded — `someone%40example.com` is an address `_EMAIL_RE` cannot see. Each
parameter also goes through the key rules, so `?new_password=` goes as `redact` would drop it."""
pairs = parse_qsl(query, keep_blank_values=True)
return "&".join(f"{key}={_redact_param(key, value)}" for key, value in pairs)
def _redact_param(key: str, value: str) -> str:
"""One decoded `key=value` pair. The parameter-NAME rule is applied to the key here rather than
left to `_SECRET_PARAM_RE` over the rebuilt pair: that pattern was written for the raw,
percent-encoded query, where a value holds no literal space, `&` or quote, and its value class
stops at the first one. On decoded text `?state=a%20b` would keep its tail and `?code=%20abc`
would not match at all — the whole credential straight into the line."""
if key.lower() in _SECRET_PARAM_NAMES:
return REDACTED
return str(redact(value, key=key))
def redact(value: Any, *, key: str = "") -> Any:
"""Recursively scrub a structure destined for the log.
A value whose KEY names a secret is dropped whole (we never log a prefix of a token — a prefix
is still a leak and still useless). Everything else is scrubbed as text. Depth is bounded by
the structures we actually log (attrs dicts), and non-JSON types fall through to `str`."""
lowered = key.lower()
if key and any(part in lowered for part in _SECRET_KEY_PARTS):
return REDACTED
if isinstance(value, dict):
return {k: redact(v, key=str(k)) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [redact(v, key=key) for v in value]
if isinstance(value, str):
return redact_text(value)
return value
# --- formatters ----------------------------------------------------------------------------
# Attributes every LogRecord carries; anything else a caller passed via `extra=` is a structured
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
_RECORD_STANDARD = frozenset(
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
| {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated"}
)
class ContextFilter(logging.Filter):
"""Copies the correlation contextvars onto the record. A Filter (not the formatter) so the
values are captured at emit time, before a QueueHandler can move the record to another
thread — the DB sink in S3 depends on that."""
def filter(self, record: logging.LogRecord) -> bool:
for name, var in _CONTEXT_VARS.items():
value = var.get()
if value is not None and not hasattr(record, name):
setattr(record, name, value)
scope = _scope.get()
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
record.user_id = scope.user_id
return True
class LevelGate(logging.Filter):
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request
whose user raised their diagnostics level (E22 S5).
The root logger's level cannot do this alone: it is one number for the whole process, and the
point is to log ONE user's requests in detail while everyone else's stay at the base level. So
the root is lowered to the lowest level any active elevation needs (`_track_elevation`), and
this gate drops what only an elevation let through unless the line belongs to that elevated
request. It also marks such a line `elevated`, which is what makes the `log_events` sink keep
every line of that request, not just its warnings."""
def filter(self, record: logging.LogRecord) -> bool:
scope = _scope.get()
if scope is not None and scope.level is not None:
record.elevated = True
return record.levelno >= min(scope.level, _base_level)
return record.levelno >= _base_level
def _record_attrs(record: logging.LogRecord) -> dict[str, Any]:
extra = {k: v for k, v in vars(record).items() if k not in _RECORD_STANDARD}
attrs = dict(getattr(record, "attrs", None) or {})
attrs.update({k: v for k, v in extra.items() if k not in _CONTEXT_VARS})
return attrs
def _safe_message(record: logging.LogRecord) -> str:
"""`record.getMessage()` raises if the args don't match the format string. A broken log call
must not take down the caller, so the failure is reported IN the line instead."""
try:
return record.getMessage()
except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
return f"{record.msg!r} (log formatting failed: {exc})"
def record_fields(record: logging.LogRecord) -> dict[str, Any]:
"""The event as structured fields, REDACTED — the one definition both the stdout line and the
`log_events` row are built from, so the two sinks can never disagree about what is safe to keep.
Absent optional fields are left out; `ts` is an aware datetime."""
fields: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, timezone.utc),
"level": record.levelname,
"sev": SEVERITY.get(record.levelname, 9),
"source": _source,
"logger": record.name,
"msg": redact_text(_safe_message(record)),
}
code = getattr(record, "event", None)
if code:
fields["event"] = code
for name in _CONTEXT_VARS:
value = getattr(record, name, None)
if value is not None:
fields[name] = value
attrs = _record_attrs(record)
if attrs:
fields["attrs"] = redact(attrs)
if record.exc_info:
fields["exc"] = redact_text(logging.Formatter().formatException(record.exc_info))
elif record.exc_text:
# A QueueHandler-style hand-off formats the traceback into exc_text and drops exc_info.
fields["exc"] = redact_text(record.exc_text)
fields["version"] = settings.app_version
return fields
class JsonFormatter(logging.Formatter):
"""One JSON object per line — the shape Loki and the `log_events` table both ingest."""
def format(self, record: logging.LogRecord) -> str:
payload = record_fields(record)
payload["ts"] = payload["ts"].isoformat(timespec="milliseconds")
return json.dumps(payload, ensure_ascii=False, default=str)
class TextFormatter(logging.Formatter):
"""Human-readable single line for a terminal (`LOG_FORMAT=text`): the same content, ordered for
reading. Redaction applies here too — a dev log is still a log someone pastes into an issue."""
def format(self, record: logging.LogRecord) -> str:
stamp = datetime.fromtimestamp(record.created, timezone.utc).strftime("%H:%M:%S.%f")[:-3]
head = f"{stamp} {record.levelname:<5} [{record.name}]"
rid = getattr(record, "request_id", None)
if rid:
head += f" {rid}"
code = getattr(record, "event", None)
line = f"{head} {code + ' — ' if code else ''}{redact_text(_safe_message(record))}"
attrs = _record_attrs(record)
if attrs:
line += f" {json.dumps(redact(attrs), ensure_ascii=False, default=str)}"
if record.exc_info:
line += "\n" + redact_text(self.formatException(record.exc_info))
return line
# --- configuration -------------------------------------------------------------------------
# Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can
# lower the ROOT level to DEBUG/TRACE without drowning the output in SQL statements and HTTP chatter
# — raising THESE is a deliberate, separate act (E22.6's console).
_THIRD_PARTY_LEVELS = {
"sqlalchemy": "WARNING",
"sqlalchemy.engine": "WARNING",
"apscheduler": "WARNING",
"urllib3": "WARNING",
"httpx": "WARNING",
"httpcore": "WARNING",
"asyncio": "WARNING",
"multipart": "WARNING",
"PIL": "WARNING",
"alembic": "INFO",
# uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
# it is one line per request with the query string in it, replaced by `log_requests` below.
"uvicorn": "INFO",
"uvicorn.error": "INFO",
"uvicorn.access": "CRITICAL",
}
def resolve_level(name: str) -> int:
"""Level name → number. Both ways in (the `LOG_LEVEL` env and the `log_level` admin knob) are
already held to the `LogLevel` choices, so an unknown name here is a programming error."""
value = logging.getLevelName(name)
if not isinstance(value, int):
raise ValueError(f"unknown log level {name!r}")
return value
def configure_logging(source: str) -> None:
"""Configure logging for THIS process. Call once, first thing, in every entry point:
`main.py` (api), `worker.main`, `downloads/runner.main`, `alembic/env.py`.
`source` names the process in every line. The level starts at `LOG_LEVEL` (the bootstrap default);
the runtime admin knob goes through `set_level()` instead, so changing it needs no restart.
Our loggers deliberately keep `propagate=True` and own no handler: everything reaches the single
root StreamHandler. That is what keeps `caplog` working in tests, and it means a new logger name
is configured the moment it is created."""
global _source
_source = source
formatter = settings.log_format
logging.config.dictConfig(
{
"version": 1,
# Never disable loggers created at import time (module-level `getLogger` calls run
# before this function does).
"disable_existing_loggers": False,
"formatters": {
"json": {"()": f"{__name__}.JsonFormatter"},
"text": {"()": f"{__name__}.TextFormatter"},
},
"filters": {
"context": {"()": f"{__name__}.ContextFilter"},
"gate": {"()": f"{__name__}.LevelGate"},
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": formatter,
"filters": ["context", "gate"],
}
},
"root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
}
)
set_level(settings.log_level)
def set_level(name: str) -> None:
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
global _base_level
_base_level = resolve_level(name)
_apply_root_level(force=True)
def current_level() -> str:
"""The configured level — not the root logger's, which an active elevation may have lowered."""
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
# --- per-user elevation (E22 S5) -----------------------------------------------------------
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
# test, a script — so the gate lets everything through there, as it did before the gate existed.
_base_level = logging.NOTSET
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
_elevations: dict[int, tuple[int, datetime]] = {}
_elevations_lock = threading.Lock()
_root_applied: int | None = None
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
with _elevations_lock:
if level is None or until is None:
_elevations.pop(user_id, None)
else:
_elevations[user_id] = (level, until)
active = bool(_elevations)
if active or _root_applied != _base_level:
_apply_root_level()
def _apply_root_level(force: bool = False) -> None:
"""Root level = the lowest of the base and every unexpired elevation (expired ones are pruned
here). `setLevel` clears every logger's level cache, so it runs only when the number changes."""
global _root_applied
if _base_level == logging.NOTSET:
return # not configured: the root keeps whatever the process set up
now = datetime.now(timezone.utc)
# The level is SET under the lock too, not just computed: two threads that each computed a
# different answer would otherwise apply them in either order, and the older one could win —
# the root back at the base while an elevation is still active.
with _elevations_lock:
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
del _elevations[user_id]
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
if force or lowest != _root_applied:
logging.getLogger().setLevel(lowest)
_root_applied = lowest
# --- erased accounts ------------------------------------------------------------------------
# `auth.purge_user` deletes an account's `log_events` rows, but a line about that account can still
# be on its way: the erasing request's own access line is logged AFTER the handler committed, a queued
# row waits for the sink's writer, a background task logs later. Those rows are stored without the
# account (`logsink` asks here). Remembered for an hour in this process — a line later than that has
# no request left to come from, and the retention job is the backstop for any other process.
ERASED_MEMORY_SECONDS = 3600.0
_erased: dict[int, float] = {}
_erased_lock = threading.Lock()
# Held by whoever checks rows against `is_erased` AND writes them (`erasure_guard`). `forget_user`
# takes it too, so an erasure waits for a write that already passed the check to commit — and the
# purge's DELETE, which runs after `forget_user`, then removes those rows as well. Without it a row
# checked a moment before the erasure could be inserted a moment after the DELETE.
_erasure_guard = threading.Lock()
def erasure_guard() -> threading.Lock:
"""Hold this around "drop erased accounts from these rows, insert them, commit"."""
return _erasure_guard
def forget_user(user_id: int) -> None:
"""Mark `user_id` erased: no line logged about it from now on is stored under its id. Call BEFORE
deleting its rows (see `_erasure_guard`)."""
now = time.monotonic()
with _erasure_guard, _erased_lock:
for stale in [u for u, at in _erased.items() if now - at > ERASED_MEMORY_SECONDS]:
del _erased[stale]
_erased[user_id] = now
with _elevations_lock:
_elevations.pop(user_id, None)
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
# and every unexpired one. Recomputed here rather than left to whichever later request happens
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
_apply_root_level()
def is_erased(user_id: int | None) -> bool:
if user_id is None:
return False
with _erased_lock:
at = _erased.get(user_id)
return at is not None and time.monotonic() - at <= ERASED_MEMORY_SECONDS
def current_source() -> str:
"""The process name `configure_logging` was given (api, worker, runner, alembic)."""
return _source
# --- request logging -----------------------------------------------------------------------
# A client may supply its own request id so a browser log line and a server line join up (E22 S4).
# Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to
# write arbitrary text into the log.
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
_request_log = logging.getLogger("siftlode.http")
async def log_requests(request, call_next):
"""The single access-log line, replacing uvicorn's.
What changes versus `uvicorn.access`: the ROUTE TEMPLATE instead of the concrete path (so
`/api/videos/{video_id}` groups), never the query string (it carried `?account=`, and at TRACE
the redacted query belongs in attrs, not in the URL), a request id echoed back in
`X-Request-Id` (with the template in `X-Route`), the duration, and the account — read from `request.state`, which the auth
dependency fills, because this middleware sits outside both the session middleware and the
downstream task's context."""
incoming = request.headers.get("x-request-id", "")
rid = incoming if _REQUEST_ID_RE.match(incoming) else uuid.uuid4().hex[:16]
token = _request_id.set(rid)
scope = _RequestScope()
scope_token = _scope.set(scope)
request.state.request_id = rid
started = time.perf_counter()
try:
response = await call_next(request)
except Exception:
# The unhandled-exception path: FastAPI's ServerErrorMiddleware re-raises after its own
# 500, so without this the ONE request that most needs a log line is the one with no
# access-log entry at all.
_request_log.exception(
"request failed",
extra={
**event(
"http.request",
method=request.method,
path=_route_template(request),
duration_ms=round((time.perf_counter() - started) * 1000, 1),
),
**_request_owner(request, logging.ERROR),
},
)
_scope.reset(scope_token)
_request_id.reset(token)
raise
duration_ms = round((time.perf_counter() - started) * 1000, 1)
status = response.status_code
quiet = request.url.path.startswith(_QUIET_PREFIXES)
if status >= 500:
level = logging.ERROR
elif status >= 400:
level = logging.WARNING
else:
level = logging.DEBUG if quiet else logging.INFO
attrs: dict[str, Any] = {
"method": request.method,
"path": _route_template(request),
"status": status,
"duration_ms": duration_ms,
}
# Teljes (TRACE) the user consented to: the query string too — redacted, and in attrs only, never
# in the message.
if request.url.query and _traced(scope):
attrs["query"] = redact_query(request.url.query)
_request_log.log(
level,
"%s %s %s",
request.method,
_route_template(request),
status,
extra={**event("http.request", **attrs), **_request_owner(request, level)},
)
response.headers["X-Request-Id"] = rid
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
# templates the path itself.
matched = _matched_route(request)
if matched:
response.headers["X-Route"] = matched
_scope.reset(scope_token)
_request_id.reset(token)
return response
def _traced(scope: _RequestScope) -> bool:
"""Whether this request's query string is logged: only under its OWN user's Teljes consent. An
admin setting the whole process to TRACE gets more detail, not every user's search terms — the
privacy policy promises those are kept only for a user who agreed to it."""
return scope.level is not None and scope.level <= TRACE
# Anonymous request lines kept per client per minute. Signed-in traffic is not capped: it is a known
# account and exactly what an admin needs to see. Anonymous traffic is where the volume can come from
# nobody in particular — a scanner walking `/wp-login.php`, a bot on a share link — and each of its
# 404s is a WARNING that would otherwise land in `log_events` without limit. The client key is an
# in-memory counter key only; no address is ever written to a log line or a row.
ANONYMOUS_LINES_PER_MINUTE = 60
_anonymous_lines = RateLimiter(ANONYMOUS_LINES_PER_MINUTE, 60.0)
def _request_owner(request, level: int) -> dict[str, Any]:
"""`extra=` fields naming who the request belongs to, and whether its line may be stored. The
account comes from `request.state` (filled by the auth dependency), so it lands in the line's
top-level `user_id` — the correlation field — rather than in `attrs`."""
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
return {"user_id": user_id}
if level < logging.INFO:
return {} # a quiet line is never stored anyway — don't spend the client's allowance on it
return {} if _anonymous_lines.allow(client_key(request)) else {PERSIST: False}
def _matched_route(request) -> str | None:
"""The matched route's template (`/api/videos/{video_id}`), or None when nothing matched."""
return getattr(request.scope.get("route"), "path", None)
def _route_template(request) -> str:
"""The matched route's template, else the raw path for a 404 or a static file. Never the query
string."""
return _matched_route(request) or request.url.path
+315
View File
@@ -0,0 +1,315 @@
"""The `log_events` sink (E22 S3): the stored copy of the log the diagnostics console reads.
stdout stays the complete log (docker keeps 3×50 MB of it); this table keeps the part worth querying
for `log_retention_days`:
* every WARNING and above,
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
static files),
* every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate`
marks them), which is the detail they consented to have kept,
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
traffic per client.
What the table must never do is slow down or break the code that logs. So a line is redacted and
turned into a row on the CALLER's thread (the same `logs.record_fields` the stdout line is built
from), put on a bounded queue without blocking, and written in batches by one background thread
on its own connection — never inside a request's transaction. A full queue drops the row and
counts it; a failed write drops the batch and says so on stderr. Both are reported rather than
retried: a log that retries into a sick database is how a small outage becomes a big one.
Flood control is the second half. On 2026-09-16 a YouTube RSS outage made prod log 11k near-identical
warnings in four hours. stdout keeps them all; the table keeps `REPEATS_PER_MINUTE` of each message
template per minute and one `log.repeats_suppressed` row saying how many more there were.
Core layer: imports `logs`, `db`, `models`, `config` — nothing above them.
"""
import atexit
import json
import logging
import queue
import sys
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, insert
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from app import logs
from app.config import settings
from app.db import engine
from app.models import LogEvent
QUEUE_MAX = 10_000
BATCH_MAX = 500
FLUSH_SECONDS = 1.0
REPEATS_PER_MINUTE = 30
REPEAT_WINDOW_SECONDS = 60.0
# Distinct templates tracked per window. Past this many, a new template is stored without counting
# — the limiter must not become the unbounded thing it exists to prevent.
_REPEAT_KEYS_MAX = 2_000
# Column widths from models.LogEvent. A value past its column would fail the WHOLE batch it rides in.
_LIMITS = {
"level": 10,
"source": 16,
"logger": 128,
"event": 64,
"request_id": 64,
"debug_session_id": 64,
"version": 64,
"msg": 8_000,
"exc": 32_000,
}
_log = logging.getLogger("siftlode.logsink")
def should_store(record: logging.LogRecord) -> bool:
"""Whether this record belongs in `log_events` (see the module docstring)."""
if getattr(record, logs.PERSIST, True) is False:
return False
if record.levelno >= logging.WARNING or getattr(record, "elevated", False):
return True
return getattr(record, "event", None) == "http.request" and record.levelno >= logging.INFO
def _without_nul(value: Any) -> Any:
"""`value` with every NUL character replaced, in keys and nested values alike. Postgres refuses
NUL in text and `\\u0000` in JSONB, and a refused row fails the WHOLE batch it rides in — every
user's rows with it. Anything logged can carry one (`GET /assets/%00` logs the decoded path)."""
if isinstance(value, str):
return value.replace("\x00", "�")
if isinstance(value, dict):
return {_without_nul(k): _without_nul(v) for k, v in value.items()}
if isinstance(value, list):
return [_without_nul(v) for v in value]
return value
def to_row(fields: dict[str, Any]) -> dict[str, Any]:
"""`logs.record_fields` output → a `log_events` row: known columns only, text clipped to its
column and free of NUL, attrs made JSON-safe (anything exotic becomes its `str`, as on stdout)."""
row = {
column: fields.get(column) for column in LogEvent.__table__.columns.keys() if column != "id"
}
for column, limit in _LIMITS.items():
value = row.get(column)
if isinstance(value, str):
value = _without_nul(value)
row[column] = value[: limit - 1] + "…" if len(value) > limit else value
if row["attrs"] is not None:
row["attrs"] = _without_nul(
json.loads(json.dumps(row["attrs"], ensure_ascii=False, default=str))
)
return row
def without_erased(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""The rows, with the account taken off any whose account was erased meanwhile
(`logs.forget_user`). Checked right before the INSERT, under `logs.erasure_guard()` held until
the commit: a row built before the erasure can still be waiting for its batch."""
for row in rows:
if logs.is_erased(row.get("user_id")):
row["user_id"] = None
return rows
def _notice(code: str, msg: str, **attrs: Any) -> dict[str, Any]:
"""A row the sink writes about ITSELF. Built directly, not logged: logging it would route it back
through this sink (and the writer thread's own records are deliberately ignored)."""
return to_row(
{
"ts": datetime.now(timezone.utc),
"level": "WARNING",
"sev": logs.SEVERITY["WARNING"],
"source": logs.current_source(),
"logger": _log.name,
"event": code,
"msg": msg,
"attrs": attrs,
"version": settings.app_version,
}
)
class _Repeats:
"""Per-template admission for one minute at a time. Used by the writer thread only (unlocked)."""
def __init__(self, per_window: int, window_seconds: float):
self.per_window = per_window
self.window = window_seconds
self.started = time.monotonic()
self.seen: dict[tuple[str, str, int], int] = {}
def admit(self, key: tuple[str, str, int] | None) -> bool:
if key is None:
return True
count = self.seen.get(key)
if count is None and len(self.seen) >= _REPEAT_KEYS_MAX:
return True
self.seen[key] = (count or 0) + 1
return self.seen[key] <= self.per_window
def roll(self, now: float) -> list[dict[str, Any]]:
"""Close the window if it is over: one notice per template that went past the limit."""
if now - self.started < self.window:
return []
notices = [
_notice(
"log.repeats_suppressed",
f"{count - self.per_window} more like this were not stored",
logger=logger,
template=logs.redact_text(template),
suppressed=count - self.per_window,
window_seconds=int(self.window),
)
for (logger, template, _level), count in self.seen.items()
if count > self.per_window
]
self.seen.clear()
self.started = now
return notices
class LogSink(logging.Handler):
"""The handler plus its writer. `start()` runs the writer thread; a test drives `write_pending()`
directly instead, on its own thread, with no timing involved."""
def __init__(self, bind: Engine = engine):
super().__init__(level=logging.NOTSET)
# Correlation fields are contextvars: capture them now, on the thread that logged.
self.addFilter(logs.ContextFilter())
self._bind = bind
self._queue: queue.Queue[tuple[tuple[str, str, int] | None, dict[str, Any]]] = queue.Queue(
QUEUE_MAX
)
self._dropped = 0
self._dropped_lock = threading.Lock()
self._repeats = _Repeats(REPEATS_PER_MINUTE, REPEAT_WINDOW_SECONDS)
self._stopping = threading.Event()
self._writer: threading.Thread | None = None
# --- caller side ---------------------------------------------------------------------------
def emit(self, record: logging.LogRecord) -> None:
if threading.current_thread() is self._writer:
return # the writer's own trouble (a driver warning mid-insert) must not feed itself
if not should_store(record):
return
try:
row = to_row(logs.record_fields(record))
except Exception: # noqa: BLE001 — a record we cannot turn into a row is reported, not raised
self.handleError(record)
return
# Request lines are one per request by nature; only the repeated SAME message is a flood.
key = (
None
if row["event"] == "http.request"
else (record.name, str(record.msg), record.levelno)
)
try:
self._queue.put_nowait((key, row))
except queue.Full:
with self._dropped_lock:
self._dropped += 1
# --- writer side ---------------------------------------------------------------------------
def write_pending(self, wait: float = 0.0) -> int:
"""Write one batch: whatever is queued (up to BATCH_MAX), waiting up to `wait` seconds for the
first item. Returns the number of rows written, notices included."""
rows: list[dict[str, Any]] = []
try:
item = self._queue.get(timeout=wait) if wait > 0 else self._queue.get_nowait()
except queue.Empty:
item = None
while item is not None:
key, row = item
if self._repeats.admit(key):
rows.append(row)
if len(rows) >= BATCH_MAX:
break
try:
item = self._queue.get_nowait()
except queue.Empty:
item = None
rows.extend(self._repeats.roll(time.monotonic()))
with self._dropped_lock:
dropped, self._dropped = self._dropped, 0
if dropped:
rows.append(
_notice("log.sink.dropped", f"{dropped} log events were dropped", dropped=dropped)
)
if not rows:
return 0
try:
# The connection is checked out BEFORE the guard, and every request path takes them in
# that order too (its session already holds a connection when it reaches the guard). The
# other way round, this thread would hold a process-global lock while waiting up to
# `pool_timeout` for a connection, and the requests that could return one are the ones
# blocked on the lock. The guard still spans the check, the insert and the commit.
with self._bind.connect() as conn, logs.erasure_guard(), conn.begin():
conn.execute(insert(LogEvent), without_erased(rows))
except Exception as exc: # noqa: BLE001 — the database is down or refused the batch
print(
f"log sink: could not store {len(rows)} log events: {logs.redact_text(str(exc))[:300]}",
file=sys.stderr,
)
return 0
return len(rows)
def _run(self) -> None:
while not self._stopping.is_set():
self.write_pending(wait=FLUSH_SECONDS)
while self.write_pending(): # drain what arrived before the stop
pass
def start(self) -> None:
self._writer = threading.Thread(target=self._run, name="log-sink", daemon=True)
self._writer.start()
def stop(self, timeout: float = 5.0) -> None:
self._stopping.set()
if self._writer is not None:
self._writer.join(timeout)
_sink: LogSink | None = None
def start() -> None:
"""Attach the sink to the root logger and start its writer. Call after `logs.configure_logging`
(which replaces the root handlers) and once the schema exists. A no-op when `LOG_DB_SINK` is off
or it is already running."""
global _sink
if _sink is not None or not settings.log_db_sink:
return
_sink = LogSink()
# The same level gate as stdout: the root may be lowered for one user's elevated requests, and
# everyone else's detail must not reach the table through that gap either.
_sink.addFilter(logs.LevelGate())
logging.getLogger().addHandler(_sink)
_sink.start()
# A short-lived process (the download child) exits without a lifespan: flush on the way out.
atexit.register(stop)
def stop() -> None:
"""Detach the sink and write what is still queued."""
global _sink
if _sink is None:
return
logging.getLogger().removeHandler(_sink)
_sink.stop()
_sink = None
def gc(db: Session, retention_days: int) -> int:
"""Stage the deletion of rows older than `retention_days` (at least one day — stored logs always
expire). The caller commits. Returns the number of rows removed."""
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff))
return result.rowcount or 0
+32 -11
View File
@@ -1,7 +1,6 @@
import asyncio
import logging
import math
import sys
from contextlib import asynccontextmanager, suppress
from pathlib import Path
@@ -9,16 +8,13 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
# When started via uvicorn --log-config the "siftlode" logger is already configured
# (see log_config.json). This block is a timestamped fallback for other entrypoints
# (tests, scripts) so our logs are never silently dropped.
_siftlode_logger = logging.getLogger("siftlode")
if not _siftlode_logger.handlers:
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s"))
_siftlode_logger.addHandler(_handler)
_siftlode_logger.setLevel(logging.INFO)
_siftlode_logger.propagate = False
# Logging is configured HERE, before the route modules are imported, so every logger they create at
# import time is already covered. ONE format for the api, the worker, the download child and alembic
# alike (app/logs.py) — this replaces the old uvicorn --log-config + the hand-rolled fallback that
# used to live here, and uvicorn's access log is replaced by the `http.request` middleware below.
from app.logs import configure_logging, current_level, log_requests, set_level
configure_logging("api")
log = logging.getLogger("siftlode.app")
from fastapi.middleware.cors import CORSMiddleware
@@ -27,7 +23,9 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import DataError
from starlette.middleware.sessions import SessionMiddleware
from app import logsink
from app import state
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.plex import stream as plex_stream
@@ -38,6 +36,7 @@ from app.routes import (
auth as auth_routes,
channels,
config as config_routes,
diagnostics as diagnostics_routes,
discover as discover_routes,
downloads,
feed,
@@ -63,9 +62,21 @@ from app.scheduler import shutdown_scheduler, start_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Siftlode starting up")
# The stored copy of the log (log_events). The entrypoint has already migrated the schema.
logsink.start()
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
# the wizard URL so the operator can open it (the app runs in setup mode until they finish).
with SessionLocal() as db:
# The admin's runtime level (sysconfig `log_level`) wins over the LOG_LEVEL bootstrap
# default, so a level chosen in the UI survives a restart without touching the environment.
# Best-effort on purpose: a LOGGING knob must never be the thing that stops the app from
# booting — an unreachable or not-yet-migrated database has to start into the env level.
try:
set_level(sysconfig.get_str(db, "log_level"))
except Exception as exc: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
log.warning(
"could not read the stored log level (%s); staying at %s", exc, current_level()
)
if not state.is_configured(db):
token = state.rotate_setup_token(db)
url = f"{settings.app_base}/setup?token={token}"
@@ -100,6 +111,7 @@ async def lifespan(app: FastAPI):
# Kills a running subtitle extraction: its worker would otherwise hold interpreter exit for
# the rest of the read (see plex_subtitles.shutdown).
plex_subtitles.shutdown()
logsink.stop() # last: write what the shutdown itself just logged
# How often idle HLS sessions are reaped (see plex/stream.py `reap_idle`).
@@ -243,6 +255,14 @@ async def security_headers(request, call_next):
return response
# The access log (app/logs.py): request id, route template, status, duration — one line, never a
# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
# request, including what the three above do. Being outermost also puts it OUTSIDE SessionMiddleware,
# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
# place a session is actually resolved into an account) rather than from `request.session`.
app.middleware("http")(log_requests)
app.include_router(health.router)
app.include_router(auth_routes.router)
app.include_router(sync.router)
@@ -251,6 +271,7 @@ app.include_router(feed.router)
app.include_router(discover_routes.router)
app.include_router(search_routes.router)
app.include_router(me.router)
app.include_router(diagnostics_routes.router)
app.include_router(notifications.router)
app.include_router(messages.router)
app.include_router(channels.router)
+42
View File
@@ -12,6 +12,7 @@ from sqlalchemy import (
Index,
Integer,
JSON,
SmallInteger,
String,
Text,
UniqueConstraint,
@@ -102,6 +103,16 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
# events (client-side signed cookies otherwise stay valid until they expire).
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# When this account last signed in (Google or password), stamped by the one sign-in path,
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
# possible). The shared demo account is never stamped — its "last login" would mean nobody.
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The diagnostics level this user consented to (E22 S5) and when it lapses back to the base
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry
# itself; read through `app.diagnostics.effective_level`, never directly (an expired level is
# not cleared eagerly — it simply stops counting).
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
@@ -539,6 +550,37 @@ class AuditLog(Base):
)
class LogEvent(Base):
"""One stored log event (E22 S3) — the `log_events` table the diagnostics console reads. Rows are
written by app/logsink.py's background writer, never inside a request's transaction, already
redacted (the same `logs.record_fields` the stdout line is built from).
`user_id` has NO foreign key on purpose: the writer runs behind the event, so a line about an
account can arrive after that account was purged, and a constraint would make it fail the whole
batch it travels in. `auth.purge_user` deletes an account's rows explicitly instead, after
`logs.forget_user` makes every later write in this process store such a line without the account;
the retention job (`log_retention_days`) bounds whatever another process writes late."""
__tablename__ = "log_events"
__table_args__ = (Index("ix_log_events_user_ts", "user_id", "ts"),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
level: Mapped[str] = mapped_column(String(10))
sev: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String(16))
logger: Mapped[str] = mapped_column(String(128))
event: Mapped[str | None] = mapped_column(String(64))
msg: Mapped[str] = mapped_column(Text)
attrs: Mapped[dict | None] = mapped_column(JSONB)
exc: Mapped[str | None] = mapped_column(Text)
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int | None] = mapped_column(Integer)
debug_session_id: Mapped[str | None] = mapped_column(String(64))
job_id: Mapped[int | None] = mapped_column(Integer)
version: Mapped[str | None] = mapped_column(String(64))
class AppState(Base):
"""Single-row global app state (admin-controlled)."""
+2 -2
View File
@@ -404,7 +404,7 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) ->
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
if not transcode:
return (False, False)
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
mode = sysconfig.get_str(db, "plex_transcode_hw")
if mode == "off" or not _render_node_present():
return (False, False)
if mode == "on":
@@ -664,7 +664,7 @@ def start_session(
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
preset = sysconfig.get_str(db, "plex_transcode_preset")
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
+65 -11
View File
@@ -1,21 +1,30 @@
"""A tiny in-process sliding-window rate limiter.
Generic groundwork: keyed by an arbitrary string (e.g. a client IP), so it can throttle any
Generic groundwork: keyed by an arbitrary string (e.g. a client — `client_key`), so it can throttle any
endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which
is fine for abuse throttling (not for anything that must survive a deploy). Not shared across
processes — if we ever run multiple workers, swap the backing store for Redis behind the same
``allow()`` interface.
"""
import ipaddress
import threading
import time
from fastapi import Request
from app.config import settings
class RateLimiter:
def __init__(self, max_events: int, window_seconds: float):
"""At most `max_events` attempts per `window_seconds` per key — or, when each attempt passes a
`cost`, at most that much total cost (a byte budget counts bytes, not calls)."""
def __init__(self, max_events: float, window_seconds: float):
self.max_events = max_events
self.window = window_seconds
self._hits: dict[str, list[float]] = {}
# key → (monotonic time, cost) of every counted attempt still inside the window
self._hits: dict[str, list[tuple[float, float]]] = {}
self._lock = threading.Lock()
def reset(self) -> None:
@@ -25,27 +34,72 @@ class RateLimiter:
with self._lock:
self._hits.clear()
def allow(self, key: str) -> bool:
"""Record an attempt for ``key`` and return whether it is within the limit.
def exhausted(self, key: str) -> bool:
"""Whether ``key``'s window is already full (``allow`` would refuse it), WITHOUT counting an
attempt — for a limit that counts only some outcomes: ask it first, ``allow`` the ones that
count."""
cutoff = time.monotonic() - self.window
with self._lock:
return sum(c for t, c in self._hits.get(key, []) if t > cutoff) >= self.max_events
True -> under the cap (the attempt is counted).
False -> the cap for the current window is already reached (attempt NOT counted, so a
def allow(self, key: str, cost: float = 1) -> bool:
"""Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit.
True -> within the cap (the attempt is counted).
False -> the attempt would pass the cap for the current window (attempt NOT counted, so a
blocked caller can't keep pushing the window forward)."""
now = time.monotonic()
cutoff = now - self.window
with self._lock:
hits = [t for t in self._hits.get(key, []) if t > cutoff]
if len(hits) >= self.max_events:
hits = [(t, c) for t, c in self._hits.get(key, []) if t > cutoff]
if sum(c for _, c in hits) + cost > self.max_events:
self._hits[key] = hits
return False
hits.append(now)
hits.append((now, cost))
self._hits[key] = hits
# Opportunistic cleanup so abandoned keys don't accumulate unboundedly.
if len(self._hits) > 4096:
for k in list(self._hits):
fresh = [t for t in self._hits[k] if t > cutoff]
fresh = [(t, c) for t, c in self._hits[k] if t > cutoff]
if fresh:
self._hits[k] = fresh
else:
del self._hits[k]
return True
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
try:
ip = ipaddress.ip_address(s.strip())
except ValueError:
return None
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
def client_key(request: Request) -> str:
"""Best-effort client identity for rate limiting: its IP, or for IPv6 the /64 the address sits in — a
subscriber is handed a whole /64, so keyed on the full address one client would have endless identities
to spread its attempts over. X-Forwarded-For is client-controlled, so we trust it
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
do NOT add that flag, or the trust check below would compare against a forgeable value."""
peer = request.client.host if request.client else "unknown"
client = peer
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
if _norm_ip(peer) in trusted:
xff = request.headers.get("x-forwarded-for")
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
if parts:
client = parts[-1]
ip = _norm_ip(client)
if isinstance(ip, ipaddress.IPv6Address):
return str(ipaddress.IPv6Network((ip, 64), strict=False))
return client
+2
View File
@@ -408,6 +408,8 @@ def _serialize_user(u: User) -> dict:
# confusing sign-in.
"google_email": u.google_email if u.google_email != u.email else None,
"created_at": iso(u.created_at),
# NULL = no sign-in since the column existed (0085), or the shared demo account.
"last_login_at": iso(u.last_login_at),
}
+19 -51
View File
@@ -3,7 +3,6 @@
Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token
helpers) stays in app/auth.py, imported below."""
import ipaddress
import secrets
from datetime import datetime, timedelta, timezone
from typing import NamedTuple
@@ -40,7 +39,7 @@ from app.auth import (
from app.config import settings
from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
from app.ratelimit import RateLimiter, client_key
from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import canonical_email, valid_email
@@ -172,37 +171,6 @@ def is_demo_allowed(db: Session, email: str) -> bool:
)
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
try:
ip = ipaddress.ip_address(s.strip())
except ValueError:
return None
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
def _client_ip(request: Request) -> str:
"""Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
do NOT add that flag, or the trust check below would compare against a forgeable value."""
peer = request.client.host if request.client else "unknown"
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
if _norm_ip(peer) in trusted:
xff = request.headers.get("x-forwarded-for")
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
if parts:
return parts[-1]
return peer
class InviteRequest(NamedTuple):
"""What an access request settled on.
@@ -511,16 +479,8 @@ async def callback(
db.flush()
_store_token(db, user, token)
_establish_session(request, user)
db.commit()
# Multi-session: remember every account that has authenticated in this browser so the
# user can switch between them without going through Google again. Only accounts that
# actually completed OAuth here land in this list (it's the proof they may be switched to).
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
_remember_epoch(request, user)
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/")
@@ -631,6 +591,8 @@ async def logout(request: Request):
out in one tab doesn't disturb another tab running a different account. If the account being
removed was the session default, promote the most recent remaining account (or clear the
session when none remain)."""
# A tab still pinned to an account already signed out in another tab resolves to nobody, so it
# signs nobody out — it used to fall back to the default and sign THAT account out in its place.
active, is_default = resolved_user_id(request)
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
request.session["account_ids"] = remaining
@@ -672,7 +634,7 @@ async def request_access(
email = (body.email or "").strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not _request_access_limiter.allow(_client_ip(request)):
if not _request_access_limiter.allow(client_key(request)):
return {"status": "pending"}
if is_allowed(db, email):
return {"status": "pending"} # already allowed — don't reveal it
@@ -689,7 +651,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
exactly like a non-match (authenticated=false) so the field can't be hammered as an
enumeration oracle. On a match it sets the session and the client reloads into the app."""
if not _demo_limiter.allow(_client_ip(request)):
if not _demo_limiter.allow(client_key(request)):
return {"authenticated": False}
email = (body.email or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email):
@@ -705,13 +667,18 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
def _establish_session(request: Request, user: User) -> None:
"""Sign `user` in for this browser session, mirroring the Google callback's multi-account
handling so password sign-in joins the same account switcher."""
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
and password login, so both join the same account switcher and both stamp `last_login_at` (the
caller commits). Multi-session: every account that authenticated in this browser is remembered so
the user can switch between them; only accounts that actually proved themselves HERE land in that
wallet (it is the proof they may be switched to). The demo entry deliberately does not come
through here: it clears the wallet instead, and its shared account has no meaningful last login."""
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
_remember_epoch(request, user)
user.last_login_at = datetime.now(timezone.utc)
def _app_base() -> str:
@@ -835,7 +802,7 @@ def register(
)
if not sysconfig.get_bool(db, "allow_registration"):
raise HTTPException(status_code=403, detail="Registration is currently closed.")
if not _register_limiter.allow(_client_ip(request)):
if not _register_limiter.allow(client_key(request)):
return {"status": "ok"} # silently throttle; uniform response
# Do the existence check + account creation OFF the response path (a background task with its own
@@ -1003,7 +970,7 @@ def verify_email_resend(
and re-registering an already-registered email is a silent no-op, so a user who lost the first
mail had no way back. Uniform response + timing regardless of whether the email has a pending
account (the lookup runs off the response path), so it can't probe for registered emails."""
if not _verify_resend_limiter.allow(_client_ip(request)):
if not _verify_resend_limiter.allow(client_key(request)):
return {"status": "ok"} # silently throttle; uniform response
email = (body.email or "").strip().lower()
if valid_email(email):
@@ -1062,7 +1029,7 @@ def password_login(
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration).
Once the password is proven correct, the owner gets a specific reason if their account is
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
if not _login_limiter.allow(_client_ip(request)):
if not _login_limiter.allow(client_key(request)):
raise _login_refused(429, "rate_limited")
email = (body.email or "").strip().lower()
password = body.password or ""
@@ -1081,6 +1048,7 @@ def password_login(
if not user.is_active:
raise _login_refused(403, "pending_approval", user)
_establish_session(request, user)
db.commit()
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
return {"ok": True}
@@ -1093,7 +1061,7 @@ def password_reset_request(
) -> dict:
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
password account (the lookup runs off the response path), so it can't probe for registered emails."""
if not _reset_limiter.allow(_client_ip(request)):
if not _reset_limiter.allow(client_key(request)):
return {"status": "ok"}
email = (body.email or "").strip().lower()
# Do the account lookup + token issue + email OFF the response path (a background task with its
@@ -1142,7 +1110,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
answer it must degrade to the old behaviour instead of declaring a live link dead.
"""
if not _reset_check_limiter.allow(_client_ip(request)):
if not _reset_check_limiter.allow(client_key(request)):
return {"ok": True}
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
+11
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import logs
from app import mailtext
from app import sysconfig
from app.audit import AuditAction
@@ -29,6 +30,14 @@ def _redact(v):
return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else v
def _apply_runtime(db: Session, key: str) -> None:
"""Some knobs change how THIS process behaves, not just what a later read returns — they have to
take effect now, without a restart. One dispatch for both call sites (set and reset), so the next
such key can't be wired into one of them and forgotten in the other."""
if key == "log_level":
logs.set_level(sysconfig.get_str(db, "log_level"))
@router.get("")
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
return sysconfig.describe(db)
@@ -80,6 +89,7 @@ def set_config(
after=None if s.secret else {"value": _redact(new)},
)
db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db)
@@ -104,6 +114,7 @@ def reset_config(
before=None if s.secret else {"value": _redact(old)},
)
db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db)
+179
View File
@@ -0,0 +1,179 @@
"""The browser's log, sent while its user has a raised diagnostics level (E22 S5).
The client keeps its log in memory (frontend `lib/log.ts`). Only after the user consented to Részletes
or Teljes in Settings → Diagnosztika does it stream that log here, in batches, and each batch lands in
`log_events` beside the server's own rows: `source="web"`, the account from the SESSION (never from
the body), and the request id each event carries, which is what joins a browser line to the server
line of the same request.
Nothing from the body is trusted as-is:
* only a JSON Content-Type is read, and the whole body is capped before it is parsed, and so are
the number of events and each event's attributes;
* the pace is capped per account, and so is what an account may store per hour;
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
modified client would not;
* an event below the consented level is dropped (a Részletes consent does not store what only
Teljes may carry), and so is the whole batch once the level has lapsed;
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
claiming to come from the future.
"""
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import insert
from sqlalchemy.orm import Session
from app import diagnostics, logs, logsink
from app.auth import current_user
from app.db import get_db
from app.models import LogEvent, User
from app.ratelimit import RateLimiter
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
BODY_MAX_BYTES = 512 * 1024
EVENTS_MAX = 200
ATTRS_MAX_BYTES = 16 * 1024
# The client flushes every few seconds and when its buffer fills; this is room for a burst, not a
# pace anything legitimate keeps up for a minute.
_batches = RateLimiter(max_events=30, window_seconds=60)
# What one account may STORE per hour. The pace cap alone still lets a self-consented account write
# ~15 MiB a minute into the one shared database, kept for the whole retention window; a real session
# at Teljes stores a few hundred kB an hour. Past the budget a batch is accepted and not stored (a
# refusal would only make the client resend it), and one notice per hour says so.
STORED_BYTES_PER_HOUR = 5 * 1024 * 1024
_stored_bytes = RateLimiter(max_events=STORED_BYTES_PER_HOUR, window_seconds=3600)
_budget_notices = RateLimiter(max_events=1, window_seconds=3600)
ClientLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class ClientEvent(BaseModel):
ts: datetime
level: ClientLevel
event: str = Field(max_length=64, pattern=r"^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$")
msg: str = Field(max_length=8_000)
attrs: dict[str, Any] | None = None
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$")
tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$")
version: str | None = Field(default=None, max_length=64)
class ClientEventsIn(BaseModel):
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
async def _capped_body(request: Request) -> bytes:
"""The request body, refused the moment it passes BODY_MAX_BYTES while still arriving. Counted,
not taken from Content-Length: a declared length can be absent (chunked) or wrong, and counting
stops reading at the same point a declared-length check would have."""
body = bytearray()
async for chunk in request.stream():
body += chunk
if len(body) > BODY_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
return bytes(body)
async def _parsed_events(request: Request) -> ClientEventsIn:
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
JSON only, by its Content-Type, as a declared body would be. Parsing any body as JSON would let a
CORS-simple `text/plain` form post from a same-site page write events into a signed-in user's
log; the app's own client always sends `application/json`, the unload beacon included."""
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
if media_type != "application/json" and not media_type.endswith("+json"):
raise HTTPException(status_code=415, detail={"code": "json_required"})
raw = await _capped_body(request)
try:
return ClientEventsIn.model_validate_json(raw)
except ValidationError as exc:
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -> dict[str, Any]:
attrs = logs.redact(item.attrs) if item.attrs else None
if attrs is not None:
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
if size > ATTRS_MAX_BYTES:
attrs = {"attrs_dropped_bytes": size}
if item.tab_id:
attrs = {**(attrs or {}), "tab_id": item.tab_id}
ts = item.ts if item.ts.tzinfo else item.ts.replace(tzinfo=timezone.utc)
return logsink.to_row(
{
"ts": min(max(ts, window_start), now),
"level": item.level,
"sev": logs.SEVERITY[item.level],
"source": "web",
"logger": "web",
"event": item.event,
"msg": logs.redact_text(item.msg),
"attrs": attrs,
"request_id": item.request_id,
"user_id": user.id,
"version": item.version,
}
)
@router.post("/events")
def ingest_events(
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
# byte of its body is read.
user: User = Depends(current_user),
body: ClientEventsIn = Depends(_parsed_events),
db: Session = Depends(get_db),
) -> dict:
level = diagnostics.effective_level(user)
if level == diagnostics.BASE:
# Lapsed or withdrawn: the client stops streaming when it sees this.
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
if not _batches.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "rate_limited"})
now = datetime.now(timezone.utc)
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
floor = logs.SEVERITY[level]
rows = [
_row(item, user, now, window_start)
for item in body.events
if logs.SEVERITY[item.level] >= floor
]
if not rows:
return {"ok": True, "stored": 0}
size = sum(len(json.dumps(row, ensure_ascii=False, default=str).encode()) for row in rows)
stored = len(rows)
if not _stored_bytes.allow(str(user.id), size):
stored = 0
if not _budget_notices.allow(str(user.id)):
return {"ok": True, "stored": 0}
rows = [_budget_notice(user, now)]
# Under the erasure guard until the commit: an account erased meanwhile gets no rows back.
with logs.erasure_guard():
db.execute(insert(LogEvent), logsink.without_erased(rows))
db.commit()
return {"ok": True, "stored": stored}
def _budget_notice(user: User, now: datetime) -> dict[str, Any]:
return logsink.to_row(
{
"ts": now,
"level": "WARNING",
"sev": logs.SEVERITY["WARNING"],
"source": "web",
"logger": "web",
"event": "diagnostics.budget_spent",
# What the budget does, exactly: it is a SLIDING hour, and a refused batch is not
# charged to it, so smaller batches keep landing while there is room for them. Saying
# "the rest of the hour is not kept" would be a row contradicting the rows beneath it.
"msg": "The browser log passed its hourly storage budget; a batch bigger than what is left of it is not kept",
"attrs": {"budget_bytes": STORED_BYTES_PER_HOUR},
"user_id": user.id,
}
)
+51 -12
View File
@@ -1,5 +1,7 @@
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, RootModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -7,6 +9,7 @@ from app.auth import (
count_admins,
count_pending_activations,
current_user,
drop_from_wallet,
google_enabled,
has_read_scope,
has_write_scope,
@@ -14,7 +17,9 @@ from app.auth import (
optional_current_user,
purge_user,
)
from app import diagnostics
from app import email as email_mod
from app import logs
from app import sysconfig
from app.db import get_db
from app.models import Invite, User
@@ -65,24 +70,26 @@ def switch_account(
target = body.user_id
if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
# wallet session, logging out every account here. (B-core-9)
u = db.get(User, target)
if u is None:
drop_from_wallet(request.session, target)
raise HTTPException(status_code=404, detail="That account no longer exists.")
if not is_allowed(db, u.email):
raise HTTPException(status_code=403, detail="That account no longer has access.")
if not u.is_active:
drop_from_wallet(request.session, target)
raise HTTPException(status_code=403, detail="That account no longer has access.")
if u.is_suspended:
# Don't make a suspended account the active one — the next request's current_user would
# see the suspension and clear the WHOLE wallet session, logging out every account here.
drop_from_wallet(request.session, target)
raise HTTPException(status_code=403, detail="That account is suspended.")
# Same reasoning for a REVOKED session (a password reset/change or "log out everywhere" bumped the
# account's epoch): making it the session default would trip current_user's epoch check on the next
# request and clear the whole wallet. Mirror the suspend guard — drop the stale account from the
# switch list and refuse, rather than logging everyone out. (B-core-9)
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
epochs = request.session.get("epochs") or {}
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
request.session["account_ids"] = [
a for a in (request.session.get("account_ids") or []) if a != target
]
drop_from_wallet(request.session, target)
raise HTTPException(
status_code=403, detail="That account was signed out elsewhere — sign in again."
)
@@ -144,6 +151,9 @@ def get_me(
"pending_activations": pending_activations,
"email_configured": email_configured,
"preferences": user.preferences or {},
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
# it to its own log and streams that log while it is raised.
"diagnostics": diagnostics.state(db, user),
}
@@ -185,16 +195,45 @@ def delete_my_account(
return {"deleted": True}
class PreferencesIn(RootModel[dict[str, Any]]):
"""Free-form UI preferences — a JSON object of any keys (theme, filters, …), merged into the
stored ones. A model rather than a bare `dict` parameter so a non-object body is a 422 at the
boundary like every other request body."""
@router.put("/preferences")
def update_preferences(
preferences: dict,
preferences: PreferencesIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
# Merge so partial updates don't wipe other keys.
merged = dict(user.preferences or {})
merged.update(preferences)
merged.update(preferences.root)
user.preferences = merged
db.add(user)
db.commit()
return {"preferences": merged}
class DiagnosticsIn(BaseModel):
level: diagnostics.DiagnosticsLevel
@router.put("/diagnostics")
def update_diagnostics(
body: DiagnosticsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the
base. The expiry is the server's: a raised level always lapses after
`diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise
it — its log would interleave every visitor's session under one account."""
if user.is_demo and body.level != diagnostics.BASE:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "diagnostics"})
diagnostics.set_level(user, body.level)
db.commit()
# The rest of THIS request, and the process's elevation registry, follow the new level now.
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
return {"ok": True, "diagnostics": diagnostics.state(db, user)}
+4 -3
View File
@@ -423,9 +423,10 @@ async def messages_ws(ws: WebSocket) -> None:
"""Live push channel: authenticated by the session cookie, registers the connection so
sent messages reach this user's open tabs instantly. We don't consume client→server frames
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
# Which signed-in account this socket acts as — the SHARED per-tab resolution (wallet-gated
# ?account= override, session default otherwise). A browser WebSocket can't send the
# X-Siftlode-Account header, so resolved_user_id falls through to the ?account= query param.
# Which signed-in account this socket acts as — the SHARED per-tab resolution (a wallet-gated
# ?account=, the session default when none is named, nobody for an account no longer signed in
# here). A browser WebSocket can't send the X-Siftlode-Account header, so resolved_user_id falls
# through to the ?account= query param.
uid, _ = resolved_user_id(ws)
if not uid:
await ws.close(code=1008)
+20 -6
View File
@@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
@@ -17,13 +17,21 @@ from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.ratelimit import RateLimiter
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
router = APIRouter(prefix="/api/public", tags=["public"])
# Slow password guessing per link (the token itself is 192-bit, so it needs no throttle).
# Slow password guessing on a link (the token itself is 192-bit, so it needs no throttle). Counted per
# viewer — the link AND the client (`client_key`) — so a client guessing wrong shuts out only itself, and a
# busy link's own unlocks don't count against its next viewer.
_unlock_limit = RateLimiter(max_events=10, window_seconds=300)
# ...under a ceiling on WRONG passwords for the link from every client together, so guessing spread over
# many addresses stays slow too; unlocking with the password never adds to it. The price of the ceiling:
# someone with the link but not its password, guessing from 50 / 10 = 5 networks, can keep the link shut
# for everyone until the window passes. It is checked before the password and counted after it, so guesses
# already running when it fills are still checked — as many as the route's threadpool runs at once.
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
def _link_or_404(db: Session, token: str) -> DownloadLink:
@@ -83,11 +91,16 @@ class WatchUnlockIn(BaseModel):
@router.post("/watch/{token}/unlock")
def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db)) -> dict:
def watch_unlock(
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
if not _unlock_limit.allow(token):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
# would grow the limiter without bound.
link = _link_or_404(db, token)
viewer = f"{token}|{client_key(request)}"
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
job, asset = _ready_target(db, link)
@@ -96,6 +109,7 @@ def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db))
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token)
raise HTTPException(status_code=403, detail="Wrong password.")
job, asset = _ready_target(db, link)
link.view_count += 1
+10 -1
View File
@@ -22,6 +22,7 @@ from app.scheduler import (
trigger_all,
trigger_job,
)
from app.sync.explore import followed_channel
from app.sync.runner import estimate_deep_backfill
from app.routes._common import not_found
@@ -137,9 +138,17 @@ def get_scheduler(_: User = Depends(admin_user), db: Session = Depends(get_db))
queue = {
"channels_recent_pending": db.scalar(
select(func.count()).select_from(Channel).where(Channel.recent_synced_at.is_(None))
# The same selection run_recent_backfill works through, or the card counts channels
# the job will never touch and never reaches zero.
select(func.count())
.select_from(Channel)
.where(Channel.recent_synced_at.is_(None), followed_channel())
)
or 0,
# The whole opted-in BACKLOG, deliberately not `run_deep_backfill`'s next-pass selection
# (which also wants `recent_synced_at`): the same figure /api/sync/my-status shows the user,
# and a channel missing its recent pull is one the same job tick fetches first, not one the
# job will never touch. Narrowing it here would under-report work that is genuinely queued.
"channels_deep_pending": deep_eta["channels_pending"],
"deep_eta_seconds": deep_eta["eta_seconds"],
"videos_pending_enrich": db.scalar(
+1 -3
View File
@@ -231,9 +231,7 @@ def search_youtube(
limit = max(1, min(limit, 100))
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
if source != "scrape":
source = "api"
source = sysconfig.get_str(db, "search_source")
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
# since the search itself spends no quota.
+16 -1
View File
@@ -10,7 +10,7 @@ from typing import Callable
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from app import audit, progress, quota
from app import audit, logsink, progress, quota
from app.audit import AuditAction
from app.config import settings
from app.db import SessionLocal
@@ -72,6 +72,7 @@ JOB_INTERVALS: dict[str, int] = {
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
"tmdb_enrich": settings.tmdb_enrich_interval_min,
"audit_gc": settings.audit_gc_minutes,
"log_gc": settings.log_gc_minutes,
}
# Sane bounds for an admin-set interval (minutes).
@@ -381,6 +382,19 @@ def _audit_gc_job() -> None:
_job("audit_gc", work)
def _log_gc_job() -> None:
# Prune log_events rows older than log_retention_days — the limit the privacy policy states, so
# this runs hourly rather than daily. Pure DB, no quota.
def work(db):
from app import sysconfig
reaped = logsink.gc(db, sysconfig.get_int(db, "log_retention_days"))
db.commit()
return {"reaped": reaped}
_job("log_gc", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = {
@@ -400,6 +414,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"plex_watch_reconcile": _plex_watch_reconcile_job,
"tmdb_enrich": _tmdb_enrich_job,
"audit_gc": _audit_gc_job,
"log_gc": _log_gc_job,
}
+19 -6
View File
@@ -11,7 +11,7 @@ grace period (mirrors the maintenance job's grace-then-delete safety)."""
import logging
from datetime import timedelta
from sqlalchemy import delete, select
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from app import sysconfig
@@ -31,6 +31,23 @@ from app.youtube.client import YouTubeClient
log = logging.getLogger("siftlode.explore")
def followed_channel():
"""WHERE clause: a channel somebody follows — a subscription, or an exploration still inside its
grace window (`purge_unkept_explored` deletes the expired ExploredChannel rows first). ONE
definition for both halves of the question: what the scheduled RSS poll and recent backfill cover
(sync/runner.py) and what the explore purge may delete.
The poll and backfill cover only these (owner decision, 2026-09-17). They used to cover EVERY
channel row, but rows also come from search results, playlist sync and dropped subscriptions, and
nothing deletes those: on prod 582 of 979 polled channels had no follower, three of them dead
feeds answering 404 on every poll. The cost of narrowing is deliberate: the shared Library stops
receiving new uploads from channels nobody follows."""
return or_(
select(Subscription.id).where(Subscription.channel_id == Channel.id).exists(),
select(ExploredChannel.id).where(ExploredChannel.channel_id == Channel.id).exists(),
)
def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
"""Enrich just the not-yet-enriched videos among `video_ids` (videos.list, 1 unit/50)."""
if not video_ids:
@@ -98,12 +115,8 @@ def purge_unkept_explored(db: Session) -> dict:
).rowcount
db.commit()
sub_exists = select(Subscription.id).where(Subscription.channel_id == Channel.id).exists()
exp_exists = select(ExploredChannel.id).where(ExploredChannel.channel_id == Channel.id).exists()
orphan_ids = (
db.execute(
select(Channel.id).where(Channel.from_explore.is_(True), ~sub_exists, ~exp_exists)
)
db.execute(select(Channel.id).where(Channel.from_explore.is_(True), ~followed_channel()))
.scalars()
.all()
)
+59 -17
View File
@@ -7,16 +7,18 @@ public reads.
import logging
import threading
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import progress, quota, sysconfig
from app import logs, progress, quota, sysconfig
log = logging.getLogger("siftlode.sync")
from app.db import SessionLocal
from app.models import Channel, OAuthToken, Subscription, User
from app.sync.explore import followed_channel
from app.sync.subscriptions import import_subscriptions
from app.sync.videos import (
apply_rss_feed,
@@ -32,6 +34,8 @@ from app.youtube.rss import RssError, fetch_channel_feed
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
RSS_POLL_WORKERS = 16
# Failed channels named in a poll's summary warning; the rest are counted by reason.
RSS_FAILURE_EXAMPLES = 5
def token_users(db: Session) -> list[User]:
@@ -52,7 +56,7 @@ def get_service_user(db: Session) -> User | None:
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str, int]:
"""Poll every channel's free RSS feed. Returns `{"new": inserted, "failed": channels_that_did
"""Poll the free RSS feed of every followed channel (`followed_channel`). Returns `{"new": inserted, "failed": channels_that_did
_not_complete}` — the failure count is part of the RESULT, not just a log line, so the scheduler
card and the audit summary can tell "no new uploads" apart from "nothing worked" (an egress
outage, or a database that can't accept writes, otherwise renders identically to a quiet hour).
@@ -67,7 +71,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
carries the FIRST underlying error, because on a small instance "all channels failed" is also
what a single dead feed id looks like."""
if channels is None:
channels = db.execute(select(Channel)).scalars().all()
channels = db.execute(select(Channel).where(followed_channel())).scalars().all()
total = len(channels)
new = 0
done = 0
@@ -76,6 +80,27 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# outage from one permanently dead feed id — which is the whole difference on an instance with
# a single subscription, where BOTH render as 100% failed.
first_error: str | None = None
# What failed, as counts per reason (`http_404`, `transport`, `apply`, …) plus a few examples —
# the ONE warning a poll writes. One line per failed channel was what turned YouTube's RSS outage
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
reasons: Counter[str] = Counter()
examples: list[str] = []
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
is a bug on our side: its FIRST occurrence per reason gets a traceback at ERROR, the rest
DEBUG — a read-only database fails every channel the same way, and 979 identical tracebacks
tell nobody anything the first one didn't. Call it from inside the `except` block."""
nonlocal failed, first_error
if unexpected is not None:
level = logging.ERROR if reasons[reason] == 0 else logging.DEBUG
log.log(level, unexpected, exc_info=True)
failed += 1
reasons[reason] += 1
first_error = first_error or detail
if len(examples) < RSS_FAILURE_EXAMPLES:
examples.append(detail)
# Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
# each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
# plain str) to the workers; the ORM instance is touched only here on the main thread.
@@ -90,16 +115,15 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# A failed poll is NOT an empty feed: skip the apply so `last_rss_at` stays where it
# was (stamping it made a permanently-broken feed look freshly polled). The shared
# counter/progress tail lives after the statement, so the two outcomes can stay two
# clauses: an RssError is EXPECTED (404, timeout) and gets one warning line; anything
# else is a bug on our side and earns a traceback.
# clauses: an RssError is EXPECTED (404, timeout) and is only counted; anything else is a
# bug on our side and earns a traceback (the first of its kind per poll).
except RssError as exc:
log.warning("RSS poll skipped for channel %s: %s", channel.id, exc)
failed += 1
first_error = first_error or f"{channel.id}: {exc}"
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
fail(exc.reason, f"{channel.id}: {exc}")
except Exception as exc:
log.exception("RSS fetch failed for channel %s", channel.id)
failed += 1
first_error = first_error or f"{channel.id}: {exc}"
fail(
"error", f"{channel.id}: {exc}", unexpected=f"RSS fetch failed for {channel.id}"
)
else:
try:
new += apply_rss_feed(db, channel, entries)
@@ -107,14 +131,27 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# A write failure (read-only DB, failover) leaves the channel just as un-polled
# as an unreachable feed — count it, or a wholly broken run reports "failed=0".
db.rollback()
failed += 1
first_error = first_error or f"{channel.id} (apply): {exc}"
log.exception("RSS apply failed for channel %s", channel.id)
fail(
"apply",
f"{channel.id} (apply): {exc}",
unexpected=f"RSS apply failed for {channel.id}",
)
# One place that advances the counter, whatever happened above.
done += 1
progress.report(done, total, "rss_poll")
if failed:
log.warning("RSS poll: %d/%d channels could not be polled", failed, total)
log.warning(
"RSS poll: %d/%d channels could not be polled",
failed,
total,
extra=logs.event(
"sync.rss.failed",
failed=failed,
total=total,
reasons=dict(reasons),
examples=examples,
),
)
if total and failed == total:
# Nothing was polled at all — an egress outage (the tinyproxy-after-reboot incident), a
# dead DNS, a read-only database. Returning normally would record the run as `ok` with the
@@ -174,7 +211,11 @@ def run_recent_backfill(
return {"channels_processed": 0, "videos_added": 0, "reason": "no service user"}
if channels is None:
channels = (
db.execute(select(Channel).where(Channel.recent_synced_at.is_(None))).scalars().all()
db.execute(
select(Channel).where(Channel.recent_synced_at.is_(None), followed_channel())
)
.scalars()
.all()
)
if max_channels:
channels = channels[:max_channels]
@@ -204,7 +245,8 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
if user is None:
return {"channels_processed": 0, "videos_added": 0, "reason": "no service user"}
# Demand-driven: only deep-backfill channels at least one user has opted into
# (Subscription.deep_requested). Recent backfill stays unconditional (cheap).
# (Subscription.deep_requested). Recent backfill needs no opt-in: it covers every followed channel
# (`followed_channel`).
deep_requested = (
select(Subscription.id)
.where(
+48 -19
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session
from app import security
from app.config import settings
from app.config import Settings, canonical_choice, choices_of, settings
from app.models import SystemConfig
log = logging.getLogger("siftlode.config")
@@ -27,7 +27,7 @@ _decrypt_warned: set[str] = set()
@dataclass(frozen=True)
class ConfigSpec:
key: str
type: str # "int" | "str" | "bool"
type: str # "int" | "str" | "bool" | "choice"
group: str # admin-UI grouping (logically related keys stay together)
default_attr: str # attribute on `settings` holding the env/config default
secret: bool = False
@@ -35,16 +35,33 @@ class ConfigSpec:
max: int | None = None
allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset"
@property
def choices(self) -> tuple[str, ...]:
"""A choice key's allowed values, read from its setting's Literal type in config.py — the one
place the list is written. The admin page renders them as a picker."""
return choices_of(Settings.model_fields[self.default_attr].annotation)
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
# off env into the admin UI; the admin page renders it automatically from this list.
# off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the
# admin page's tab order (first appearance of each group), so keep a group's keys together.
SPECS: tuple[ConfigSpec, ...] = (
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
# --- Email / SMTP (one logical group; the password is the only secret) ---
ConfigSpec("smtp_host", "str", "email", "smtp_host"),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True),
ConfigSpec("smtp_from", "str", "email", "smtp_from"),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
ConfigSpec(
@@ -59,7 +76,7 @@ SPECS: tuple[ConfigSpec, ...] = (
max=100_000,
),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
ConfigSpec("search_source", "str", "quota", "search_source"),
ConfigSpec("search_source", "choice", "quota", "search_source"),
# --- Backfill (recent-first first pass per channel) ---
ConfigSpec(
"backfill_recent_max_videos",
@@ -96,16 +113,6 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
# Days an un-kept live-search result video is kept before the cleanup job removes it.
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
ConfigSpec(
@@ -137,7 +144,7 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec(
"download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650
),
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
ConfigSpec("download_layout", "choice", "downloads", "download_layout"),
ConfigSpec(
"download_worker_concurrency",
"int",
@@ -150,6 +157,13 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Audit log ---
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
# --- Diagnostics (E22) ---
# Runtime log level for the API process. Applied without a restart — see routes/config.py and
# main.py's lifespan.
ConfigSpec("log_level", "choice", "diagnostics", "log_level"),
# Days the log_events table keeps a row (the log_gc job prunes). No "0 = forever": stored logs
# carry account-linked request history, and the privacy policy names a limit.
ConfigSpec("log_retention_days", "int", "diagnostics", "log_retention_days", min=1, max=90),
# --- Plex integration (optional; local-file playback + Plex metadata) ---
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
@@ -159,10 +173,10 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
# Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
# crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
@@ -194,11 +208,23 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr)
def _choice(s: ConfigSpec, raw: object) -> str:
"""The canonical spelling of `raw` among the key's choices (any letter case), or ValueError."""
option = canonical_choice(s.choices, raw)
if option is None:
raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}")
return option
def _coerce(s: ConfigSpec, raw: str):
if s.type == "int":
return int(raw)
if s.type == "bool":
return raw == "true"
if s.type == "choice":
# A stored value that is no longer a choice (the list shrank) reads as the default, like a
# non-numeric int override does — see get().
return _choice(s, raw)
return raw
@@ -274,6 +300,8 @@ def set_value(db: Session, key: str, value) -> None:
raw = str(v)
elif s.type == "bool":
raw = "true" if value else "false"
elif s.type == "choice":
raw = _choice(s, value)
else:
raw = "" if value is None else str(value)
@@ -316,6 +344,7 @@ def describe(db: Session) -> dict:
"max": s.max,
"is_set": is_set, # an override row exists
"allow_empty": s.allow_empty, # if true, the UI stores "" rather than deleting the row
"choices": list(s.choices), # the picker's options; [] for any other type
}
if s.secret:
item["default_is_set"] = bool(_default(s))
+17 -6
View File
@@ -28,6 +28,8 @@ from datetime import datetime, timezone
from sqlalchemy import text
from app import logs
from app import logsink
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
@@ -491,15 +493,20 @@ def _worker_loop(idx: int) -> None:
if job_id is None:
_stop.wait(settings.download_poll_seconds)
continue
try:
_process_job(job_id)
except Exception: # noqa: BLE001
log.exception("processing job %s crashed", job_id)
_fail_running(job_id, error="Worker error", error_code=None)
# Tag every line this job produces (including the crash below) with the job id: a worker runs
# N jobs at once on N threads, so without it the interleaved output is unreadable. The bind
# wraps the handler too — bound inside the `try`, it was already reset by the time the crash
# line was logged.
with logs.bind(job_id=job_id):
try:
_process_job(job_id)
except Exception: # noqa: BLE001
log.exception("processing job %s crashed", job_id)
_fail_running(job_id, error="Worker error", error_code=None)
def main() -> None:
logging.basicConfig(level=logging.INFO)
logs.configure_logging("worker")
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
@@ -515,6 +522,10 @@ def main() -> None:
if _stop.is_set():
return # asked to shut down while waiting — that IS a clean exit
raise SystemExit(1)
# After the wait, not before: the DB is reachable now. The wait checks `download_jobs`, not
# `log_events`, so on the deploy that first adds the table the worker may start before the API
# has migrated — those few seconds of batches fail and say so on stderr; nothing else breaks.
logsink.start()
_recover_orphans()
n = max(1, settings.download_worker_concurrency)
log.info("Download worker started (concurrency=%d, root=%s).", n, settings.download_root)
+13 -3
View File
@@ -14,7 +14,14 @@ class RssError(Exception):
Deliberately NOT the same as an empty feed: a failed poll used to return `[]`, which the
caller stamped as a successful `last_rss_at` — so a channel whose feed 404s permanently
(deleted, renamed id) looked like it was being polled fine forever, and nothing surfaced it."""
(deleted, renamed id) looked like it was being polled fine forever, and nothing surfaced it.
`reason` is a short stable code (`http_404`, `transport`) so a poll over many channels can report
WHAT failed as counts instead of one line per channel."""
def __init__(self, message: str, reason: str):
super().__init__(message)
self.reason = reason
def fetch_channel_feed(channel_id: str) -> list[dict]:
@@ -24,9 +31,12 @@ def fetch_channel_feed(channel_id: str) -> list[dict]:
try:
resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
except httpx.HTTPError as exc:
raise RssError(f"RSS fetch failed for {channel_id}: {exc}") from exc
raise RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport") from exc
if resp.status_code != 200:
raise RssError(f"RSS fetch for {channel_id} returned HTTP {resp.status_code}")
raise RssError(
f"RSS fetch for {channel_id} returned HTTP {resp.status_code}",
f"http_{resp.status_code}",
)
parsed = feedparser.parse(resp.content)
out: list[dict] = []
+6 -2
View File
@@ -11,8 +11,12 @@ echo "Starting Siftlode API..."
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
#
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. auth._client_ip trusts
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. ratelimit.client_key trusts
# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those
# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and
# reopening the rate-limit bypass (SA3).
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75
#
# No --log-config: app/logs.py configures logging for every Siftlode process (api, worker, download
# child, alembic) in one place, and it silences uvicorn's access log in favour of our own
# `http.request` line (route template, no query string, request id).
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --timeout-keep-alive 75
-40
View File
@@ -1,40 +0,0 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(asctime)s %(levelprefix)s [%(name)s] %(message)s"
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": "%(asctime)s %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s"
},
"plain": {
"format": "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
}
},
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stdout"
},
"access": {
"class": "logging.StreamHandler",
"formatter": "access",
"stream": "ext://sys.stdout"
},
"plain": {
"class": "logging.StreamHandler",
"formatter": "plain",
"stream": "ext://sys.stdout"
}
},
"loggers": {
"uvicorn": { "handlers": ["default"], "level": "INFO", "propagate": false },
"uvicorn.error": { "handlers": ["default"], "level": "INFO", "propagate": false },
"uvicorn.access": { "handlers": ["access"], "level": "INFO", "propagate": false },
"siftlode": { "handlers": ["plain"], "level": "INFO", "propagate": false }
}
}
+4 -2
View File
@@ -1,7 +1,9 @@
# Test + lint tooling — NEVER in the prod image. Installed only by backend/Dockerfile.test (the
# throwaway test image), which the prod build (root Dockerfile) never references. Pinned so the
# suite is reproducible: pytest for the pure-logic tests, and ruff at the SAME version the gate's
# backend lint lane currently runs on the host (see the run_lint note in siftlode.sh). httpx is
# already an app dependency (requirements.txt), so its TestClient needs nothing extra here.
# backend lint lane runs on the host (the gate fails when the host's ruff is not this one). httpx2:
# Starlette 1.x's TestClient is built on it (`import httpx2 as httpx`) and only falls back to the
# app's own httpx with a deprecation warning on every run.
pytest==8.4.2
ruff==0.15.21
httpx2==2.13.0
+16
View File
@@ -57,6 +57,10 @@ def _db_reachable() -> bool:
_DB_REACHABLE = _db_reachable()
# No background log writer in tests: every TestClient would otherwise start one on app startup and
# write into tables the fixtures truncate between tests. The sink's own tests drive it explicitly.
settings.log_db_sink = False
@pytest.fixture(scope="session")
def _schema():
@@ -210,3 +214,15 @@ def _reset_rate_limiters():
for obj in vars(mod).values():
if isinstance(obj, RateLimiter):
obj.reset()
@pytest.fixture(autouse=True)
def _reset_log_erasures():
"""`logs._erased` is module-level and remembers an erased account for an hour of monotonic time
— longer than any suite run — while the `db` fixture hands serial ids back out from 1 via
TRUNCATE … RESTART IDENTITY. So a test that purges user 1 would otherwise make a LATER test's
rows for user 1 store with `user_id = None`, failing somewhere nowhere near the cause and only
in some file orders. Same reasoning as the limiter sweep above, different global."""
from app import logs
logs._erased.clear()
+92
View File
@@ -0,0 +1,92 @@
{
"_comment": "One redaction contract for both logs: backend/app/logs.py redact() and frontend/src/lib/log.ts redact() must turn every `value` (under `key`) into `expected`. Both test suites run this file (backend tests/test_logs.py, frontend src/lib/log.contract.test.ts), so the two implementations cannot drift apart. Some cases prove a secret goes; others prove ordinary content (a search term, a Plex title, a chat line) is not mangled — what a LEVEL may carry is decided at the call site, not here.",
"cases": [
{
"name": "a password field is dropped whole",
"key": "",
"value": {
"email": "a@b.co",
"password": "hunter2",
"current_password": "x"
},
"expected": {
"email": "[email]",
"password": "[redacted]",
"current_password": "[redacted]"
}
},
{
"name": "a token under any spelling of the key, nested and in lists",
"key": "",
"value": {
"plex": { "plex_server_token": "abc", "Authorization": "Bearer z" },
"items": [{ "access_token": "t1" }, { "id": 3 }]
},
"expected": {
"plex": {
"plex_server_token": "[redacted]",
"Authorization": "[redacted]"
},
"items": [{ "access_token": "[redacted]" }, { "id": 3 }]
}
},
{
"name": "E2EE material and cookies",
"key": "",
"value": {
"ciphertext": "AAAA",
"passphrase": "p",
"private_key": "k",
"cookies": "SID=1"
},
"expected": {
"ciphertext": "[redacted]",
"passphrase": "[redacted]",
"private_key": "[redacted]",
"cookies": "[redacted]"
}
},
{
"name": "a reset link and a verify fragment",
"key": "msg",
"value": "open https://app.example/#reset=QWErty123 or /?verify=abc&lang=hu",
"expected": "open https://app.example/#reset=[redacted] or /?verify=[redacted]&lang=hu"
},
{
"name": "an OAuth callback URL",
"key": "url",
"value": "/auth/callback?code=4/0Ab-xyz&state=s3cr3t&scope=email",
"expected": "/auth/callback?code=[redacted]&state=[redacted]&scope=email"
},
{
"name": "an email address inside a sentence",
"key": "message",
"value": "Invitation sent to Some.One+tag@mail.example.org.",
"expected": "Invitation sent to [email]."
},
{
"name": "a search term is kept (TRACE decides whether it is logged at all)",
"key": "q",
"value": "lo-fi beats to relax",
"expected": "lo-fi beats to relax"
},
{
"name": "a Plex title with an at-sign is not an address",
"key": "title",
"value": "Live @ Wembley 1986",
"expected": "Live @ Wembley 1986"
},
{
"name": "a chat line is text like any other; its only secret part is scrubbed",
"key": "body",
"value": "see you at 8, my mail is me@home.example",
"expected": "see you at 8, my mail is [email]"
},
{
"name": "non-text values pass through",
"key": "",
"value": { "status": 404, "ok": false, "ratio": 0.5, "none": null },
"expected": { "status": 404, "ok": false, "ratio": 0.5, "none": null }
}
]
}
+80
View File
@@ -1,6 +1,8 @@
"""Per-source adapter selection + the source-specific policy each one carries. These lock the
behaviour that used to live inline in worker.build_uploader_url / backfill._should_refetch."""
from app.config import settings
from app.titles import normalize_title
from app.downloads.adapters import (
FacebookAdapter,
RedditAdapter,
@@ -110,6 +112,28 @@ def test_client_sets_and_retriable_are_per_source():
assert SourceAdapter().retriable("Sign in to confirm you're not a bot") is False
assert YouTubeAdapter().retriable("Sign in to confirm you're not a bot") is True
assert YouTubeAdapter().retriable("some unrelated network blip") is False
# A client list yt-dlp empties because none of its clients take cookies does NOT raise "No
# player clients have been requested" (that raise sits earlier in yt-dlp, before the
# cookie-capability removal). Measured with a real cookie: the emptied request fails with
# "Requested format is not available", which is retriable here already — so the wasted attempt
# is recoverable and `_COOKIE_CAPABLE` exists to avoid spending it at all.
assert (
YouTubeAdapter().retriable(
"ERROR: [youtube] abc: Requested format is not available. Use --list-formats"
)
is True
)
# The media fetch refused after a successful extraction: the format URLs belong to the client
# that minted them, so the next client set is a real second chance (it used to end the job).
assert (
YouTubeAdapter().retriable(
"ERROR: unable to download video data: HTTP Error 403: Forbidden"
)
is True
)
assert (
SourceAdapter().retriable("ERROR: unable to download video data: HTTP Error 403") is False
)
# Facebook: a second, Chrome-impersonating attempt only WITH the user's cookie, and only behind
# the parse wall (without a cookie that wall is the login wall — no disguise gets past it).
fb = FacebookAdapter()
@@ -120,6 +144,37 @@ def test_client_sets_and_retriable_are_per_source():
assert fb.retriable("ERROR: [facebook] 1: No video formats found!") is False
def test_a_signed_in_youtube_download_only_runs_cookie_capable_clients(monkeypatch):
yt = YouTubeAdapter()
monkeypatch.setattr(settings, "download_player_clients", "android_vr")
monkeypatch.setattr(settings, "download_player_clients_fallback", "web_safari,web,android")
# Signed out: the admin's ladder exactly as configured.
assert yt.client_sets() == [["android_vr"], ["web_safari", "web", "android"]]
# Signed in: yt-dlp REMOVES every client that takes no cookies, and a set it empties that way
# raises "No player clients have been requested" — which would fail the download on attempt 1,
# before the POT-backed web fallback the cookie exists for. So the adapter never asks for one:
# the android_vr-only set is gone and `android` is dropped out of the fallback.
assert yt.client_sets(authed=True) == [["web_safari", "web"]]
def test_a_signed_in_download_still_has_clients_when_the_admin_configured_none(monkeypatch):
# Both knobs cookie-incapable: fall back to the POT-backed web pair rather than no attempt at all.
monkeypatch.setattr(settings, "download_player_clients", "android_vr")
monkeypatch.setattr(settings, "download_player_clients_fallback", "android,ios")
assert YouTubeAdapter().client_sets(authed=True) == [["web_safari", "web"]]
def test_the_cookie_capable_clients_match_the_installed_ytdlp():
# The list is yt-dlp's, not ours (INNERTUBE_CLIENTS[...]["SUPPORTS_COOKIES"]). Pinned here so a
# yt-dlp bump that renames or re-flags a client turns the gate red, instead of silently sending
# a signed-in download to a client that drops the cookie.
from yt_dlp.extractor.youtube._base import INNERTUBE_CLIENTS
live = {k for k, v in INNERTUBE_CLIENTS.items() if v.get("SUPPORTS_COOKIES")}
assert live == set(YouTubeAdapter._COOKIE_CAPABLE)
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
assert ea["youtube"]["player_client"] == ["android_vr"]
@@ -139,3 +194,28 @@ def test_wants_metadata_backfill_matches_old_should_refetch():
def test_nfo_uniqueid_type_is_the_adapter_key():
assert YouTubeAdapter().nfo_uniqueid_type() == "youtube"
assert SourceAdapter().nfo_uniqueid_type() == "generic"
def test_title_is_yt_dlps_for_a_source_without_placeholders():
assert SourceAdapter().title({"title": "Video", "description": "text"}) == "Video"
assert SourceAdapter().title({}) is None
def test_facebook_placeholder_title_takes_the_posts_text():
# Measured: signed in, yt-dlp titles every reel "Video" (an 18+ reel read as Chrome: "Facebook"),
# while the post's text is still in the description.
a = FacebookAdapter()
info = {"title": "Video", "description": "\n Tune of The Day\nsecond line"}
assert a.title(info) == "Tune of The Day"
assert a.title({**info, "title": "Facebook"}) == "Tune of The Day"
# A real title (signed out) is left for normalize_title, engagement prefix and all.
assert a.title({**info, "title": "456K views | Tune"}) == "456K views | Tune"
# Nothing better on the page: keep what yt-dlp gave.
assert a.title({"title": "Facebook", "description": None}) == "Facebook"
def test_facebook_title_from_a_long_description_is_the_whole_line_after_cleaning():
# Dev UAT: a cut with "…" came out of normalize_title as "… live at." (NFKC "..." collapsed to "."),
# a sentence that seemed to end. The whole first line survives the cleaning intact instead.
line = 'Tune of The Day, Argy playing his version of the classic "Resurrection" with Paul Oakenfold live at Space'
assert normalize_title(FacebookAdapter().title({"title": "Video", "description": line})) == line
+70
View File
@@ -12,6 +12,7 @@ Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False
from datetime import timedelta
import pytest
from fastapi.testclient import TestClient
from app.auth import ACTIVE_ACCOUNT_HEADER
@@ -57,6 +58,75 @@ def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client, make_user):
# Two accounts in one browser: a tab pinned to Alice, Bob the default. Alice signs out in another
# tab. The pinned tab used to go on acting as the default, Bob — its player checkpoints and pref
# saves landed in his account, and its own Sign out signed HIM out.
alice = make_user(email="alice3@example.com", password=_PW)
make_user(email="bob3@example.com", password=_PW)
assert _login(client, "alice3@example.com", _PW).status_code == 200
assert _login(client, "bob3@example.com", _PW).status_code == 200 # Bob is now the default
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
assert client.get("/api/feed", headers=tab).status_code == 200
assert client.post("/auth/logout", headers=tab).status_code == 200 # Alice signs out
assert client.get("/api/feed", headers=tab).status_code == 401
assert client.post("/auth/logout", headers=tab).status_code == 200
assert client.get("/api/feed").status_code == 200 # Bob is still signed in, the session intact
# The bootstrap probe still answers as the default: how the stale tab learns who it is now.
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
client, make_user, db, ended
):
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
alice = make_user(email="alice4@example.com", password=_PW)
make_user(email="bob4@example.com", password=_PW)
assert _login(client, "alice4@example.com", _PW).status_code == 200
assert _login(client, "bob4@example.com", _PW).status_code == 200
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
assert client.get("/api/feed", headers=tab).status_code == 200
if ended == "revoked":
alice.session_epoch = (alice.session_epoch or 0) + 1
else:
alice.is_suspended = True
db.commit()
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_from_the_list(
client, make_user, db, monkeypatch, ended
):
# The account switcher lists this browser's wallet. A revoked account was dropped from it on a refused
# switch, but a suspended one stayed, refused on every click; a deactivated one was not refused at all —
# made the default, the next request found it inactive and signed every account here out.
monkeypatch.setattr("app.routes.me.is_allowed", lambda _db, _email: True) # both hold an invite
alice = make_user(email="alice5@example.com", password=_PW)
make_user(email="bob5@example.com", password=_PW)
assert _login(client, "alice5@example.com", _PW).status_code == 200
assert _login(client, "bob5@example.com", _PW).status_code == 200
if ended == "revoked":
alice.session_epoch = (alice.session_epoch or 0) + 1
elif ended == "suspended":
alice.is_suspended = True
else:
alice.is_active = False
db.commit()
assert client.post("/api/me/switch", json={"user_id": alice.id}).status_code == 403
assert [a["email"] for a in client.get("/api/me/accounts").json()] == ["bob5@example.com"]
assert client.get("/api/feed").status_code == 200 # Bob is still signed in
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
+11 -9
View File
@@ -7,14 +7,14 @@ of the caller's already-translated fallback, so from that commit on the demo use
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
Pinned at all FOUR sites: the shared dependency, the two auth routes that hand-roll the same check,
and the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
Pinned at every site: the shared dependency, the two auth routes that hand-roll the same check,
the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
the code the way round 6's did. Needs the DB.
the code the way round 6's did. (E22 S5 added the fifth: raising the diagnostics level.) Needs the DB.
"""
import re
@@ -33,6 +33,8 @@ def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as
("post", "/auth/logout-others", None),
("post", "/auth/set-password", {"password": "a long enough password"}),
("delete", "/api/me/account", None),
# E22 S5: the shared account's log would interleave every visitor's session.
("put", "/api/me/diagnostics", {"level": "DEBUG"}),
]:
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
assert r.status_code == 403, (url, r.text)
@@ -55,9 +57,9 @@ def test_every_is_demo_refusal_is_on_the_list():
for path in sorted(app_dir.rglob("*.py")):
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
# `if user.is_demo:` / `if u.is_demo:` — the hand-rolled gate, not the ORM filters
# (`User.is_demo.is_(False)`) and not the column definition.
if not re.search(r"^\s*if\s+\w+\.is_demo\s*:", line):
# `if user.is_demo:` / `if u.is_demo and …:` — the hand-rolled gate, not the ORM
# filters (`User.is_demo.is_(False)`) and not the column definition.
if not re.search(r"^\s*if\s+\w+\.is_demo\b[^:]*:", line):
continue
block = "\n".join(lines[i : i + 6])
if "403" not in block:
@@ -67,9 +69,9 @@ def test_every_is_demo_refusal_is_on_the_list():
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
f"unstructured detail — the frontend cannot localize it:\n{block}"
)
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete. A new one is welcome,
# but it has to be added to the loop above in the same commit.
assert len(sites) == 4, sites
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete and diagnostics level. A
# new one is welcome, but it has to be added to the loop above in the same commit.
assert len(sites) == 5, sites
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
+492
View File
@@ -0,0 +1,492 @@
"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in.
Three parts:
* the level itself: the server computes its expiry, a lapsed one reads as the base, the demo
account cannot raise it (DB lane, through the real routes);
* `POST /api/diagnostics/events`: refused at the base level, and nothing in a batch is trusted —
the account comes from the session, everything is redacted again, events under the consented
level are dropped, sizes and pace are capped (DB lane);
* the server side of the elevation: ONE user's requests are logged in detail while everyone
else's stay at the base level, and the account reaches a handler's own lines at all (pure, an
in-process app).
"""
import json
import logging
import threading
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import diagnostics, logs, logsink, sysconfig
from app.config import settings
from app.models import LogEvent, User
from app.routes import diagnostics as diagnostics_routes
_PW = "correct horse battery"
# --- the level ------------------------------------------------------------------------------
def test_a_raised_level_counts_until_it_lapses_and_not_a_moment_longer():
now = datetime.now(timezone.utc)
assert diagnostics.effective_level(User()) == "INFO"
raised = User(diagnostics_level="TRACE", diagnostics_until=now + timedelta(minutes=1))
assert diagnostics.effective_level(raised, now) == "TRACE"
assert diagnostics.effective_level(raised, now + timedelta(minutes=2)) == "INFO"
# A level without an expiry is not a level that never ends — it is no level at all.
assert diagnostics.effective_level(User(diagnostics_level="DEBUG")) == "INFO"
def _signed_in(client, make_user, **kw):
"""A real sign-in: `/api/me` resolves through `optional_current_user`, which the `as_user`
override does not cover (see test_me_email_flag)."""
user = make_user(email="diag@example.com", password=_PW, **kw)
r = client.post("/auth/password-login", json={"email": "diag@example.com", "password": _PW})
assert r.status_code == 200, r.text
return user
def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db, client, make_user):
user = _signed_in(client, make_user)
r = client.put("/api/me/diagnostics", json={"level": "DEBUG", "until": "2099-01-01T00:00:00Z"})
assert r.status_code == 200, r.text
db.refresh(user)
expected = datetime.now(timezone.utc) + timedelta(hours=diagnostics.ELEVATION_HOURS)
assert abs((user.diagnostics_until - expected).total_seconds()) < 60
assert r.json()["diagnostics"]["level"] == "DEBUG"
assert client.get("/api/me").json()["diagnostics"]["level"] == "DEBUG"
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
db.refresh(user)
assert (user.diagnostics_level, user.diagnostics_until) == (None, None)
assert client.get("/api/me").json()["diagnostics"] == {
"level": "INFO",
"until": None,
"retention_days": 7,
}
def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
_signed_in(
client,
make_user,
diagnostics_level="TRACE",
diagnostics_until=datetime.now(timezone.utc) - timedelta(minutes=1),
)
assert client.get("/api/me").json()["diagnostics"] == {
"level": "INFO",
"until": None,
"retention_days": 7,
}
def test_only_the_three_levels_can_be_chosen(db, make_user, as_user):
client = as_user(make_user())
for bogus in ("WARNING", "debug", "EVERYTHING"):
assert client.put("/api/me/diagnostics", json={"level": bogus}).status_code == 422
def test_the_demo_account_can_return_to_the_base_but_never_raise_it(db, make_user, as_user):
client = as_user(make_user(is_demo=True))
r = client.put("/api/me/diagnostics", json={"level": "TRACE"})
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
def test_preferences_must_be_a_json_object(db, make_user, as_user):
client = as_user(make_user())
assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422
r = client.put("/api/me/preferences", json={"theme": "dark"})
assert r.status_code == 200 and r.json()["preferences"]["theme"] == "dark"
# --- the browser's batches ------------------------------------------------------------------
def _event(**over) -> dict:
return {
"ts": datetime.now(timezone.utc).isoformat(),
"level": "INFO",
"event": "nav.view",
"msg": "view",
"tab_id": "a1b2c3",
"version": "0.88.0",
**over,
}
@pytest.fixture
def elevated(db, make_user, as_user):
"""A signed-in client whose user consented to `level` a minute ago, and a reader for its rows."""
diagnostics_routes._batches.reset()
def _make(level: str = "DEBUG"):
user = make_user(
diagnostics_level=level,
diagnostics_until=datetime.now(timezone.utc) + timedelta(hours=23),
)
def rows() -> list[LogEvent]:
db.expire_all()
return list(db.execute(select(LogEvent).order_by(LogEvent.id)).scalars())
return user, as_user(user), rows
yield _make
diagnostics_routes._batches.reset()
def test_nothing_is_accepted_at_the_base_level(db, make_user, as_user):
client = as_user(make_user())
r = client.post("/api/diagnostics/events", json={"events": [_event()]})
assert r.status_code == 409 and r.json()["detail"]["code"] == "diagnostics_off"
assert not db.execute(select(LogEvent)).first()
def test_an_anonymous_caller_is_refused(client):
assert client.post("/api/diagnostics/events", json={"events": [_event()]}).status_code == 401
def test_a_batch_is_stored_as_the_sessions_web_events_and_redacted_again(elevated):
user, client, rows = elevated("DEBUG")
r = client.post(
"/api/diagnostics/events",
json={
"events": [
_event(
level="WARNING",
event="ui.notify",
msg="sent to someone@example.com",
attrs={"password": "hunter2", "url": "/?token=abc123", "user_id": 999},
request_id="0123456789abcdef",
),
_event(level="DEBUG", event="ui.click", attrs={"control": "feed.refresh"}),
],
"user_id": 999, # ignored: the account is the session's
},
)
assert r.json() == {"ok": True, "stored": 2}
warning, click = rows()
assert (warning.source, warning.logger, warning.user_id) == ("web", "web", user.id)
assert warning.msg == "sent to [email]"
assert warning.attrs["password"] == logs.REDACTED
assert "abc123" not in json.dumps(warning.attrs)
assert warning.attrs["tab_id"] == "a1b2c3"
assert warning.request_id == "0123456789abcdef"
assert (warning.sev, click.level, click.version) == (13, "DEBUG", "0.88.0")
def test_an_erased_accounts_batch_is_stored_without_the_account(elevated):
"""The sink's writer is not the only path into `log_events` — this route inserts too, and an
account erased while its browser was mid-flush must not get its rows back through it. Faked at
the `forget_user` seam the purge itself calls: once the User row is actually gone the session
no longer resolves, so the reachable window is exactly a request already past `current_user`.
(`logs._erased` is cleared before every test by conftest's `_reset_log_erasures`.)"""
user, client, rows = elevated("DEBUG")
logs.forget_user(user.id)
r = client.post("/api/diagnostics/events", json={"events": [_event(level="WARNING")]})
assert r.json() == {"ok": True, "stored": 1}
(stored,) = rows()
assert (stored.source, stored.user_id) == ("web", None)
def test_an_event_under_the_consented_level_is_dropped(elevated):
_user, client, rows = elevated("DEBUG")
r = client.post(
"/api/diagnostics/events",
json={"events": [_event(level="TRACE", msg="typed: lofi"), _event(level="DEBUG")]},
)
assert r.json()["stored"] == 1
assert [row.level for row in rows()] == ["DEBUG"]
def test_a_timestamp_outside_the_consent_window_is_clamped(elevated):
_user, client, rows = elevated("DEBUG")
now = datetime.now(timezone.utc)
future = (now + timedelta(days=365)).isoformat()
past = (now - timedelta(days=30)).isoformat()
client.post("/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]})
late, early = rows()
assert late.ts <= datetime.now(timezone.utc)
assert early.ts >= now - timedelta(hours=diagnostics.ELEVATION_HOURS, minutes=1)
def test_sizes_are_capped(elevated):
_user, client, rows = elevated("DEBUG")
too_many = [_event() for _ in range(diagnostics_routes.EVENTS_MAX + 1)]
assert client.post("/api/diagnostics/events", json={"events": too_many}).status_code == 422
huge = "x" * (diagnostics_routes.BODY_MAX_BYTES + 1)
r = client.post(
"/api/diagnostics/events",
content=json.dumps({"events": [_event(attrs={"blob": huge})]}),
headers={"content-type": "application/json"},
)
assert r.status_code == 413
big_attrs = {"blob": "y" * (diagnostics_routes.ATTRS_MAX_BYTES + 10)}
assert (
client.post("/api/diagnostics/events", json={"events": [_event(attrs=big_attrs)]}).json()[
"stored"
]
== 1
)
(row,) = rows()
assert "blob" not in row.attrs and row.attrs["attrs_dropped_bytes"] > 0
bad_code = client.post("/api/diagnostics/events", json={"events": [_event(event="Nav View")]})
assert bad_code.status_code == 422
def test_the_pace_is_capped_per_user(elevated):
_user, client, _rows = elevated("DEBUG")
codes = [
client.post("/api/diagnostics/events", json={"events": []}).status_code for _ in range(31)
]
assert codes[:30] == [200] * 30 and codes[30] == 429
def test_what_an_account_stores_per_hour_is_capped_and_said_once(elevated, monkeypatch):
user, client, rows = elevated("DEBUG")
monkeypatch.setattr(
diagnostics_routes, "_stored_bytes", diagnostics_routes.RateLimiter(3_000, 3600)
)
batch = {"events": [_event(msg="m" * 1_000)]}
stored = [client.post("/api/diagnostics/events", json=batch).json()["stored"] for _ in range(4)]
assert stored == [1, 1, 0, 0]
events = [row.event for row in rows()]
# Two batches, then ONE notice for the hour, not one per refused batch.
assert events == ["nav.view", "nav.view", "diagnostics.budget_spent"]
assert rows()[-1].user_id == user.id
def test_only_a_json_body_is_read(elevated):
_user, client, rows = elevated("DEBUG")
# What a same-site `<form enctype="text/plain">` can send without a preflight.
r = client.post(
"/api/diagnostics/events",
content=json.dumps({"events": [_event()]}),
headers={"content-type": "text/plain"},
)
assert r.status_code == 415 and r.json()["detail"]["code"] == "json_required"
assert rows() == []
ok = client.post(
"/api/diagnostics/events",
content=json.dumps({"events": [_event()]}),
headers={"content-type": "application/json; charset=utf-8"},
)
assert ok.json()["stored"] == 1
# --- the server side of an elevation ---------------------------------------------------------
class _Capture(logging.Handler):
"""The stdout handler's filters (context + gate), capturing records."""
def __init__(self):
super().__init__(level=logging.NOTSET)
self.records: list[logging.LogRecord] = []
self.addFilter(logs.ContextFilter())
self.addFilter(logs.LevelGate())
def emit(self, record):
self.records.append(record)
@pytest.fixture
def gated():
"""A process configured at INFO with a gated handler on the ROOT (where the real one sits), and
every piece of module state the elevation touches restored afterwards."""
root = logging.getLogger()
saved = (logs._base_level, dict(logs._elevations), logs._root_applied, root.level)
handler = _Capture()
root.addHandler(handler)
logs._elevations.clear()
logs.set_level("INFO")
try:
yield handler
finally:
root.removeHandler(handler)
logs._base_level, logs._root_applied = saved[0], saved[2]
logs._elevations.clear()
logs._elevations.update(saved[1])
root.setLevel(saved[3])
def _app() -> TestClient:
"""What a real request does: the request middleware, then a SYNC auth dependency that resolves
the account (run by FastAPI in a threadpool, on a copy of the context), then a handler."""
app = FastAPI()
app.middleware("http")(logs.log_requests)
handler_log = logging.getLogger("siftlode.difftest")
def account(user_id: int, level: str = "INFO"):
until = datetime.now(timezone.utc) + timedelta(hours=1)
logs.set_user(user_id, level, until)
@app.get("/api/thing/{user_id}")
def _thing(user_id: int, _=Depends(account)):
handler_log.debug("detail only an elevated request keeps")
handler_log.info("an ordinary line")
return {}
return TestClient(app)
def test_the_account_reaches_the_handlers_own_lines(gated):
_app().get("/api/thing/41")
line = next(r for r in gated.records if r.getMessage() == "an ordinary line")
assert getattr(line, "user_id", None) == 41
def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gated):
client = _app()
client.get("/api/thing/7", params={"level": "DEBUG"})
client.get("/api/thing/8")
detail = [r for r in gated.records if r.getMessage().startswith("detail only")]
assert [r.user_id for r in detail] == [7]
assert getattr(detail[0], "elevated", False) is True
# Every line of the elevated request is marked for storage — the sink keeps it at any level.
ordinary = [r for r in gated.records if r.getMessage() == "an ordinary line"]
assert [logsink.should_store(r) for r in ordinary] == [True, False]
# The process's configured level is still what it reports, however low the root went.
assert logs.current_level() == "INFO" and logging.getLogger().level == logging.DEBUG
def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated):
client = _app()
client.get("/api/thing/7", params={"level": "TRACE"})
assert logging.getLogger().level == logs.TRACE
client.get("/api/thing/7") # the same user, back at the base
assert logging.getLogger().level == logging.INFO
logs._track_elevation(9, logging.DEBUG, datetime.now(timezone.utc) - timedelta(seconds=1))
assert logging.getLogger().level == logging.INFO # an already-expired one is pruned at once
def test_the_query_string_is_logged_only_for_a_traced_request_and_redacted(gated):
client = _app()
client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"})
client.get("/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"})
lines = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert "query" not in lines[0].attrs
assert "q=lofi" in lines[1].attrs["query"] and "SECRET" not in lines[1].attrs["query"]
def test_an_address_typed_into_a_search_is_redacted_though_the_browser_encodes_it(gated):
_app().get("/api/thing/7?level=TRACE&q=someone%40example.com&new_password=hunter2")
(line,) = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert line.attrs["query"] == "level=TRACE&q=[email]&new_password=[redacted]"
@pytest.mark.parametrize(
"raw",
["state=a%20b", "code=%20SECRET", "sig=a%22b", "key=a%26b", "reset=a+b"],
ids=["space", "leading-space", "quote", "ampersand", "plus"],
)
def test_a_credential_param_goes_whole_however_its_value_decodes(raw):
"""Decoding is what lets `_EMAIL_RE` see `%40`, but it also puts spaces and separators into a
value that `_SECRET_PARAM_RE`'s value class stops at — so the name rule decides, not the text."""
name = raw.split("=", 1)[0]
assert logs.redact_query(f"{raw}&q=lofi") == f"{name}={logs.REDACTED}&q=lofi"
def test_an_admin_level_of_trace_does_not_log_other_users_query_strings(gated):
logs.set_level("TRACE")
_app().get("/api/thing/7", params={"q": "something private"})
(line,) = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert "query" not in line.attrs
def test_the_admin_level_applies_to_the_running_process_when_set_and_when_reset(
gated, db, make_user, as_user
):
admin = as_user(make_user(role="admin"))
r = admin.patch("/api/admin/config/log_level", json={"value": "DEBUG"})
assert r.status_code == 200, r.text
assert logs.current_level() == "DEBUG"
assert admin.delete("/api/admin/config/log_level").status_code == 200
assert logs.current_level() == settings.log_level
def test_erasing_the_only_elevated_account_puts_the_process_level_back(gated):
"""The root level is the lowest of the base and every unexpired elevation. `forget_user` drops
an elevation like any other path that drops one, so it owes the same recompute — or the process
keeps building every TRACE record for an account that no longer exists, to throw them away."""
until = datetime.now(timezone.utc) + timedelta(hours=1)
try:
logs.set_user(7, "TRACE", until)
assert logging.getLogger().level == logs.TRACE
logs.forget_user(7)
assert logging.getLogger().level == logging.INFO
finally:
# `set_user` writes the request scope, and outside a request that scope is this process's
# for good — a later test would store its INFO lines as an elevated session's.
logs.set_user(None)
def test_the_stored_admin_level_is_applied_at_boot(gated, db, monkeypatch):
import asyncio
import app.main as app_main
sysconfig.set_value(db, "log_level", "DEBUG")
db.commit()
monkeypatch.setattr(app_main.state, "is_configured", lambda db: True)
monkeypatch.setattr(app_main, "start_scheduler", lambda: None)
monkeypatch.setattr(app_main, "shutdown_scheduler", lambda: None)
monkeypatch.setattr(app_main.plex_subtitles, "shutdown", lambda: None)
async def _no_sweep():
return None
monkeypatch.setattr(app_main, "_sweep_hls_orphans", _no_sweep)
levels: list[str] = []
async def _cycle():
async with app_main.lifespan(app_main.app):
levels.append(logs.current_level())
asyncio.run(_cycle())
assert levels == ["DEBUG"]
def test_the_root_level_is_applied_in_the_order_the_elevations_changed(gated, monkeypatch):
"""Two requests change the elevations at once: A's user leaves (the root may go back to INFO),
B's user arrives (it must go to DEBUG). B's change is the later one, so DEBUG must be what stays
— even when A is paused between working out its level and applying it."""
root = logging.getLogger()
logs._track_elevation(1, logging.DEBUG, datetime.now(timezone.utc) + timedelta(hours=1))
real_set = root.setLevel
other: list[threading.Thread] = []
def paused_set(level):
if level == logging.INFO and not other:
b = threading.Thread(
target=logs._track_elevation,
args=(2, logging.DEBUG, datetime.now(timezone.utc) + timedelta(hours=1)),
)
other.append(b)
b.start()
b.join(0.3) # B runs to completion here unless A's level change holds it off
real_set(level)
monkeypatch.setattr(root, "setLevel", paused_set)
logs._track_elevation(1, None, None) # A
other[0].join(5)
assert root.level == logging.DEBUG and 2 in logs._elevations
def test_a_real_sign_in_carries_the_consented_level_into_the_process(gated, client, db, make_user):
user = _signed_in(client, make_user)
assert client.put("/api/me/diagnostics", json={"level": "DEBUG"}).status_code == 200
logs._elevations.clear() # only a request resolving the account through auth may put it back
client.get("/api/me")
assert logs._elevations[user.id][0] == logging.DEBUG
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
assert user.id not in logs._elevations
+51 -3
View File
@@ -39,13 +39,23 @@ class TestHostKeyForUrl:
assert credentials.host_key_for_url("https://www.facebook.com/watch?v=1") == "facebook"
assert credentials.host_key_for_url("https://fb.watch/abc/") == "facebook"
assert credentials.host_key_for_url("https://x.com/i/status/1") == "twitter"
# YouTube gates individual videos behind its bot check; the cookie is the only way past it.
for url in (
"https://www.youtube.com/watch?v=abc",
"https://youtu.be/abc",
"https://music.youtube.com/watch?v=abc",
"https://www.youtube-nocookie.com/embed/abc",
):
assert credentials.host_key_for_url(url) == "youtube", url
def test_look_alike_host_never_matches(self):
assert credentials.host_key_for_url("https://myfacebook.com/x") is None
assert credentials.host_key_for_url("https://facebook.com.evil.test/x") is None
assert credentials.host_key_for_url("https://notyoutube.com/watch?v=abc") is None
assert credentials.host_key_for_url("https://youtube.com.evil.test/x") is None
def test_unrelated_or_empty_url_has_no_host_key(self):
assert credentials.host_key_for_url("https://www.youtube.com/watch?v=abc") is None
assert credentials.host_key_for_url("https://vimeo.com/123456") is None
assert credentials.host_key_for_url("") is None
assert credentials.host_key_for_url(None) is None
@@ -72,6 +82,21 @@ def test_authed_sig_differs_per_user_and_from_the_shared_sig():
assert a != shared and b != shared and a != b
def test_an_authed_asset_queued_before_a_signature_version_bump_still_gets_its_cookie(monkeypatch):
# A job keeps the asset it was queued with. A deploy that bumps the signature version between the
# queueing and the download used to make the user's private asset stop counting as theirs, so the
# download went without its cookie and failed on the login wall.
spec = formats.normalize({"mode": "a"})
queued = MediaAsset(format_sig=credentials.authed_format_sig(spec, 1, "facebook"))
rebuild = credentials.rebuild_salt(7, credentials.cookie_salt(1, "facebook"))
rebuilt = MediaAsset(id=7, format_sig=formats.format_sig(spec, salt=rebuild))
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", formats._AUDIO_SIG_VERSION + 1) # the deploy
for asset in (queued, rebuilt):
assert credentials.is_authed_asset(asset, spec, 1, FB_URL)
assert not credentials.is_authed_asset(asset, spec, 2, FB_URL) # still private to its user
def test_write_cookiefile_is_private_and_survives_the_retry_wipe(tmp_path):
staging = tmp_path / "staging"
staging.mkdir()
@@ -160,6 +185,22 @@ class TestCachePrivacy:
asset = db.get(MediaAsset, job.asset_id)
assert asset.format_sig == formats.format_sig(formats.normalize({}))
def test_a_youtube_job_reaches_the_gate_through_its_rebuilt_watch_url(self, db):
# The one join nothing else makes. A YouTube job stores a bare 11-char ref, and the URL the
# cookie gate keys on is REBUILT by the adapter (service.source_url -> YouTubeAdapter). Both
# ends are pinned apart — the watch-URL shape in test_adapters, the host_key in
# TestHostKeyForUrl — so this welds them: an enqueue that ever handed the gate the raw ref
# would silently stop salting, every YouTube cookie would quietly stop being attached, and
# signed-out downloads would keep working, so nothing else here would go red.
u = _user(db)
credentials.upsert_credential(db, u.id, "youtube", COOKIES)
db.commit()
job = service.enqueue(db, u.id, "youtube", "VIDID12345_", {})
asset = db.get(MediaAsset, job.asset_id)
spec = formats.normalize({})
assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "youtube")
assert asset.format_sig != formats.format_sig(spec)
def test_two_users_never_share_the_authed_asset(self, db):
a = _user(db, "a@example.com")
b = _user(db, "b@example.com")
@@ -214,9 +255,16 @@ class TestResolveCookiefile:
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
db.commit()
spec = formats.normalize({})
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
yt = "https://www.youtube.com/watch?v=abc"
# A URL of ANOTHER host (supported or not) never gets this credential's cookie. YouTube is
# a supported host in its own right now, so the case needs a host that is not.
other = "https://vimeo.com/123456"
salted = credentials.authed_format_sig(spec, u.id, "facebook")
assert (
credentials.resolve_cookiefile(db, u.id, other, self._asset_sig(salted), spec, tmp_path)
is None
)
# ...and a YouTube link gets nothing either: the stored credential is Facebook's.
yt = "https://www.youtube.com/watch?v=abc"
assert (
credentials.resolve_cookiefile(db, u.id, yt, self._asset_sig(salted), spec, tmp_path)
is None
+66
View File
@@ -84,6 +84,72 @@ _REEL = "https://www.facebook.com/reel/1736360027626346/?fs=e&fs=e"
_GROUP_POST = "https://www.facebook.com/groups/983554479435596/permalink/1755988225525547/?"
# Verbatim from prod job 273 (2026-09-15). YouTube writes the apostrophe as U+2019, which is why
# neither the rule nor the catalog entry may spell out "you’re" with a straight quote.
_YT_BOT_WALL = (
"ERROR: [youtube] 7JPmS_bWjhI: Sign in to confirm you’re not a bot. Use "
"--cookies-from-browser or --cookies for the authentication. See "
"https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually "
"pass cookies."
)
_YT = "https://www.youtube.com/watch?v=7JPmS_bWjhI"
def test_youtube_bot_wall_is_that_video_own_signed_out_gate():
# Measured on prod: all 12 player clients fail on this video while its neighbours download, the
# POT sidecar answers, and the newest yt-dlp fails identically — a cookie is the only way in.
assert classify(_YT_BOT_WALL) == "login_required"
for url in (
_YT,
"https://youtu.be/7JPmS_bWjhI",
"https://www.youtube-nocookie.com/embed/7JPmS_bWjhI",
"https://music.youtube.com/watch?v=7JPmS_bWjhI",
):
assert dlerrors.diagnose(_YT_BOT_WALL, url=url) == (
"login_required",
"youtube.bot_wall",
), url
def test_youtube_bot_wall_with_a_cookie_is_the_cookie_refused():
# Telling a user who already gave a cookie to add one is what this entry exists to avoid.
assert dlerrors.diagnose(_YT_BOT_WALL, url=_YT, cookie_attached=True) == (
"login_required",
"youtube.cookie_rejected",
)
# Verbatim from the isolated test stack (2026-09-16), the SAME video ~90 minutes later: yt-dlp got
# the player response and picked formats, and the CDN then refused the stream.
_YT_MEDIA_403 = "ERROR: unable to download video data: HTTP Error 403: Forbidden"
def test_youtube_media_403_is_the_wall_one_step_later_not_a_network_fault():
# Generically this text is a network failure — and for every other host it stays one.
assert classify(_YT_MEDIA_403) == "network"
assert dlerrors.diagnose(_YT_MEDIA_403, url="https://vimeo.com/1") == ("network", None)
# On a signed-out YouTube download it is the gate: measured, the same video downloaded in full
# once the owner's cookie was attached. Calling that "network error" is the honest-states bug.
assert dlerrors.diagnose(_YT_MEDIA_403, url=_YT) == (
"login_required",
"youtube.media_forbidden",
)
def test_youtube_media_403_with_a_cookie_is_not_claimed_as_the_gate():
# Only the cookie-less case was measured. With a cookie the same text keeps its bare category
# rather than telling a signed-in user to sign in.
assert dlerrors.diagnose(_YT_MEDIA_403, url=_YT, cookie_attached=True) == ("network", None)
def test_the_youtube_entries_never_match_another_site():
# Host-scoped: the same words about a Facebook link stay the bare category.
assert dlerrors.diagnose(
"ERROR: [facebook] 1: Sign in to confirm you’re not a bot",
url="https://www.facebook.com/reel/1/",
) == ("login_required", None)
def test_facebook_cannot_parse_data_is_the_login_wall_without_a_cookie():
for url in (
_REEL,
@@ -314,3 +314,131 @@ def test_0084_backfills_only_the_facebook_parse_failures_it_measured(db):
finally:
if not upgraded:
command.upgrade(cfg, "head")
def _ffmpeg_media(path, *codec_args):
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440"]
+ list(codec_args)
+ ["-t", "2", str(path)],
check=True,
)
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_signed_in_facebook_download_is_titled_with_its_posts_text(db, monkeypatch, tmp_path):
# Prod jobs 265–268 (2026-09-14): with a cookie yt-dlp titles every reel "Video", so the row, the
# file name and the share preview all said "Video". The post's text is in the description.
u = _user(db, "fb-title@example.com")
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
media = staging / "2131884261040652.m4a"
_ffmpeg_media(media, "-c:a", "aac")
return {
"id": "2131884261040652",
"ext": "m4a",
"title": "Video",
"description": "Tune of The Day, Argy playing his version",
"extractor_key": "Facebook",
"filepath": str(media),
}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
pipeline._download(j.id, a.id, "url", _REEL, {"mode": "a"}, u.id)
db.expire_all()
asset, job = db.get(MediaAsset, a.id), db.get(DownloadJob, j.id)
assert asset.title == job.display_name == "Tune of The Day, Argy playing his version"
assert "Tune_of_The_Day" in asset.rel_path
def test_the_early_title_takes_the_posts_text_too(db):
u = _user(db, "fb-early-title@example.com")
a, _ = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
a.title = None
db.commit()
pipeline._fill_asset_meta_early(
a.id,
{
"id": "1",
"title": "Video",
"description": "Almás palacsinta",
"extractor_key": "Facebook",
},
)
db.expire_all()
assert db.get(MediaAsset, a.id).title == "Almás palacsinta"
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_an_mp3_download_is_stored_as_an_mp3(db, monkeypatch, tmp_path):
# Prod job 268: the "Audio (MP3)" profile's extracted .mp3 was remuxed into an .mp4 and served as
# video/mp4, which iPad's WebKit will not play. It stays an .mp3 (served as audio/mpeg).
u = _user(db, "fb-mp3@example.com")
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
media = staging / "2131884261040652.mp3"
_ffmpeg_media(media, "-c:a", "libmp3lame")
return {"id": "2131884261040652", "ext": "mp3", "title": "Reel", "filepath": str(media)}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
pipeline._download(
j.id, a.id, "url", _REEL, formats.normalize({"mode": "a", "audio_format": "mp3"}), u.id
)
db.expire_all()
asset = db.get(MediaAsset, a.id)
assert (asset.container, asset.acodec, asset.media_kind) == ("mp3", "mp3", "audio")
assert asset.rel_path.endswith(".mp3")
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
@pytest.mark.parametrize("mode", ["av", "v"])
def test_a_video_download_is_still_made_browser_playable(db, monkeypatch, tmp_path, mode):
# The mp3 fix skips the browser-playable pass for AUDIO only: a video that arrives in a container
# browsers won't play (an .mkv) is still remuxed into an .mp4.
u = _user(db, f"fb-mkv-{mode}@example.com")
a, j = _asset_and_job(
db, u, sig=f"mkv-{mode}", asset_status="downloading", job_status="running"
)
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
media = staging / "1736360027626346.mkv"
audio = ["-f", "lavfi", "-i", "sine=frequency=440", "-c:a", "aac"] if mode == "av" else []
subprocess.run(
[
"ffmpeg",
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=size=64x64:rate=10",
]
+ audio
+ ["-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(media)],
check=True,
)
return {"id": "1736360027626346", "ext": "mkv", "title": "Reel", "filepath": str(media)}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
pipeline._download(j.id, a.id, "url", _REEL, formats.normalize({"mode": mode}), u.id)
db.expire_all()
asset = db.get(MediaAsset, a.id)
assert (asset.container, asset.vcodec) == ("mp4", "h264")
assert asset.rel_path.endswith(".mp4")
+63
View File
@@ -0,0 +1,63 @@
"""The scheduled RSS poll and recent backfill cover FOLLOWED channels only (owner decision, 2026-09-17).
A channel row also comes from a search result, a playlist item or a subscription somebody dropped,
and nothing deletes those rows. Polling all of them was 582 of 979 channels on prod with no follower,
three of them dead feeds answering 404 every 20 minutes. Followed = subscribed by someone, or explored
by someone. The admin card counting "channels to sync" must use the same selection, or it counts
channels the backfill will never touch. DB lane.
"""
from app.models import Channel, ExploredChannel, Subscription
from app.sync import runner
def _channels(db, make_user):
user = make_user()
for cid in ("UC_sub", "UC_explored", "UC_search", "UC_playlist"):
db.add(Channel(id=cid, title=cid, from_search=cid == "UC_search"))
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UC_sub"))
db.add(ExploredChannel(user_id=user.id, channel_id="UC_explored"))
db.commit()
return user
def test_the_rss_poll_skips_channels_nobody_follows(db, make_user, monkeypatch):
_channels(db, make_user)
polled: list[str] = []
monkeypatch.setattr(runner, "fetch_channel_feed", lambda cid: polled.append(cid) or [])
monkeypatch.setattr(runner, "apply_rss_feed", lambda db, ch, entries: 0)
assert runner.run_rss_poll(db) == {"new": 0, "failed": 0}
assert sorted(polled) == ["UC_explored", "UC_sub"]
def test_the_recent_backfill_skips_channels_nobody_follows(db, make_user, monkeypatch):
user = _channels(db, make_user)
done: list[str] = []
class _Client:
def __init__(self, *a):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(runner, "get_service_user", lambda db: user)
monkeypatch.setattr(runner, "YouTubeClient", _Client)
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: False)
monkeypatch.setattr(
runner, "backfill_channel_recent", lambda db, yt, ch: done.append(ch.id) or 0
)
monkeypatch.setattr(runner, "reconcile_full_history", lambda db: 0)
runner.run_recent_backfill(db)
assert sorted(done) == ["UC_explored", "UC_sub"]
def test_the_dashboard_counts_the_channels_the_backfill_will_process(db, make_user, as_user):
_channels(db, make_user)
admin = make_user(role="admin")
queue = as_user(admin).get("/api/admin/scheduler").json()["queue"]
assert queue["channels_recent_pending"] == 2
+21
View File
@@ -158,3 +158,24 @@ def test_a_normal_cut_list_is_untouched():
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
out = editmod.normalize_edit_spec({"segments": segs})
assert len(out["segments"]) == 2
def test_audio_versions_its_cache_identity_apart_from_video(monkeypatch):
# The .mp3/.m4a-stays-itself fix must re-derive cached remuxed audio WITHOUT re-downloading every
# cached video: bumping one version moves only its own mode's signature.
audio, video = {"mode": "a", "audio_format": "mp3"}, {"mode": "av"}
before = formats.format_sig(audio), formats.format_sig(video)
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", formats._AUDIO_SIG_VERSION + 1)
assert formats.format_sig(audio) != before[0]
assert formats.format_sig(video) == before[1]
monkeypatch.setattr(formats, "_SIG_VERSION", formats._SIG_VERSION + 1)
assert formats.format_sig(video) != before[1]
def test_the_mp3_fix_moved_the_audio_cache_identity(monkeypatch):
# Before the fix every mode hashed with version 4, so an audio signature still at 4 would find the
# cached mp3-remuxed-into-mp4 assets iPad refuses (prod job 268) instead of re-deriving them.
audio = {"mode": "a", "audio_format": "mp3"}
current = formats.format_sig(audio)
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
assert formats.format_sig(audio) != current
+97
View File
@@ -0,0 +1,97 @@
"""`users.last_login_at` (E22 S1), DB-lane.
The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It
would not have: only password login called it, and the Google callback carried its own copy of the
same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp
(a refused password, the shared demo account) and the admin list that shows it.
"""
from datetime import datetime, timedelta, timezone
from app.config import settings
from app.models import DemoWhitelist
from app.routes import auth as auth_routes
_PW = "correct horse battery"
def _recent(ts: datetime | None) -> bool:
return ts is not None and datetime.now(timezone.utc) - ts < timedelta(minutes=1)
def test_a_password_sign_in_stamps_last_login(client, db, make_user):
user = make_user(email="pw@example.com", password=_PW)
assert user.last_login_at is None
r = client.post("/auth/password-login", json={"email": "pw@example.com", "password": _PW})
assert r.status_code == 200
db.refresh(user)
assert _recent(user.last_login_at)
def test_a_refused_password_does_not_stamp(client, db, make_user):
user = make_user(email="wrong@example.com", password=_PW)
r = client.post(
"/auth/password-login", json={"email": "wrong@example.com", "password": "not it"}
)
assert r.status_code == 401
db.refresh(user)
assert user.last_login_at is None
class _FakeGoogle:
"""authlib's client, reduced to the one call the callback makes after Google redirects back."""
def __init__(self, userinfo: dict):
self._userinfo = userinfo
async def authorize_access_token(self, _request):
return {"userinfo": self._userinfo, "access_token": "at", "scope": "openid email profile"}
def test_a_google_sign_in_stamps_last_login(client, db, make_user, monkeypatch):
from cryptography.fernet import Fernet
from app import security
# The callback stores the OAuth grant encrypted; a throwaway key stands in for TOKEN_ENCRYPTION_KEY.
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
user = make_user(email="g@example.com", password=None, google_sub="sub-g")
monkeypatch.setattr(settings, "admin_emails", "g@example.com") # the bootstrap allow-list
fake = _FakeGoogle({"sub": "sub-g", "email": "g@example.com", "email_verified": True})
monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: fake)
r = client.get("/auth/callback", follow_redirects=False)
assert r.status_code in (302, 307) and r.headers["location"] == "/"
db.refresh(user)
assert _recent(user.last_login_at)
# …and it is a real sign-in, not only a stamp: the session now answers as this account.
assert client.get("/api/me").json()["id"] == user.id
def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
db.add(DemoWhitelist(email="visitor@example.com"))
db.commit()
r = client.post("/auth/demo", json={"email": "visitor@example.com"})
assert r.json() == {"authenticated": True}
demo = auth_routes.get_or_create_demo_user(db)
db.refresh(demo)
assert demo.last_login_at is None
def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin")
never = make_user(email="never@example.com")
admin.last_login_at = datetime(2026, 9, 16, 8, 30, tzinfo=timezone.utc)
db.commit()
rows = {u["email"]: u for u in as_user(admin).get("/api/admin/users").json()}
assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
assert rows[never.email]["last_login_at"] is None
+320
View File
@@ -0,0 +1,320 @@
"""E22 S2 — the one logging configuration, its redaction, and the request line that replaced
uvicorn's access log.
Redaction is the part worth guarding hardest: it is applied at the HANDLER, so these cases are the
only thing standing between a future call site and a token in a log we attach to a Gitea issue.
The request-line cases pin what uvicorn's access log got wrong — the query string (`?account=1`,
`?token=…`) was printed on every request, and nothing correlated two lines of the same request.
Pure logic + an in-process ASGI app: no database.
"""
import json
import logging
from pathlib import Path
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from app import logs
from app.config import LogLevel, choices_of
class _Sink(logging.Handler):
"""Captures records AND their formatted JSON, with the same filter/formatter the real stdout
handler uses — so a test sees exactly the bytes a deployment would write."""
def __init__(self):
super().__init__(level=logging.NOTSET)
self.lines: list[str] = []
self.records: list[logging.LogRecord] = []
self.setFormatter(logs.JsonFormatter())
self.addFilter(logs.ContextFilter())
def emit(self, record):
self.lines.append(self.format(record))
self.records.append(record)
def objects(self) -> list[dict]:
return [json.loads(line) for line in self.lines]
@pytest.fixture
def sink():
"""Attached to `siftlode` (not root) so a `configure_logging` call inside a test — which
replaces the ROOT handlers — can't silently detach it mid-test."""
logger = logging.getLogger("siftlode")
handler = _Sink()
previous = logger.level
logger.addHandler(handler)
logger.setLevel(logs.TRACE)
try:
yield handler
finally:
logger.removeHandler(handler)
logger.setLevel(previous)
# --- redaction ------------------------------------------------------------------------------
@pytest.mark.parametrize(
"text, must_not_contain",
[
("user signed in: someone@example.com", "someone@example.com"),
("callback https://app/auth/callback?code=4/0AY&state=xyz", "4/0AY"),
("mail link https://app/#verify=abc123def", "abc123def"),
("share https://app/s?token=SHARETOKEN", "SHARETOKEN"),
("reset https://app/#reset=RESETTOKEN", "RESETTOKEN"),
],
)
def test_free_text_secrets_never_reach_the_line(text, must_not_contain):
assert must_not_contain not in logs.redact_text(text)
def test_a_key_that_names_a_secret_is_dropped_whole():
# Not truncated to a prefix: a prefix is still a leak and is useless for triage anyway.
out = logs.redact(
{
"password": "hunter2",
"current_password": "hunter2",
"plex_server_token": "abc",
"Cookie": "session=xyz",
"authorization": "Bearer abc",
"nested": {"refresh_token": "rt", "keep": "visible"},
"list": [{"api_key": "k"}],
"count": 7,
}
)
assert out["password"] == logs.REDACTED
assert out["current_password"] == logs.REDACTED
assert out["plex_server_token"] == logs.REDACTED
assert out["Cookie"] == logs.REDACTED
assert out["authorization"] == logs.REDACTED
assert out["nested"]["refresh_token"] == logs.REDACTED
assert out["nested"]["keep"] == "visible" # only the named keys go
assert out["list"][0]["api_key"] == logs.REDACTED
assert out["count"] == 7
def test_an_email_in_an_attribute_value_is_scrubbed_too():
assert logs.redact({"to": "a.b+tag@mail.example.org"})["to"] == logs.REDACTED_EMAIL
# The SAME cases frontend/src/lib/log.contract.test.ts runs against the browser's copy of redact():
# the one thing that keeps the two logs' idea of "safe to keep" from drifting apart.
_REDACTION_CASES = json.loads(
(Path(__file__).parent / "fixtures" / "log_redaction_cases.json").read_text(encoding="utf-8")
)["cases"]
@pytest.mark.parametrize("case", _REDACTION_CASES, ids=[c["name"] for c in _REDACTION_CASES])
def test_the_shared_redaction_cases(case):
assert logs.redact(case["value"], key=case["key"]) == case["expected"]
def test_redaction_runs_on_the_message_the_attrs_and_the_traceback(sink):
log = logging.getLogger("siftlode.test")
try:
raise RuntimeError("boom for someone@example.com")
except RuntimeError:
log.exception(
"failed for %s",
"someone@example.com",
extra=logs.event("test.failed", password="hunter2", url="https://x/?token=T0K3N"),
)
line = sink.lines[-1]
assert "someone@example.com" not in line
assert "hunter2" not in line
assert "T0K3N" not in line
payload = json.loads(line)
assert payload["attrs"]["password"] == logs.REDACTED
assert "RuntimeError" in payload["exc"] # the diagnosis survives the scrubbing
# --- event shape ----------------------------------------------------------------------------
def test_every_line_carries_the_agreed_fields(sink):
logging.getLogger("siftlode.test").info("hello", extra=logs.event("test.hello", n=1))
payload = sink.objects()[-1]
for field in ("ts", "level", "sev", "source", "logger", "msg", "event", "version"):
assert field in payload, field
assert payload["level"] == "INFO"
assert payload["sev"] == 9
assert payload["event"] == "test.hello"
assert payload["attrs"] == {"n": 1}
def test_a_plain_log_call_still_produces_a_well_formed_line(sink):
"""An event code is optional — 125 existing call sites have none yet (E22.5 adds them), and they
must not be worse off than before this module."""
logging.getLogger("siftlode.test").warning("just a message")
payload = sink.objects()[-1]
assert payload["msg"] == "just a message"
assert payload["level"] == "WARNING"
assert "event" not in payload
def test_a_broken_format_call_reports_itself_instead_of_raising():
"""A record is formatted directly rather than logged: a genuinely broken call ALSO breaks
pytest's own capture handler (stdlib formatting raises there too), which would fail the test for
the wrong reason. What is under test is our formatter's refusal to raise."""
record = logging.LogRecord(
"siftlode.test", logging.INFO, __file__, 0, "two args %s %s", ("only-one",), None
)
payload = json.loads(logs.JsonFormatter().format(record))
assert "log formatting failed" in payload["msg"]
def test_bound_correlation_appears_and_is_released(sink):
log = logging.getLogger("siftlode.test")
with logs.bind(job_id=42):
log.info("inside")
log.info("outside")
assert sink.objects()[-2]["job_id"] == 42
assert "job_id" not in sink.objects()[-1]
def test_every_selectable_level_resolves_to_a_real_logging_level():
# The picker's options come from `LogLevel`; each must be a level `logging` knows, TRACE included.
assert [logs.resolve_level(n) for n in choices_of(LogLevel)] == [
logs.TRACE,
logging.DEBUG,
logging.INFO,
logging.WARNING,
logging.ERROR,
]
with pytest.raises(ValueError):
logs.resolve_level("nonsense")
def test_configure_logging_silences_the_uvicorn_access_log_and_keeps_ours_propagating():
logs.configure_logging("api")
assert logging.getLogger("uvicorn.access").getEffectiveLevel() >= logging.CRITICAL
# propagate stays on so every line reaches the ONE root handler — and so pytest's caplog works.
assert logging.getLogger("siftlode.anything").propagate is True
assert len(logging.getLogger().handlers) == 1
# --- the request line -----------------------------------------------------------------------
def _client() -> TestClient:
app = FastAPI()
app.middleware("http")(logs.log_requests)
@app.get("/api/videos/{video_id}")
def _video(video_id: str, q: str | None = None):
return {"ok": video_id}
@app.get("/healthz")
def _health():
return {"ok": True}
@app.get("/boom")
def _boom():
raise RuntimeError("kaboom")
return TestClient(app, raise_server_exceptions=False)
def test_the_request_line_has_the_route_template_and_no_query_string(sink):
logging.getLogger("siftlode.http").setLevel(logs.TRACE)
r = _client().get("/api/videos/abc123?q=cats&token=SECRET")
assert r.status_code == 200
payload = sink.objects()[-1]
assert payload["event"] == "http.request"
# The TEMPLATE, so every video request groups into one queryable path...
assert payload["attrs"]["path"] == "/api/videos/{video_id}"
# ...and the query string — uvicorn's access log printed it, `?account=1` and all — is gone.
assert "SECRET" not in sink.lines[-1]
assert "cats" not in sink.lines[-1]
assert payload["attrs"]["status"] == 200
assert isinstance(payload["attrs"]["duration_ms"], float)
def test_anonymous_request_lines_are_capped_per_client_for_storage_but_still_logged(sink):
# A scanner's 404s are WARNINGs, and WARNINGs are stored. Past the cap they stay on stdout but are
# marked not-for-storage; a signed-in request is never capped. No address appears in any line.
logs._anonymous_lines.reset()
client = _client()
for _ in range(logs.ANONYMOUS_LINES_PER_MINUTE + 3):
client.get("/wp-login.php")
lines = sink.objects()[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
assert len(lines) == logs.ANONYMOUS_LINES_PER_MINUTE + 3 # stdout keeps every one
records = sink.records[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
stored = [getattr(r, logs.PERSIST, True) for r in records]
assert stored.count(False) == 3 and all(stored[: logs.ANONYMOUS_LINES_PER_MINUTE])
assert "testclient" not in "".join(sink.lines) # the client key never reaches the line
logs._anonymous_lines.reset()
def test_a_signed_in_request_is_never_capped_and_names_its_account(sink):
logs._anonymous_lines.reset()
app = FastAPI()
app.middleware("http")(logs.log_requests)
@app.get("/api/me")
def _me(request: Request):
request.state.user_id = 42 # what the auth dependency does
return {}
client = TestClient(app)
for _ in range(logs.ANONYMOUS_LINES_PER_MINUTE + 3):
client.get("/api/me")
records = sink.records[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
assert all(getattr(r, logs.PERSIST, True) for r in records)
assert sink.objects()[-1]["user_id"] == 42 and "user_id" not in sink.objects()[-1]["attrs"]
def test_the_request_id_is_echoed_and_shared_by_every_line_of_that_request(sink):
client = _client()
r = client.get("/api/videos/abc123")
rid = r.headers["X-Request-Id"]
assert rid and sink.objects()[-1]["request_id"] == rid
def test_the_route_template_goes_back_to_the_browser_but_never_a_raw_path(sink):
# The browser logs its own copy of each request, grouped by this template (frontend lib/http.ts).
client = _client()
assert client.get("/api/videos/SeCrEt-VaLuE").headers["X-Route"] == "/api/videos/{video_id}"
# Unmatched: the only template there is the raw path, which may carry a value — so none is sent.
assert "X-Route" not in client.get("/api/share/SeCrEt-VaLuE").headers
def test_a_client_supplied_id_is_reused_only_when_it_is_well_formed(sink):
client = _client()
good = client.get("/api/videos/x", headers={"X-Request-Id": "web-0123456789ab"})
assert good.headers["X-Request-Id"] == "web-0123456789ab"
# An unbounded header would be a way to write arbitrary text into the log; short/odd ones are
# replaced with our own id rather than trusted.
for bogus in ("short", "has spaces and \n newline", "x" * 200):
r = client.get("/api/videos/x", headers={"X-Request-Id": bogus})
assert r.headers["X-Request-Id"] != bogus
def test_the_healthcheck_is_logged_at_debug_so_it_cannot_drown_the_log(sink):
http = logging.getLogger("siftlode.http")
http.setLevel(logs.TRACE)
_client().get("/healthz")
assert sink.objects()[-1]["level"] == "DEBUG"
# ...but it is still logged when something is wrong with it: the floor drops, nothing is hidden.
http.setLevel(logging.INFO)
before = len(sink.lines)
_client().get("/healthz")
assert len(sink.lines) == before
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
client = _client()
client.get("/api/videos/missing/deeper") # 404 from the router
assert sink.objects()[-1]["level"] == "WARNING"
client.get("/boom")
payload = sink.objects()[-1]
# The request that most needs a line is the one that raised: uvicorn's access log wrote it, but
# our middleware sees the exception instead of a response, so it has to log it explicitly.
assert payload["level"] == "ERROR"
assert payload["event"] == "http.request"
assert "RuntimeError" in payload["exc"]
+260
View File
@@ -0,0 +1,260 @@
"""E22 S3 — the `log_events` sink: what reaches the table, in what shape, and what it refuses to do.
The sink is driven synchronously (`write_pending()` on the test's own thread) so nothing here waits on
a timer. It is attached to a private logger rather than the root, for the same reason test_logs.py's
capture handler is: a `configure_logging` call elsewhere replaces the root handlers.
DB lane (`db` fixture): the rows are read back from a real Postgres.
"""
import logging
import threading
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import BackgroundTasks
from sqlalchemy import create_engine, func, select
from app import logs, logsink, scheduler
from app.auth import purge_user
from app.db import engine
from app.models import LogEvent
@pytest.fixture
def stored(db):
"""A LogSink on the logger `siftlode.sinktest`, and a reader for the rows it wrote."""
sink = logsink.LogSink(engine)
logger = logging.getLogger("siftlode.sinktest")
logger.addHandler(sink)
logger.setLevel(logs.TRACE)
def rows() -> list[LogEvent]:
sink.write_pending()
db.expire_all()
return list(db.execute(select(LogEvent).order_by(LogEvent.id)).scalars())
try:
yield sink, logger, rows
finally:
logger.removeHandler(sink)
def test_warnings_and_request_lines_are_stored_and_everything_else_is_not(stored):
_sink, log, rows = stored
log.info("an ordinary info line")
log.debug("GET /healthz 200", extra=logs.event("http.request", path="/healthz", status=200))
log.info("GET /api/feed 200", extra=logs.event("http.request", path="/api/feed", status=200))
log.warning("scanner", extra={**logs.event("http.request", status=404), logs.PERSIST: False})
log.warning("disk nearly full")
assert [r.msg for r in rows()] == ["GET /api/feed 200", "disk nearly full"]
def test_a_row_is_redacted_exactly_like_the_stdout_line(stored):
_sink, log, rows = stored
try:
raise ValueError("reset link https://x.test/#reset=abc123")
except ValueError:
log.exception(
"sign-in failed for someone@example.com",
extra=logs.event("auth.failed", password="hunter2", reason="bad_password"),
)
(row,) = rows()
assert row.level == "ERROR" and row.sev == logs.SEVERITY["ERROR"]
assert row.event == "auth.failed"
assert "someone@example.com" not in row.msg and logs.REDACTED_EMAIL in row.msg
assert row.attrs == {"password": logs.REDACTED, "reason": "bad_password"}
assert "abc123" not in row.exc and "ValueError" in row.exc
assert row.source == logs.current_source() and row.version
def test_correlation_is_captured_when_the_line_is_logged_not_when_it_is_written(stored):
_sink, log, rows = stored
with logs.bind(job_id=7, request_id="req-0123456789"):
log.warning("inside the job")
# The writer runs later, outside the bind — the row still belongs to job 7.
(row,) = rows()
assert (row.job_id, row.request_id) == (7, "req-0123456789")
def test_a_flood_of_one_message_is_stored_up_to_the_limit_and_then_summarised(stored):
sink, log, rows = stored
limit = logsink.REPEATS_PER_MINUTE
for i in range(limit + 70):
log.warning("RSS poll skipped for channel %s: HTTP 404", f"UC{i}")
log.warning("a different message")
assert len(rows()) == limit + 1 # the flood capped; the other message untouched
sink._repeats.started -= logsink.REPEAT_WINDOW_SECONDS # the minute is over
notice = rows()[-1]
assert notice.event == "log.repeats_suppressed"
assert notice.attrs["suppressed"] == 70
assert notice.attrs["template"] == "RSS poll skipped for channel %s: HTTP 404"
def test_request_lines_are_never_treated_as_a_flood(stored):
_sink, log, rows = stored
for _ in range(logsink.REPEATS_PER_MINUTE + 5):
log.info("%s %s %s", "GET", "/api/feed", 200, extra=logs.event("http.request"))
assert len(rows()) == logsink.REPEATS_PER_MINUTE + 5
def test_a_full_queue_drops_and_says_how_many(db, monkeypatch):
monkeypatch.setattr(logsink, "QUEUE_MAX", 3)
sink = logsink.LogSink(engine)
log = logging.getLogger("siftlode.sinktest.small")
log.addHandler(sink)
try:
for i in range(5):
log.warning("message number %d", i)
finally:
log.removeHandler(sink)
assert sink.write_pending() == 4
notice = db.execute(select(LogEvent).where(LogEvent.event == "log.sink.dropped")).scalar_one()
assert notice.attrs == {"dropped": 2}
def test_an_unreachable_database_costs_the_batch_not_the_caller(capsys):
dead = create_engine(
"postgresql+psycopg://x:y@127.0.0.1:1/none", connect_args={"connect_timeout": 1}
)
sink = logsink.LogSink(dead)
log = logging.getLogger("siftlode.sinktest.dead")
log.addHandler(sink)
try:
log.error("this has nowhere to go") # must not raise
finally:
log.removeHandler(sink)
assert sink.write_pending() == 0
assert "could not store 1 log events" in capsys.readouterr().err
def test_the_writer_thread_does_not_store_its_own_records(stored):
sink, log, rows = stored
sink._writer = threading.current_thread() # as if this line came from inside the writer
log.error("a driver warning raised while inserting")
sink._writer = None
assert rows() == []
def test_gc_keeps_the_retention_window_and_never_keeps_forever(db):
now = datetime.now(timezone.utc)
def add(age: timedelta) -> None:
db.add(
LogEvent(ts=now - age, level="WARNING", sev=13, source="api", logger="t", msg=str(age))
)
for age in (timedelta(hours=1), timedelta(days=2), timedelta(days=8)):
add(age)
db.commit()
assert logsink.gc(db, 7) == 1
assert logsink.gc(db, 0) == 1 # 0 is not "forever": stored logs always expire (floor: 1 day)
db.commit()
assert [r.msg for r in db.execute(select(LogEvent)).scalars()] == [str(timedelta(hours=1))]
def test_log_gc_is_a_scheduled_job():
assert "log_gc" in scheduler.JOB_FUNCS and scheduler.JOB_INTERVALS["log_gc"] >= 1
def test_deleting_an_account_deletes_its_stored_log_events(db, make_user):
gone, kept = make_user(), make_user()
for uid in (gone.id, kept.id, gone.id):
db.add(
LogEvent(
ts=datetime.now(timezone.utc),
level="INFO",
sev=9,
source="api",
logger="t",
msg="x",
user_id=uid,
)
)
db.commit()
purge_user(db, gone, BackgroundTasks())
assert [r.user_id for r in db.execute(select(LogEvent)).scalars()] == [kept.id]
def test_an_erased_accounts_late_lines_are_stored_without_it(stored, db, make_user):
"""The DELETE in purge_user cannot reach a row still on its way: one queued before the erasure,
or the erasing request's own access line, logged after the handler committed."""
_sink, log, rows = stored
user = make_user()
# `logs._erased` outlives the test; conftest's `_reset_log_erasures` clears it before each one.
log.warning("queued before the erasure", extra={"user_id": user.id})
purge_user(db, user, BackgroundTasks())
log.info("DELETE /api/me/account 200", extra={**logs.event("http.request"), "user_id": user.id})
assert [(r.msg, r.user_id) for r in rows()] == [
("queued before the erasure", None),
("DELETE /api/me/account 200", None),
]
def test_every_path_into_the_table_drops_an_erased_account():
"""The two behavioural tests above cover the two writers there are today (this sink and
`POST /api/diagnostics/events`). This one covers the THIRD one nobody has written yet: a new
`insert(LogEvent)` that forgets the filter would leave an erased account's rows named, and no
test of an existing path can notice. Source-level on purpose — it is a rule about call sites."""
import pathlib
root = pathlib.Path(__file__).resolve().parents[1] / "app"
unfiltered = [
f"{path.relative_to(root)}:{n}"
for path in root.rglob("*.py")
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if "insert(LogEvent)" in line and "without_erased" not in line
]
assert unfiltered == [], f"insert(LogEvent) without logsink.without_erased at {unfiltered}"
def test_a_nul_character_costs_nothing_but_itself(stored):
"""Postgres refuses NUL in text and JSONB, and a refused row fails its whole batch — every other
user's rows with it. `GET /assets/%00` logs exactly that."""
_sink, log, rows = stored
log.warning("before")
log.warning("GET /assets/\x00 404", extra=logs.event("http.request", path="/assets/\x00"))
log.warning("after")
before, nul, after = rows()
assert (before.msg, after.msg) == ("before", "after")
assert nul.msg == "GET /assets/� 404" and nul.attrs["path"] == "/assets/�"
def test_the_writer_drains_what_was_queued_when_it_is_stopped(db):
sink = logsink.LogSink(engine)
log = logging.getLogger("siftlode.sinktest.drain")
log.addHandler(sink)
try:
for i in range(logsink.BATCH_MAX + 5):
log.warning(f"line {i}") # distinct templates: no flood limiter involved
finally:
log.removeHandler(sink)
sink._stopping.set()
sink._run() # stopped before its first wait: everything goes through the drain
assert (
db.execute(select(func.count()).select_from(LogEvent)).scalar_one() == logsink.BATCH_MAX + 5
)
def test_the_running_sink_keeps_the_base_level_for_everyone_the_elevation_is_not_for(
db, monkeypatch
):
"""`logsink.start()` as production runs it. One user's elevation lowers the ROOT logger, so an
ordinary request line below the base level reaches every handler — the sink's own level gate is
what keeps it out of the table, as stdout's keeps it off the terminal."""
root = logging.getLogger()
saved = (logs._base_level, dict(logs._elevations), logs._root_applied, root.level)
monkeypatch.setattr(logsink.settings, "log_db_sink", True)
logs.set_level("WARNING")
logs._track_elevation(1, logging.DEBUG, datetime.now(timezone.utc) + timedelta(hours=1))
log = logging.getLogger("siftlode.sinktest.live")
try:
logsink.start()
log.info("GET /api/feed 200", extra={**logs.event("http.request"), "user_id": 2})
log.warning("a warning")
finally:
logsink.stop()
logs._base_level, logs._root_applied = saved[0], saved[2]
logs._elevations.clear()
logs._elevations.update(saved[1])
root.setLevel(saved[3])
assert [r.msg for r in db.execute(select(LogEvent)).scalars()] == ["a warning"]
+80
View File
@@ -0,0 +1,80 @@
"""The password unlock of a shared /watch link (routes/public.py), DB-lane.
Guesses are slowed per viewer (the link AND the client — an IPv6 client by its /64), under a per-link
ceiling on wrong passwords, so a client guessing wrong shuts out only itself until it spreads its guesses
over as many networks as the ceiling allows.
"""
import pytest
from fastapi.testclient import TestClient
from app.config import settings
from app.downloads import probe as probemod
from app.main import app
from app.ratelimit import RateLimiter
from app.routes import public
_PW = "open sesame"
@pytest.fixture
def unlock_url(as_user, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload", files={"file": ("clip.mp4", b"fake-video", "video/mp4")}
)
assert job.status_code == 200, job.text
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
assert link.status_code == 200, link.text
return f"/api/public/watch/{link.json()['token']}/unlock"
def _from(ip: str) -> TestClient:
# Its own socket peer: with no trusted proxy configured, that is the client's IP.
return TestClient(app, client=(ip, 50000))
def test_a_link_holder_without_the_password_cannot_lock_out_its_viewers(unlock_url):
# The limit used to be keyed on the link alone: ten wrong guesses every five minutes kept every
# viewer out, the ones with the password included.
guesser = _from("203.0.113.5")
for _ in range(10):
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 403
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 429
assert _from("198.51.100.7").post(unlock_url, json={"password": _PW}).status_code == 200
def test_an_ipv6_client_is_one_viewer_across_its_64(unlock_url):
# One subscriber gets a whole /64: keyed on the full address, it could rotate through endless viewers.
for _ in range(10):
wrong = _from("2001:db8:1:2::5").post(unlock_url, json={"password": "guess"})
assert wrong.status_code == 403
assert _from("2001:db8:1:2::6").post(unlock_url, json={"password": _PW}).status_code == 429
assert _from("2001:db8:1:3::5").post(unlock_url, json={"password": _PW}).status_code == 200
def test_a_token_naming_no_link_leaves_nothing_in_the_limiter(unlock_url):
# The limiter used to record the attempt before looking the link up: posting made-up tokens grew it by
# an entry each, kept for the whole window.
keys = len(public._unlock_limit._hits)
for n in range(5):
missing = _from("203.0.113.5").post(unlock_url.replace("/watch/", f"/watch/x{n}"), json={})
assert missing.status_code == 404
assert len(public._unlock_limit._hits) == keys
def test_wrong_passwords_from_many_clients_still_reach_the_links_ceiling(unlock_url, monkeypatch):
monkeypatch.setattr(public, "_unlock_failures", RateLimiter(max_events=3, window_seconds=300))
viewer = _from("198.51.100.7")
for _ in range(5): # unlocking with the password never adds to the ceiling
assert viewer.post(unlock_url, json={"password": _PW}).status_code == 200
for n in range(3):
wrong = _from(f"203.0.113.{n}").post(unlock_url, json={"password": "guess"})
assert wrong.status_code == 403
late = _from("203.0.113.9").post(unlock_url, json={"password": "guess"})
assert late.status_code == 429
+13
View File
@@ -57,6 +57,19 @@ def test_reset_clears_every_key():
assert lim.allow("b") is True
def test_exhausted_reports_a_full_window_without_counting_an_attempt():
# The asking half of a limit that counts only some outcomes (a share link's unlock counts only
# wrong passwords toward its ceiling): asking must never use up the window it asks about.
lim = RateLimiter(max_events=2, window_seconds=300)
for _ in range(5):
assert lim.exhausted("link") is False
assert lim.allow("link") is True
assert lim.exhausted("link") is False
assert lim.allow("link") is True
assert lim.exhausted("link") is True
def test_expired_attempts_fall_out_of_the_window():
# A zero-length window means every previous attempt is already outside it.
lim = RateLimiter(max_events=1, window_seconds=0)
+53 -3
View File
@@ -1,6 +1,8 @@
"""Guards that stop one bad YouTube answer from cascading: the subscription prune guard, the
playlist-push circuit breaker, and RSS telling a failure apart from an empty feed."""
import logging
import httpx
import pytest
@@ -120,7 +122,7 @@ class TestRssPollResult:
# One dead channel among healthy ones: counted, but the run itself is still a run.
def fetch(cid):
if cid == "UC_dead":
raise rss.RssError("404")
raise rss.RssError("404", "http_404")
return [{"id": "v"}]
out = self._run(
@@ -143,6 +145,54 @@ class TestRssPollResult:
)
assert out == {"new": 1, "failed": 1}, "an unstored channel must not read as ok"
def test_many_failures_write_one_warning_with_counts_not_one_per_channel(
self, monkeypatch, caplog
):
# YouTube's RSS outage of 2026-09-16: ~900 of 979 feeds failed on every 20-minute poll and
# each wrote its own WARNING — 11k lines in four hours, which the log_events table would
# have stored too. One summary per poll, per-channel detail at DEBUG.
channels = [f"UC{i}" for i in range(40)]
def fetch(cid):
n = int(cid[2:])
if n < 30:
raise rss.RssError(f"RSS fetch for {cid} returned HTTP 404", "http_404")
if n < 35:
raise rss.RssError(f"RSS fetch failed for {cid}: timed out", "transport")
return [{"id": "v"}]
caplog.set_level(logging.DEBUG, logger="siftlode.sync")
out = self._run(monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=channels)
assert out == {"new": 5, "failed": 35}
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warnings) == 1
assert warnings[0].event == "sync.rss.failed"
assert warnings[0].attrs["reasons"] == {"http_404": 30, "transport": 5}
assert len(warnings[0].attrs["examples"]) == runner.RSS_FAILURE_EXAMPLES
assert sum(r.levelno == logging.DEBUG for r in caplog.records) == 35
def test_a_failure_on_our_side_gets_one_traceback_per_poll_not_one_per_channel(
self, monkeypatch, caplog
):
# A read-only database fails every channel's apply identically. The first gets a traceback
# at ERROR; repeating it for each of 979 channels would be the RSS flood all over again.
def apply_(db, ch, entries):
raise RuntimeError("read-only transaction")
caplog.set_level(logging.DEBUG, logger="siftlode.sync")
with pytest.raises(RuntimeError):
self._run(
monkeypatch,
fetch=lambda cid: [{"id": "v"}],
apply_=apply_,
channels=("A", "B", "C"),
)
errors = [r for r in caplog.records if r.levelno == logging.ERROR]
assert len(errors) == 1 and errors[0].exc_info is not None
assert (
sum(r.levelno == logging.DEBUG and r.exc_info is not None for r in caplog.records) == 2
)
def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch):
quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0)
assert quiet == {"new": 0, "failed": 0} # same "new", different "failed" — that's the point
@@ -152,7 +202,7 @@ class TestRssPollResult:
# here recorded the scheduler run as "ok", with the outage only visible inside the summary
# text — a green pill for a run that did nothing at all.
def boom(cid):
raise rss.RssError("Connection refused")
raise rss.RssError("Connection refused", "transport")
with pytest.raises(RuntimeError, match="all 2 channels"):
self._run(monkeypatch, fetch=boom, apply_=lambda db, ch, e: 1)
@@ -162,7 +212,7 @@ class TestRssPollResult:
# The card has to say which: `HTTP 404` = fix the subscription, `Connection refused` =
# the egress is down.
def dead(cid):
raise rss.RssError(f"RSS fetch for {cid} returned HTTP 404")
raise rss.RssError(f"RSS fetch for {cid} returned HTTP 404", "http_404")
with pytest.raises(RuntimeError, match="HTTP 404"):
self._run(monkeypatch, fetch=dead, apply_=lambda db, ch, e: 1, channels=("UC_dead",))
+93
View File
@@ -0,0 +1,93 @@
"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
The admin Configuration page used to keep its own hand-written group order, which had already drifted
(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
tab order: one run of keys per group.
A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
`log_level`, `search_source`, `download_layout` and the transcode `hw`/`preset` used to be free-text
fields: the admin could type anything and every consumer carried its own `or "auto").lower()` guess
at what a typo meant. Now the value set is a `Literal` in config.py, pydantic holds the env to it at
boot, sysconfig holds a stored override to it, and the admin page renders a picker from the same list.
The DB lane (`db` fixture) covers the store; the rest is pure logic.
"""
import pytest
from pydantic import ValidationError
from app import sysconfig
from app.config import Settings, choices_of
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
runs = [
s.group
for i, s in enumerate(sysconfig.SPECS)
if i == 0 or sysconfig.SPECS[i - 1].group != s.group
]
assert len(runs) == len(set(runs)), runs
assert runs[:2] == ["access", "email"]
def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
assert CHOICE_KEYS == {
"search_source",
"download_layout",
"log_level",
"plex_transcode_hw",
"plex_transcode_preset",
}
for s in sysconfig.SPECS:
field = Settings.model_fields[s.default_attr]
if s.type == "choice":
assert s.choices, s.key
assert field.default in s.choices, s.key
else:
# A Literal-typed setting exposed as "str" would be a free-text field again.
assert s.choices == () and choices_of(field.annotation) == (), s.key
def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value():
assert Settings(log_level="debug").log_level == "DEBUG"
assert Settings(search_source=" API ").search_source == "api"
with pytest.raises(ValidationError):
Settings(log_level="verbose")
class _Row:
def __init__(self, value):
self.value, self.encrypted = value, False
class _FakeDb:
"""Just enough of a Session for sysconfig's reads: one stored override row per key."""
def __init__(self, **rows):
self.rows = {k: _Row(v) for k, v in rows.items()}
def get(self, _model, key):
return self.rows.get(key)
def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default():
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off"
# A value that is no longer (or never was) a choice is not passed through to the consumer.
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="maybe"), "plex_transcode_hw") == "auto"
def test_describe_hands_the_picker_its_options():
items = {i["key"]: i for g in sysconfig.describe(_FakeDb())["groups"].values() for i in g}
assert items["log_level"]["choices"] == ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
assert items["log_level"]["value"] == "INFO"
assert items["quota_daily_budget"]["choices"] == []
def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
sysconfig.set_value(db, "search_source", "API")
assert sysconfig.get_str(db, "search_source") == "api"
with pytest.raises(ValueError, match="scrape, api"):
sysconfig.set_value(db, "search_source", "bing")
db.rollback()
+34
View File
@@ -236,3 +236,37 @@ def test_stop_child_noop_if_already_exited(killpg_rec):
proc.returncode = 0 # already gone
worker._stop_child(proc, grace_s=0.01)
assert killpg_rec == []
def test_a_crashed_jobs_crash_line_and_cleanup_carry_the_job_id(monkeypatch):
"""The job id is bound for the WHOLE job, the crash handler included: bound inside the `try`, it
was already reset when the one line that says the job crashed was logged."""
from app import logs
seen: dict[str, object] = {}
claims = iter([42])
def claim():
job = next(claims, None)
if job is None:
worker._stop.set()
return job
def crash(_job_id):
raise RuntimeError("boom")
monkeypatch.setattr(worker, "_claim_job", claim)
monkeypatch.setattr(worker, "_process_job", crash)
monkeypatch.setattr(
worker.log, "exception", lambda *a, **k: seen.setdefault("crash", logs._job_id.get())
)
monkeypatch.setattr(
worker, "_fail_running", lambda *a, **k: seen.setdefault("cleanup", logs._job_id.get())
)
monkeypatch.setattr(worker.settings, "download_poll_seconds", 0)
worker._stop.clear()
try:
worker._worker_loop(0)
finally:
worker._stop.clear()
assert seen == {"crash": 42, "cleanup": 42}
+13
View File
@@ -11,6 +11,15 @@
# applies local migrations to THIS local DB only. Seed the catalog once with a pg_dump from
# another instance if you want real data (see RUNBOOK).
# Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API
# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a
# busy instance and a hard ceiling on the disk it can take.
x-logging: &logging
driver: json-file
options:
max-size: "50m"
max-file: "3"
services:
db:
image: postgres:16-alpine
@@ -27,6 +36,7 @@ services:
interval: 5s
timeout: 5s
retries: 12
logging: *logging
restart: unless-stopped
api:
@@ -63,6 +73,7 @@ services:
- ./downloads:/downloads
# Plex media, read-only (see the plex_media volume note below). Maps to plex_path_map.
- plex_media:/plex-media:ro
logging: *logging
restart: unless-stopped
# Dedicated download worker: same image, runs the yt-dlp job loop instead of the API.
@@ -96,6 +107,7 @@ services:
volumes:
- ./downloads:/downloads
- plex_media:/plex-media:ro
logging: *logging
restart: unless-stopped
# PO-token provider: mints YouTube Proof-of-Origin tokens on demand so the worker bypasses
@@ -106,6 +118,7 @@ services:
init: true
security_opt:
- apparmor:unconfined
logging: *logging
restart: unless-stopped
volumes:
+13
View File
@@ -10,6 +10,15 @@
# Everything else — the admin account, Google sign-in, SMTP — is set in the web wizard on
# first run. See docs/self-hosting.md.
# Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API
# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a
# busy instance and a hard ceiling on the disk it can take.
x-logging: &logging
driver: json-file
options:
max-size: "50m"
max-file: "3"
services:
db:
image: postgres:16-alpine
@@ -27,6 +36,7 @@ services:
interval: 10s
timeout: 5s
retries: 12
logging: *logging
restart: unless-stopped
api:
@@ -71,6 +81,7 @@ services:
timeout: 5s
retries: 5
start_period: 30s
logging: *logging
restart: unless-stopped
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
@@ -109,6 +120,7 @@ services:
- ALL
tmpfs:
- /tmp
logging: *logging
restart: unless-stopped
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
@@ -119,6 +131,7 @@ services:
networks: [internal]
security_opt:
- no-new-privileges:true
logging: *logging
restart: unless-stopped
volumes:
+13
View File
@@ -1,3 +1,12 @@
# Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API
# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a
# busy instance and a hard ceiling on the disk it can take.
x-logging: &logging
driver: json-file
options:
max-size: "50m"
max-file: "3"
services:
db:
image: postgres:16-alpine
@@ -12,6 +21,7 @@ services:
interval: 5s
timeout: 5s
retries: 12
logging: *logging
restart: unless-stopped
api:
@@ -49,6 +59,7 @@ services:
timeout: 5s
retries: 5
start_period: 25s
logging: *logging
restart: unless-stopped
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
@@ -83,6 +94,7 @@ services:
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
# Same read-only Plex library as the API — the worker resolves library paths too.
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
logging: *logging
restart: unless-stopped
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
@@ -90,6 +102,7 @@ services:
bgutil-pot:
image: brainicism/bgutil-ytdlp-pot-provider:1.3.1
init: true
logging: *logging
restart: unless-stopped
volumes:
+10
View File
@@ -17,6 +17,16 @@ siftlode e2e-seed # (re)seed the test account only, no tests
The localdev stack must be up (`siftlode dev`). `global.setup.ts` reseeds the account and logs in
once, sharing the session via `e2e/.auth/state.json` (git-ignored).
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile). Install the engines once per
machine with `npx playwright install chromium webkit`; run one project with
`npx playwright test --project=ipad`. WebKit catches what every iOS browser inherits from the engine
(JS, CSS/layout, event order). It does **not** reproduce iPad media refusals: Playwright's WebKit
decodes through the host OS (Media Foundation on Windows, GStreamer on Linux), and measured on
2026-09-14 it played an mp3-in-mp4 and an AV1 mp4 that a real iPad refuses. Check a media format
on a real iPad against the dev stack over the LAN (`http://<desktop LAN IP>:8080`, e.g. a
login-free `/watch/<token>` share link).
**Target** — `PW_BASE_URL` (default `http://127.0.0.1:8080`):
- **`:8080`** (baked SPA) is authoritative and same-origin. After a **frontend change you must
+6 -2
View File
@@ -67,13 +67,17 @@ setup("seed + authenticate", async ({ page }) => {
const me = await (await page.request.get("/api/me")).json();
expect(me.authenticated, "session not established after login").toBeTruthy();
// 4. Pin the UI locale and suppress the first-login onboarding wizard (a full-screen overlay
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
// that would intercept every click — the test account has no YouTube connection, so it
// auto-opens). The dismissed flag is per-account: `siftlode.onboarding.dismissed.<id>`.
// auto-opens), and opt the account out of starting on Home. Since E24 a bare URL lands on
// Home, and the feed, channel and player flows this net locks all open "/" expecting the
// feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are
// per-account: `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
await page.goto("/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.lang", "en");
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
localStorage.setItem(`siftlode.startOnHome.${id}`, "0");
}, me.id);
mkdirSync(dirname(STORAGE), { recursive: true });
await page.context().storageState({ path: STORAGE });
+47
View File
@@ -14,6 +14,36 @@ import globals from "globals";
//
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only.
const NETWORK_OWNERS = {
fetch: "src/lib/http.ts",
XMLHttpRequest: "src/lib/api/core.ts",
WebSocket: "src/lib/messagesSocket.ts",
EventSource: null,
};
const networkGlobals = (exempt) =>
Object.entries(NETWORK_OWNERS)
.filter(([name]) => name !== exempt)
.map(([name, owner]) => ({
name,
message: owner
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
: `No \`${name}\` without a logged owner module (E22 S4).`,
}));
const networkProperties = [
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
{
object: "navigator",
property: "sendBeacon",
message: "Use lib/api/core.ts `beacon` (E22 S4).",
},
];
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
{
@@ -42,6 +72,11 @@ export default tseslint.config(
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
"@typescript-eslint/no-explicit-any": "error",
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
"no-console": "error",
"no-restricted-globals": ["error", ...networkGlobals(null)],
"no-restricted-properties": ["error", ...networkProperties],
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
"@typescript-eslint/no-unused-expressions": [
@@ -131,5 +166,17 @@ export default tseslint.config(
],
},
},
// The network owners (see NETWORK_OWNERS), each exempt from its own global only.
...Object.entries(NETWORK_OWNERS)
.filter(([, owner]) => owner)
.map(([name, owner]) => ({
files: [owner],
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
})),
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
files: ["src/**/*.test.{ts,tsx}"],
rules: { "no-restricted-globals": "off", "no-restricted-properties": "off" },
},
prettier,
);
+1 -1
View File
@@ -12,7 +12,7 @@
"build": "npm run typecheck:app && npm run typecheck:node && vite build",
"preview": "vite preview",
"knip": "knip",
"lint": "eslint .",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
+14
View File
@@ -47,5 +47,19 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
dependencies: ["setup"],
},
// WebKit catches engine-level differences (JS, CSS/layout, event order) that every iOS browser
// inherits. It does NOT reproduce iPad MEDIA refusals: Playwright's WebKit decodes through the
// host OS (Media Foundation on Windows, GStreamer on Linux), and measured on 2026-09-14 it played
// an mp3-in-mp4 and an AV1 mp4 that iPad refuses. Media checks need a real iPad.
{
name: "webkit",
use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE },
dependencies: ["setup"],
},
{
name: "ipad",
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
dependencies: ["setup"],
},
],
});
+62 -86
View File
@@ -2,16 +2,15 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { setLanguage, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
import {
loadLayout,
normalizeLayout,
PREF_KEY,
saveLayoutLocal,
seedLayout,
type PanelId,
type PanelLayout,
} from "./lib/panelLayout";
@@ -19,12 +18,14 @@ import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
import { useChannelLayout } from "./components/channelsContext";
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
import { moduleDef, moduleOrder } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
import { lazyWithPreload } from "./lib/lazyWithPreload";
import { LoadingState } from "./components/ui/QueryState";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
@@ -38,7 +39,6 @@ import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap";
import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
@@ -48,11 +48,12 @@ import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
import { FRONTEND_VERSION } from "./lib/version";
import { applyDiagnostics } from "./lib/diagnostics";
// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
@@ -79,13 +80,20 @@ export default function App() {
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
const { setView } = useFeedViewActions();
const queryClient = useQueryClient();
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
feed: loadLayout("feed"),
plex: loadLayout("plex"),
playlists: loadLayout("playlists"),
}));
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
// changed on another device is right on the first paint; the local copy covers a key the server
// has never stored.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
return {
feed: seedLayout("feed", prefs),
plex: seedLayout("plex", prefs),
playlists: seedLayout("playlists", prefs),
};
});
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
@@ -104,8 +112,8 @@ export default function App() {
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
localKey: LS.filterCollapsed,
@@ -119,26 +127,15 @@ export default function App() {
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
const queryClient = useQueryClient();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
// We check this before anything else and render the wizard; `me` is held back until we know the
// instance is set up (otherwise it would 503 against the setup-mode lock).
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
// and waits for: App mounts only once both have answered, so their caches are always populated here.
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
staleTime: Infinity,
enabled: false,
});
const needsSetup = setupQuery.data?.configured === false;
// Poll the session so an account suspended/deleted by an admin is noticed even with no
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
// Only poll while signed in (data present) — polling on the public login page is pointless and
// its periodic refetch caused a visible flicker there.
const meQuery = useQuery({
queryKey: qk.me(),
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
@@ -155,14 +152,33 @@ export default function App() {
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
);
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
// tab always sends its own account header, so cross-tab changes can't reach it. An explicit
// switch on THIS tab sets the override itself (so this no-ops then).
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or
// the server saying it is off) refetches `me`, so Settings shows the base level again.
const diagnostics = meQuery.data?.diagnostics;
useEffect(() => {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() }));
}, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
// requestIdleCallback, hence the timer).
useEffect(() => {
const me = meQuery.data;
if (!me) return;
const run = () => {
preloadPages([...moduleOrder(me), "settings"]);
ChannelPage.preload().catch(() => {});
};
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
if (idle) {
const id = idle(run, { timeout: 3000 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(run, 1000);
return () => window.clearTimeout(id);
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
@@ -238,28 +254,6 @@ export default function App() {
clearParams("admin");
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
// FeedViewProvider — each via its own passive qk.me() observer.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefs[PREF_KEY[p]];
if (raw) {
const l = normalizeLayout(p, raw);
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
saveLayoutLocal(p, l);
}
});
const lang = prefs.language;
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
// dedicated effect above, which also covers accounts that have no stored preferences.)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
// fresh load (which is why the notice used to linger until F5 after an approval).
@@ -304,32 +298,14 @@ export default function App() {
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading)
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
if (needsSetup) return <SetupWizard />;
if (meQuery.isLoading)
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
// /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
// network/5xx failure, and no data means "not signed in" → the public landing.
if (meQuery.error)
return (
<div className="min-h-screen grid place-items-center">
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
// probe — so no data here means "not signed in" → the public landing.
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
);
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
const pageFallback = <LoadingState className="flex-1 justify-center" />;
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
+2 -1
View File
@@ -4,13 +4,14 @@ import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
import { formatDateTime } from "../lib/format";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
function fmtDate(d?: string | null): string {
if (!d) return "—";
const t = new Date(d);
return isNaN(t.getTime()) ? d : t.toLocaleString();
return isNaN(t.getTime()) ? d : formatDateTime(d);
}
// About dialog: app + build + schema versions (frontend/backend/database), plus a
+2 -1
View File
@@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
+16 -1
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } from "lucide-react";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
@@ -9,6 +9,7 @@ import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
@@ -168,6 +169,20 @@ export default function AccountPanel({
}
tone={a.has_google ? "ok" : "muted"}
/>
{/* The exact moment; the list row beside it only says how long ago. Null has two different
meanings, so it says which: nothing recorded yet, or the shared demo account, which is
deliberately never stamped. */}
<AccessRow
icon={LogIn}
label={t("users.account.lastLogin")}
value={
a.last_login_at
? formatDateTime(a.last_login_at)
: a.is_demo
? t("users.account.lastLoginDemo")
: t("users.account.lastLoginNone")
}
/>
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
{!a.is_allowed && (
<AccessRow
@@ -4,7 +4,8 @@ import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
+2 -1
View File
@@ -7,7 +7,8 @@ import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -24,7 +24,7 @@ vi.mock("../lib/useMe", () => ({
pending_activations: 0,
}),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
}));
@@ -36,6 +36,7 @@ vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() }));
import AdminUsers from "./AdminUsers";
import { api, type AdminUserRow } from "../lib/api";
import { relativeTime } from "../lib/format";
import i18n from "../i18n";
const row = {
@@ -76,6 +77,27 @@ function mount() {
return users;
}
describe("the last sign-in column (E22 S1)", () => {
it("says how long ago each account signed in, and a dash where nothing is recorded", async () => {
const at = new Date(Date.now() - 3 * 86_400_000).toISOString();
vi.spyOn(api, "adminUsers").mockResolvedValue([
{ ...row, id: 3, email: "seen@x", last_login_at: at },
{ ...row, id: 4, email: "never@x", last_login_at: null },
{ ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null },
] as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<AdminUsers />
</QueryClientProvider>,
);
expect(await screen.findByText(relativeTime(at))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(2);
});
});
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
+29 -7
View File
@@ -24,6 +24,7 @@ import {
type PendingAccessRow,
} from "../lib/api";
import { notify } from "../lib/notifications";
import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Button } from "./ui/Button";
@@ -34,8 +35,9 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
@@ -49,7 +51,7 @@ import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
@@ -310,6 +312,23 @@ function UsersRoles({ me }: { me: Me }) {
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
edge lines the values up into one column. The exact moment is the hint here and a
line in the account panel; below `sm` the row has no room, and the panel still says it. */}
<Tooltip
hint={
u.last_login_at
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at) })
: u.is_demo
? t("users.roles.lastLoginDemo")
: t("users.roles.lastLoginNone")
}
>
<span className="hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
</span>
</Tooltip>
</div>
);
})}
@@ -413,7 +432,7 @@ function PendingAccessRowView({
{row.created_at && (
<span>
{t("users.pendingAccess.signedUp", {
time: new Date(row.created_at).toLocaleString(),
time: formatDateTime(row.created_at),
})}
</span>
)}
@@ -601,7 +620,10 @@ function AdminInvites() {
inv.invited_at
? "settings.invites.inviteConfirmAgain"
: "settings.invites.inviteConfirm",
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
{
email: inv.email,
time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()),
},
)}
</p>
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
@@ -700,7 +722,7 @@ function AdminInvites() {
hint={
i.invited_at
? t("settings.invites.invitedAt", {
time: new Date(i.invited_at).toLocaleString(),
time: formatDateTime(i.invited_at),
})
: t("settings.invites.inviteHint")
}
@@ -898,7 +920,7 @@ function InviteRow({
{inv.requested_at && (
<div className="text-2xs text-muted">
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
time: formatDateTime(inv.requested_at),
})}
</div>
)}
@@ -10,10 +10,10 @@ import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent";
import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider";
import { useSavedViews } from "./SavedViewsProvider";
import { useNavigationActions } from "./navigationContext";
import { usePlaylistsActions } from "./playlistsContext";
import { usePrefs } from "./prefsContext";
import { useSavedViews } from "./savedViewsContext";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
+1 -1
View File
@@ -8,7 +8,7 @@ import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import { Button } from "./ui/Button";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
//
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
//
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import BootGate from "./BootGate";
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import { api, type Me } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
useEffect(() => {
mounts++;
}, []);
return <div data-testid="app">{page}</div>;
}
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<BootGate>
<NavigationProvider>
<Probe />
</NavigationProvider>
</BootGate>
</QueryClientProvider>,
);
return qc;
}
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear(); // a NEW tab: nothing pinned yet
window.history.replaceState(null, "", "/");
renders.length = 0;
mounts = 0;
await i18n.changeLanguage("en");
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("BootGate", () => {
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
});
it("renders the app in the account's language from its first render", async () => {
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
mount();
await screen.findByTestId("app");
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
});
it("mounts nothing of the app while the identity is still unknown", async () => {
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
mount();
await act(async () => {}); // the setup probe resolves; `me` never does
expect(screen.queryByTestId("app")).toBeNull();
expect(renders).toHaveLength(0);
});
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
vi.spyOn(api, "me").mockResolvedValue(null);
mount();
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
vi.mocked(api.setupStatus).mockResolvedValue({ configured: false });
const me = vi.spyOn(api, "me").mockResolvedValue(account());
mount();
await screen.findByTestId("app");
expect(me).not.toHaveBeenCalled();
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
vi.mocked(api.setupStatus)
.mockRejectedValueOnce(new Error("502"))
.mockResolvedValue({ configured: true });
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(renders.length).toBeGreaterThan(0);
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
});
it("an unanswered first account probe is an error with a retry", async () => {
vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a background refetch that fails keeps the app mounted, as it was", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
me.mockRejectedValue(new Error("502"));
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
// react-query hands the error to its observers on a timer: let that render land before asserting.
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(qc.getQueryState(qk.me())?.status).toBe("error");
expect(screen.getByTestId("app")).toBeTruthy();
expect(mounts).toBe(1);
});
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(null); // the session ended
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
});
it("an identity change reloads without moving the pin first", async () => {
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
// moved they would land under the NEW account's local keys (a player volume still pending, say).
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })));
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
expect(mounts).toBe(1);
expect(i18n.language).toBe("en"); // the language is adopted once, never over the user's own choice
});
});
+124
View File
@@ -0,0 +1,124 @@
import { useLayoutEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
import { qk } from "../lib/queryKeys";
import i18n, { isSupported, setLanguage } from "../i18n";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The boot sequence, in ONE place: nothing of the app renders until this tab knows WHO it is.
//
// Per-account state is everywhere — the landing page, the feed's filters, the Plex scope, the manager's
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
// the provider tree — whose initializers are then simply right, with nothing to re-read.
//
// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to
// match the account the server answered as before they run. And the tree is only ever built for one
// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it
// was pinned to was signed out in another tab and the server now answers as the browser's default),
// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous
// account's query cache, whose keys are not per account.
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
* loading visual, full screen. Its reveal counts from navigation start, so the gate's screen, the
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
export function BootScreen() {
return (
<div className="min-h-screen grid place-items-center">
<LoadingState since={0} delayMs={400} />
</div>
);
}
export default function BootGate({ children }: { children: ReactNode }) {
const { t } = useTranslation();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
// every other route is locked — so `me` is held back until the instance is known to be set up
// (otherwise it would 503 against the setup-mode lock).
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
staleTime: Infinity,
});
const needsSetup = setupQuery.data?.configured === false;
// THE fetching `me` query; every other reader is a passive (enabled:false) observer of its cache.
// Polled while signed in so an ended session is noticed with no interaction: /api/me answers a
// signed-out (or suspended) caller with 200 and no account, the identity drops to "nobody", and the
// tab reloads to the login page. Not polled signed out — that refetch is pointless and used to
// flicker the public landing.
const meQuery = useQuery({
queryKey: qk.me(),
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
const me = meQuery.data;
// Who this tab is, once known: an account id, `null` for "nobody" (signed out, or setup mode — App
// renders the landing / the wizard for those), `undefined` while unknown or unanswerable.
// Keyed on the DATA, not the status: a background refetch that fails keeps the last answer, so a
// server blip never tears the whole app down to this screen.
const identity: number | null | undefined = !setupQuery.data
? undefined
: needsSetup
? null
: me === undefined
? undefined
: (me?.id ?? null);
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
// before the browser paints: the frame that first shows the app already has the account pinned.
// • Pin this tab to the account the server answered as. Without the pin, a tab riding the
// session's default account would silently swap identity when ANOTHER tab changes that default
// (adds or switches an account); once pinned it sends its own account header. The server honours
// that header only for an account still signed in to this browser, so a pin naming one that is
// not is corrected here — otherwise the tab would run as the default account on the server while
// reading and writing the gone account's local state.
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed.
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
useLayoutEffect(() => {
if (identity === undefined) return;
if (readyFor !== undefined) {
// The tree was built for another identity — see the header. The pin stays as it is: the tree
// being torn down still runs its unmount saves before the page goes, and with the pin moved they
// would land under the new account's local keys. The reloaded page's first probe corrects it.
location.reload();
return;
}
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
const lang = me?.preferences?.language;
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
setReadyFor(identity);
// `me` is read for the identity it produced; a background refetch of the same account must not
// re-run the adoption over a language the user has just changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity]);
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
// out": rendering the login page to a signed-in user would be a lie. After a retry the identity
// lands and the tree mounts from scratch, like any first load.
const failed =
!setupQuery.data && setupQuery.isError
? setupQuery
: me === undefined && meQuery.isError
? meQuery
: null;
if (failed)
return (
<div className="min-h-screen grid place-items-center">
<StateMessage tone="error" onRetry={() => failed.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
if (identity === undefined || readyFor !== identity) return <BootScreen />;
return <>{children}</>;
}
+2 -2
View File
@@ -21,8 +21,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import ColumnSortControl from "./ui/ColumnSortControl";
import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout } from "./ChannelsProvider";
import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
@@ -16,11 +16,11 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./FeedFiltersProvider", () => ({
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
@@ -14,11 +14,11 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./FeedFiltersProvider", () => ({
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
+5 -4
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider";
import { useNavigationActions } from "./navigationContext";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
import {
@@ -24,9 +24,10 @@ import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { PageToolbar } from "./ui/PageShell";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import ChannelStatsPopover from "./ChannelStatsPopover";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { formatEta, formatNumber } from "../lib/format";
beforeAll(() => {
class Stub {
@@ -74,7 +74,7 @@ describe("ChannelStatsPopover", () => {
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic
expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic
});
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
@@ -11,8 +11,9 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
@@ -63,13 +64,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
{
icon: Film,
label: t("channels.stats.myVideos"),
value: status.my_videos.toLocaleString(),
value: formatNumber(status.my_videos),
hint: t("channels.stats.myVideosHint"),
},
{
icon: Zap,
label: t("channels.stats.quotaLeft"),
value: status.quota_remaining_today.toLocaleString(),
value: formatNumber(status.quota_remaining_today),
hint: t("channels.stats.quotaLeftHint"),
},
];
+2 -1
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
+6 -6
View File
@@ -36,11 +36,11 @@ import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useWizard } from "./WizardProvider";
import { useConfirm } from "./confirmContext";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useWizard } from "./wizardContext";
import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
@@ -292,7 +292,7 @@ export default function Channels() {
if (filtersResetToken === resetRef.current) return;
resetRef.current = filtersResetToken;
setSearch("");
}, [filtersResetToken]);
}, [filtersResetToken, setSearch]);
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
@@ -2,22 +2,22 @@
//
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
// re-routed through usePersistedTab in R13 S2b, so it's exercised here rather than only by hand E2E.
// re-routed through useAccountPersistedState in R13 S2b, so it's exercised here rather than only by hand E2E.
//
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
// usePersistedTab / useSortState / useFilterState all resolve their account-scoped keys; only
// NavigationProvider (the sole runtime dependency) is mocked, pinned to the channels page.
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
useNavigationActions: () => ({ setPage: vi.fn() }),
}));
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
import { setActiveAccount } from "../lib/api/core";
import { ChannelsProvider } from "./ChannelsProvider";
import { useChannels, useChannelsActions } from "./channelsContext";
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelsProvider>{children}</ChannelsProvider>
@@ -101,20 +101,17 @@ describe("ChannelsProvider — S8 shareable URL", () => {
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
});
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
// persisting the seed overwrote the account's real stored sort + filter map.
localStorage.setItem(
"siftlode.channelSort.1",
JSON.stringify({ key: "subscribers", dir: "desc" }),
);
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
const { result } = render();
act(() => setActiveAccount(1)); // App pins the account after /api/me
// The present dim (tab) is adopted from the link...
expect(result.current.view).toBe("hidden");
+34 -127
View File
@@ -1,7 +1,5 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -9,12 +7,11 @@ import {
useState,
type ReactNode,
} from "react";
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { hydrateSort, type SortState } from "../lib/columnSort";
import { getAccountRaw, LS, useAccountPersistedState } from "../lib/storage";
import { parseChannelLayout } from "../lib/channelLayout";
import { hydrateSort } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror";
import { rememberNavPosition } from "../lib/history";
import {
@@ -23,10 +20,16 @@ import {
hasChannelsParams,
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "../lib/tableColumns";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import type { ChannelsView } from "./Channels";
import {
ChannelLayoutContext,
ChannelsActionsContext,
ChannelsStateContext,
type ChannelsActions,
type ChannelsState,
} from "./channelsContext";
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
@@ -39,6 +42,8 @@ import type { ChannelsView } from "./Channels";
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
//
// The contexts, their value types and the hooks live in channelsContext.ts.
function clampView(v: string | null): ChannelsView {
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
@@ -74,90 +79,16 @@ function readStoredFilters(): FilterMap {
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
function readChannelsUrlSeed(): ChannelsUrlState | null {
const params = new URLSearchParams(window.location.search);
if (!hasChannelsParams(params)) return null;
const state = channelsParamsToState(params, {
return channelsParamsToState(params, {
view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(),
});
return {
state,
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
};
}
type ChannelsState = {
search: string;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
filters: FilterMap;
view: ChannelsView;
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
sort: SortState;
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
// specific set (the header's "without full history" link).
filtersResetToken: number;
focusName: string | null;
// Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the
// manager's name filter even when the name is unchanged.
focusToken: number;
};
interface ChannelsActions {
setSearch: (q: string) => void;
setFilters: (f: FilterMap) => void;
setView: (v: ChannelsView) => void;
setLayout: (l: ChannelLayout) => void;
setSort: (s: SortState) => void;
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
const StateContext = createContext<ChannelsState>({
search: "",
filters: {},
view: "subscribed",
sort: null,
filtersResetToken: 0,
focusName: null,
focusToken: 0,
});
// The table/cards density gets its OWN context, deliberately narrow: App reads it (to decide the
// shell's top fade) and would otherwise re-render the whole app on every keystroke in the manager's
// search box, since the state context above changes with it.
const LayoutContext = createContext<ChannelLayout>("table");
const ActionsContext = createContext<ChannelsActions>({
setSearch: () => {},
setFilters: () => {},
setView: () => {},
setLayout: () => {},
setSort: () => {},
focusChannel: () => {},
goToFullHistory: () => {},
});
export function useChannels(): ChannelsState {
return useContext(StateContext);
}
/** The manager's table/cards density on its own — subscribe to this instead of the whole state when
* the layout is all you need (App does, for the shell's top fade). */
export function useChannelLayout(): ChannelLayout {
return useContext(LayoutContext);
}
export function useChannelsActions(): ChannelsActions {
return useContext(ActionsContext);
}
export function ChannelsProvider({ children }: { children: ReactNode }) {
@@ -168,13 +99,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
// heal them for display. setView layers the history logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
// view + layout are persisted tabs (useAccountPersistedState = the shared account-scoped tab-state hook).
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
// logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
const [layoutRaw, setLayout] = useAccountPersistedState(LS.channelLayout, "table");
const layout = parseChannelLayout(layoutRaw);
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
// it against the live columns for display, so the provider stores it un-healed (no options passed).
@@ -185,43 +115,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const accountId = useActiveAccountId();
const seededRef = useRef(false);
const persistedRef = useRef(false);
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
// entry. A normal load has urlSeed === null and this is a no-op.
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
useLayoutEffect(() => {
if (!urlSeed || seededRef.current) return;
seededRef.current = true;
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
const { state, present } = urlSeed;
if (present.view && state.view !== view) setViewRaw(state.view);
if (present.filters) setFilters(state.filters);
if (present.sort) setSort(state.sort);
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
setFilters(urlSeed.filters);
setSort(urlSeed.sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
// already skips (marked dirty above), so this only touches storage, not state.
useEffect(() => {
if (!urlSeed || accountId == null || persistedRef.current) return;
persistedRef.current = true;
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
const { state, present } = urlSeed;
if (present.view) setAccountRaw(LS.channelsView, state.view);
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
}, [urlSeed, accountId]);
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer).
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
@@ -237,7 +144,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
if (v === viewRef.current) return;
setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
setViewRaw(v); // persists (via useAccountPersistedState)
// Only own an entry while the manager is already on screen. Arriving from elsewhere
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
// one navigation never costs two Back presses.
@@ -316,10 +223,10 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
return (
<ActionsContext.Provider value={actions}>
<LayoutContext.Provider value={layout}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</LayoutContext.Provider>
</ActionsContext.Provider>
<ChannelsActionsContext.Provider value={actions}>
<ChannelLayoutContext.Provider value={layout}>
<ChannelsStateContext.Provider value={state}>{children}</ChannelsStateContext.Provider>
</ChannelLayoutContext.Provider>
</ChannelsActionsContext.Provider>
);
}
@@ -0,0 +1,140 @@
// @vitest-environment jsdom
//
// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
// as the active option rather than no option at all.
//
// i18n is a key-passthrough, except for an option's label: a full key would distort the
// bar-or-dropdown choice, which weighs the real label length, so a choice id resolves to its value
// under a one-character marker. The marker is what makes a lost lookup visible — rendering the bare
// value instead of the id would be indistinguishable from `label: c`. The admin config API is stubbed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
// Only an id the fixture really defines resolves: a dropped lookup renders the bare value and
// a malformed id renders the key (long enough to turn every bar into a dropdown), so the
// assertions below see both.
t: (key: string) => CHOICE_LABELS[key] ?? key,
i18n: { language: "en" },
}),
};
});
const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
key,
type: "choice",
group: "g",
secret: false,
min: null,
max: null,
is_set,
allow_empty: false,
choices,
value,
default: choices[0],
});
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
const LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"];
const CHOICE_LABELS: Record<string, string> = Object.fromEntries(
(
[
["plex_transcode_hw", ["auto", "on", "off"]],
["plex_transcode_preset", PRESETS],
["log_level", LEVELS],
] as const
).flatMap(([key, choices]) =>
choices.map((c) => [`config.choices.${key}.${c}`, `~${c}`] as const),
),
);
const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({
groups: {
g: [
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
choice("plex_transcode_preset", PRESETS, "veryfast"),
choice("log_level", LEVELS, "INFO"),
],
},
secrets_manageable: true,
}),
setConfig: (key: string, value: unknown) => setConfig(key, value),
resetConfig: (key: string) => resetConfig(key),
},
};
});
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
import ConfigPanel from "./ConfigPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mount = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
describe("ConfigPanel choice keys", () => {
it("renders a short list as a segmented bar and saves the pick", async () => {
mount();
const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
const pressed = () =>
[...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
expect(pressed()).toEqual(["~on"]);
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
fireEvent.click(screen.getByRole("button", { name: "~off" }));
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
});
it("shows the default as active once the override is reset, and Save resets it", async () => {
mount();
await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
expect(screen.getByRole("button", { name: "~auto" }).getAttribute("aria-pressed")).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
});
it("renders a long list as a dropdown showing only the value", async () => {
mount();
const trigger = await screen.findByRole("button", {
name: "config.fields.plex_transcode_preset.label: ~veryfast",
});
expect(trigger.textContent).toBe("~veryfast");
fireEvent.click(trigger);
// The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
fireEvent.click(rows.find((r) => r.textContent === "~slow")!);
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
});
it("a few options with long labels are a dropdown too: a bar that wide does not fit a phone", async () => {
mount();
await screen.findByRole("button", { name: "config.fields.log_level.label: ~INFO" });
expect(screen.queryByRole("group", { name: "config.fields.log_level.label" })).toBeNull();
});
});
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
//
// E22 round-2 review: locks the stacking that makes an admin config row usable on a phone. The
// control column cannot shrink, so beside a wide control it left the label and its hint a sliver of
// the ~250 px a phone leaves next to the navigation rail. The sibling fix — a long-labelled choice
// becoming a dropdown — is covered in ConfigPanel.choice.dom.test.tsx, but that one does nothing for
// a text, number or secret key, whose `w-52` input is the wide control: this is their only fix.
//
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const text = (key: string): ConfigItem => ({
key,
type: "str",
group: "g",
secret: false,
min: null,
max: null,
is_set: false,
allow_empty: true,
choices: [],
value: "",
default: "",
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }),
},
};
});
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
import ConfigPanel from "./ConfigPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ConfigPanel field row (E22 round 2)", () => {
it("stacks the label above the control below `sm`, and lets the control fit the screen", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
const label = await screen.findByText("config.fields.site_name.label");
const row = label.parentElement!.parentElement!; // label → the label/hint column → the row
// One column on a phone, the two-column row only from `sm` up:
expect(row.className).toContain("flex-col");
expect(row.className).toContain("sm:flex-row");
// The control column may not out-width the screen, and only stops shrinking from `sm` up:
const control = row.lastElementChild as HTMLElement;
expect(control.className).toContain("max-w-full");
expect(control.className).toContain("sm:shrink-0");
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
});
});
+69 -29
View File
@@ -8,34 +8,21 @@ import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import Tabs from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = [
"access",
"email",
"youtube",
"google",
"quota",
"backfill",
"shorts",
"batch",
"maintenance",
"downloads",
"audit",
"plex",
"tmdb",
];
// applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
// needing a second, hand-kept order list here.
export default function ConfigPanel() {
const { t } = useTranslation();
@@ -64,7 +51,7 @@ export default function ConfigPanel() {
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab(LS.configTab);
const [tab, setTab] = useAccountPersistedState(LS.configTab);
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />;
}
const groupKeys = Object.keys(data.groups).sort(
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
);
const groupKeys = Object.keys(data.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
@@ -330,6 +315,7 @@ function Field({
const { t } = useTranslation();
const isNum = item.type === "int";
const isBool = item.type === "bool";
const label = t(`config.fields.${item.key}.label`, item.key);
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
@@ -354,22 +340,26 @@ function Field({
const showReset = item.is_set && !isBool;
return (
<div className="flex items-start justify-between gap-3 py-3">
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
// label and hint a sliver of a phone's width.
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div>
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")}
</p>
<p className="text-2xs text-muted mt-1">{status}</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
{isBool ? (
<Switch
label={t(`config.fields.${item.key}.label`, item.key)}
label={label}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
/>
) : item.type === "choice" ? (
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
) : (
<input
type={item.secret ? "password" : isNum ? "number" : "text"}
@@ -379,7 +369,7 @@ function Field({
min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={t(`config.fields.${item.key}.label`, item.key)}
aria-label={label}
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
)}
@@ -400,3 +390,53 @@ function Field({
</div>
);
}
// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
// default on Save) shows the default as the active option, as a text field shows it as placeholder.
//
// "Short" is also about the words, not just their number: a bar cannot shrink or wrap, and five level
// names (TRACE … WARNING, ~300 px) are wider than a phone leaves beside the navigation rail (~250 px).
// Past MAX_SEGMENT_CHARS of labels, in the language shown, it is a dropdown too.
const MAX_SEGMENTS = 6;
const MAX_SEGMENT_CHARS = 22;
function ChoiceField({
item,
label,
value,
onChange,
}: {
item: ConfigItem;
label: string;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
const options = item.choices.map((c) => ({
value: c,
label: t(`config.choices.${item.key}.${c}`, c),
}));
const current = value || String(item.default ?? "");
const labelChars = options.reduce((sum, o) => sum + o.label.length, 0);
if (options.length <= MAX_SEGMENTS && labelChars <= MAX_SEGMENT_CHARS) {
return (
<SegmentedToggle
options={options}
value={current}
onChange={onChange}
ariaLabel={label}
size="md"
/>
);
}
return (
<FilterSelect
label={label}
hideLabel
options={options}
selected={[current]}
onChange={([next]) => next && onChange(next)}
/>
);
}
+3 -58
View File
@@ -1,65 +1,10 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
// if (await confirm({ message: "…", danger: true })) { … }
//
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
// went ahead and deleted).
interface ConfirmOptions {
title?: string;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
interface Choice {
id: string;
label: string;
danger?: boolean;
}
export interface ChoiceOptions {
title?: string;
message: ReactNode;
cancelLabel?: string;
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
choices: Choice[];
}
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
export function useConfirm(): ConfirmFn {
const ask = useContext(ChoiceContext);
return useCallback(
async (opts: ConfirmOptions) => {
const picked = await ask({
title: opts.title,
message: opts.message,
cancelLabel: opts.cancelLabel,
choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }],
});
return picked === "confirm";
},
[ask],
);
}
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
export function useChoice(): ChoiceFn {
return useContext(ChoiceContext);
}
// The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract).
export function ConfirmProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
+2 -1
View File
@@ -1,7 +1,8 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
//
// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DiagnosticsLevel, DiagnosticsState, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts?.count != null ? `${key}:${opts.count}` : key,
i18n: { language: "en" },
}),
};
});
const setLevel = vi.fn(
(level: DiagnosticsLevel): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
Promise.resolve({
ok: true,
diagnostics: {
level,
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
retention_days: 7,
},
}),
);
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: { ...actual.api, setDiagnosticsLevel: (l: DiagnosticsLevel) => setLevel(l) },
};
});
import DiagnosticsSettings from "./DiagnosticsSettings";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function mount(me: Partial<Me>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 },
...me,
});
render(
<QueryClientProvider client={qc}>
<DiagnosticsSettings />
</QueryClientProvider>,
);
return qc;
}
const levelButton = (name: string) =>
within(screen.getByRole("group", { name: "settings.diagnostics.level" })).getByRole("button", {
name,
});
describe("DiagnosticsSettings", () => {
it("choosing a detailed level asks first and changes nothing until confirmed", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const consent = screen.getByTestId("diagnostics-consent");
expect(consent.textContent).toContain("settings.diagnostics.consent.what.DEBUG");
expect(consent.textContent).toContain("settings.diagnostics.consent.server:7");
expect(setLevel).not.toHaveBeenCalled();
fireEvent.click(
within(consent).getByRole("button", { name: "settings.diagnostics.consent.cancel" }),
);
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(setLevel).not.toHaveBeenCalled();
});
it("the consent button raises the level, and the status line offers the way back", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(setLevel).toHaveBeenCalledWith("TRACE");
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.TRACE").getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.revert" }));
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(setLevel).toHaveBeenLastCalledWith("INFO");
});
it("going back to the base level needs no consent", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
});
it("the focus goes back to the level picker when the box it was in goes away", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const cancel = screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" });
cancel.focus();
fireEvent.click(cancel);
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
const confirm = screen.getByTestId("diagnostics-consent-confirm");
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.TRACE"));
const revert = screen.getByRole("button", { name: "settings.diagnostics.revert" });
revert.focus();
fireEvent.click(revert);
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
});
it("the watch outlives a render in which the element it is watching is still there", async () => {
// The distinguishing property of the restore, and the one the browser found: it keeps looking
// until the element is GONE, instead of spending itself on the first render after the change.
// Chrome commits a saved level as two renders and jsdom as one, so this drives the general
// case directly — the user moved the focus somewhere else while the request was out, and that
// somewhere else disappears later, several renders on.
vi.useFakeTimers();
try {
// Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering.
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
const elsewhere = document.createElement("button");
document.body.append(elsewhere);
elsewhere.focus();
// Cancel closes the box; the watch is armed on `elsewhere`, which is still in the page.
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" }));
expect(document.activeElement).toBe(elsewhere); // their place is kept, nothing is stolen
// Renders keep coming (the countdown ticks) while it is still there, and must not spend it.
await vi.advanceTimersByTimeAsync(60_000);
expect(document.activeElement).toBe(elsewhere);
elsewhere.remove(); // now the focus really has fallen to <body>
expect(document.activeElement).toBe(document.body);
await vi.advanceTimersByTimeAsync(30_000);
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.DEBUG"));
} finally {
vi.useRealTimers();
}
});
it("the ticking status line is not a live region", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
const status = screen.getByTestId("diagnostics-status");
expect(status.closest('[role="status"], [aria-live]')).toBeNull();
});
it("a `me` fetch that left before the change cannot put the old level back", async () => {
const qc = mount({});
let answerStale: (me: unknown) => void = () => {};
const stale = qc
.fetchQuery({
queryKey: qk.me(),
queryFn: () => new Promise((resolve) => (answerStale = resolve)),
})
.catch(() => null);
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
answerStale({
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 },
});
await stale;
expect(qc.getQueryData<Me>(qk.me())?.diagnostics.level).toBe("TRACE");
expect(screen.getByTestId("diagnostics-status")).toBeTruthy();
});
it("a lapsed level shows as the base level", () => {
const until = new Date(Date.now() - 60_000).toISOString();
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("diagnostics-status")).toBeNull();
});
it("the shared demo account gets an explanation instead of the picker", () => {
mount({ is_demo: true });
expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
});
});
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel } from "../lib/api";
import { levelInForce } from "../lib/diagnostics";
import { updateMeCache, useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
function useRemaining(until: string | null): { h: number; m: number } | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!until) return;
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
const minutes = Math.max(0, Math.ceil((Date.parse(until) - now) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
//
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
// the server keeps it and for how long, and that it lapses; only its button raises the level.
// Returning to Alap needs no consent and happens at once.
export default function DiagnosticsSettings() {
const { t } = useTranslation();
const me = useMe();
const qc = useQueryClient();
const state = me.diagnostics;
const current = levelInForce(state);
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
// The consent box and the status line each go away with the button that closed them — the one
// holding the focus. Put it back on the level picker (its chosen option) rather than let it fall
// to <body>, where the next Tab starts over from the top of the page. Only when it did fall: a
// user who moved on while the request was out keeps their place.
//
// Watched until that button is actually GONE, not for one render: a saved level arrives as two
// updates (the `me` cache, then the closed box), and the first render still has the button in the
// page — measured in Chrome, where a one-shot check spent itself there and the focus then fell.
const pickerRef = useRef<HTMLDivElement>(null);
const closedFrom = useRef<Element | null>(null);
useEffect(() => {
const from = closedFrom.current;
if (!from || from.isConnected) return;
closedFrom.current = null;
if (document.activeElement && document.activeElement !== document.body) return;
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
});
const watchFocus = () => {
closedFrom.current = document.activeElement;
};
const change = useMutation({
mutationFn: api.setDiagnosticsLevel,
meta: { failTitle: "settings.diagnostics.failed" },
onSuccess: (result) => {
watchFocus();
updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics }));
setPending(null);
},
});
const cancel = () => {
watchFocus();
setPending(null);
};
const choose = (level: DiagnosticsLevel) => {
if (level === "INFO") {
setPending(null);
if (current !== "INFO") change.mutate("INFO");
} else {
setPending(level === current ? null : level);
}
};
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
return (
<>
<Section title={t("settings.diagnostics.title")}>
{me.is_demo ? (
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
) : (
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<div ref={pickerRef}>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</div>
</SettingRow>
)}
{pending && (
<div
data-testid="diagnostics-consent"
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
>
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
<li>
{t("settings.diagnostics.consent.server", {
count: state?.retention_days ?? 7,
})}
</li>
<li>{t("settings.diagnostics.consent.never")}</li>
<li>
{t("settings.diagnostics.consent.lapse")}{" "}
<a
href="/privacy#diagnostics"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t("settings.diagnostics.consent.policy")}
</a>
</li>
</ul>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={cancel}>
{t("settings.diagnostics.consent.cancel")}
</Button>
<Button
size="sm"
data-testid="diagnostics-consent-confirm"
loading={change.isPending}
onClick={() => change.mutate(pending)}
>
{t("settings.diagnostics.consent.confirm")}
</Button>
</div>
</div>
)}
{current !== "INFO" && !pending && remaining && (
// Not a live region: the countdown changes every minute, and a screen reader would read
// the whole line out again each time for as long as the page is open.
<div
data-testid="diagnostics-status"
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
>
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
<span>
{t("settings.diagnostics.status", {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
})}
</span>
</span>
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
{t("settings.diagnostics.revert")}
</Button>
</div>
)}
</Section>
<Section title={t("settings.myLog.title")}>
<MyLogViewer />
</Section>
</>
);
}
@@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,

Some files were not shown because too many files have changed in this diff Show More