diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3387919f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1789 @@ +# Changelog + +Every released version of Siftlode, newest first. + +**Generated from `frontend/src/lib/releaseNotes.ts` by `scripts/gen-changelog.mjs` — do not edit by hand;** +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.81.0 — 2026-09-05 + +If you run your own Siftlode, this one is mostly for you: there is now a way back from a bad upgrade, backup scripts that actually restore, and a way to let someone in on an instance that cannot send mail. If you just use Siftlode, the visible part is that a download can no longer quietly blow past your storage limit, and that the box refuses addresses on the server's own network. + +### Features + +- Admins get an account panel: click anyone in Users and it says, in one place, why that person can or cannot get in — whether their address is confirmed, whether an admin has approved them, whether they have a password, which Google account is attached, and whether their address is on the allow-list. Every recovery action for that account sits under the readout, so you pick one having read what is actually wrong instead of guessing. +- Someone locked out of an instance with no email setup can now be let back in. "Create a set-password link" gives you a one-time link to hand them however you like — chat, in person, on paper. It lasts 24 hours, works once, and signs out anyone already using that account. Issuing it changes nothing by itself: a misclick cannot lock anyone out. +- Self-hosters can pin a version. Put `IMAGE_TAG=0.81.0` in your `.env` and Siftlode stays there until you change the line, so an upgrade happens when you decide it does. The docs now also carry a rollback recipe — and say plainly that restoring the dump you took first is the way back that does not lose data. +- The backup and restore scripts work. On Windows they no longer corrupt the dump on the way out of the container, and on the documented self-host layout — the one where you download a single compose file — they now find it. The docs spell out that a backup is three things, not one: the database dump, your `.env` (without `TOKEN_ENCRYPTION_KEY` the restored secrets are unreadable), and your downloaded media. +- Plex works on the public compose files. The read-only media mount and its empty-volume fallback are in both of them, with a short setup section — before, the feature existed but the file you were told to run could not satisfy it. +- Siftlode is licensed, under AGPL-3.0. + +### Fixes + +- A download can no longer exceed your storage quota. The limit was only checked when you queued a job, so a single large download could pass that check and then fill the disk — and once you were at your cap, the download running at that moment was the one with no ceiling at all. Downloads are now bounded by what you have left, and a job that finds nothing left says so instead of running anyway. +- The download box refuses an address on the server's own network, and tells you why in your own language. Pasting an internal address used to make the server fetch it and hand the result back, which turned the box into a way to probe the machine Siftlode runs on. A malformed link is now refused as one rather than failing with a server error. +- One person holding down the sync button no longer spends the shared YouTube budget for everyone on the instance. Sync and playlist pull now say, in plain words, that you have synced a few times just now and that background syncing keeps running either way — a pace limit rather than a failure. +- A password-reset link opened in a tab you are already signed in to now says what to do with it. It used to do nothing at all: the page loaded, and the link appeared broken. The link itself stays in the address bar, so signing out from that same tab picks it up. +- Admin actions that fail now say so. Sending a confirmation link on an instance whose mail is misconfigured used to produce nothing on screen at all, which reads exactly like it worked. +- The row you click to open a user's account panel now looks clickable. + +### Internal + +- E12, five sprints. S1: the backup/restore scripts honour COMPOSE_FILE and the PowerShell pair moves the dump with `docker compose cp` rather than through the pipeline (binary does not survive it on 5.1, and `-Encoding Byte` does not exist on 7); docs gained an Upgrading section, and `siftlode release` now tags `v$VERSION` and refuses on a stale CHANGELOG.md. S2: AGPL-3.0, the Plex mount and `init: true` on the worker in both public compose files. S3 (SEC-12/13/14): a URL guard that resolves the host and refuses loopback/private/link-local at enqueue AND again in the worker, a generalised network-class error so it stops working as a port oracle, playlist_items/max_downloads/max_filesize derived from the caller's remaining quota, and a per-user cooldown on the manual sync triggers. S4: the three E9.5 deferrals (DX-16, DX-17, canonical resolution in the setup wizard). S5 (SEC-15/16): the account panel and the admin-issued set-password link, after a mockup round. +- The closing review ran six rounds across eight dimensions and converged 47 → 34 → 15 → 7 → 6 → 0. Round one's 47 deduplicated to 20 and 18 survived 60 adversarial verifier runs. Rounds two, four and five each found that a previous round's own fix had broken something — the ops half every time, because no gate can run shell scripts or a documented command. The three worth recording: the per-user quota refusal was writing onto a MediaAsset shared between users, so one person hitting their cap failed a second person's queued job for the same video with the first one's reason; the restore scripts' automatic restart re-ran `alembic upgrade head`, undoing the restore on the very rollback path the docs recommend; and the command printed in its place resolved to the other compose file, whose volumes differ, recreating the database empty. Two fixes were reverted rather than patched a third time. Round five also caught that the loop had been running seven of the gate's eight lanes. Residuals documented rather than closed: SEC-17 (redirects walk past the URL guard — the same socket-level fix as DNS rebinding), SEC-18 (the playlist push routes), SEC-19 (the edit path does not consult the byte cap). + +## 0.80.0 — 2026-09-05 + +Getting into Siftlode used to take nine steps, three of which could quietly end in nothing — and one of which deleted the password you had just chosen. It is now one mail and one click in the usual case, every screen says which of the two gates you are actually waiting on, and the messages arrive in your own language. + +### Features + +- Siftlode's emails are now written in Hungarian or English, whichever you use. Every message — confirming your address, an approval, a password reset, a role change, an account closure — follows the language you signed up in or later chose in the app. Nothing else on the site changes: a mail arrives once, in whatever language it was written in, which is the one place you cannot simply switch and re-read. +- Being approved usually takes one message now. If you have already signed up and your address is still unconfirmed, the approval mail carries the confirmation link itself and says so — you no longer have to find and open a second message that arrived hours earlier. When you have not signed up yet, it says a confirmation mail will follow, so its absence is something you would notice. +- The two gates — confirming your email and being let in by an admin — no longer have to happen in a set order, and the screen names the one you are actually waiting on, with the exact address it has on file. If your address needs confirming, it offers to send a fresh link on the spot. +- Signed in with Google under a different address than the one you registered with? Settings and the account menu now show both, so the pill saying "Connected" also says who you are connected as. It only appears when the two differ. +- Admins can see the people who signed up but still cannot get in, and which of the two gates each one is stuck behind. One click lets in someone whose address was approved before they registered, and a resend button gets a fresh confirmation link to anyone whose first one went astray — and says plainly if the instance has no working mail setup rather than reporting a send that never happened. +- Whitelisting an address stays silent, as it always has, but telling that person is now its own button — with a dialog that picks the language to write them in, and warns you if you have already written to them. + +### Fixes + +- The link in your confirmation email now works wherever you open it. In a browser already signed in to Siftlode it did nothing at all — the page loaded, the link was gone from the address bar, and nothing said whether it had worked; only a private window ever confirmed an address. Pasting the link into a tab that already had Siftlode open failed the same way. +- Gmail addresses that differ only by dots or a +tag are one mailbox again, and Siftlode treats them as one person. Approving hartmann.peter1986@ no longer leaves the account registered as hartmannpeter1986@ locked out, and signing in with Google no longer quietly files a second access request for someone who was approved weeks ago. +- The address you registered with is yours to keep. Signing in with Google used to overwrite it with the Google account's primary address, which cannot contain a +tag — so a me+siftlode@gmail.com sign-up lost its tag and could no longer sign in with the address it had chosen. Password resets and confirmation resends now reach you under any spelling of your mailbox, and the mail goes to the address on your account. +- An admin approving your account no longer deletes the password you chose at sign-up. Because approval usually happens before you read your mail, "your password was cleared for security" had become the normal experience rather than the rare one. +- Signing up again with an address that was already registered but never confirmed sends the confirmation link again. It used to answer with the same cheerful "check your inbox" and send nothing at all, which is the likeliest explanation for confirmation mails that "never arrived". +- Opening a password-reset link that has expired or was already used no longer draws the full "set a new password" form for you to fill in before telling you. It says the link is spent and offers a new one. The check is done without spending the link, so simply opening it costs you nothing. +- A refused sign-in explains itself in your language instead of showing a raw English message, and the emails now say how long their links last and that they work only once. +- The three messages you can be left waiting on now name the spam folder, because the first mail to a brand-new address is the one most likely to land there. + +### Internal + +- E9.5, seven sprints. S1 removed the SEC-10 revoke from the admin-approval path (it stays on Google adoption, where ownership is attested). S2 moved verify-token consumption above App's signed-in/signed-out branch and snapshots the entry URL at import time, so no later URL writer can steal it; a hashchange listener covers fragment-only navigation, which UAT found on a plain-text mailed link. S3 added utils.canonical_email + a CanonicalEmailMixin deriving users/invites.email_canonical on every write (migration 0079), swept all eight matching sites, and kept the typed address for delivery; users.google_email (0080) surfaces the second address only when it differs. S4 gave every password_login refusal the {code, reason} shape and added a non-consuming password-reset/check. S5 added can_activate/count_pending_activations in core plus the admin surface and a badge that counts what actually waits on an admin. S6 branched the approval mail on what is left to do (7-day token) and recorded invites.invited_at (0081). S7 moved twelve mails into app/mailtext.py with a parity test over keys AND placeholders, resolved the language in the request handler rather than the background task, and captured invites.lang (0082) so a decision mail answers in the language the request was made in. +- The closing review ran four rounds across seven dimensions and converged 12 → 4 → 3 → 2, every finding adversarially verified. It caught that clearEntryUrl had been blanking the whole query string on every load — which would have shipped a broken first-run setup — that the env allow-list still matched literally while invites had gone canonical, that invited_at recorded the intent rather than the send, and that three rewritten mailers never rendered in any test. It also refuted S1's own argument that the approval-time revoke was redundant, and then, in round 2, refuted the round-1 fix for that: "the newest registration owns an unverified account" is symmetric, so an attacker can simply register last, and it handed anonymous callers a write onto a real account's password — including the rows SEC-10 had deliberately left password-less. Reverted, with the attempt written into the roadmap so it is not tried again; the residual is SEC-16 and the owner's recourse remains password-reset. + +## 0.79.0 — 2026-09-03 + +A playlist now tells you how long it is, and its header takes half the room it used to — so you see more of the list and less of the frame around it. + +### Features + +- Every playlist header shows the total playtime beside the video count: "32 videos · 2h 17m". It is the whole playlist, not whatever a filter is currently showing, so it answers the question you actually have — is there time for this tonight? +- The header was three stacked rows next to a large cover; it is two tight ones next to a smaller cover. Nothing was hidden behind a menu to get there — the title, rename, the YouTube link, Play all, the sync and delete buttons, group-by-channel, the view controls and undo are all still on screen. That band never scrolls, so the height it gave back is list you can now see: about eighty pixels on a normal window. +- Plex playlists show their total playtime in the same place, in the same words. + +### Fixes + +- The column header no longer jolts upward the instant you start scrolling a playlist. It used to sit twelve pixels below the top of the list and snap flush on the first scroll, which read as the page flinching. +- A playlist’s length is written one way everywhere now. The list on the left said "2:15:30" while the header beside it said "2h 15m" for the very same playlist — one number in two shapes, side by side. +- A playlist shorter than a minute reported "0m". A single Short is 45 seconds long, and the header said it had no runtime at all; it now says "45s". +- A Plex playlist holding exactly one thing said "1 items" in English. + +### Internal + +- 12D reads the durations already in hand rather than adding an endpoint: both headers reduce over their own items, and formatRuntime grew a sub-minute tier because an aggregate is the first input that can legitimately land between 1 and 59 seconds — the fix lives in the function, not behind a `>= 60` guard at each of its seven call sites. PlaylistsRail moved off formatDuration onto the same helper so a playlist total has one renderer. 12A keeps every control and its gating (`items.length > 1 || viewActive`, editable/canWrite/canEdit, linked && dirty) byte-for-byte; 12B drops the wrapper’s pt-3 and passes fadeTop:false for the playlists page, matching the channel table. formatRuntime had no unit tests across seven call sites and now has a tier table plus a sweep asserting no non-zero second ever renders as "0m". Epic-end deep review over five dimensions: eighteen raw findings, ten distinct, two dropped after 3-of-3 adversarial refutations (meta-line segment order; the null-duration lower bound, which matches the server’s own long-shipped SUM), six fixed, two logged as deferrals. Rounds converged 6 → 1 → 0. + +## 0.78.8 — 2026-09-02 + +Links in Siftlode's emails are now real links in every mail app, not just the ones that guess. + +### Fixes + +- Siftlode's messages were plain text, which left it to your mail app to decide whether the address in them was clickable. Gmail, Outlook and Apple Mail do that for you; lighter web-based mail readers show the address as plain text you have to select and copy by hand — in a message whose whole point is that one link, like confirming your email or resetting your password. Every message now carries a formatted version alongside the plain one, so the link is a link wherever you read it. + +### Internal + +- _send() attaches an HTML alternative generated from the same plain body rather than per-template HTML that would drift from it: one converter, all eleven templates, text/plain still the primary part. Escaping runs before linkification — bodies interpolate untrusted values (a requester's address, the operator contact) — so no caller can inject markup, and a query string's & is already & when it reaches the href. Sentence-final punctuation is trimmed out of the href while url-safe token characters (- and _) are left alone. Inline styles only: no images, no external CSS, no tracking. + +## 0.78.7 — 2026-09-02 + +The verification link in your sign-up email did nothing — it opened a plain sign-in page. Accounts an admin approved before that link was clicked lost the password they registered with, and could not get a reset link either, which left them shut out with no way back. + +### Fixes + +- Confirming your email works again. The link carries its token in the part of the address after the #, and the app was wiping that away as it started up, before the sign-in card ever read it — so the click looked like it had done nothing at all. Password-reset links arrived through the same door and were lost the same way. +- If an admin approved your account before you confirmed your email, the password you chose at sign-up is deleted for security — someone else could have registered your address and chosen it. That part is intended, but it used to be a dead end: sign-in refused you, and Forgot password sent nothing, because the account no longer looked like a password account. It does now, so a reset link reaches you and you can choose a new password. +- Confirming your email in that situation now says so, and offers the reset in the same breath, instead of leaving you to work out why a password you just typed correctly is being refused. +- Existing accounts already stuck this way are repaired on upgrade — including those that had confirmed their email after the fact, who had the least way out. An account that has only ever signed in with Google is deliberately unaffected: it still adds a password from Settings while signed in, not by email. + +### Internal + +- urlWithParams/clearParams now carry window.location.hash. The nav-param mirrors (useUrlMirror in the FeedFilters/Playlists/Plex/Channels providers) mount ABOVE the lazily-loaded , so their mount effect replaceState'd the fragment away before AuthCard snapshotted it — the emailed token never reached POST /auth/verify. New users.password_revoked_at (migration 0078) records SEC-10's revoke so _send_reset_if_eligible can tell a stripped account from a Google-only one; cleared on reset-confirm and set-password. POST /auth/verify returns password_reset_required, driving an ok_reset banner. The backfill stamps every local, non-demo, passwordless row — deliberately not also requiring email_verified=false, since the revoke leaves the verify token usable and those victims have no other route back. Deep-reviewed across seven dimensions: nine findings, one confirmed (that backfill predicate) and fixed; the rest logged. + +## 0.78.6 — 2026-09-01 + +Admins can delete a whitelist entry, and an instance that cannot send email finally says so — in the inbox when a message is dropped, and on the screens where it matters. + +### Features + +- Delete an entry under Users → Access requests: an address you whitelisted earlier, or a request nobody has acted on. Deny records your decision and keeps the record; delete forgets the address, and a later request from it arrives as a new one. Entries that already have an account behind them show the control disabled — that row is the account's approval gate, so it is managed from Users & roles, where deleting the account removes the entry too. If the address is also allow-listed in the server's configuration, the confirmation warns you before you commit, because then removing the entry does not actually take access away. +- When a message cannot go out, every admin gets it in their inbox, naming one of the messages that were lost — an access request, a verification link, a password reset — so you know what your users did not get. Sending is deliberately fail-soft so a mail problem never breaks sign-up, but that used to mean it left no trace anywhere. Throttled to one notice an hour, so a dead relay cannot bury the inbox; the log keeps the full list. +- A standing warning where it can be acted on: on Configuration → Email / SMTP while the settings are incomplete, and above the access-request list, where it explains that nobody is getting a verification link and no request will reach you by email. + +### Fixes + +- On the sign-in card, Create an account / Forgot password / Didn't get the verification email are proper history steps now, so Back — including the mouse's back button — returns to the previous view. It used to move the address bar and leave the form on screen unchanged. + +### Internal + +- DELETE /api/admin/invites/{id}, refused with 409 while a User row exists for the address, whatever its state — orphaning an account from its invite would leave someone who can neither sign in nor be approved. The list carries has_account so the UI disables rather than hides the control, and the response reports still_allowed_by_env, since is_allowed falls back to ALLOWED_EMAILS / ADMIN_EMAILS once no row exists. Audited as invite_delete. +- email._send() records an email_undelivered notification for every active admin on both the not-configured and the send-failed path, opening its own session and swallowing its own errors so the alert can never become a new way to break a send; a failed or unnecessary write releases the throttle rather than costing the next hour. /api/me carries email_configured for admins, read through the request's own session. The login card's mode moved to useHistorySubview, covered by a render test that fails if it goes back to useState, and the per-test rate-limiter reset now sweeps every loaded app.* module instead of a hand-kept list. + +## 0.78.5 — 2026-09-01 + +On an instance that cannot send email, signing up with an address that had already been whitelisted or had already requested access created no account at all. The sign-up said it worked, nothing existed, and trying again did the same. + +### Fixes + +- Such a sign-up now creates the account. Before, there was nothing behind the request for an admin to approve, and every retry silently repeated the same nothing. Other sign-ups were unaffected — a brand-new address, one that had been denied and was asking again, and every sign-up on an instance with working email all created their account as expected. +- Admins: a new account still starts inactive. If the person had merely requested access, approve their request as usual. If you had already whitelisted their address, there is no pending request to approve — re-enter the address in the whitelist box under Users → Access requests and that activates their account. +- Operator note: an incomplete SMTP setting makes Siftlode skip every message silently by design — that is what left the sign-ups above with nothing to fall back on. If your instance has never delivered mail, check Configuration → Email / SMTP and use “Send test email”. + +### Internal + +- Registration commits the new account explicitly. upsert_pending_invite() commits only when it actually writes an invite row — not when the address already has a pending or approved one — and the only other commit on that path is gated on working SMTP, so with mail unavailable the flushed user row was rolled back when the background task's session closed. That one line is the whole behavioural change in production; the only other shipped edit is a test-only RateLimiter.reset() helper. Two attempts to also fix the pre-approved account's activation UX were reverted after review — roadmap UX-25 records both dead ends and the safe shape. +- Tests: three that fail without the fix (pre-approved and already-pending sign-ups on a no-SMTP instance, and the admin-activation recovery that depends on the account existing), guards for the unknown-email, previously-denied and SMTP-on branches and for verification not granting access on its own, the first end-to-end tests of the /auth/register route and its allow_registration gate, unit tests for the rate limiter, and an autouse fixture resetting the module-level limiters between tests so their windows stop pooling across the suite. + +## 0.78.4 — 2026-08-30 + +A fix for stuttering and stalling when the Plex player transcodes a video — playback now starts cleanly and keeps a healthy buffer, whether you play from the start or resume/seek partway through. + +### Fixes + +- A transcoded Plex video (any format your browser can't play natively) no longer rebuffers every second, and resuming or seeking into one no longer leaves it stuck on “Loading…” forever. The player now starts the stream at the position you asked for and buffers ahead, instead of chasing the live edge of an in-progress transcode. + +### Internal + +- The player builds its HLS client to start at the requested position (the transcode session already begins encoding there), which fixes both the every-second rebuffer and the resume/seek stall in one change. Verified against the HLS library's live-vs-VOD start logic and put through a multi-agent deep-review (correctness, player behaviour, test coverage) that came back clean. + +## 0.78.3 — 2026-08-30 + +A new Plex filter: narrow the movie library by how each title plays — Direct Play, Direct Stream, or Transcode (hardware or software). See at a glance which titles are light on the server and which need a re-encode. + +### Features + +- Filter movies by playback class in the sidebar: Direct Play (plays as-is), Direct Stream (a light remux), Transcode · HW (re-encoded on the GPU) and Transcode · SW (re-encoded on the CPU — the heaviest). Pick several to combine them. A small coloured dot on each movie card shows its class at a glance. + +### Internal + +- The class is derived at sync from the codec, then refined by a bounded background probe that reads each transcode title's real bit depth/HDR — so a 10-bit HEVC that needs the heavier software path is labelled correctly. Went through several multi-agent deep-review rounds (probe-freshness on file replacement, facet self-exclusion, HU/EN parity, an accessibility label on the class dot) — re-reviewed until a round came back clean. + +## 0.78.2 — 2026-08-30 + +HDR and 10-bit videos in the Plex player now transcode on the server GPU instead of falling back to the CPU. HDR is tone-mapped to SDR on the GPU so the picture looks right in the browser, at a fraction of the CPU cost. + +### Features + +- A 10-bit / HDR video (HDR10, HLG — common on 4K and modern HEVC files) that needs transcoding now runs on the Intel GPU, tone-mapping HDR→SDR on-device so colours look right in the browser and the CPU is barely touched. Previously these fell back to a heavy software encode; now the GPU does the work. If the GPU path can't start for any reason, playback still falls back to software automatically, so a video never fails to play. + +### Fixes + +- A 10-bit video that is NOT HDR (standard-range 10-bit, common in anime and some HEVC encodes) is no longer run through the HDR tone-curve — it gets a plain down-convert, so its colours are preserved exactly instead of being subtly darkened. + +### Internal + +- The transcode pipeline now decides hardware vs software (and HDR tone-mapping) from a real probe of the source, and the GPU HDR path uses the Intel OpenCL runtime baked into the image. The whole change went through several multi-agent deep-review rounds — including an adversarially-verified fix to a start/seek race in the session fallback — re-reviewed until a round came back clean. + +## 0.78.1 — 2026-08-30 + +A fix for how downloads and uploads are recognised: an audio file with embedded cover art (or a still image) is no longer mistaken for a video, so it gets the right player and the right controls. + +### Fixes + +- A downloaded audio track that carries album art — and some files from sites that report a video stream that isn't really there — were sometimes classified as video, giving a Play button that opened a broken, picture-less player. Siftlode now decides what a finished file actually is by inspecting the file itself, so audio stays audio, images stay files, and only real videos get the video player. + +### Internal + +- The media-kind classifier now trusts the on-disk probe over the source site's metadata whenever the probe could read the file, so stale metadata can't resurrect a video track the file doesn't have. Went through multi-agent deep-review, findings fixed and re-reviewed until clean. + +## 0.78.0 — 2026-08-30 + +The Plex player now plays any format. Videos in codecs your browser can't open natively — HEVC/H.265, VP9, AV1, MPEG-2 and older formats — are transcoded on the fly, hardware-accelerated on a supported server GPU with an automatic software fallback. Plus three player touches: an up-next card at episode end, on-screen 10-second skip buttons, and a stats overlay. + +### Features + +- Play any Plex video in the browser, even formats it can't open natively (HEVC/H.265, VP9, AV1, MPEG-2, older codecs). Siftlode re-encodes the video on the fly to something every browser plays, with seeking, subtitles, audio-track switching and audio offset all still working. It uses a supported server GPU when available (barely touching the CPU) and falls back to software automatically, so playback just works. +- At the end of an episode, an up-next card shows the next one with a ~10-second countdown — let it advance, hit “Play now”, or Cancel to stay on the finished frame. Binge on your terms instead of an instant jump (on by default; togglable). +- On-screen 10-second rewind and forward buttons now flank Play/Pause, matching the ← / → keys — a discoverable control for touch and mouse, distinct from the previous/next-episode buttons. +- A “stats for nerds” overlay (press D) shows the live playback mode (direct / remux / transcode), resolution, dropped frames, buffer and more — handy for seeing exactly how a video is being served. + +### Fixes + +- While a transcoded stream warms up, the player shows an honest “Preparing stream…” instead of a plain spinner, so a slightly longer start doesn't read as a hang. +- The browser (and mouse) Back button now closes the stats or up-next overlay first, instead of jumping straight out of the player. + +### Internal + +- The transcode path uses VAAPI hardware encoding on a supported Intel GPU, with a de-prioritised, concurrency-capped software fallback so it never starves the rest of the app. The whole epic went through a multi-agent deep-review — findings fixed and re-reviewed each round until a round came back clean. + +## 0.76.0 — 2026-08-29 + +The Download Center becomes a real media library: play your downloaded and uploaded video and audio right in the app, upload any file into the same library, and share anything in one click. Plus a fix so a cropped clip is framed exactly as you selected. + +### Features + +- Play a finished download or upload in the app — video and audio open in a proper in-app player with seek, volume and fullscreen, no separate download needed. +- Upload your own files into the library. Video and audio join with the full function set (play, edit, share); any other file (PDF, zip, document…) joins as a download-only item with its own info card. Drag-and-drop or pick a file, with a live progress bar you can cancel. +- One-click Share: the Share button now creates a link (if there isn't one), copies it, and offers “open in new tab” — all in a single click, downloadable by default. The full link controls (password, expiry, revoke, share-with-a-user) stay one click away. +- Library rows now show the file format and size at a glance (e.g. “MP4 · 142 MB”), and a shared link to a plain file shows a clean info card with a Download button. + +### Fixes + +- Cropping a clip in the editor now produces output framed exactly as the on-screen selection showed — the crop rectangle and the saved result no longer drift apart on a non-16:9 video. +- Every download is now guaranteed to play in any browser (including iPhone/iPad): the output is a universally-playable MP4/H.264 (or M4A/MP3 for audio), so “Play” always works. + +### Internal + +- The whole epic went through several multi-agent deep-review rounds — findings fixed and re-reviewed each round until a round came back clean. Included a security hardening pass on the new upload path (an uploaded file can never be served in a way that lets it run code in your browser) and a fix so a failed upload never leaves an orphaned file on disk. + +## 0.75.0 — 2026-08-29 + +A grab-bag batch across Plex, downloads and playlists: your watch state stays truer to Plex, shared audio plays on iPad, downloads from a site you're logged into work with your own cookies, and syncing a playlist to YouTube is now a true two-way merge with a preview. + +### Features + +- Syncing a playlist to YouTube is now a true two-way merge: a video you added on YouTube and one you added in Siftlode both survive, genuine removals carry across, and you always see a preview of exactly what will change on each side before anything is written. +- Download from a site that only shows a video to logged-in members (like a private Facebook group you belong to): paste your own browser cookies for that site under Settings → Account, and downloads from it use your login. Cookies are encrypted, kept private to your account, and only ever sent to the matching site. +- Shared audio files (mp3) now play on iPhone and iPad, in a proper audio player instead of a broken video box. + +### Fixes + +- A movie you finish on Plex no longer lingers as "in progress" in Siftlode until the nightly reconcile — scrubbing through the end credits now marks it watched within the next sync. +- A download that fails because the site needs a login you haven't set up now says so clearly and doesn't offer a pointless Retry; a genuinely temporary failure still offers one. +- The Plex sort control's direction toggle no longer wraps onto two lines on the narrower popover, so it matches the feed's. +- Adding a download no longer makes the app feel sluggish while it's converted in the background — the conversion is de-prioritised so the rest of the app and your share links stay responsive. + +### Internal + +- Introduced one shared action-button primitive across the app (the channel Subscribe button now matches Full history), and put the whole batch through several multi-agent deep-review rounds — findings fixed and re-reviewed each round until it came back clean, including data-integrity fixes to the new playlist merge. + +## 0.74.0 — 2026-08-28 + +A grab-bag batch across the Plex player, Plex browsing, and the Channel Manager: smoother episode auto-advance, your watch progress kept in step with Plex, shareable browse links, and an editable Personal Views panel. + +### Features + +- Share a Plex browse view — or a Channel Manager view — just by copying its link. The scope, filters, sort, and active tab all live in the address bar now, so the link opens (and reloads) to exactly what you were looking at. +- New “Last watched” sort for Plex, applied automatically when you switch to the In-progress view so the thing you were most recently watching is right at the top — and your previous sort comes back when you leave. +- Personal Views are editable in place: remove a captured filter as a chip (with undo), or save your currently-applied filters into an existing view — no need to delete and recreate it. +- The channel detail page gained an Unhide button, and the tag “+” now sits at a fixed spot so it no longer drifts to the side as you add tags. + +### Fixes + +- Fixed the Plex player briefly stalling a few seconds into each auto-advanced episode; a loading indicator now shows while the stream warms up or while you seek. +- Your Plex watch progress now stays in step with Plex as the source of truth — resuming, finishing, or rewinding on one side carries across to the other far more promptly. +- Redesigned the channel priority control into a cleaner −/+ stepper with click-to-type, on both the manager table and the channel page (no more “popping” wider when you edit it). + +### Internal + +- The batch went through a multi-agent deep-review pass — findings fixed and re-reviewed until clean, including two data-integrity fixes to the new watch-sync and shareable-link work. + +## 0.73.0 — 2026-08-26 + +Feed filtering and the Channel Manager catch up with Plex: filter to channels you haven't tagged, filter the feed by year, hover any card for its full details, and let Siftlode suggest your own tags for untagged channels based on how you've tagged similar ones. + +### Features + +- New “No tag” filter — narrow the feed and the Channel Manager to channels you haven't personally tagged yet, so what still needs organising is easy to find. +- Filter the feed by year: quick chips for recent years with per-year video counts, plus a “More…” dialog listing every year, sortable by year or count. +- Hover (or keyboard-focus) any feed card for an info box with the full title, channel, date, duration, a description snippet, and the channel's tags — without opening the video. +- The Channel Manager now suggests your own tags for untagged channels, learned from how you've tagged similar ones. Accept a suggestion with one tap, dismiss it, or accept all the confident ones at once. + +### Internal + +- The whole epic went through several multi-agent deep-review passes — findings fixed and re-reviewed each round until it came back clean. + +## 0.72.1 — 2026-08-24 + +Polish and fixes on top of the Plex overhaul: Play-all now works on a collection of shows, your default Personal View applies on a fresh visit, and card buttons behave properly on touch and keyboard. + +### Features + +- Play-all (and Shuffle) now work on a collection of shows — they play through every episode in order, not just standalone movies. If a collection has nothing playable yet, it now tells you instead of doing nothing. +- Your starred default Personal View now applies automatically the first time you open Siftlode in a fresh browser or tab — on both the feed and Plex — without needing a reload. + +### Fixes + +- Card actions that appear on hover (like Mark watched) no longer sit invisibly over the card on touchscreens, and no longer catch keyboard Tab focus while hidden. On touch you just tap the card; with a keyboard, focusing the card reveals them. +- In the video player, pressing Escape once now closes only the open “Add to playlist” menu; a second Escape closes the player — instead of both closing at once. + +### Internal + +- Shared the filter-chip overflow logic behind the feed and channel-tag rows, and put the whole batch through two multi-agent deep-review rounds (findings fixed and re-reviewed until clean). + +## 0.72.0 — 2026-08-23 + +A big Plex browsing overhaul: a cleaner filter bar, a full Collection page you can edit straight through to Plex, poster-collage tiles, new Discover rows, and scroll position remembered as you move around. + +### Features + +- The Plex filter bar now matches the feed: watch-state (All / Unwatched / …) moved out of the sidebar into a toolbar pill, the collection filter is multi-select with an Any/All switch, and the playlists picker is a compact search box. Your active filters show as removable chips. +- The Discover ↔ Browse switch and new Playlist / Collection browse modes live in the header pill now, so switching how you look at Plex is one tap from anywhere. +- Discover rows now honour your sidebar filters — narrow to a genre or a collection and the rails update to match, instead of jumping you to the full grid. +- New Collection page: open any collection for a proper hero (artwork, year range, certificate, total runtime, summary), Play-all or Shuffle, and — for your own manual collections — rename, add or remove titles, delete, and create a brand-new collection. Every edit writes straight through to Plex; smart and external (IMDb/TMDb/…) lists are clearly marked read-only. +- Browse-by-genre and collection tiles now show a poster collage of what's inside, falling back to a colour when there's no artwork yet. +- A new “Recently watched” row on Discover, and “Surprise me” now only suggests things you haven't seen, capped to a handful of movies and shows. +- Your Personal Views (saved filter setups) now work on Plex too, alongside the feed. +- Plex remembers your scroll position per page, so going Back returns you exactly where you were, while opening something new starts at the top. + +### Internal + +- The whole batch went through two multi-agent deep-review rounds (findings fixed each round, then re-reviewed until clean). + +## 0.71.0 — 2026-08-21 + +A much richer Plex experience: a Discover home, detailed movie/show and cast pages with ratings, facts, keywords and crew, a gently crossfading backdrop, and searchable filters. + +### Features + +- New Plex “Discover” home. Instead of opening straight into the full grid, Plex now greets you with rows — Continue watching, Recently added, browse-by-genre tiles, a “Surprise me” shuffle, and “Because you watched …” recommendations. The full grid is still one tap away under “Browse”. +- Movie and show pages are much richer: several rating sources side by side (IMDb, TMDb, audience), a facts panel (release, runtime, certificate, budget, revenue, studios, countries), tappable keyword chips that filter your library, and the full key crew — click a name to open that person’s page. +- Cast and crew pages now show a biography, birth facts, and a “Known for” row of that person’s titles you have in your library. +- Detail and person pages gain a slow, gently crossfading backdrop drawn from the title’s artwork. It respects a reduced-motion preference and can be switched off from the page’s customise menu. +- Clearer Plex filters: type-to-search pickers for genre, age rating and a new studio filter, plus the collection filter — each shows your choices as removable chips and is fully keyboard-operable. + +### Internal + +- All the enriched data — ratings, keywords, crew, budgets, biographies and recommendations — is fetched from The Movie Database in the background and stored locally, so pages stay fast and keep working even when TMDB is unavailable. Reviewed with a standard review pass and a multi-agent deep review. + +## 0.70.0 — 2026-08-20 + +The address bar now shows a real link for what you're viewing, so you can bookmark or share a channel, a Plex title, a playlist, or a filtered feed — and card buttons work on touch screens. + +### Features + +- You can now bookmark or share the exact view you're on. The address bar reflects the open channel page, a Plex movie/show, a selected playlist, or the feed with its filters applied, so copying the link reproduces that view for you or anyone you send it to. This replaces the old “Share view” button, which is no longer needed. (The browser tab keeps showing the short page name.) + +### Fixes + +- On touch screens the buttons on a video or Plex card — mark watched, save, add to playlist, info — are now always visible and tappable, instead of only appearing on mouse hover. Watched items also show a filled check, not colour alone, so the state is clear without relying on colour. +- Opening and closing a dialog no longer closes a channel or Plex page you reached from a bookmarked or shared link. + +### Internal + +- A large internal front-end refactor with no behaviour change: several oversized screens (the Plex browser, the video player, the downloads page) were split into focused files; account preferences and the address-bar handling were folded into single reusable hooks; the three filter sidebars now share one shell; and the pop-up dialogs gained proper keyboard focus handling. Covered by the existing tests plus two review passes — a standard review and a multi-agent deep review. + +## 0.69.2 — 2026-08-19 + +Download thumbnails now load for everyone, including multi-account sessions. + +### Fixes + +- Fixed Library thumbnails staying blank when you're signed into more than one account. The poster image is loaded directly by the browser, which can't send the active-account header, so it resolved to your default account and returned “not found” for downloads owned by another of your accounts. The poster URL now carries the active account (the same way the file download and filmstrip already do), so the local poster loads regardless of which of your accounts is active. + +## 0.69.1 — 2026-08-19 + +Smoother fast-scrolling in the feed, reliable download thumbnails, and a tidier channel About popover. + +### Fixes + +- Download thumbnails are reliable again. The Library now shows the copy saved on the server instead of the source's own link — which for Facebook videos expires within days (and can be blocked by a browser tracker shield), leaving a blank thumbnail even though the image was on disk the whole time. +- Fast-scrolling the feed no longer stalls. A quick flick of the scroll wheel used to outrun the loader and leave you waiting several seconds on “Load more”; the feed now keeps a couple of screens of videos loaded ahead, so the content is usually already there when you reach it. +- The channel About popover stays on screen at higher browser zoom. A long description could push the top of the box above the edge of the window where you couldn't read it; it now shrinks to fit the available height and scrolls inside. +- Channel descriptions are tidier: the runs of blank lines YouTube pads its contact/link blocks with are stripped, so the About box is more compact. + +## 0.69.0 — 2026-08-19 + +Mostly under-the-hood consolidation of the backend, with a few reliability wins you'll notice in Plex and downloads. + +### Fixes + +- Movies and shows removed from Plex now disappear from your library on the next sync, instead of lingering and failing to play. A safety guard skips the cleanup whenever Plex returns an incomplete listing, so a temporary hiccup can never wipe a library. +- Downloads are more reliable: a slow re-encode is no longer mistaken for a stalled download and stopped; queuing a second copy of the same video while the first is still downloading no longer blocks the queue behind it; and canceling or deleting a download can no longer remove a file another download still shares, even when several run at once. + +### Internal + +- A large internal refactor with no behavior change: the Plex browse and download-worker god-modules were split into focused modules, download state transitions and file bookkeeping were centralized, five unused endpoints were removed, admin config settings were unified into one store, and the request contracts and module docs were tidied — all under new automated tests and four clean code-review passes. + +## 0.68.0 — 2026-08-18 + +A big performance pass on the feed and search: pages load and search returns near-instantly, even on a large library, and marking videos no longer stutters. + +### Fixes + +- The feed loads much faster. The default “newest” feed and channel pages now use a matching database index instead of scanning the whole video table on every page, so scrolling stays quick even with hundreds of thousands of videos (this was the likely cause of the occasional slow feed). +- Search is dramatically faster — a search that could take several seconds now returns in a fraction of one, and still matches both video text and channel names. +- The Watched / Hidden / In-progress views open faster, and deep scrolling (far down a long feed) no longer slows down page by page. +- Marking a video watched or hidden, and closing the player, no longer briefly reloads the whole feed — the change shows instantly and the list stays put. + +### Internal + +- The sidebar's total count and per-tag channel counts now come from a single request + one database pass instead of four, cutting the work done on every filter change. +- The background enrichment and maintenance scans, and the channel search branch, are now index-served instead of full-table scans — much less database load per scheduler run. Backed by new automated equivalence/paging tests and verified end-to-end. + +## 0.67.0 — 2026-08-18 + +A foundation release: reliability and security hardening under the hood, plus a few fixes you'll actually notice. + +### Fixes + +- Canceling or pausing a download can no longer delete a video file that another download is still using — closing a rare race that could remove a file out from under a live download. +- Switching to an account that was signed out elsewhere (after a password reset or “log out everywhere”) now just asks you to sign back in to that one account, instead of logging you out of every account in the switcher. +- A failed YouTube search now shows the real reason instead of a generic “connection lost” message that silently retried and spent extra search quota. +- If the server's encryption key is ever misconfigured, the app now starts and degrades gracefully instead of failing to load at all. + +### Internal + +- Security hardening around account linking and email verification: a password set on an address before it was verified is no longer trusted once the real owner proves ownership (via Google or admin approval), and linking a Google account now always attaches it to the account you're actually using. +- A test safety net for the security-critical core — login and session revocation, feed paging, download finalization, and database-migration drift — that is required to actually run: the test lane now fails loudly instead of silently skipping when its database or tools are missing. +- Reproducible, audited builds: dependencies are pinned to an exact lockfile that is scanned for known vulnerabilities on every build (which caught and cleared 4 cryptography-library CVEs), the runtime moved to Node 22, a server-side CI pipeline runs the full gate on every push, and baseline security response headers were added. + +## 0.66.0 — 2026-08-17 + +A big Plex update: Hungarian metadata, scoped search with instant suggestions, cast & crew pages, and a tidier toolbar that finally works on a phone. + +### Features + +- Hungarian Plex library: with the app in Hungarian, film and show titles, summaries and genres appear in Hungarian (falling back to English where a title has no Hungarian version). Search and the genre filter work in the Hungarian vocabulary too, and switching language clears a genre selection so it can't silently hide everything. +- Scoped Plex search: a pill in the search box narrows a search to Titles, Cast, Descriptions or Episodes, and a live list of matching titles drops down as you type — jumping straight to a film's page or a show's episodes. +- Cast & crew pages: click an actor or director anywhere in Plex to see their filmography across your libraries, or filter the library by that person. +- Ratings now separate an IMDb score (shown with a link to IMDb) from Plex's own audience score, so a title with no IMDb rating still shows its audience score instead of nothing. +- Plex toolbar tidy-up: the movie/show scope sits next to the search box and the sort control sits above the cards (like the feed), and the header no longer collapses or overlaps its own controls on a narrow window or a phone. + +### Internal + +- Large internal refactor of the Plex module (search/card/query builders split out, the feed read-model extracted, the database queries modernized) backed by characterization tests, plus a full-codebase review. No behaviour change intended. + +## 0.65.2 — 2026-08-16 + +Fixes the automatic cleanup of expired downloads, which had quietly stopped running. + +### Fixes + +- The scheduled cleanup of downloaded videos works again. A download with a very long title — for example a Facebook or Instagram reel, whose entire caption is used as the title — could jam the cleanup job, so expired downloads stopped being removed and their disk space wasn't reclaimed. Over-long titles are now trimmed for the notification only (the full title is kept everywhere else), so the cleanup runs to completion on schedule. + +### Internal + +- Added a regression test that a very long download title can no longer break the cleanup job, and enabled a Postgres query-statistics extension on the server so a transient slow-loading report can be diagnosed if it recurs. + +## 0.65.1 — 2026-08-16 + +“Get full history” now finishes very large channels in one pass instead of appearing to stall. + +### Fixes + +- Asking for a channel's full history now completes very large channels (thousands of videos) in a single pass. Previously a channel just over ~3,000 videos would look stuck — the button kept spinning while the rest of the catalogue trickled in over the background schedule for many minutes. The button now also shows a live “stored / total” count while it fills. + +### Internal + +- Backend hardening: the full-history backfill now saves its progress after every page (so an interrupted run resumes where it left off instead of re-fetching and re-spending API quota), and the shared daily-quota guard was centralized so a long run no longer holds a database connection open during each network call. Added automated tests that lock in both behaviours. + +## 0.65.0 — 2026-08-15 + +The channel manager table now fits your screen at any zoom, and asking for a channel's full history fills it in seconds instead of over many minutes. + +### Features + +- Channel manager table redesign: it fits the width at any zoom now (no more sideways scrolling), fully-synced channels get a ✓ next to their stored count, and the crowded N/S/L and Sync columns are gone — the channel-type breakdown moved to the channel page. +- “Get full history” is now immediate: clicking it fills the whole channel's back-catalogue in seconds rather than waiting for the background schedule, and the channel page's counts climb live as it fills — no need to refresh. +- The channel page shows the Normal / Shorts / Live breakdown for the videos it holds. + +### Fixes + +- The channel name and description columns now shrink and show “…” (full name on hover) instead of pushing the table off-screen. + +### Internal + +- Backend: the immediate full-history backfill coordinates with the background scheduler so the two never duplicate work, its API-quota use is recorded correctly, and it's capped so one burst can't crowd out others. + +## 0.64.0 — 2026-08-15 + +A consistency pass across the app: sorting and filtering now look and work the same everywhere, and the channel manager toolbar is tidier. + +### Features + +- Hidden channels now have their own “Hidden” tab in the channel manager, separate from Blocked — so you can see and un-hide them at a glance. +- Sorting and filtering work the same way everywhere now — the feed, Plex, the channel manager and discovery all share one sort control and one style of filter chips, with a row of active-filter chips you can clear one at a time. +- Tidier channel-manager toolbar: the catalog actions (read subscriptions, backfill) tuck into a “⋯” menu, the catalog stats collapse into a “Stats” chip you hover for the numbers, and the tags editor opens from the “✎ Tags” label. + +### Fixes + +- Fixed a faint one-pixel jitter where the page could shift slightly when filters changed at certain zoom or text-size settings. + +### Internal + +- Large internal refactor unifying the shared UI controls (sort, filter chips, tabs, overflow menus) behind single reusable components, with new automated tests. No change to how anything looks beyond the consistency improvements above. + +## 0.63.3 — 2026-08-13 + +### Internal + +- Internal player cleanup: the video-player volume logic is now shared between the YouTube and Plex players (one tested core), and the Plex player's settings menu moved into its own component. No change to how either player looks or behaves. + +## 0.63.2 — 2026-08-13 + +### Internal + +- Fairer download queue: when one person queues a very large batch, the worker no longer lets it crowd out everyone else's downloads — each person's work is picked up as their own slots free up. +- Internal database and API cleanup (dropped redundant indexes; bounded the download list queries). No change to how anything looks or works. + +## 0.63.1 — 2026-08-13 + +### Fixes + +- Downloaded videos whose title ends in a period (some Facebook reels, for example) now save their Plex/Kodi info and poster files right next to the video so Plex picks them up — previously those files landed under a slightly different name and were missed. +- The info file for a non-YouTube download (Facebook, Reddit, a direct link) now records the correct source instead of always labelling it as YouTube. + +### Internal + +- Internal refactor: consolidated the per-source download behaviour (YouTube / Facebook / Reddit / generic) behind one small adapter layer. No change to how downloading looks or works. + +## 0.63.0 — 2026-08-12 + +### Internal + +- Hardened the download worker: each download or clip now runs in its own supervised process, so a stuck or misbehaving one can be stopped cleanly and can never freeze the rest of the download queue. No change to how downloads look or work. + +## 0.62.2 — 2026-08-12 + +### Fixes + +- Fixed a download that could get stuck and freeze the whole download queue — a video whose title had a long run of hashtags (common on Facebook reels) would hang and stop everything else from downloading. The queue keeps moving now. +- Reddit links that failed right at the end while preparing the thumbnail now download correctly, and link-share previews (the thumbnail shown in Messenger / Teams) are generated more reliably. + +## 0.62.1 — 2026-08-12 + +### Internal + +- Internal refactor of the Plex player: split the large player component into focused, self-contained hooks. No change to how the player looks or behaves. + +## 0.62.0 — 2026-08-11 + +Actions that used to fail or finish silently now tell you what happened — consistent feedback across Plex, downloads, saved views and access requests. + +### Features + +- Downloads (and clip/crop edits) now drop a notification in your inbox when they're ready, with a link straight to the Downloads page. +- Denied access requests now email the requester, the same way approvals already did — no more silent limbo. + +### Fixes + +- Plex mark-watched / mark-unwatched now shows an error if it fails, instead of the click appearing to do nothing. +- Saved views: a failed rename, reorder, delete or create now shows an error and recovers cleanly (a failed drag-reorder snaps back to the real order), instead of silently swallowing the failure — and a failed create keeps your typed name so you can retry. + +## 0.61.1 — 2026-08-11 + +### Internal + +- Unified the primary, danger and warning action-button styling onto shared recipes, so the same kind of button looks and behaves the same everywhere (a few primary buttons gained the standard subtle hover; nothing about what the buttons do changed). + +## 0.61.0 — 2026-08-11 + +### Features + +- You can now set a channel's feed priority right from its own page — the same stepper as the channel manager, with press-and-hold to change it quickly or click the number to type a value. +- Playing a Plex series now rolls straight into the next season: when a season's last episode ends, the following season's first episode plays automatically. + +### Fixes + +- Refreshed hover on the feed and Plex cards: the thumbnail or poster now gently zooms in place instead of the whole card lifting, and Plex posters gained the same depth and title alignment as video cards. +- The left navigation is now a slim icon rail — each icon shows its name in a small label when you hover it or tab to it with the keyboard. +- The filter sidebar no longer springs open just because you moved the pointer toward the navigation; it opens only when you actually point at its tab. +- With a background image on, Plex artwork now fills the whole screen behind the app instead of only the content area. +- When a video finishes and nothing is queued next, the player returns to the previous page instead of leaving a black screen. + +## 0.60.10 — 2026-08-11 + +### Fixes + +- Selected filters, tabs and chips now share one consistent highlight, so the same kind of control looks the same everywhere — e.g. the channel manager's filter rows no longer differ from each other. + +### Internal + +- Unified the selected / current / active control styling onto shared recipes, which also gives filter pills proper keyboard-focus rings and screen-reader selected state (no behaviour change to what the controls do). + +## 0.60.9 — 2026-08-09 + +### Fixes + +- In dropdown menus, hovering an already-checked row now gives it the same vivid highlight as any other row, instead of only a faint outline. + +## 0.60.8 — 2026-08-09 + +### Fixes + +- Text fields across the app now share one consistent style with a clearer accent focus ring when you tab to them. +- Warning and info banners' action buttons now respond to hover. + +### Internal + +- Performance mode now also drops the heavy background blur behind dialogs and the player. +- Consolidated the input, header-pill and modal-backdrop styles onto shared recipes (no behaviour change). + +## 0.60.7 — 2026-08-09 + +### Fixes + +- Refined the frosted-glass look when a background image is on: the feed cards now let the image gently show through, while the panels and menus stay solid so text everywhere stays readable. + +## 0.60.6 — 2026-08-09 + +### Fixes + +- The text-size setting now scales every label — small counts, badges and captions that used to stay a fixed size now grow and shrink with the slider like the rest of the text. +- At larger text sizes the floating top bar no longer overlaps the content beneath it, and the side rails widen to keep their labels from being clipped. + +## 0.60.5 — 2026-08-09 + +Status colours across the app are now consistent and readable in both light and dark themes. + +### Fixes + +- Success, warning and error colours now hold their contrast in the light theme — some were previously too faint to read on a white background. +- Info notifications now use a distinct blue, so an info message is no longer mistaken for an error in schemes with a red or orange accent. + +## 0.60.4 — 2026-08-09 + +### Fixes + +- Plex: when an actor appears across several titles, the cast/people sync no longer breaks — cast photos and the people filters now populate reliably, including on a first full sync of a newly added library. + +## 0.60.3 — 2026-08-08 + +### Fixes + +- In a menu, the highlight now stays on the row you last hovered when you move the pointer off it, instead of snapping back to the first item. + +## 0.60.2 — 2026-08-08 + +### Fixes + +- Follow-up: made the frosted panels solid enough over a background image that the feed no longer shows through the sidebar and menus. + +## 0.60.1 — 2026-08-08 + +### Fixes + +- With a background image on, the frosted panels (nav, menus, dialogs) are less see-through — the content behind them no longer reads through and distracts. + +## 0.60.0 — 2026-08-08 + +Keyboard focus is now clearly visible across the whole app, menu highlights are steadier, and the styling engine was modernised for consistent theming. + +### Features + +- Keyboard focus is now visible everywhere — buttons, links, menu items and inputs all show a clear accent outline when you move to them with Tab. + +### Fixes + +- A selected item in a menu keeps its highlight when you hover or arrow onto it, instead of fading to a faint tint. +- The main search box, the About tooltip and the share-link field now show a focus ring when reached by keyboard. +- A saved view's row actions (rename, share, delete) are now reachable by keyboard, not only on hover. + +### Internal + +- Migrated the styling engine to Tailwind v4, so theme colours and their opacities render consistently across every scheme and in both light and dark. +- Removed dead styles (unused shadows and animations) and consolidated focus-ring and menu-highlight styling into shared recipes. + +## 0.59.1 — 2026-08-06 + +### Internal + +- Corrected a past release note: the 0.57.0 entry no longer claims background audio for shared videos on iPhone/iPad — that never worked and the attempt was dropped. +- Added the project's first browser-environment unit-test lane and used it to cover the player's nested Back/Escape (fullscreen → maximise → close) behaviour — internal test tooling only. + +## 0.59.0 — 2026-07-31 + +The menus, dropdowns and tooltips across the app are rebuilt on one system — with fuller keyboard and screen-reader support — and the player's Add to playlist and Download now work in fullscreen. + +### Features + +- Player: Add to playlist and Download now work while the player is in fullscreen. +- Channel page: for a channel you follow, your tags are now shown and editable right in the header, next to Subscribe. + +### Fixes + +- Menus and dropdowns now support full keyboard navigation — arrow keys, Home/End and type-to-jump — and return focus to where you were when they close. +- Escape now closes only the menu or dialog on top, instead of also closing the player or window behind it. +- Tooltips and hover cards are steadier and no longer linger or get in the way; the header language switcher now opens on click. +- Channel tags stay on one line in the manager and reveal any that don't fit in a hover card, so the table no longer reflows as you add them. + +### Internal + +- Rebuilt the app's popovers, menus and tooltips on a single anchored-positioning system, replacing ~11 separate hand-written implementations. + +## 0.58.0 — 2026-07-29 + +Housekeeping: a refreshed public landing page and internal frontend cleanup — nothing changes for signed-in users. + +### Internal + +- Landing: the signed-out welcome page has a new screenshot gallery (feed, channels, playlists, player, search, downloads and Plex) and refreshed, plainer copy describing what the app does. +- Frontend: finished splitting the data-fetching layer into per-domain modules behind a thin façade (no behaviour change). +- Docs: the README is brought up to date (feature list, Plex, search behaviour) and its landing screenshot fixed. + +## 0.57.2 — 2026-07-28 + +### Internal + +- Sharing: the shared-video page no longer routes audio through a Web Audio workaround — it interfered with the browser's own media handling on iOS (suspending the audio when the tab was backgrounded, the opposite of the goal). Playback is still advertised to the OS via the Media Session API as before. + +## 0.57.1 — 2026-07-28 + +### Fixes + +- Sharing: a YouTube video's shared-link preview now uses the original YouTube thumbnail, which Facebook Messenger reliably shows — the previously-served self-hosted image was a valid picture everywhere else but Facebook's stricter processor rejected it as “corrupted”. + +## 0.57.0 — 2026-07-28 + +Downloads and channels polish — clearer download failures, a friendlier share flow, and richer channel controls. + +### Features + +- Downloads: the page now opens on the Queue tab, the library shows when each item was downloaded, the Share dialog opens with a ready-to-copy link already created (all the other link options stay available), and you can paste a link from the clipboard with one tap. +- Downloads: a failed item now explains WHY in a readable message — video unavailable, sign-in required, region-blocked, no downloadable format, and so on — with the raw technical detail one click away and a Retry offered when the failure looks temporary. +- Channels: a channel's page now shows how many of its videos are stored versus its true total, with a “Full history” button to pull the rest. The priority arrows repeat (and speed up) while held instead of one click at a time, the value sits where the cursor can't cover it, and you can click the number to type any priority directly — negatives included. + +### Fixes + +- Downloads: thumbnails that had gone missing (when the original video was deleted or its link expired) now fall back to the saved poster instead of showing a broken image. +- Sharing: shared-link previews now reliably include the thumbnail when pasted into Facebook Messenger on the desktop web (dimensions are declared so its scraper shows the image on the first fetch). + +## 0.56.0 — 2026-07-28 + +Internal frontend hardening — the whole client API layer is now type-checked end to end and the largest module was broken up for maintainability. No changes to normal use. + +### Internal + +- Type safety: the fetch wrapper and every one of the ~150 API calls now carry real types, closed-value fields (watch status, playlist kind/source, download job kind) are strict value sets, and every loosely-typed `any` was removed and is now blocked going forward — a whole class of mistakes is caught at build time instead of in the browser. +- Cache correctness: all ~200 React Query cache keys now come from one typed source of truth, so a mistyped key can no longer silently fail to refresh a list after an action. +- Code structure: the ~1800-line API module was split — the shared request machinery and the downloads, Plex, and messaging areas each moved to their own file behind the exact same interface, with no behaviour change. Plus a validation message and browser-tab titles now read from a single source so they can't drift. + +## 0.55.0 — 2026-07-26 + +Internal hardening — every write request is type-checked at the edge, quota accounting and Plex sync hold up cleanly when something fails partway, and a new database-backed test net guards it all. No changes to normal use. + +### Internal + +- API contract: every mutating endpoint (~50) now validates its request body against a typed request model at the edge, replacing scattered hand-written checks. Malformed input is rejected consistently with a clear validation error instead of occasionally causing an internal error, and response status codes were made consistent across modules. +- Transaction correctness: YouTube quota accounting now writes in its own transaction — it stays accurate even if the surrounding job rolls back, and no longer commits a job's partial work early. Plex library sync commits each library as it finishes, so a failure partway through keeps the libraries already mirrored. Existing genre-less Plex rows were normalised. +- Testing: a database-backed integration test lane (an ephemeral Postgres spun up for the test run) now runs in the release gate, covering the transaction behaviour and the Plex genre-less-library fix that the pure-logic tests couldn't reach. + +## 0.54.0 — 2026-07-26 + +A sturdier player — press F to go big, arrow-key volume that's remembered — plus downloads and subscription sync that hold up when something goes wrong. + +### Features + +- Player: press F to maximise the video to a full-window view — now with its own control bar (previous/next, auto-advance, loop, add-to-playlist, download, mark watched) that auto-hides while you watch, plus a button for true fullscreen. Scroll-wheel and ↑/↓ volume work in every mode (the full window and fullscreen too), and your volume is remembered per account. +- Volume: Ctrl+scroll zooms the page as usual again instead of being captured by the player. Keyboard shortcuts keep working after you click into the video, and the mouse/browser Back button now steps out one level at a time (fullscreen → full window → the normal player → closed) instead of closing the whole player at once. + +### Fixes + +- Downloads: fixed a class of downloads that could fail with an internal error (a produced file the downloader couldn't locate), and mp4 downloads are no longer needlessly re-encoded — they finish faster and keep their original quality. +- Downloads: leftover temporary playback files from an interrupted session are now cleaned up automatically on restart, and a cancelled download stops reliably instead of hanging. +- Subscription sync: a one-off empty response from YouTube can no longer wipe your subscription list; playlist sync now stops safely once a daily quota limit is hit instead of burning through the rest; and a permanently unavailable channel is reported instead of silently looking healthy. + +## 0.53.1 — 2026-07-25 + +The Plex page no longer errors when a mirrored library has items without genres. + +### Fixes + +- Opening the Plex module could fail with a server error when a mirrored library contained items with no genre metadata (for example a freshly added, sparsely-tagged library). The filter sidebar now handles genre-less items correctly, so the page loads normally. + +## 0.53.0 — 2026-07-23 + +Account & sign-in dead ends fixed, a way out of a forgotten messaging passphrase, and share-link password controls — plus security hardening. + +### Features + +- Forgot your secure-messaging passphrase? You can now reset it from the unlock screen and start fresh with a new one. Resetting permanently clears your existing message history (it can't be decrypted without the old passphrase) — you're warned clearly before it happens. +- Didn't get your verification email, or the link expired? You can now request a new one right from the sign-in screen. +- Share links: you can now change or remove the password on an existing link, not just when creating it. Changing it immediately stops the old password from working. + +### Fixes + +- Completing a password reset now also verifies your email — so an account that never clicked the original verification link isn't left stuck. +- Cancelling Google sign-in now returns you to the login page with a clear message instead of a raw error page; the same for cancelling a Google connect/upgrade from inside the app. + +### Internal + +- Security hardening: rotating a share-link password now instantly revokes links already handed out; the share recipient list and messaging key lookup no longer reveal suspended/deactivated accounts; and assorted race-condition and abuse-surface fixes. + +## 0.52.0 — 2026-07-22 + +Honest loading and error states across the app — pages no longer look empty when a fetch is still loading or has failed. + +### Fixes + +- When a page's data is still loading or fails to load (e.g. right after a server restart), it now shows a spinner or a clear error with a Retry button — instead of looking like it's simply empty ("No channels", an empty inbox…). Rolled out across the feed, channels, playlists, downloads, messages, notifications, Plex, admin and stats pages. +- A failed action now shows the server's actual reason (e.g. "Not enough quota left today") instead of a generic "failed" message. +- The startup screen now offers a Retry if the app can't reach the server on load. + +## 0.51.1 — 2026-07-22 + +Follow-ups to the downloads update: cleaner titles and file names on re-download. + +### Fixes + +- Re-downloading an item now also refreshes its title and saved file name — the “1.6M views · … |” social clutter that stuck to older downloads is cleared on re-download (both on the card and in the saved file name). +- Downloaded file and folder names now contain only safe characters — no “!”, brackets, or other shell-unpleasant punctuation. +- The app now cleans up after itself: stale leftover folders and images from a previous naming scheme (e.g. an old accented folder left behind by a re-download) are removed automatically. + +## 0.51.0 — 2026-07-22 + +Downloads that play on every device, cleaner file names, and one-click re-download. + +### Features + +- Downloads now default to 720p — quicker and plays everywhere. 1080p and Best are still one click away in the format list. +- New “Re-download” button on each item in your Library rebuilds the file under the latest rules; any share links you've already sent keep working and point to the new file. + +### Fixes + +- Shared videos now play in every browser, including on iPhone and iPad (Safari and Brave): a file that arrived in a format Apple can't decode (such as AV1) is now automatically converted to a universally-playable one, so the “broken play button” is gone. +- Downloaded folder and file names are now plain, accent-free ASCII with underscores instead of spaces — portable, and no longer shown as “?” in some file tools. +- Titles that start with social clutter like “1.6M views · 66K reactions |” are cleaned up automatically. + +## 0.50.0 — 2026-07-22 + +A sweep of small correctness, keyboard and accessibility fixes across the app. + +### Fixes + +- Downloads: two different formats of the same video no longer overwrite each other on disk (and deleting one no longer removes the other's file). Videos with very long titles — especially accented, non-English ones — no longer fail to save. +- In the video player, pressing Escape now closes just the top layer — the open popover or dialog — instead of also closing the player behind it. +- Plex player: the audio and subtitle menus are readable in the light theme again (they were white-on-white). +- A repeated notification that resurfaces now stays on screen for its full time instead of vanishing early. +- Unsubscribing from a channel now tells you if it failed, instead of failing silently. +- The ✕ close button on dialogs is now translated (it was always English), and screen readers now announce toast notifications. + +### Internal + +- Keyboard: reorder a side panel's sections with the keyboard, and Space now activates a focused button in the player instead of toggling playback. +- Hardening: rate-limited the access-request endpoint and closed a couple of sign-in token races; the demo account can no longer spend YouTube quota; assorted internal safety and performance fixes. + +## 0.49.0 — 2026-07-21 + +Groundwork: a quality gate and the project's first backend tests. Nothing changes on screen. + +### Internal + +- Every change now passes an automatic gate before it can be published: type-checking, linting (ESLint + ruff), formatting (Prettier), and the test suites. Releases can no longer go out over code that fails a check. +- The backend gained its first tests (title cleanup, download path safety incl. the traversal guard, and the share-link signatures), and the frontend test net grew; the end-to-end browser suite is wired into the release gate. +- The TypeScript compiler was tightened (notably checked indexed access), catching a class of possible-undefined mistakes across the app before they can ship. All behaviour is unchanged. + +## 0.48.0 — 2026-07-21 + +Playlists get real column headers — and sorting one no longer rewrites it behind your back. + +### Features + +- The playlist view has sortable, filterable column headers: sort by title, channel or length, and filter by title or channel to find something in a long list. +- Sorting is now only a way of LOOKING at the list. Your playlist's own order stays exactly as you saved it until you press "Save this order" — so trying a sort can no longer rewrite the list, or leave a YouTube-synced playlist marked as unsynced. Saving is undoable like any other reorder. +- The list tells you which view you are in: whether the current sort matches your saved order, differs from it and can be saved, or is filtered (where saving would drop the hidden videos, so it is not offered). The "#" column always shows each video's position in the SAVED order. +- Drag-reordering now works from the keyboard: focus a drag handle, press space, move with the arrow keys and press space again. + +### Fixes + +- Deleting a playlist synced with YouTube asked twice, and cancelling the second question still deleted it locally. It is one question now, with each outcome on its own button — and closing the dialog deletes nothing. +- Confirmation dialogs put the keyboard focus on Cancel when the action is destructive. Previously the confirm button took focus, so a stray Enter on an open dialog could run "Delete user" or "Clear audit log". +- Removing a video from a playlist now asks first, and keeps your undo history for the videos that remain. +- Typing in a table's column filter re-created the input on every keystroke, which moved the cursor and broke accented and IME input mid-word. This affected the channel manager, Discover and the audit log as well. +- A playlist that failed to load showed "Loading…" forever; it now says so and offers to retry. Failed reorders and renames are reported instead of passing silently. +- Reordering a playlist no longer requires YouTube write access — it never needed it. + +### Internal + +- The table column header is now shared between the playlist and the other tables, and the playlist's view logic is covered by unit tests. + +## 0.47.0 — 2026-07-20 + +Relative timestamps tell the truth again — and now come from your language itself. + +### Features + +- Relative timestamps are now produced by your language's own rules rather than written by hand, so they read naturally in Hungarian and English alike. Hungarian is unchanged; in English they are a little shorter ("59m ago" rather than "59 min ago"). + +### Fixes + +- Every "time ago" label named the wrong unit: something four minutes old read "4s ago", an hour old "1 min ago", a day old "1 hour ago". The number was right all along, only the word was wrong. This affected every timestamp in the app — the feed, channels, messages, notifications, the player and the audit log. + +### Internal + +- First unit tests in the project, covering every relative-time range at its exact boundary so this class of mistake cannot come back unnoticed. + +## 0.46.0 — 2026-07-20 + +The channel manager, overhauled — read it as a table or as cards, with sorting, paging and each channel's full description at hand. + +### Features + +- The channel manager has a card view alongside the familiar table. Pick it from the switcher next to the tabs; your choice sticks, and both Subscriptions and Discover follow it. +- A new About column shows each channel's description, and hovering it opens the full profile — description, links, country, language, topics and keywords — without leaving the list. Links in descriptions are clickable, and social handles resolve to their profiles. +- Sorting is shared between the two views: sort by clicking a column header in the table or from the dropdown in cards, and your choice carries over when you switch. +- Both views page through your channels — "Rows per page" in the table, "Cards per page" in cards — and the table now has a pager at the bottom as well as the top. Turning a page takes you back to the top of the list. +- Filter the table column by column: funnels on priority, channel, sync state and tags narrow it without disturbing your top-level filters. +- Moving between the manager's tabs now goes into your browser history, so Back steps back through the tabs you visited instead of leaving the manager altogether. + +### Fixes + +- The channel table stays centred with "Rows per page" set to All. A single long channel name used to stretch its column and push the whole table off to one side. +- The tag editor no longer opens behind the card below it. + +## 0.45.1 — 2026-07-19 + +A few player and sharing fixes. + +### Fixes + +- Plex: the video now fills the whole screen in fullscreen. It used to stay at its windowed size, small and centred with black bars around it. +- Copying a share link now works reliably, including on iPad and Safari. +- The pop-out YouTube player is back to its previous, more compact size. + +## 0.45.0 — 2026-07-19 + +Read the feed the way you like it, and explore your Plex library by cast. + +### Features + +- The feed has five view modes now — large cards, small cards, rows, compact rows, and a multi-column tile view. Pick the density that suits you from the switcher next to the sort control; your choice sticks. +- Explore your Plex library by the people in it: click any cast member on a film or show's page to see everything they're in. Pick several and, by default, you'll get titles featuring any of them — flip to "All" to require every one. The actors you've picked now show as cards above the grid, each with a photo and a title count, and a click to drop them. + +### Fixes + +- Plex: clicking a lower-billed cast member no longer empties the grid. We now index a title's full cast (not just the top few), so filtering by anyone shown actually finds their films. + +### Internal + +- A large under-the-hood cleanup of the app's internal structure (no behaviour change) — smaller, clearer building blocks that make future features quicker and safer to add. + +## 0.44.0 — 2026-07-16 + +The filter panels get out of the way, and you can finally see — and pick — what's narrowing your feed. + +### Features + +- The Filters, Plex and Playlists panels now sit as a small tab beside the sidebar and slide open over the page when you point at them, so they cost you nothing until you want them. Prefer one always open? Pin it. The feed starts a lot further left than it used to. +- Everything currently filtering your feed now shows as a row of chips above it — drop any one with a click, or clear the lot. No more opening the panel just to see what you'd set. +- Filter the feed by channel — at last. Pick channels in the panel's new Channel section (searchable), or hit "Show in my feed" on any channel's page. Pick several and you'll get all of them, with your other filters still applied. +- Your sync status moved out of the sidebar into a small chip at the top, next to the search: the video count at a glance, and the details, the missing-history link and pause a click away. + +## 0.43.1 — 2026-07-15 + +A fix for the new slim sidebar. + +### Fixes + +- The slim sidebar no longer stays stuck open after you use the language switcher or the account menu — it tucks away again as soon as you click elsewhere. + +## 0.43.0 — 2026-07-14 + +A slimmer, smarter navigation sidebar, a couple of account tweaks, and an optional live appearance tuner. + +### Features + +- The navigation sidebar now stays slim by default and slides open over the page when you hover it or tab into it — so it barely takes any room until you need it. Prefer it always open? Pin it with the button at the top of the rail. +- The top bar reclaims the empty space on the left: it now hugs your content instead of leaving a fixed gap when the sidebar is slim. +- A logout button now sits right next to the language and about buttons, and your role shows as a tooltip on your name (the separate badge is gone). +- New optional glass tuner: a floating panel for live-tweaking the frosted-glass look. It's off by default — switch it on under Settings → Appearance if you like to fiddle. + +## 0.42.0 — 2026-07-13 + +A big visual refresh: the “glass over image” dark theme, a redesigned top bar, and one unified floating panel system for filters and playlists. + +### Features + +- New background image: in dark theme a subtle backdrop, tuned to your colour scheme, now sits behind the app — so the frosted-glass surfaces have real depth to refract. Turn it on or off, and dial its strength, with the new controls in Settings → Appearance. +- Two new colour schemes: Starship (deep-space blue) and Matrix (terminal green). +- Redesigned top bar: the module name sits in a floating pill with back/forward arrows to step through your modules, and each module's search moved into a matching floating search bar. +- The Feed, Plex and Playlists side panels are now one consistent floating system — collapse any of them to a slim tab beside the navigation, reorder and hide their sections, and your arrangement is saved per panel. +- The navigation rail is now a floating rounded panel too, matching the rest of the app; at high browser zoom its list scrolls without a scrollbar, with a soft edge fade hinting there's more. + +### Fixes + +- Repeated identical notifications now fold into a single entry with a count, instead of stacking up. + +### Internal + +- Reworked the translucent “glass” surface system so the whole app shares one consistent, tunable look. +- Retired the German interface language; Siftlode is now available in English and Hungarian. + +## 0.41.0 — 2026-07-12 + +Richer channel pages, a new admin audit log, and channel-page fixes. + +### Features + +- Channel pages now show Country, Topics and Keywords in the About tab. +- From the channel Discovery tab you can open a channel's page to look it over — its About and videos — before subscribing. +- New admin Audit log: an append-only record of admin actions and scheduler activity (who did what, and when), with filtering and a configurable retention window. + +### Fixes + +- Channel banners that briefly failed to load now recover on their own instead of staying broken. +- The channel header no longer shifts a few pixels when switching between the Videos and About tabs, and channels without a banner lay out correctly. +- The Source (search-results) filter is hidden on a single channel's page, where it didn't apply. + +## 0.40.0 — 2026-07-12 + +A wave of polish and reliability fixes closing out the internal cleanup pass. + +### Fixes + +- Your place in a video is now saved even if you refresh or close the tab right after skipping around. +- Auto-advance no longer gets stuck on a video that can't be played — it moves on to the next one. +- The unread badge on your messages now updates live across your other open tabs. +- Download storage/limit messages now appear in your language, with the right number format. +- Removing a demo email or denying an access request now asks for confirmation first. +- After syncing your subscriptions, the feed refreshes right away to reflect the change. + +### Internal + +- Many internal correctness and efficiency fixes across search, playlists, channels, messages, and downloads; admin-panel polish. No feature changes. + +## 0.39.1 — 2026-07-12 + +### Fixes + +- Signing out and back in no longer disconnects your YouTube access. + +## 0.39.0 — 2026-07-12 + +Sign-in security hardening. + +### Features + +- New “Log out other sessions” button in Settings → Account, to sign out everywhere else. + +## 0.38.0 — 2026-07-12 + +The Plex filter sidebar can now be collapsed, plus a large internal cleanup. + +### Fixes + +- The Plex library no longer stops loading more titles after you open one and go back. + +## 0.37.0 — 2026-07-11 + +Movies and shows in one library, a Plex-style series view, and smarter filters. + +### Features + +- Movies and TV shows now share one unified library — browse them together and narrow to Both, Movies, or Shows from the sidebar. +- A Plex-web-style series view: drill into a show's seasons and episodes, with quick watched toggles for a whole show, a whole season, or a single episode. +- Filters are now adaptive — picking one narrows the others, so you're only offered options that still return results. In genre “All” (match-all) mode, only genres that actually co-occur with your current pick are shown. +- The Collections filter is back in the sidebar as a searchable picker that spans all your libraries. +- Series pages got a polish pass: season/episode quick actions and a subtle full-page art backdrop. + +### Fixes + +- The grid now refreshes reliably after playback or a collection edit. +- Filter counts and ranges no longer include hidden titles, so they match what you actually see. + +### Internal + +- Faster whole-show/season “mark watched” and quicker show opening; internal cleanup of dead code and duplication. + +## 0.36.4 — 2026-07-10 + +### Internal + +- Internal in-app player cleanup. + +## 0.36.1 — 2026-07-10 + +Scroll anywhere over the player to change the volume. + +### Features + +- Scroll to change the volume now works anywhere over the player window — not only directly on the video. + +## 0.36.0 — 2026-07-10 + +Full access to YouTube's own player menu in the in-app player. + +### Features + +- You can now open and navigate YouTube's own player menu — Quality, Speed, Captions and More options — all the way down. When you use YouTube's built-in controls, the app's click-to-play and scroll-for-volume shortcuts briefly step aside (a small badge shows this), and they return the moment you move the pointer off the video. + +## 0.35.0 — 2026-07-10 + +Two-way Plex watch sync: watch in either app and both stay in step. + +### Features + +- Two-way Plex watch sync is complete. Mark something watched — or leave it half-finished — in Siftlode and it now updates Plex too; and anything you watch (or un-mark) in the Plex app flows back to Siftlode automatically. Turn it on in Settings → Account. (Your private “hidden” marks stay in Siftlode and are never sent to Plex.) + +### Fixes + +- Auto-advancing to the next episode now marks the episode you actually finished — not the next one. Previously, if the next episode wasn't available locally, the wrong episode could be marked watched. + +## 0.34.2 — 2026-07-09 + +Polish: the Plex watch-sync switch now responds instantly. + +### Fixes + +- The Plex watch-sync switch now flips the moment you click it and shows an "Importing…" indicator while it works, instead of appearing to do nothing until the import finished. + +## 0.34.1 — 2026-07-09 + +Fix: turning on Plex watch sync could show a server error. + +### Fixes + +- Enabling Plex watch sync could briefly show a "Server error" if the switch was toggled again while the first import was still running. The import is now safe to run more than once, and the switch is disabled while it works. (If you saw the error, your Plex history still imported correctly.) + +## 0.34.0 — 2026-07-09 + +Bring your Plex watch history into Siftlode. + +### Features + +- Plex watch sync (first step): turn it on in Settings → Account and Siftlode imports what you've already watched (and what you're mid-way through) from Plex, so your Plex history shows up here too. Two-way syncing — so watching in either app updates the other — follows in the next updates. + +## 0.33.1 — 2026-07-09 + +Fix: opening a channel no longer errors when YouTube is briefly unreachable. + +### Fixes + +- Opening a channel page could pop a blocking "Server error" when the connection to YouTube was momentarily unavailable. The page now loads regardless — its latest channel details simply fill in once the connection is back — instead of failing. + +## 0.33.0 — 2026-07-09 + +Watch your Plex library right here — a full player — plus smarter Plex search. + +### Features + +- Plex player: play any film or episode straight from Siftlode. It resumes where you left off, remembers your volume, and — when the file's format needs it — streams through an on-the-fly conversion so it just plays. +- Player controls: scrub the timeline by clicking or dragging it, hover for a timestamp, switch audio tracks and subtitles, and see the expected finish time in brackets next to the time remaining. A live clock sits in the top corner. +- Skip Intro / Skip Credits buttons appear on marked titles, with an optional auto-skip countdown (which pauses when you pause); credits can auto-advance to the next episode. +- Personalise the player from the gear menu — subtitle/audio sync offsets, seek step, subtitle style (size, position, colour, background, shadow) and the corner clock. The whole player is 25% larger for lean-back viewing, and Back / Esc (keyboard or mouse) step out of an open menu before leaving the player. + +### Fixes + +- Plex search now has its own box: it no longer leaks into — or gets left behind by — the feed search, and it clears on reload instead of greeting you with a stale query. +- A Plex search that comes up empty while a filter is active now says so and offers a one-click "Clear filters", instead of a bare "No matches". + +## 0.32.1 — 2026-07-07 + +Fix: shared links now actually unfurl in chat apps. + +### Fixes + +- Shared links now show a rich preview in Messenger and other chat apps. The site's robots.txt was telling every crawler — including link-preview bots — to skip /watch/ pages, so Facebook got a blocked response and showed just a bare link. Link-preview crawlers are now allowed to fetch share pages (they still stay out of search indexes). + +## 0.32.0 — 2026-07-07 + +Clickable channels, poster images for any source, and a dialog-dismiss fix. + +### Features + +- Downloads: the auto-detected channel under a download is now a clickable link to the real channel for YouTube and Facebook sources. Your existing downloads were backfilled, so they're clickable too. +- Downloads: a video from a source with no thumbnail (e.g. a direct Reddit video link) now gets a poster image generated from the video itself — shown on the card, on the shared watch page, and in rich link previews. Shared-link previews now use this self-hosted image, so they render reliably in chat apps and don't break over time. Existing downloads were backfilled. + +### Fixes + +- Dialogs no longer close by accident when you press the mouse inside (e.g. selecting text in a field), drag outside, and release — a popup now only closes when the click both starts and ends on the backdrop. + +## 0.31.1 — 2026-07-07 + +Fix: a download could fail when the video's title was very long. + +### Fixes + +- Downloads: fixed a download failing right at the end when the source's title was very long (e.g. a whole recipe in the title) — the auto-filled display name overflowed its limit. Long titles are now trimmed for the name; the full title still shows everywhere else. + +## 0.31.0 — 2026-07-07 + +Richer downloads — edit details, clickable channels & links, and prettier share-link previews. + +### Features + +- Downloads: the edit (pencil) button now edits the full details — title, channel name and a channel link, plus any number of extra reference links. The channel and links appear on the download card as clickable links, and a source that carries a channel (like YouTube or Facebook) fills the channel link in automatically. +- Downloads: shared watch pages now show the correct title, channel and links — including your own edits — with every link clickable. (Renaming a download is now reflected on its share link too.) +- Shared links now unfurl richly in chat apps like Messenger, with the video's title, channel and thumbnail instead of a blank card. Videos without a thumbnail still show a clean text card. + +### Internal + +- Shorter, clearer site description for search engines and link previews. + +## 0.30.0 — 2026-07-06 + +Plex playlists — your own ordered watch-lists. + +### Features + +- Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page ("Add to playlist"), then find your playlists in the Plex sidebar. +- Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit "Play all" to watch straight through — the player steps through the whole list in order, auto-advancing to the next. + +## 0.29.0 — 2026-07-06 + +Plex collections — admins can now create and curate them. + +### Features + +- Plex collections (admin): a movie's info page has a “Collections” button to create a new collection (seeded with that movie), add or remove the movie from your collections, and delete a collection. Changes are written back to Plex, so they show up on every client and for everyone. +- Plex collections (admin): existing plain Plex collections can be “taken over” (marked as yours to manage) from the same dialog. Smart collections and external auto-lists (IMDb Top 250, TMDb Trending, …) stay read-only — they're regenerated outside Plex. Editing is admin-only, since collections are shared library-wide. + +## 0.28.3 — 2026-07-06 + +Fix: Plex subtitles that wouldn't play — now instant, no restart. + +### Features + +- Plex subtitles: fixed playback failing ("Playback couldn't start") when picking a subtitle on films whose subtitles are external sidecar files (very common) — those aren't inside the video, so the old approach couldn't mux them. Subtitles are now served as standalone tracks the browser overlays, so turning one on (or switching) is instant and no longer restarts the video. Image-based subtitles (Blu-ray/DVD) are hidden for now (they can't be shown as text yet). + +## 0.28.2 — 2026-07-06 + +Much faster, smoother Plex browsing — right-sized poster images. + +### Features + +- Plex images: posters and art are now resized by Plex to the size actually shown (instead of shipping the full-resolution originals — some were several MB each), so the movie grid scrolls smoothly and images pop in almost instantly. Also shrinks the image cache dramatically. + +## 0.28.1 — 2026-07-06 + +Fix: slow / broken Plex poster images on collection-heavy pages. + +### Features + +- Plex images: fixed the poster/art proxy exhausting the database connection pool when a page fired many poster requests at once (the culprit behind slow, partially-loaded collection grids) — image requests no longer hold a database connection while fetching from Plex, and repeat views are served straight from the disk cache. + +## 0.28.0 — 2026-07-06 + +Plex collections — browse them, filter by them, and see a title's collection right on its info page. + +### Features + +- Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries. +- Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter. +- Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first). +- Plex info page: show or hide each collection-strip type independently — keep genuine franchises (Avatar, James Bond) while hiding the big auto-lists (IMDb Top 250, TMDb Trending), remembered per account. +- Plex info page: a refreshed, wider layout — content floats as frosted-glass panels over a faint, fixed backdrop of the title's own art (toggleable), matching the app's glassy look. + +## 0.27.0 — 2026-07-06 + +Search Plex by cast & crew — find every film an actor or director is in, with clickable person cards. + +### Features + +- Plex search: the top search box now matches cast and crew names, not just titles — search “drew” and Drew Barrymore's films come up first (ranked above films that merely mention the word). +- Plex search: matching actors and directors appear as person cards (photo + how many titles) above the results — click one to filter the library to their films and add them to the sidebar filter. + +## 0.26.0 — 2026-07-05 + +Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading. + +### Features + +- Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length) with an ascending/descending toggle. +- Plex filters: pick several genres and choose Any (match one) or All (match every one); stack multiple actors, directors and studios at once (from the info page). +- Plex info page: the year, genres, director, cast and studio are now clickable — tap one to filter the whole library by it (e.g. every film with an actor, or everything rated 7+). Browser Back returns to the info page. +- Plex: posters and cast photos are cached on disk after first view, so scrolling the library is much smoother on repeat visits. + +### Fixes + +- Plex player: leaving a video with Back now fully stops it — no more audio playing in the background until a refresh. + +## 0.25.0 — 2026-07-05 + +Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos. + +### Features + +- Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls. +- Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback. +- Plex info: two view preferences you can toggle and that stick — a faint art backdrop and the cast & crew row. +- Plex: you can now mark a title unwatched or clear its resume position from the info page. + +## 0.24.0 — 2026-07-05 + +Plex player polish: a Stop button, a keyboard help overlay, time-remaining and a clock, and Back returns you to your place in the library. + +### Features + +- Plex player: added an explicit Stop button, a time-remaining readout next to the seek bar, and a live clock in the top corner (both fade with the controls). +- Plex player: press “H” (or the keyboard button) for an on-screen cheat sheet of all shortcuts. +- Plex player: Backspace now stops playback and returns to the library — the same as the mouse Back button — matching a typical HTPC remote. + +### Fixes + +- Plex library: pressing Back from the player now returns you to the exact spot you were scrolled to, instead of jumping to the top of the library. + +## 0.23.2 — 2026-07-05 + +The player now steps through the exact list it was opened with, so watching along no longer breaks Loop and Next/Previous. + +### Fixes + +- Player: it now plays through a frozen snapshot of the filtered, sorted list it was launched with. Previously, as each video was auto-marked watched it dropped out of a filtered feed, silently shrinking the queue mid-playback — which jumped playback to an unrelated video, and stopped Loop “All” from ever wrapping back to the first video once earlier ones were watched. Next/Previous and both Loop modes now work against the original list for the whole session. + +## 0.23.1 — 2026-07-05 + +Clearer Plex playback errors, and an admin media-mount check. + +### Features + +- Admins: the Plex “Test connection” now also checks that a real media file is readable through the configured mount, so a missing media mount or a wrong path map is caught immediately. + +### Fixes + +- Plex player: when a video can't start, it now explains why — the media file isn't reachable on the server, or its format needs transcoding — instead of showing “Loading…” forever. + +## 0.23.0 — 2026-07-05 + +New optional Plex module: browse, search and play your Plex movies & TV shows. + +### Features + +- Plex integration (optional): when your admin turns it on, a new “Plex” section appears in the sidebar. Browse and search your Plex movies and TV shows as cards, just like the feed, and drill into a show's seasons and episodes. +- Rich Plex player: play a movie or episode right in the app. Play/pause, resume where you left off (or restart), seek, skip intro/credits, step to the previous/next episode with auto-advance, pick the audio track and turn on subtitles, adjust volume, go fullscreen — all keyboard-controllable (Space, F, arrows). +- Your watched and in-progress state is saved per account and shown on the cards. Hover a card for a quick Play/Resume and a mark-watched toggle. +- Download the original media file straight from the player. +- Admins: enable and set up Plex under Settings → Configuration (server URL, token, media path map, which libraries to expose), with a “Test connection” + library picker. + +## 0.22.3 — 2026-07-04 + +Step through the feed in the player, vertical videos fit, and downloads show their source. + +### Features + +- In-app player: step to the previous/next video in the feed's order — click the arrows beside the player or press Shift+←/→ (plain ←/→ still seek within the video). +- Player auto-advance and loop, saved to your account: when a video ends, play the next, previous or a random one — and loop just that video or the whole list. Works for players opened from the feed and from playlists. +- Every download now shows a “downloaded from” link on the Downloads page — open the original page in a new tab, or copy the link. Works for YouTube and external links (e.g. Facebook reels). + +### Fixes + +- The in-app player no longer blocks YouTube's own controls — volume, captions, settings and fullscreen are all clickable again. +- The player's title description now waits a moment before popping up, so it doesn't flash as you move the mouse past. +- Saved views now stick: reloading keeps the view you're on instead of snapping back to your default. (Your default view still loads on a brand-new account.) +- A shared vertical/short video now plays in a player that fits its shape, instead of a wide box with black bars on the sides. + +## 0.22.2 — 2026-07-04 + +Faster loading everywhere and better accessibility across the app. + +### Fixes + +- The landing page loads much faster — its preview screenshots are now served in a modern, far smaller image format, and static assets are cached so repeat visits are near-instant. +- The app now loads in smaller pieces: each section is fetched only when you open it, so the first screen appears sooner and a shared watch link no longer downloads the whole app. +- Accessibility: toggles, colour swatches, sort menus and settings inputs now carry proper labels for screen readers and voice control, across every section of the app. + +### Internal + +- Search-engine metadata (page description, robots.txt) and a cleaner console on the public page. + +## 0.22.1 — 2026-07-04 + +Downloaded and shared videos now play in every browser, including on iPhone and iPad. + +### Fixes + +- Downloads now use widely-compatible video (H.264) and audio (AAC) so a saved file — and a public watch link — plays everywhere, including iOS/Safari, where the previous format left a broken player. Existing downloads keep their old format; re-download to get the compatible version. + +## 0.22.0 — 2026-07-04 + +The Download Center — save videos to the server, edit them into clips, and share them. + +### Features + +- Download Center: save any video (or a search result) to the server with yt-dlp, laid out Plex-style with a poster + info file. Choose a built-in format preset or make your own, keep an eye on your per-user storage quota, and save the finished file to your device. +- Built-in video editor: trim, crop, and cut a download into segments — keep the parts you want as separate clips or join them into one file. Pick a precise (frame-accurate) or fast cut, with a scrub timeline and a hover preview. +- Sharing: share a download with another user (it appears in their “Shared with me”, where they can edit their own copy or remove it), or hand out a public watch link that plays on a clean, login-free page — with an optional password, an expiry, and a stream-only vs downloadable toggle. +- Tidier video titles across the feed, search and downloads — clickbait ALL-CAPS and trailing hashtag clutter are cleaned up for display (the original title is kept underneath). + +### Fixes + +- The browser tab now carries an app icon and shows the section you're on; clicking the Siftlode logo returns you to the feed. + +## 0.21.0 — 2026-07-02 + +Run a different account in each browser tab, a roomier collapsible layout, and nav polish. + +### Features + +- Multi-account, per tab: open two tabs and sign into a different account in each — each tab stays on its own account. Switching or adding an account in one tab no longer changes what the others show. Every tab keeps its own filters, theme and settings. +- Roomier layout: the filter sidebar is now a full-height column you can collapse like the left nav (a thin rail keeps the active-filter count). Your video counts and sync status moved to the top of the left rail, and the Mine/Library switch sits with the filters. Both panels remember whether you left them open or collapsed. +- Nav touches: a role badge (admin / user / demo) next to your name, the current language (HU/EN/DE) shown on the language button, and a clearer “Admin” divider above the admin tools. +- The sign-in page now highlights that Siftlode is open source, with a link to the code. + +### Fixes + +- Your filters, selected playlist, theme and other per-browser settings no longer carry over between different accounts signed into the same browser. +- Opening one pop-up from inside another (e.g. Release notes from the About box) no longer makes it flash and vanish. + +## 0.20.2 — 2026-07-01 + +A pop-up flash fix, and an open-source link on the sign-in page. + +### Features + +- Siftlode is open source — the sign-in page now links to its public source code. + +### Fixes + +- Opening one pop-up from inside another — for example Release Notes from the About box — no longer makes it flash and vanish instantly. It opens and stays put, and Back closes it as expected. + +## 0.20.1 — 2026-07-01 + +A refreshed sign-in / landing page. + +### Internal + +- Rewrote the landing-page copy and replaced its preview screenshots with richer, real examples. No change to the app itself. + +## 0.20.0 — 2026-07-01 + +Saved views: keep several filter setups and switch between them in one click. + +### Features + +- Saved views: save your current feed filters (and sort) under a name, then apply them again with a single click. Rename them, drag to reorder, share a view by link, and star one as your default — the default loads automatically when you open the app. They live in a new “Saved views” box at the top of the filter sidebar. + +### Fixes + +- The “Filters active” count and Clear all now also cover the Source filter and the Mine/Library switch, so clearing gives a truly clean slate — back to Mine with search results hidden. The default view (Mine, unwatched, no search results) is unchanged. +- The search box now has a clear (✕) button. Clearing your filters keeps the search text — the search has its own ✕ — and clearing the search returns the feed to its normal order. + +## 0.19.0 — 2026-07-01 + +Channel pages, much smarter search, and self-tidying YouTube search results. + +### Features + +- Channel pages: open any channel's own page inside Siftlode — banner, stats (subscribers, videos, views, joined) and its uploads, with Videos and About tabs. Click a channel name anywhere to open it. +- Explore channels you don't follow: opening an un-subscribed channel loads its recent uploads just for you — private until you subscribe. Channels you explore but don't keep tidy themselves away automatically after a while. +- Smarter search: results are now relevance-ranked and match far more than the title — a video's tags, its description, and even the searches that first surfaced it all count, so you can find things by meaning instead of exact words. In your own feed (Mine), a Source filter separates your subscriptions from your search finds. +- Temporary search results: live YouTube search results are clearly marked temporary — they clear themselves unless you watch or save one, with a “Clear now” button to do it at once. Pick how many to fetch (20/40/60/100), and videos you already have are sorted below fresh discoveries. +- Block channels: hide a channel everywhere — from search, your feed and channel pages — and manage your blocked channels from the Channel manager. +- The Channel manager gets a search box and clickable tag chips, and a floating “back to top” button appears on long pages. + +### Fixes + +- The Back button no longer does nothing the first time: after you closed the video player (or a dialog) with its ✕/Escape and opened another, the next Back press now closes it right away — everywhere in the app. +- Reloading the page now keeps your YouTube search results on screen (they re-fetch for free) instead of dropping you back to the feed. + +## 0.18.0 — 2026-06-29 + +Search all of YouTube from your feed, and new keyboard/scroll controls in the video player. + +### Features + +- Live YouTube search: type a search and press Enter (or the YouTube button) to search all of YouTube right from the feed — results show up as normal cards you can play, save and add to playlists. They're kept out of your regular Library by default; a Source filter lets you show or hide search-found videos. Shorts and currently-live/upcoming results are left out automatically. +- Live search uses no YouTube API quota by default, so it's no longer limited to a handful of searches per day. (Admins can switch back to the official API under Configuration → Quota if the quota-free source ever stops working.) +- Video player shortcuts: press F for fullscreen, Space to play/pause, and scroll the mouse wheel over the video to change the volume (with an on-screen volume bar). YouTube's own controls — the seek bar, captions and settings — still work as before. +- Feed search now ignores accents, so searching “tiesto” also finds “Tiësto”. + +### Fixes + +- After a live YouTube search, the Back button now closes the open video player first and keeps you on the search results, instead of jumping straight back to your feed. +- The search box no longer flickers or briefly blanks the feed while you type. + +## 0.17.0 — 2026-06-29 + +Messaging and notification fixes, plus a large under-the-hood cleanup. + +### Fixes + +- Switching between signed-in accounts no longer drops you on a blank chat: after you switch, a message you just received shows up in the right conversation instead of an empty screen. +- You're now reliably notified when a video you'd saved or added to a playlist is removed because it's no longer available on YouTube — this notice could previously be missed. +- “Time remaining” estimates (e.g. on sync progress) and a few small labels are now translated in Hungarian and German like the rest of the app. + +### Internal + +- A large internal code cleanup: removed duplicated logic across the backend and frontend and factored out shared building blocks (form controls, the save/discard bar, popovers, browser-storage helpers, time/date formatters). No change to how the app behaves — groundwork that keeps future features consistent and quicker to build. + +## 0.16.2 — 2026-06-26 + +Smoother navigation and tidier access-request handling. + +### Fixes + +- The browser/mouse Back button now steps back the way you'd expect: from an open message conversation it returns to your conversation list (not the previous module), and it closes the video player or an open dialog instead of jumping off the page. +- Access requests: the “Review” link opens straight to the Access requests tab, the notice clears itself the moment you approve (no refresh needed), and a leftover sign-in link no longer lingers in the address bar. + +## 0.16.1 — 2026-06-26 + +Clearer action messages, and a fix for background syncing on self-hosted setups. + +### Fixes + +- Action confirmations now name what they acted on — which channel was unsubscribed or reset, which address was added to a list, which tag was deleted, and which playlist was reloaded or synced. +- Background syncing keeps working when the server's public IP changes: an instance can now be pointed at a fixed-IP egress proxy, so an IP-restricted YouTube API key no longer breaks after a network change. + +## 0.16.0 — 2026-06-26 + +Private messaging: chat with other members in real time, end-to-end encrypted. + +### Features + +- A new Messages module: send direct messages to other members of your instance. Messages are end-to-end encrypted — only you and the person you're chatting with can read them, not even an admin. The first time you open it, you set a message passphrase that protects your key; it never leaves your device. +- Real-time delivery: messages arrive instantly, with an unread badge on the Messages icon. A new message pops up (or flashes) a small Messenger-style chat window in the bottom-right corner that you can keep using while you browse — minimise, close, or open several at once. +- A friendly Siftlode welcome message greets you in your inbox on first arrival. + +## 0.15.0 — 2026-06-25 + +A faster, smoother feed that stays light no matter how far you scroll. + +### Features + +- The feed now draws only the videos currently on screen, so scrolling stays fluid and the page stays light even after you've loaded thousands of videos. +- Paging through the feed is quicker and steadier: scrolling deep into a large catalogue no longer slows down, and videos arriving in the background while you scroll can no longer cause an item to show up twice or be skipped. + +## 0.14.1 — 2026-06-21 + +Internal cleanup: the project's original working name was fully retired in favour of Siftlode. + +### Internal + +- Renamed the last remaining references to the project's original codename throughout the codebase, deployment config and database identity. No behavioural change. One side effect: locally remembered view preferences (theme, feed filters, dismissed hints) reset to their defaults once, because the browser-storage keys were renamed too. + +## 0.14.0 — 2026-06-21 + +Run your own Siftlode: a guided first-run setup and a one-command self-host package. + +### Features + +- A first-run setup wizard: the very first time a fresh instance is opened, a guided wizard walks you through creating the first admin account and (optionally) entering your Google and email credentials — no editing config files by hand. Once finished, the instance is marked configured and the wizard steps aside. +- Google sign-in is now optional: if an instance hasn't been given Google credentials, the Google buttons and YouTube-account features are simply hidden, so a self-hoster can run the app with email-and-password accounts only. + +### Internal + +- A one-command self-host package: a ready-made compose file with a bundled database plus install scripts for Linux/macOS and Windows that generate the needed secrets, start everything and print the setup URL. The app now ships as a prebuilt image, so hosts pull the exact released artifact instead of building it. + +## 0.13.0 — 2026-06-19 + +Sign in with email & password (not just Google), a redesigned welcome page, account management for admins, and self-service account deletion. + +### Features + +- Sign in with an email and password, alongside Google. Register with email+password (with email verification and admin approval), set or change a password under Settings → Account, and link a Google account to your existing account — or set a password on a Google-only account — so either method gets you in. +- A redesigned welcome page: a short product tour with screenshots you can click to enlarge, and a one-click way to try the shared demo. +- Admin account management: a Users page with tabs for roles, access requests and the demo whitelist. Promote or demote users, suspend or unsuspend an account (a suspended user can't sign in by any method and is signed out at once), and delete an account. The affected person is emailed on approval, role change, suspension, reinstatement and deletion. +- Delete your account yourself: Settings → Account now has a permanent “delete my account and all data” option that erases your subscriptions, tags, watch history and playlists, revokes the app's access to your Google account, and emails you a confirmation. + +### Fixes + +- Landing-page screenshots now appear in the real app, not only the dev preview; a suspended or deleted account is taken to the login page right away with a clear explanation; and the login page no longer occasionally flickers. +- Signing in with Google now correctly counts your email as verified, and an admin-assigned role is kept across logins instead of being reset. + +### Internal + +- Tab-ified the admin Configuration and Users pages, and added a reusable tab and image-lightbox component. +- Self-hosting without email configured still works: registrations are auto-verified and gated by admin approval. The demo account now shows sample channels in its Channel manager. + +## 0.12.0 — 2026-06-19 + +A dedicated Usage & stats page, a live Configuration page for admins, and polish on the channel-discovery list. + +### Features + +- Usage & stats is now its own page (moved out of Settings): see your sync status and your personal API usage at a glance, with a clear, translated breakdown by action. Admins get an extra System tab with the instance-wide quota dashboard and the background-sync pause control. +- “Discover from playlists” now shows each channel's total video count, so you can size it up before following — and subscribing asks for confirmation first, since it changes your real YouTube account and uses a little quota. + +### Internal + +- Admins can edit operational settings live from a new Configuration page — email/SMTP, the YouTube API key, the daily quota budget, and backfill/Shorts-probe limits — stored in the database and applied without a redeploy. Secrets (SMTP password, API key) are encrypted and write-only. +- Standardized the names of API-usage actions with full English/Hungarian/German labels, and unified the version/demo banners into one reusable component. + +## 0.11.2 — 2026-06-19 + +Clearer messages when a YouTube action can't be completed. + +### Fixes + +- When a YouTube action can't go through — syncing or pushing a playlist, removing a playlist on YouTube, or looking up a video — you now get a clear explanation of why, instead of a misleading “connection lost” notice. + +## 0.11.1 — 2026-06-19 + +A fix for subscribing from the discovery list. + +### Fixes + +- Subscribing to a channel you already follow on YouTube now simply records it here instead of showing a misleading “connection lost” error — which also clears the case where such a channel kept reappearing under “Discover from playlists”. + +### Internal + +- YouTube-side action failures now surface as a clear message instead of being mistaken for a transient connection drop. + +## 0.11.0 — 2026-06-19 + +Discover channels from your playlists, notifications that link to where they point, and a clearer, faster background sync. + +### Features + +- Discover channels from your playlists: the Channel manager has a new “Discover from playlists” tab listing channels that appear in your playlists but that you don't follow yet. Each shows its subscriber count and avatar so you can size it up first, then follow it in one click — your own channel is left out. +- Notifications now link to where they point, and the links keep working after a reload: subscribing names the channel with shortcuts to open it in the Channel manager or on YouTube; a “Marked watched” notice gains a “Find in feed” action (matching “Hidden”); and a finished background job links back to the Scheduler. + +### Fixes + +- The header's sync indicator now spins only while a sync is actually running — instead of perpetually whenever some full-history backfill was still pending — and it refreshes more promptly while work is in progress. +- “N without full history” in the header now reliably opens the Channel manager on the right tab and shows exactly those channels, even if you'd previously left a channel filter applied. +- New uploads are picked up faster: the background feed check now reads many channels' feeds at once instead of one by one, cutting a full pass from minutes to seconds. + +### Internal + +- Shared a single channel-link component and YouTube-URL helper across the app, and the admin Scheduler now shows live progress for every job type. + +## 0.10.0 — 2026-06-19 + +Settings you save explicitly, plus a calmer, more honest playback experience. + +### Features + +- Settings now save explicitly: your changes preview instantly, but only apply to your account when you press Save — with a clear “Settings saved” confirmation. A Discard button reverts everything, and switching to another section with unsaved changes asks before discarding them. + +### Fixes + +- The in-app player no longer throws a spurious “Server error (502)” when a video finishes or while the server is briefly busy: passing hiccups are retried automatically, and if the server is momentarily unreachable you get a single “connection lost” notice that clears itself the moment it's back — instead of an error you had to dismiss. +- Confirmations like “Marked watched” or “Hidden” now appear only after the server has actually applied the change, so you'll never see a success message for something that didn't go through. +- Tidied the Settings page height — no more unnecessary empty space and scrolling. + +## 0.9.0 — 2026-06-18 + +A notifications inbox, and automatic clean-up of videos that can no longer be played. + +### Features + +- New Notifications center in the left sidebar, with an unread badge: one place for both durable system updates (about your library and background jobs) and your recent activity (with quick Unhide / Unwatch / Find-in-feed actions). Mark items read, dismiss them individually, or clear everything. Pop-up toasts still appear as before. +- Automatic library maintenance: a background job spots videos that can no longer be played anywhere — deleted, made private, or premieres that never went live — hides them from your feed right away, and removes them after a grace period. Genuine 24/7 livestreams are left alone. +- If a video you'd saved or added to a playlist gets removed this way, you get a single summary notification listing them — never one message per video. +- Admin Scheduler: run any background job on demand with a “Run now” button (or “Start all now”), watch a live progress bar while long jobs run, and get an inbox notification with the result when a job you started finishes. + +## 0.8.0 — 2026-06-18 + +Richer channel columns, a proper tag workflow, video reset, and clearer errors. + +### Features + +- Channel manager: new sortable columns — last upload, total length, and a Normal/Shorts/Live breakdown — with subscriber counts shown compactly (e.g. 919K). Columns size to their content. +- Tags, reworked: each channel row now shows only the tags you've attached, with a “+” to add or remove them per channel. A tag manager (open it from the Channel manager or the feed sidebar) lets you add, rename and delete tags — deleting only asks to confirm when a tag is actually in use. +- Filter the feed by a tag: click a tag on a channel, and a new “Your tags” widget in the feed sidebar shows that filter so you can see and clear it. In the tag manager, hovering a tag's channel count lists those channels, each a link that jumps to it in the Channel manager. +- Video cards get a reset action that returns a video to pristine — clearing watch progress and status, as if you'd never opened it. + +### Fixes + +- When the server can't complete an action (for example a duplicate tag name), you now get a clear pop-up explaining why, instead of a cryptic failure. +- Pressing Esc with a dialog stacked over another now closes only the top one, leaving your work underneath open. + +## 0.7.0 — 2026-06-17 + +A redesigned, sortable Channel manager — plus a reorganized sidebar and several fixes. + +### Features + +- Channel manager is now a sortable, filterable table: sort by any column, filter channels by name or tag straight from the headers, choose how many rows per page (or show them all), and jump to any page. Your sorting, filters and page are remembered across reloads. +- Channel rows: open a channel on YouTube (middle-click the name or the ↗ icon), a single “fully synced” badge once a channel is fully caught up, and instant tag toggling. Admins can reset a channel to re-fetch it from scratch. +- Reorganized left sidebar: your content (Feed, Channels, Playlists) and the admin tools (Stats, Scheduler) are now grouped, and the language, About and notifications controls moved into the sidebar. Pop-up notifications now appear bottom-left, by the bell. +- Playlists: the “new playlist” box moved to the top of the list. + +### Fixes + +- Marking a video watched at the end of playback now sticks — it leaves the Unwatched feed and stays gone after a refresh. +- Settings now remembers which tab you were on after a reload. +- The usage chart's daily bars are always visible now, not just on hover. + +## 0.6.0 — 2026-06-16 + +Admins get a live Background Scheduler dashboard. + +### Features + +- New admin Scheduler page: a live, self-refreshing view of the background jobs — what's running, each job's last result and a countdown to its next run — plus the work still queued and the shared API quota. Each job has a tooltip explaining what it does, and its run frequency is editable inline. + +## 0.5.1 — 2026-06-16 + +A clearer message when a video can't play in the in-app player. + +### Fixes + +- When a video can't be played here — some music tracks and other videos have embedding disabled by their owner — the player now shows a clear note and an “Open on YouTube” button instead of a blank error screen. + +## 0.5.0 — 2026-06-16 + +Clearer live videos — and they now settle into normal videos once they end. + +### Features + +- Live and premiere videos now show a LIVE / upcoming badge where the duration usually sits, so a missing runtime is obviously by design, not a gap. + +### Fixes + +- When a live stream ends, it now updates by itself into a normal video with its real length, instead of staying stuck as “live” with no duration. + +### Internal + +- Shared demo-account mode: a curated whitelist can preview the app (shared library, playlists, hiding) without signing in; admin whitelist + state reset in Settings. + +## 0.4.1 — 2026-06-16 + +Feed controls above the videos, and clearer view counts and dates. + +### Features + +- The view filter (Unwatched / In progress / All / …) and the content type (Normal / Shorts / Live) are now quick chips right above the videos. +- Cleaner ordering: pick what to sort by, then flip ascending/descending with one arrow (and 'Most viewed' is now 'Popular'). +- Video cards and the player show the exact upload date next to '… ago', and the full view count on hover. + +## 0.4.0 — 2026-06-16 + +A clearer left-hand navigation, a glassier look, and account switching. + +### Features + +- New left navigation sidebar: Feed, Channels, Playlists and Stats are now always visible (no longer tucked under your avatar), and the rail collapses to icons when you want more room. +- Switch accounts without signing in again: the account menu lists everyone signed in on this browser — pick one to switch instantly, or “Add another account”. Signing out drops you to the next account if you have one. +- Settings is now its own page that opens right where you are, instead of a panel sliding in from the far side. +- The browser/back button now steps back through the app (Feed, Channels, …) instead of leaving the app. +- A frosted-glass refresh across the navigation, header and dialogs. + +## 0.3.0 — 2026-06-16 + +Playlists — build your own, mirror your YouTube ones, and sync edits back to YouTube. + +### Features + +- Local playlists: create named, ordered playlists, reorder by drag, and add videos straight from the feed or the player. Play a whole playlist with auto-advance and prev/next. +- Watch later is now a built-in playlist — the bookmark button adds to it, and you can play and manage it like any other list. +- Your YouTube playlists are mirrored in automatically and kept fresh, shown with a YouTube badge and an “open on YouTube” link. +- Two-way YouTube sync: export a local playlist to YouTube, or push your edits (add, remove, reorder, rename) back to a linked playlist. Every push shows an estimate and a confirm first, because YouTube playlist writes are quota-heavy. +- Edit mirrored playlists freely — your unsynced changes are protected from the automatic refresh, an “Unsynced” marker shows when you have pending edits, and “Reset to YouTube” discards them to reload the original. +- Sort a playlist by title, duration or channel (ascending/descending) and optionally group by channel — with full undo/redo (buttons and Ctrl/Cmd+Z / Ctrl+Y). +- The playlist list can be ordered by name, item count or total length (and float your unsynced lists to the top); it shows each list’s total length and remembers your selection. + +## 0.2.0 — 2026-06-15 + +Browse beyond your own subscriptions, and a much smarter filter sidebar. + +### Features + +- Shared library: a new Mine / Library switch in the header lets you browse every channel the community has added — not just your own subscriptions — and even works without granting YouTube access. Your watch, save and hide state always stays private to you. +- Smarter filter chips: topic and language chips now show live counts for your current view and hide the ones that no longer match anything, so narrowing down is quicker. Chips are ordered by size, and topics have a clear Any / All match toggle (All = channels that have all the selected topics). +- “Surprise me” ordering now has a reshuffle button to reroll the order, and the topic/language chips show how many channels fall under each. +- Share a view: copy a link that reproduces your current filters. Filters are otherwise no longer kept in the address bar. + +### Fixes + +- The channel manager now tells apart a full-history backfill you requested from one another member already queued, and the header shows “fetching history” while the back-catalogue is still downloading instead of claiming everything is synced. + +## 0.1.0 — 2026-06-15 + +First release: a self-hosted, multi-user reader for your own YouTube subscriptions — fast local-first feed with rich filtering, in-app playback with resume, and channel management. + +### Features + +- Multi-user feed of your own YouTube subscriptions: invite-only Google sign-in, per-user watch/save/hide state, encrypted token storage. +- Ingest: subscription import, free RSS new-video detection, recent-first and full-history backfill, metadata enrichment (duration, views, category, Shorts & livestream classification), a shared daily API-quota guard, and a background scheduler. +- Automatic tagging: system tags for channel language and topic (your own tags are never overwritten). +- Reader UI: grid/list feed scoped to your subscriptions; faceted tag filters, content-type toggles (Normal / Shorts / Live), date range, search and sort; four colour schemes (dark/light) with adjustable text size. +- In-app player: a modal YouTube player that resumes where you left off, auto-marks watched near the end, and turns description timestamps and links into clickable seeks. +- Watch progress: a resume progress bar on video cards, Continue / Restart buttons, and an “In progress” feed filter — your position is saved server-side, so it follows you across devices. +- Channel manager: per-channel priority, hide, personal tags, sync badges, and per-channel full-history opt-in. +- Header status: your video count vs. the whole catalogue, sync progress, and (for admins) a pause control; onboarding wizard; notification centre; admin usage & quota stats. +- Multilingual interface — switch between English, Hungarian and German; your choice is saved to your account and the default is guessed from your Google locale. +- About dialog and this Release Notes view, with a heads-up banner when a new version ships. + +### Fixes + +- Backfill no longer skips the band of videos sitting on the 365-day boundary page. +- A channel whose full catalogue is already stored is no longer stuck showing “needs full history”. + +### Internal + +- Public-readiness repo hygiene: internal planning/infra details removed, build stamped with version/commit. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..be3f7b28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 3115260b..1869a810 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,18 @@ docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull docker compose -f docker-compose.selfhost.yml up -d ``` -Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the -video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on -Windows) and restore with `scripts/restore.sh`. +That follows `latest`, which moves with every release. Set `IMAGE_TAG=0.80.0` in `.env` to pin a +version and upgrade on your own schedule; released versions are listed in [`CHANGELOG.md`](CHANGELOG.md) +and in the app's Release Notes. The registry keeps only the newest few of those, so a pin decides +*when* you take the next hop rather than letting you stay put forever — see +[docs/self-hosting.md](docs/self-hosting.md#upgrading). + +**Back up before upgrading.** Migrations run automatically on startup and are not undone by pulling +the old image back. A backup is the database dump **plus your `.env`** — `TOKEN_ENCRYPTION_KEY` is +what decrypts the Google, SMTP and Plex secrets stored in that database, so a dump restored without it +is only half an instance. Use `scripts/backup.sh` (or `backup.ps1` on Windows) and `scripts/restore.sh`; +the equivalent one-liners, the pinning recipe and the rollback steps are in +[docs/self-hosting.md](docs/self-hosting.md#backups). ## How it works @@ -111,6 +120,12 @@ Windows) and restore with `scripts/restore.sh`. FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query frontend; packaged as a single Docker image with Docker Compose. +## License + +[GNU AGPL-3.0](LICENSE). You can run, study, modify and share Siftlode; if you modify it and let other +people use it over a network, they are entitled to your modified source. Copyright © 2026 Siftlode +contributors. + ## Note This project is developed with AI assistance. diff --git a/VERSION b/VERSION index e0d6b5ea..c85a67dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.80.0 \ No newline at end of file +0.81.0 \ No newline at end of file diff --git a/backend/app/audit.py b/backend/app/audit.py index dae44955..8a87f95c 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -31,6 +31,7 @@ class AuditAction: # recorded because both are the admin acting ON someone's access, not merely reading a list. USER_ACTIVATE = "user_activate" USER_VERIFY_RESEND = "user_verify_resend" + USER_SETPW_LINK = "user_setpw_link" INVITE_APPROVE = "invite_approve" INVITE_DENY = "invite_deny" INVITE_ADD = "invite_add" diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index a9028ff5..aabeba7b 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -54,6 +54,10 @@ class EditAborted(Exception): # --- edit spec ------------------------------------------------------------------------------ +# SEC-13: ceiling on a join's cut-list. An accurate join emits two filters per segment into a single +# ffmpeg `filter_complex`, so "however many the caller sent" is an unbounded command line. +_MAX_SEGMENTS = 100 + def normalize_edit_spec(spec: dict | None) -> dict: """Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties. @@ -88,6 +92,12 @@ def normalize_edit_spec(spec: dict | None) -> dict: if en > st: norm.append({"start_s": round(st, 3), "end_s": round(en, 3)}) norm.sort(key=lambda x: x["start_s"]) + # SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment, + # so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes. + # The cap is far above any real cut-list (the UI's own splitter produces a handful) and the + # excess is DROPPED rather than raising: the surplus segments are the attack, the first + # _MAX_SEGMENTS are a legitimate edit. + del norm[_MAX_SEGMENTS:] if norm: out["segments"] = norm # A join always chooses a codec path: accurate=filter-concat re-encode, diff --git a/backend/app/downloads/errors.py b/backend/app/downloads/errors.py index 0c883bfa..1d4a7ccb 100644 --- a/backend/app/downloads/errors.py +++ b/backend/app/downloads/errors.py @@ -8,9 +8,28 @@ explanation (and decides whether a retry makes sense). The raw text stays availa `_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this". """ +# Two worker-authored strings that this module owns because both are coupled to `_RULES`. +# +# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule +# below matches a fragment of it, and test_download_errors pins the pair, because a message and its +# rule drifting apart is exactly how a category silently becomes unreachable. +# +# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so +# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever +# does, it must carry the original code rather than re-deriving one from this text. +# +# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and +# "produced no output file" have markers here; the urlguard refusal deliberately has none and +# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more. +QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again." +NETWORK_REDACTED_MESSAGE = "The server could not reach that address." + # (marker, category) — first match wins, so order most-specific → most-general. Matching is # case-insensitive on a substring, against the raw yt-dlp/ffmpeg message. _RULES: tuple[tuple[str, str], ...] = ( + # Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed + # first — it is our wording, so it can never be a false match on an upstream message. + ("download storage is full", "quota_exceeded"), # Login / age / members / bot-check — the user must be signed in (we deliberately are not). ("sign in to confirm your age", "login_required"), ("confirm your age", "login_required"), @@ -91,3 +110,20 @@ def classify(msg: str | None) -> str: if marker in low: return category return "unknown" + + +def user_error_text(source_kind: str, msg: str, code: str) -> str: + """The failure text to STORE on the job, given the raw message and its category. + + SEC-12: for a user-supplied URL, a transport-level failure text distinguishes "connection + refused" from "HTTP Error 401" from "timed out" per host:port — which is a port scanner with + extra steps, since the raw text is shown to the user behind the details disclosure. `urlguard` + stops the fetch aiming at our own network; this stops the remaining readout. + + Only the network class, and only for ``url`` jobs: a YouTube job has no user-chosen host, and + every other class (login_required, geo_blocked, drm, …) is real, useful diagnosis. A separate + function rather than an inline branch in the worker so it can be pinned by a test — the raw + text leaking back is a security regression that would otherwise pass a green suite.""" + if source_kind == "url" and code == "network": + return NETWORK_REDACTED_MESSAGE + return msg diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index cf836462..040eb6f4 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -116,6 +116,7 @@ def build_ydl_opts( progress_hook, extra: dict | None = None, cookiefile: str | None = None, + max_bytes: int | None = None, ) -> dict: """Assemble yt-dlp options for a single download to `outtmpl` (a full path template). @@ -125,12 +126,20 @@ def build_ydl_opts( `cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra` - merge so a source adapter can never clobber it.""" + merge so a source adapter can never clobber it. + + `max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger + than it rather than filling the disk and only then being caught by the quota accounting.""" s = normalize(spec) h = s["max_height"] opts: dict = { "outtmpl": outtmpl, "noplaylist": True, + # SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to the single video. A + # URL that is PURELY a playlist or channel has no such video to fall back to, so it still + # expands into every entry — one enqueue, an unbounded number of downloads. One job means + # one item; a user who wants the whole list enqueues it item by item, under their quota. + "playlist_items": "1", "quiet": True, "no_warnings": True, "noprogress": True, @@ -154,6 +163,16 @@ def build_ydl_opts( if extra: opts.update(extra) + # SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason + # `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set. + # + # A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because + # nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused` + # → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the + # cap. Delete the worker's refusal and this branch silently uncaps the one user already over it. + if max_bytes and max_bytes > 0: + opts["max_filesize"] = max_bytes + # The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not # be able to override the authenticated session the worker resolved for this specific user. if cookiefile: diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 2ed78b94..d1293eb6 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -15,7 +15,8 @@ from app.config import settings from app.db import SessionLocal from app.downloads import edit as editmod from app.downloads import errors as dlerrors -from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage +from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage +from app.downloads import urlguard from app.downloads.jobdb import ( _edit_staging_dir, _job_status, @@ -36,6 +37,18 @@ class _Aborted(Exception): """Raised inside the progress hook when the job was paused/canceled mid-download.""" +class _OwnerRefused(Exception): + """A refusal that belongs to THIS job's owner, not to the source. + + MediaAssets are shared: two users asking for the same video and format get one row (see + `service.get_or_create_asset`), and the generic failure handler writes the message onto it — + correctly, because an upstream failure really is everyone's. A per-user refusal is not. Written + to the shared row it would fail a second user's queued job for the same video with the first + user's private reason (`worker._process_job` reads `asset.error` verbatim), telling someone with + an empty library to free up space. So this is caught separately: the job fails, the asset goes + back to `pending` for whoever else is holding it.""" + + # Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its # hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while # run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status` @@ -305,13 +318,17 @@ def _media_info(info: dict) -> dict: return {**info, **chosen} -def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None): +def _extract_with_fallback( + job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None +): """Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format error); other sources make a single attempt. Returns the info dict of the successful attempt. `cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by - `credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.""" + `credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated. + + `max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt.""" client_sets = adapter.client_sets() outtmpl = str(staging / "%(id)s.%(ext)s") last_exc: Exception | None = None @@ -322,6 +339,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookie _make_progress_hook(job_id, asset_id), extra=adapter.ydl_opts_extra(clients), cookiefile=cookiefile, + max_bytes=max_bytes, ) opts["postprocessor_hooks"] = [_make_pp_hook(job_id)] _touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow / @@ -415,8 +433,17 @@ def _download( _set_job(job_id, phase="downloading", progress=0) adapter = adapters.resolve_adapter(source_kind, source_ref) url = service.source_url(source_kind, source_ref) + # SEC-12, second half: the enqueue guard ran when the job was created, which may have been + # hours ago and against a different DNS answer. This is where the socket actually gets + # opened, so the check is repeated here and the job fails rather than fetching. + if source_kind == "url": + try: + urlguard.check_url(url) + except urlguard.BlockedUrl as e: + raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e # S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND # this asset is that user's private, salted identity (the double gate lives in credentials). + remaining_bytes: int | None = None with SessionLocal() as cdb: asset_sig = cdb.execute( select(MediaAsset.format_sig).where(MediaAsset.id == asset_id) @@ -425,6 +452,19 @@ def _download( cookiefile = credentials.resolve_cookiefile( cdb, user_id, url, asset_sig, spec, staging ) + # SEC-13: what this user has left. `check_enqueue` only compared the footprint as it + # stood when the job was QUEUED — a download's size is unknown until it finishes, so a + # single job could previously blow through the cap by any amount. Handing the remainder + # to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up + # front instead. + remaining_bytes = quota.remaining_bytes(cdb, user_id) + # …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap + # while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so + # passing it down would leave the ceiling unset — the one user already at their cap would be + # the only one downloading unbounded. Fail the job here instead. Not retryable until they + # free space, which is what the `quota_exceeded` category tells them. + if remaining_bytes == 0: + raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE) info = _media_info( _extract_with_fallback( job_id, @@ -434,6 +474,7 @@ def _download( staging, adapter, cookiefile=str(cookiefile) if cookiefile else None, + max_bytes=remaining_bytes, ) ) # Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only @@ -525,8 +566,31 @@ def _download( # job's status (the route set it to paused/canceled). ref_count is adjusted by the route. _reset_asset_pending(asset_id) log.info("job %s aborted (pause/cancel)", job_id) + except _OwnerRefused as exc: + # Fail this job only. The asset is never marked `error`; it goes back to `pending` (below), + # so another user's job for the same video is claimed and downloaded normally instead of + # inheriting a reason that is about someone else's account. + msg = str(exc)[:500] + with SessionLocal() as db: + service.finalize_if_running( + db, + job_id, + status=JobStatus.ERROR, + error=msg, + error_code=dlerrors.classify(msg), + phase=None, + ) + db.commit() + # Released AFTER the job is terminal: the moment the asset is `pending` another job can claim + # it, and this one must already be out of the way. (The staging tree is still torn down in + # `finally` below — the same narrow window the pause/cancel path above has always had.) + _reset_asset_pending(asset_id) + log.info("job %s refused for its owner: %s", job_id, msg) except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive msg = str(exc)[:500] + code = dlerrors.classify(msg) + # SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text). + msg = dlerrors.user_error_text(source_kind, msg, code) with SessionLocal() as db: asset = db.get(MediaAsset, asset_id) if asset: @@ -539,7 +603,7 @@ def _download( job_id, status=JobStatus.ERROR, error=msg, - error_code=dlerrors.classify(msg), + error_code=code, phase=None, ) db.commit() diff --git a/backend/app/downloads/quota.py b/backend/app/downloads/quota.py index f91c1c80..2fcd174e 100644 --- a/backend/app/downloads/quota.py +++ b/backend/app/downloads/quota.py @@ -7,6 +7,13 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads). + +DOWNLOAD jobs additionally consult `remaining_bytes` in the worker, because enqueue can only compare +the footprint as it stood when the job was created and earlier jobs may have filled the cap since. +That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same +remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of +max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size +is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19). """ from dataclasses import dataclass @@ -122,6 +129,21 @@ def check_enqueue(db: Session, user_id: int) -> None: raise QuotaExceeded("max_bytes", lim.max_bytes, used) +def remaining_bytes(db: Session, user_id: int) -> int | None: + """Storage this user may still spend on ONE download: ``None`` for an unlimited user, else the + cap minus the current footprint, floored at 0. + + Zero is a real answer, not "no limit" — it means the cap is already met and the caller must + REFUSE rather than proceed unbounded. `check_enqueue` cannot make that refusal on the caller's + behalf: it ran when the job was queued, while the user was still under the cap, and the jobs + that filled it have landed since. Keeping the arithmetic here (rather than inline in the worker) + is what keeps every max_bytes decision in this module.""" + lim = resolve(db, user_id) + if lim.unlimited: + return None + return max(0, lim.max_bytes - footprint(db, user_id)) + + def check_upload(db: Session, user_id: int, add_bytes: int) -> None: """Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding this file would push the footprint over max_bytes. A download can't pre-check its size (unknown diff --git a/backend/app/downloads/urlguard.py b/backend/app/downloads/urlguard.py new file mode 100644 index 00000000..3ca5a946 --- /dev/null +++ b/backend/app/downloads/urlguard.py @@ -0,0 +1,108 @@ +"""Refuse a user-supplied download URL that points back into our own network (SEC-12). + +The Download Center deliberately accepts an arbitrary ``http(s)`` URL — that is the feature — and +hands it to yt-dlp running on the WORKER. Without a guard, any approved user can make the server +fetch anything the server can reach: other containers on the compose network, the LAN, a cloud +metadata endpoint. The failure text then comes back to them in ``job.error``, so "connection +refused" vs "HTTP Error 401" vs "timed out" turns the download box into an internal port scanner. + +What this closes: a URL whose host resolves to a loopback / private / link-local / reserved address +is refused, at enqueue AND again in the worker before extraction. Both, because DNS can answer +differently between the two moments, and the worker is where the socket is actually opened. + +What it does NOT close, stated plainly rather than implied, because both need the same fix — gating +the connection itself with a custom socket factory inside yt-dlp, a much larger change than this: + +* DNS rebinding: a host that passes the worker's check and then answers yt-dlp's own resolution + with an internal address. +* HTTP redirects: a public host that answers 3xx with an internal ``Location``. yt-dlp follows it, + and only the URL the user TYPED was ever judged here. An attacker-controlled host or any public + open redirect reaches it, so this is the cheaper of the two. It does NOT usually yield the + response body — a non-media body fails the mp4 remux and stores nothing — but it does hand back + the readout above: the redaction in ``errors.user_error_text`` fires only for the ``network`` + class, so a dead internal port (refused → ``network`` → redacted) is distinguishable from a live + one (an HTTP answer that is not media → ``unsupported_host``/``format_unavailable``, raw text + kept). Tracked as SEC-17. + +The two checks here stop the straightforward case (typing ``http://127.0.0.1:6379`` or an internal +hostname into the box), which is the reachable one. + +Not used for YouTube jobs: those resolve to a fixed public host from an 11-char id, so there is no +user-controlled destination to guard. +""" + +import ipaddress +import socket +from urllib.parse import urlparse + +# Reason codes travel to the client inside the structured 422 detail, so they are part of the API +# vocabulary (frontend `localizeDetail`) — keep them stable. +REASON_SCHEME = "scheme" # not http(s) +REASON_NO_HOST = "no_host" # URL carries no hostname +REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it +REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked") +REASON_PRIVATE = "private" # resolves into a network the server must not fetch from + + +class BlockedUrl(Exception): + """A user-supplied URL that must not be fetched server-side. ``reason`` is one of the + REASON_* codes above.""" + + def __init__(self, reason: str): + super().__init__(reason) + self.reason = reason + + +def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Every address family we must not open a connection to. Checked explicitly rather than + leaning on ``is_private`` alone, whose coverage differs between IPv4 and IPv6.""" + # ::ffff:127.0.0.1 is loopback wearing an IPv6 hat — unwrap before judging it. + mapped = getattr(ip, "ipv4_mapped", None) + if mapped is not None: + ip = mapped + return bool( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ) + + +def _resolve(host: str, port: int | None) -> list[str]: + """Every address ``host`` resolves to. Raises BlockedUrl(REASON_UNRESOLVABLE) when DNS has no + answer — reported as its own outcome, because "we could not look that up" and "we refuse to + fetch that" are different things to tell a user.""" + try: + infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) + except socket.gaierror as exc: + raise BlockedUrl(REASON_UNRESOLVABLE) from exc + return [info[4][0] for info in infos] + + +def check_url(raw: str) -> None: + """Raise BlockedUrl unless ``raw`` is an http(s) URL whose host resolves only to addresses the + server is allowed to fetch from. A host with even ONE forbidden address is refused: a + round-robin answer must not become a coin flip.""" + # `urlparse` itself raises ValueError on a malformed authority ("http://[::1/x" — an unclosed + # bracket), and `.port` raises on ":99999" / ":abc" when READ rather than at parse time. Both + # escape the caller's `except BlockedUrl` as a 500 unless caught here, which is the opposite of + # what this module exists to do: turn a bad URL into a structured, localizable refusal. + try: + u = urlparse((raw or "").strip()) + port = u.port + except ValueError as exc: + raise BlockedUrl(REASON_MALFORMED) from exc + if u.scheme not in ("http", "https"): + raise BlockedUrl(REASON_SCHEME) + host = u.hostname + if not host: + raise BlockedUrl(REASON_NO_HOST) + for addr in _resolve(host, port): + try: + ip = ipaddress.ip_address(addr) + except ValueError: # getaddrinfo returned something unparseable; treat as unusable + raise BlockedUrl(REASON_PRIVATE) from None + if _is_forbidden(ip): + raise BlockedUrl(REASON_PRIVATE) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c1343dd0..b9b3a04f 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -32,7 +32,12 @@ from app.models import ( User, ) from app.routes._common import get_or_404 -from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link +from app.routes.auth import ( + APPROVAL_VERIFY_TTL, + issue_set_password_link, + issue_verification_link, + send_verification_link, +) from app.utils import canonical_email, iso, valid_email router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -168,9 +173,7 @@ def _activate_user_by_email(db: Session, email: str) -> None: def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: - inv = db.get(Invite, invite_id) - if inv is None: - raise HTTPException(status_code=404, detail="No such invite") + inv = get_or_404(db, Invite, invite_id, detail="No such invite") # Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale # approve or deny re-runs its side effects — re-activating a user, or (now that deny emails) # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and @@ -361,9 +364,7 @@ def delete_invite( falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still promoted to admin on its next Google sign-in). The admin has to hear that, not guess it.""" - inv = db.get(Invite, invite_id) - if inv is None: - raise HTTPException(status_code=404, detail="No such invite") + inv = get_or_404(db, Invite, invite_id, detail="No such invite") if _accounts_for(db, [inv.email]): raise HTTPException( status_code=409, @@ -397,7 +398,15 @@ def _serialize_user(u: User) -> dict: "email_verified": u.email_verified, "is_demo": u.is_demo, "has_password": u.password_hash is not None, + # Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" — + # the same distinction _send_reset_if_eligible needs, and the one an admin has to see before + # deciding what kind of help someone needs. + "password_revoked_at": iso(u.password_revoked_at), "has_google": u.google_sub is not None, + # Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same + # statement as "connected as someone else", and the second one is the one that explains a + # confusing sign-in. + "google_email": u.google_email if u.google_email != u.email else None, "created_at": iso(u.created_at), } @@ -612,6 +621,63 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get return out +@router.get("/users/{user_id}/access") +def user_access( + user_id: int, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Everything the account panel shows about why this person can or cannot get in (E12 S5). + + Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a + query, and the panel opens for ONE user at a time — folding them into the list would pay that cost + per row on every load to answer a question nobody asked yet.""" + target = get_or_404(db, User, user_id, detail="No such user") + return _serialize_user(target) | { + "is_allowed": is_allowed(db, target.email), + "can_activate": can_activate(db, target), + "email_configured": email_mod.email_enabled(db), + } + + +@router.post("/users/{user_id}/set-password-link") +def create_set_password_link( + user_id: int, + admin: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Mint a one-time link that lets this account set a password, with no email involved (E12 S5). + + The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin + hands the link over by whatever channel they trust; opening it sets a password and signs out + whoever held the old one. + + Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a + revoked password, for a Google-only account that wants one, and for a plain forgotten password on + an instance with no mail — narrowing it to one of those would only make the admin's real case the + one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to + change its password is handing them everyone else's session.""" + target = get_or_404(db, User, user_id, detail="No such user") + if target.is_demo: + raise HTTPException( + status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email} + ) + url, expires_at = issue_set_password_link(db, target) + # The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live + # credential-setting link is the last thing that belongs in it. That the link was issued, by whom, + # for whom, is the auditable fact. + audit.record( + db, + admin.id, + AuditAction.USER_SETPW_LINK, + target_type="user", + target_id=target.id, + summary=target.email, + ) + db.commit() + return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email} + + @router.post("/users/{user_id}/activate") def activate_user( user_id: int, diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index ec3a25ea..c76bd49f 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -52,6 +52,16 @@ VERIFY_TTL = timedelta(hours=24) # in and sets no password. APPROVAL_VERIFY_TTL = timedelta(days=7) RESET_TTL = timedelta(hours=1) +# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the +# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and +# the affordance the admin was shown. +ADMIN_SETPW_TTL = timedelta(hours=24) +# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same +# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a +# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address; +# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must +# not silently satisfy the email gate. See password_reset_confirm. +_PASSWORD_TOKEN_KINDS = ("reset", "setpw") _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) @@ -773,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None: return db.get(User, row.user_id) +def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None: + """Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not + swallowed, because the caller has to treat them differently — only a mailed reset attests the + address.""" + for kind in _PASSWORD_TOKEN_KINDS: + user = _consume_token(db, raw, kind) + if user is not None: + return user, kind + return None + + +def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]: + """An admin-issued, single-use link that lets `user` set a password without any email (E12 S5). + + One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is + auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address + stays takeable until its owner resets, and password-reset needs working mail), and the everyday + "no SMTP configured and someone forgot their password". + + Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the + account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch, + which is what evicts a squatter — see password_reset_confirm.""" + expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL + raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL) + # The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to + # consume it, and a second entry shape would be a second thing to keep correct. + return f"{_app_base()}/#reset={raw}", expires_at + + @router.post("/register") def register( body: EmailPasswordIn, @@ -1105,7 +1144,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends( """ if not _reset_check_limiter.allow(_client_ip(request)): return {"ok": True} - return {"ok": _peek_token(db, body.token, "reset") is not None} + return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)} @router.post("/password-reset/confirm") @@ -1118,16 +1157,23 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) status_code=400, detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN}, ) - user = _consume_token(db, token, "reset") - if user is None: + spent = _consume_password_token(db, token) + if spent is None: raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"}) + user, kind = spent user.password_hash = hash_password(new_password) user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent # A valid reset link proves control of the mailbox, so completing a reset also verifies the # email — this rescues an account that registered but never clicked the verification link (it # can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched # here: a reset proves email ownership, not that an admin approved the account. - user.email_verified = True + # + # An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by + # whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let + # an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is + # about. The person still confirms their address the normal way. + if kind == "reset": + user.email_verified = True bump_session_epoch( user ) # SA4: a reset (often a compromise response) kills all existing sessions. @@ -1135,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) for row in db.execute( select(AuthToken).where( AuthToken.user_id == user.id, - AuthToken.kind == "reset", + AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS), AuthToken.used_at.is_(None), ) ).scalars(): diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 7ba0e12c..a6dbb662 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -28,6 +28,7 @@ from app.downloads import edit as editmod from app.downloads import links as linksmod from app.downloads import quota, service, storage from app.downloads import upload as uploadmod +from app.downloads import urlguard from app.models import ( DownloadJob, DownloadLink, @@ -65,7 +66,18 @@ def resolve_source(raw: str) -> tuple[str, str]: if _VIDEO_ID.match(raw): return "youtube", raw if raw.startswith(("http://", "https://")): - u = urlparse(raw) + # `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs + # BEFORE the urlguard check that would otherwise turn it into a structured refusal — so + # unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the + # same typo class: ":99999" is caught there and localized, and one of the two arriving as a + # bare English string would be exactly the split the canonical-patterns table forbids. + try: + u = urlparse(raw) + except ValueError as exc: + raise HTTPException( + status_code=422, + detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED}, + ) from exc host = u.netloc.lower() if "youtube.com" in host: if u.path.startswith(("/shorts/", "/embed/", "/live/")): @@ -352,6 +364,17 @@ def enqueue_download( body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: source_kind, source_ref = resolve_source(body.source) + # SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the + # ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can + # move between here and the download); see downloads/urlguard.py for what that does and does + # not cover. + if source_kind == "url": + try: + urlguard.check_url(source_ref) + except urlguard.BlockedUrl as e: + raise HTTPException( + status_code=422, detail={"code": "url_blocked", "reason": e.reason} + ) from e spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec) display_name = (body.display_name or "").strip() or None try: @@ -532,7 +555,12 @@ def _clean_url(raw) -> str | None: return None if not url.startswith(("http://", "https://")): return None - parsed = urlparse(url) + # The third site in this module handing user input to `urlparse`, which raises on a malformed + # authority. This one already has a "not a plausible URL" answer, so the typo belongs in it. + try: + parsed = urlparse(url) + except ValueError: + return None if not parsed.netloc: return None return url[:2048] diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 397df8fb..dfae5af9 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -20,10 +20,30 @@ from app.sync.playlists import ( repull_playlist, sync_user_playlists, ) +from app.ratelimit import RateLimiter from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/playlists", tags=["playlists"]) +# SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the +# caller owns and then every item in each, against the instance-wide YouTube budget. Its own +# limiter rather than a shared one — spending the manual subscription-import allowance must not +# also close the playlist button, and vice versa. +# +# It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a +# scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of +# two routes with the same quota action is the asymmetric fix this codebase keeps re-learning. +# Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt, +# including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse +# legitimate work. Their exposure is tracked as SEC-18 instead. +_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600) + + +def _check_pull_cooldown(user: User) -> None: + """Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again.""" + if not _playlist_pull_limiter.allow(str(user.id)): + raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) + def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: pl = db.get(Playlist, playlist_id) @@ -284,6 +304,7 @@ def sync_youtube( status_code=403, detail="Connect YouTube (read access) to sync your playlists.", ) + _check_pull_cooldown(user) with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return sync_user_playlists(db, user) @@ -304,6 +325,7 @@ def revert_youtube( raise HTTPException( status_code=403, detail="Connect YouTube (read access) to sync your playlists." ) + _check_pull_cooldown(user) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return repull_playlist(db, user, pl) diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py index 1c1b5b58..a57e0d8a 100644 --- a/backend/app/routes/setup.py +++ b/backend/app/routes/setup.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import Session from app import email as email_mod from app import mailtext from app import state, sysconfig +from app.utils import canonical_email from app.auth import PASSWORD_MIN_LEN from app.db import get_db from app.models import User @@ -70,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic raise HTTPException( status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters." ) - user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + # Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's + # whole point is that two spellings of one Gmail mailbox are one person — matching literally here + # would let a second run under the other spelling create the DUPLICATE admin row the epic exists + # to prevent. Every other lookup went canonical in S3; this one was outside that diff. + user = db.execute( + select(User).where(User.email_canonical == canonical_email(email)) + ).scalar_one_or_none() if user is None: user = User(email=email, role="admin") db.add(user) diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 9a70bc11..8b1e7462 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -7,12 +7,20 @@ from app.audit import AuditAction from app.auth import admin_user, current_user, has_read_scope, require_human from app.db import get_db from app.models import Channel, Subscription, User, Video +from app.ratelimit import RateLimiter from app.sync.runner import estimate_deep_backfill from app.sync.subscriptions import import_subscriptions from app.scheduler import running_job_ids router = APIRouter(prefix="/api/sync", tags=["sync"]) +# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import +# walks every page of the caller's subscriptions — so one user holding down a manual sync button can +# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the +# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual +# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop. +_sync_limiter = RateLimiter(max_events=3, window_seconds=600) + @router.post("/subscriptions") def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: @@ -22,6 +30,8 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend status_code=403, detail="Connect your YouTube account (read access) to import subscriptions.", ) + if not _sync_limiter.allow(str(user.id)): + raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m: result = import_subscriptions(db, user) result["quota_used_estimate"] = m.used diff --git a/backend/tests/test_abuse_limits.py b/backend/tests/test_abuse_limits.py new file mode 100644 index 00000000..a6e9c80e --- /dev/null +++ b/backend/tests/test_abuse_limits.py @@ -0,0 +1,112 @@ +"""E12 · S3 — the route-level halves of SEC-12 and SEC-14. + +`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually +consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a +manual sync trigger stops spending the instance-wide YouTube budget when held down. + +DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable. +""" + +from app.downloads import urlguard + + +def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail( + db, as_user, make_user, monkeypatch +): + user = make_user() + db.commit() + monkeypatch.setattr( + urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))] + ) + r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"}) + assert r.status_code == 422 + # The shape matters as much as the refusal: a bare English string under a bilingual UI is the + # thing the canonical-patterns table forbids. + assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE} + + +def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch): + # Mutation check for the test above: with a public answer the request must fail LATER (or + # succeed), never with url_blocked — otherwise the guard would be refusing everything and the + # first test would still pass. + user = make_user() + db.commit() + monkeypatch.setattr( + urlguard.socket, + "getaddrinfo", + lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))], + ) + r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"}) + detail = r.json().get("detail") if r.status_code >= 400 else None + assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked") + + +def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch): + # A YouTube job resolves to a fixed public host from an 11-char id, so there is no + # user-controlled destination — resolving one would be pure latency on every enqueue. + def boom(*a, **kw): + raise AssertionError("the URL guard resolved DNS for a YouTube source") + + user = make_user() + db.commit() + monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom) + as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"}) + + +def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch): + from app.routes import sync as syncroutes + + user = make_user() + db.commit() + # Neither the YouTube grant nor the import itself is under test — the limiter is. + monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True) + monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0}) + + c = as_user(user) + codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)] + # 3 per 10 minutes: the first three pass, the fourth is refused. + assert codes[:3] == [200, 200, 200] + assert codes[3] == 429 + assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"} + + +def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch): + # Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user + # exhausting their allowance must not lock everyone else out of their own sync button. + from app.routes import sync as syncroutes + + a, b = make_user(), make_user() + db.commit() + monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True) + monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0}) + + ca = as_user(a) + for _ in range(4): + ca.post("/api/sync/subscriptions") + assert ca.post("/api/sync/subscriptions").status_code == 429 + assert as_user(b).post("/api/sync/subscriptions").status_code == 200 + + +def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch): + # SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key + # as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one + # route over. Both must draw on the one allowance. + from app.models import Playlist + from app.routes import playlists as plroutes + + user = make_user() + pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123") + db.add(pl) + db.commit() + monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True) + monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True}) + monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True}) + + c = as_user(user) + codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)] + assert codes[:3] == [200, 200, 200] + assert codes[3] == 429 + # One allowance, not one each: the bulk route is now on cooldown too. + r = c.post("/api/playlists/sync-youtube") + assert r.status_code == 429 + assert r.json()["detail"] == {"code": "sync_cooldown"} diff --git a/backend/tests/test_download_errors.py b/backend/tests/test_download_errors.py index 3614ec55..680d2d13 100644 --- a/backend/tests/test_download_errors.py +++ b/backend/tests/test_download_errors.py @@ -1,5 +1,6 @@ """Category classification of raw yt-dlp / ffmpeg download-failure strings (S2).""" +from app.downloads import errors as dlerrors from app.downloads.errors import classify @@ -54,3 +55,22 @@ def test_login_beats_generic_unavailable_when_both_present(): "This video is unavailable" ) assert classify(msg) == "login_required" + + +def test_our_own_worker_refusals_classify_as_themselves(): + # The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a + # fragment of the text. Pinning message and rule together is what stops an edit to the wording + # from silently orphaning the category — the UI would then fall back to "Download failed". + assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded" + + +def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text(): + # SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is + # replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost. + raw = "ERROR: Unable to download webpage: " + assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE + # Same failure, but the destination was never user-chosen — nothing to leak, keep the detail. + assert dlerrors.user_error_text("youtube", raw, "network") == raw + # A user-chosen host, but a category that says something real about the CONTENT, not the network. + login = "ERROR: [facebook] 123: You must be logged in to see this post" + assert dlerrors.user_error_text("url", login, "login_required") == login diff --git a/backend/tests/test_download_limits.py b/backend/tests/test_download_limits.py index 29322697..ce216652 100644 --- a/backend/tests/test_download_limits.py +++ b/backend/tests/test_download_limits.py @@ -10,6 +10,8 @@ Uses the `db` fixture (real Postgres) — skipped when no database is reachable. its OWN session, so every test COMMITS its setup before calling it. """ +from app.config import settings +from app.downloads import pipeline, quota from app.models import DownloadJob, DownloadQuota, MediaAsset, User from app.worker import _claim_job @@ -169,3 +171,110 @@ def test_list_downloads_is_capped(db, monkeypatch): monkeypatch.setattr(dl, "_MAX_LIST", 2) rows = dl.list_downloads(user=u, db=db) assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk + + +# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------ + + +def _sized_asset(db, size_bytes, ref): + a = MediaAsset( + source_kind="url", + source_ref=ref, + format_sig="s", + status="ready", + ref_count=1, + size_bytes=size_bytes, + ) + db.add(a) + db.flush() + return a + + +def test_remaining_bytes_is_none_for_an_unlimited_user(db): + u = User(email="unlimited@example.com") + db.add(u) + db.flush() + _quota(db, u.id, max_concurrent=2, unlimited=True) + db.flush() + # None, not a number: "no cap" and "no budget left" must never be the same value downstream — + # build_ydl_opts leaves max_filesize unset for both, so only the caller can tell them apart. + assert quota.remaining_bytes(db, u.id) is None + + +def test_remaining_bytes_is_the_unspent_part_of_the_cap(db): + u = User(email="partial@example.com") + db.add(u) + db.flush() + db.add( + DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False) + ) + a = _sized_asset(db, 400, "partial-1") + _job(db, u.id, queue_pos=0, status="done", asset_id=a.id) + db.flush() + assert quota.remaining_bytes(db, u.id) == 600 + + +def test_remaining_bytes_is_zero_once_the_cap_is_met(db): + # THE case this exists for: `check_enqueue` let these jobs in while the user was under the cap, + # and an earlier one has filled it since. Zero is what makes the worker refuse; if this ever + # returned the raw negative or None instead, the user already over quota would be the only one + # downloading with no size ceiling at all. + u = User(email="atcap@example.com") + db.add(u) + db.flush() + db.add( + DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False) + ) + a = _sized_asset(db, 1500, "atcap-1") # overshot: one job finished larger than the remainder + _job(db, u.id, queue_pos=0, status="done", asset_id=a.id) + db.flush() + assert quota.remaining_bytes(db, u.id) == 0 + + +def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch, tmp_path): + """A user at their cap fails their own job, and another user's queued job for the same video + still downloads. + + MediaAssets are deduped across users, and the generic failure handler writes the message onto + that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which + `worker._process_job` would then hand verbatim to someone with an empty library. + """ + monkeypatch.setattr(settings, "download_root", str(tmp_path)) + over, other = User(email="over@example.com"), User(email="other@example.com") + db.add_all([over, other]) + db.flush() + db.add( + DownloadQuota( + user_id=over.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False + ) + ) + full = _sized_asset(db, 1500, "already-held") + _job(db, over.id, queue_pos=0, status="done", asset_id=full.id) + + # `downloading` is the state the worker leaves it in — `_claim_asset` compare-and-sets it before + # the child runs — and `_reset_asset_pending` only acts on that status. Seeded as `pending` this + # assertion would pass with the release deleted. + shared = MediaAsset( + source_kind="youtube", + source_ref="x" * 11, + format_sig="s", + status="downloading", + ref_count=2, + ) + db.add(shared) + db.flush() + mine = _job(db, over.id, queue_pos=1, status="running", asset_id=shared.id) + theirs = _job(db, other.id, queue_pos=2, asset_id=shared.id) + db.commit() + + # The refusal happens before extraction, so nothing needs stubbing — if it ever stops happening + # first, this test starts reaching the network, which is its own signal. + pipeline._download(mine.id, shared.id, "youtube", "x" * 11, {}, over.id) + + db.expire_all() + assert db.get(DownloadJob, mine.id).status == "error" + assert db.get(DownloadJob, mine.id).error_code == "quota_exceeded" + # The asset is released, NOT marked error — otherwise the next line's job inherits the refusal. + assert db.get(MediaAsset, shared.id).status == "pending" + assert db.get(MediaAsset, shared.id).error is None + assert db.get(DownloadJob, theirs.id).status == "queued" diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py index c5eca415..1fd45233 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -345,3 +345,28 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use == 200 ) assert client.get("/api/me").json()["google_email"] == "me@gmail.com" + + +# --- DX-17 · the setup wizard resolves its admin canonically --------------------------------- + + +def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db): + """The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one + person. Resolving by the literal string here would create a SECOND admin row — exactly the + duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17). + + Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under + test, and the test fixtures present an already-configured instance.""" + from app.routes.setup import SetupAdminIn, setup_admin + + setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db) + setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db) + + admins = db.execute(select(User).where(User.role == "admin")).scalars().all() + assert len(admins) == 1, [u.email for u in admins] + # The address stays AS TYPED on the row it matched; only identity is canonical. + assert admins[0].email == "a.b.c@gmail.com" + # And the second run's password is the live one — it updated, it did not fork. + from app.security import verify_password + + assert verify_password("another good passphrase", admins[0].password_hash) diff --git a/backend/tests/test_formats.py b/backend/tests/test_formats.py index b5803d05..6c1fbbdd 100644 --- a/backend/tests/test_formats.py +++ b/backend/tests/test_formats.py @@ -109,3 +109,52 @@ def test_browser_transcode_flags_universal_playability(): assert F("h264", "aac", False) == (True, False, False) # A failed probe / missing stream is never re-encoded. assert F(None, None, True) == (False, False, False) + + +# --- SEC-13 · one enqueue is one bounded download ------------------------------------------- + + +def test_a_playlist_url_can_only_yield_one_item(): + # `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is + # purely a playlist or channel has no such video, so without this it expands into every entry. + opts = _opts({}) + assert opts["noplaylist"] is True + assert opts["playlist_items"] == "1" + + +def test_the_remaining_quota_becomes_a_hard_size_ceiling(): + opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000) + assert opts["max_filesize"] == 5_000_000 + + +def test_no_budget_means_no_size_filter(): + # Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at + # enqueue, not an impossible yt-dlp filter to express here. + for budget in (None, 0, -1): + assert "max_filesize" not in formats.build_ydl_opts( + {}, "/tmp/x", lambda d: None, max_bytes=budget + ) + + +def test_a_source_adapter_cannot_raise_the_size_ceiling(): + # The adapter merge happens after max_filesize is set, so this pins that no adapter option can + # widen a storage cap — the mirror of the cookiefile rule, in the opposite direction. + opts = formats.build_ydl_opts( + {}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000 + ) + assert opts["max_filesize"] == 5_000_000 + + +def test_a_join_cut_list_is_capped(): + # An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded + # list is an unbounded command line. The surplus is dropped; the legitimate head survives. + segs = [{"start_s": i, "end_s": i + 1} for i in range(500)] + out = editmod.normalize_edit_spec({"segments": segs}) + assert len(out["segments"]) == editmod._MAX_SEGMENTS + assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0} + + +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 diff --git a/backend/tests/test_set_password_link.py b/backend/tests/test_set_password_link.py new file mode 100644 index 00000000..2e7f196f --- /dev/null +++ b/backend/tests/test_set_password_link.py @@ -0,0 +1,280 @@ +"""E12 · S5 — the admin-issued set-password link. + +One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so +a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until +its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs". + +The property that must not regress is the one that makes it honest: an admin-issued link proves +NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not +confirm the address the way a mailed reset does. +""" + +from app.models import AuthToken + + +def _link(client_admin, user_id: int): + r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link") + assert r.status_code == 200, r.text + return r.json()["url"].split("#reset=", 1)[1] + + +def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client): + """The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing + it proves ownership and confirms the address. An admin-issued link does not, and claiming + otherwise would let an admin confirm an address nobody ever proved.""" + admin = make_user(role="admin") + target = make_user(email_verified=False) + db.commit() + + token = _link(as_user(admin), target.id) + r = client.post( + "/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"} + ) + assert r.status_code == 200, r.text + + db.refresh(target) + assert target.password_hash is not None + assert target.email_verified is False # the address is still unproven — and says so + + +def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client): + """The mutation check for the test above: if `email_verified` were simply never set, this would + fail — and the rescue path for "registered but never clicked the link" would be gone.""" + from datetime import timedelta + + from app.routes.auth import _issue_token + + user = make_user(email_verified=False) + db.commit() + token = _issue_token(db, user, "reset", timedelta(hours=1)) + + r = client.post( + "/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"} + ) + assert r.status_code == 200, r.text + + db.refresh(user) + assert user.email_verified is True + + +def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client): + """SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password. + The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather + than writing a second one that could forget to bump.""" + target = make_user() + db.commit() + before = target.session_epoch + + admin = make_user(role="admin") + db.commit() + token = _link(as_user(admin), target.id) + client.post( + "/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"} + ) + + db.refresh(target) + assert target.session_epoch != before + + +def test_issuing_does_not_touch_the_current_password(db, as_user, make_user): + """An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes + anything — which is also why it is safe to offer on a perfectly healthy account.""" + target = make_user() + db.commit() + original = target.password_hash + + admin = make_user(role="admin") + db.commit() + _link(as_user(admin), target.id) + + db.refresh(target) + assert target.password_hash == original + assert target.password_revoked_at is None + + +def test_the_link_is_single_use(db, as_user, make_user, client): + target = make_user() + admin = make_user(role="admin") + db.commit() + + token = _link(as_user(admin), target.id) + first = client.post( + "/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"} + ) + second = client.post( + "/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"} + ) + assert first.status_code == 200 + assert second.status_code == 400 + + +def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client): + """Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset + would leave a live admin link behind — a second key to a door someone just changed the lock on.""" + target = make_user() + admin = make_user(role="admin") + db.commit() + + first = _link(as_user(admin), target.id) + second = _link(as_user(admin), target.id) + + ok = client.post( + "/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"} + ) + assert ok.status_code == 200 + stale = client.post( + "/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"} + ) + assert stale.status_code == 400 + + +def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client): + """`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked + only at `reset` tokens, so without teaching it the new kind every admin-issued link would open + onto "this link is spent".""" + target = make_user() + admin = make_user(role="admin") + db.commit() + + token = _link(as_user(admin), target.id) + r = client.post("/auth/password-reset/check", json={"token": token}) + assert r.json() == {"ok": True} + + +def test_only_an_admin_can_mint_one(db, as_user, make_user): + plain = make_user() + victim = make_user() + db.commit() + + r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link") + assert r.status_code in (401, 403) + assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0 + + +def test_the_shared_demo_account_is_refused(db, as_user, make_user): + """The demo account is shared, so handing anyone a way to change its password hands them + everyone else's session on it.""" + admin = make_user(role="admin") + demo = make_user(is_demo=True) + db.commit() + + r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link") + assert r.status_code == 400 + assert r.json()["detail"]["reason"] == "demo" + + +def test_the_access_panel_reports_the_gates(db, as_user, make_user): + admin = make_user(role="admin") + target = make_user(email_verified=False, is_active=False) + db.commit() + + body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json() + assert body["email_verified"] is False + assert body["is_active"] is False + assert set(body) >= { + "is_allowed", + "can_activate", + "email_configured", + "password_revoked_at", + "google_email", + "has_password", + "has_google", + } + + +def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user): + """ "Connected" and "connected as someone else" are different statements, and only the second one + explains a confusing sign-in. Repeating the account's own address would be noise on every row.""" + admin = make_user(role="admin") + same = make_user(email="same@example.com") + same.google_email = "same@example.com" + other = make_user(email="typed@example.com") + other.google_email = "google-primary@example.com" + db.commit() + + c = as_user(admin) + assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None + assert ( + c.get(f"/api/admin/users/{other.id}/access").json()["google_email"] + == "google-primary@example.com" + ) + + +def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user): + """The audit log is read by humans and kept long; a live credential-setting link is the last + thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact.""" + from app.models import AuditLog + + admin = make_user(role="admin") + target = make_user() + db.commit() + + url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"] + secret = url.split("#reset=", 1)[1] + + rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all() + assert len(rows) == 1 + assert rows[0].summary == target.email + assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after) + + +def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client): + """Deliberately not gated on the account's state: the admin's real case is whichever one it is, + and narrowing this would only make it refuse exactly then.""" + admin = make_user(role="admin") + google_only = make_user(password=None) + db.commit() + assert google_only.password_hash is None + + token = _link(as_user(admin), google_only.id) + r = client.post( + "/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"} + ) + assert r.status_code == 200 + db.refresh(google_only) + assert google_only.password_hash is not None + + +def test_an_unknown_user_is_a_404(db, as_user, make_user): + admin = make_user(role="admin") + db.commit() + assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404 + assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404 + + +def test_the_issued_token_is_stored_hashed(db, as_user, make_user): + """Same rule as every other token here: the DB holds a hash, so a database read cannot hand + someone a working link.""" + admin = make_user(role="admin") + target = make_user() + db.commit() + + raw = _link(as_user(admin), target.id) + rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all() + assert len(rows) == 1 + assert rows[0].token_hash != raw + assert raw not in rows[0].token_hash + + +def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user): + """It travels out of band — the admin has to actually reach the person — so the mailed reset's + one hour is far too short.""" + from datetime import datetime, timedelta, timezone + + admin = make_user(role="admin") + target = make_user() + db.commit() + + _link(as_user(admin), target.id) + row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one() + assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2) + + +def test_users_listing_still_serializes(db, as_user, make_user): + """The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel + endpoint exists to keep out of it.""" + admin = make_user(role="admin") + db.commit() + rows = as_user(admin).get("/api/admin/users").json() + assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0] + assert "can_activate" not in rows[0] diff --git a/backend/tests/test_urlguard.py b/backend/tests/test_urlguard.py new file mode 100644 index 00000000..9a9ca11d --- /dev/null +++ b/backend/tests/test_urlguard.py @@ -0,0 +1,99 @@ +"""SEC-12 · the download URL guard. + +The Download Center hands a user-supplied URL to yt-dlp on the worker, so the server fetches +whatever the user typed. These pin that a URL resolving into our own network is refused, that the +refusal reasons stay distinct (an unresolvable host is not the same message as a blocked one), and +that a normal public URL still goes through. + +DNS is stubbed rather than live: the point under test is the address JUDGEMENT, and a test that +resolves real names would be slow, offline-fragile, and would silently start passing if the guard +stopped resolving at all. +""" + +import pytest + +from app.downloads import urlguard + + +def _stub_dns(monkeypatch, addrs): + """Make every lookup answer with `addrs` (a list of textual IPs).""" + + def fake_getaddrinfo(host, port, *a, **kw): + return [(None, None, None, "", (addr, port or 0)) for addr in addrs] + + monkeypatch.setattr(urlguard.socket, "getaddrinfo", fake_getaddrinfo) + + +def _blocked_reason(url): + with pytest.raises(urlguard.BlockedUrl) as e: + urlguard.check_url(url) + return e.value.reason + + +@pytest.mark.parametrize( + "addr", + [ + "127.0.0.1", # loopback + "10.1.2.3", # RFC1918 + "192.168.0.5", # RFC1918 + "172.16.9.9", # RFC1918 + "169.254.169.254", # link-local — the cloud metadata endpoint + "0.0.0.0", # unspecified + "::1", # IPv6 loopback + "fe80::1", # IPv6 link-local + "fc00::1", # IPv6 unique-local + "::ffff:127.0.0.1", # loopback wearing an IPv6 hat + ], +) +def test_addresses_on_our_own_network_are_refused(monkeypatch, addr): + _stub_dns(monkeypatch, [addr]) + assert _blocked_reason("http://whatever.example/video") == urlguard.REASON_PRIVATE + + +def test_one_bad_answer_in_a_round_robin_blocks_the_whole_host(monkeypatch): + # A host answering with both a public and an internal address must not become a coin flip: + # yt-dlp would pick for itself, so the guard refuses if ANY answer is forbidden. + _stub_dns(monkeypatch, ["93.184.216.34", "127.0.0.1"]) + assert _blocked_reason("https://mixed.example/v") == urlguard.REASON_PRIVATE + + +def test_a_public_address_passes(monkeypatch): + _stub_dns(monkeypatch, ["93.184.216.34"]) + urlguard.check_url("https://example.com/watch?v=abc") # does not raise + + +def test_unresolvable_is_its_own_reason(monkeypatch): + import socket as _socket + + def boom(*a, **kw): + raise _socket.gaierror("nope") + + monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom) + # Distinct from PRIVATE on purpose: "we couldn't look that up" and "we refuse to fetch that" + # are different things to tell a user, and the frontend renders them differently. + assert _blocked_reason("https://nx.example/v") == urlguard.REASON_UNRESOLVABLE + + +@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host/1"]) +def test_non_http_schemes_are_refused_before_any_lookup(monkeypatch, url): + def boom(*a, **kw): # a scheme rejection must not have resolved anything first + raise AssertionError("DNS was consulted for a non-http scheme") + + monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom) + assert _blocked_reason(url) == urlguard.REASON_SCHEME + + +def test_a_url_without_a_host_is_refused(): + assert _blocked_reason("http:///just/a/path") == urlguard.REASON_NO_HOST + + +def test_a_malformed_url_is_a_refusal_not_a_crash(monkeypatch): + # Both shapes raise ValueError LATE — `urlparse` on an unclosed bracket, `.port` only when the + # property is READ — and `.hostname` happily returns first. Unguarded, either turns a user typo + # into a 500 that escapes the caller's `except BlockedUrl` and loses the localized detail this + # module exists to produce. + _stub_dns(monkeypatch, ["93.184.216.34"]) + assert _blocked_reason("http://example.com:99999/v.mp4") == urlguard.REASON_MALFORMED + assert _blocked_reason("http://example.com:abc/v.mp4") == urlguard.REASON_MALFORMED + assert _blocked_reason("http://[::1/v.mp4") == urlguard.REASON_MALFORMED + assert _blocked_reason("http://[example.com]/v.mp4") == urlguard.REASON_MALFORMED diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 6192ff6b..1c79fbd8 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -53,6 +53,11 @@ services: # Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env # to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there. - ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads + # Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set + # PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the + # admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty + # named volume — harmless, and the Plex module is off by default. + - ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro security_opt: - no-new-privileges:true cap_drop: @@ -74,6 +79,9 @@ services: worker: image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest} command: ["python", "-m", "app.worker"] + # tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a + # download child's process group (the worker reaps its own direct children via subprocess.wait). + init: true env_file: - .env environment: @@ -93,6 +101,8 @@ services: networks: [internal] volumes: - ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads + # Same read-only Plex library as the API — the worker resolves library paths too. + - ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro security_opt: - no-new-privileges:true cap_drop: @@ -114,6 +124,8 @@ services: volumes: siftlode_pgdata: siftlode_downloads: + # Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off). + siftlode_plex_media: networks: internal: diff --git a/docker-compose.yml b/docker-compose.yml index a7fa9896..27cfd8cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,11 @@ services: # Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env # to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there. - ${DOWNLOAD_HOST_PATH:-downloads}:/downloads + # Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set + # PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the + # admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty + # named volume — harmless, and the Plex module is off by default. + - ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro healthcheck: test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"] interval: 15s @@ -57,6 +62,9 @@ services: GIT_SHA: ${GIT_SHA:-unknown} BUILD_DATE: ${BUILD_DATE:-} command: ["python", "-m", "app.worker"] + # tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a + # download child's process group (the worker reaps its own direct children via subprocess.wait). + init: true env_file: .env environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode} @@ -73,6 +81,8 @@ services: condition: service_started volumes: - ${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 restart: unless-stopped # PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection @@ -85,3 +95,5 @@ services: volumes: pgdata: downloads: + # Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off). + plex_media: diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 9cd7e2b6..b6265c73 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -115,13 +115,34 @@ DOWNLOAD_HOST_PATH=/mnt/media/youtube The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 `. +## Plex library (optional) + +If you run a **Plex** server, Siftlode can browse and play your movies and shows beside your YouTube +feed. Two things have to line up: + +1. **Server URL + token**, entered as admin under **Configuration → Plex**. That is how Siftlode reads + your libraries. +2. **The media files themselves**, mounted into the container read-only. Siftlode streams the files + directly rather than proxying Plex, so it has to be able to open them. + +Point `PLEX_MEDIA_HOST_PATH` at the directory holding your library and re-run `up -d`: + +```bash +PLEX_MEDIA_HOST_PATH=/mnt/media +``` + +It appears inside the container at `/plex-media`, read-only. Siftlode then rewrites the paths Plex +reports into that mount using the **path map** on the same admin page — its default, `/data=/plex-media`, +means "wherever Plex says `/data/movies/…`, look in `/plex-media/movies/…`". Set the left-hand side to +whatever prefix your Plex server uses, and mount the matching host directory so the rest of the path +still lines up. + +Leave `PLEX_MEDIA_HOST_PATH` unset if you don't use Plex — the mount falls back to an empty volume and +the module stays off. + ## Day-to-day ```bash -# Update to the latest release -docker compose -f docker-compose.selfhost.yml pull -docker compose -f docker-compose.selfhost.yml up -d - # Logs / status (api = web, worker = downloads/edits) docker compose -f docker-compose.selfhost.yml logs -f api docker compose -f docker-compose.selfhost.yml logs -f worker @@ -131,6 +152,166 @@ docker compose -f docker-compose.selfhost.yml ps docker compose -f docker-compose.selfhost.yml down ``` -Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata` -Docker volume — back that up to keep your instance's state. Database migrations run automatically -when the app starts, so updating is just pull + up. +## Backups + +**A backup is three things, not one.** Copying only the database gets you an instance that starts and +then cannot read its own secrets. + +1. **The database** — accounts, subscriptions, playlists, the video catalog, and every setting you + made in the admin pages. It lives in the `siftlode_pgdata` Docker volume; dump it with the command + below. +2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex + token and every stored refresh token are encrypted with it *inside* the database. Restore a dump + against a different key and those values are lost — you would have to clear the affected rows from + `system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private. +3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_downloads` volume, or + whatever `DOWNLOAD_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their + metadata are in the database, the files themselves are not. + +Dump and restore the database with these two commands. They also exist as scripts +(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have +the source checkout: + +If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below — +the scripts read them from the environment, these one-liners cannot. + +```bash +# Back up. Write to a .part name and rename only on success: redirecting straight into the final +# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte +# file in backups/ that you will not discover until the day you need it. +mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \ + docker compose -f docker-compose.selfhost.yml exec -T db \ + pg_dump -U siftlode -Fc siftlode > "$out.part" \ + && [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \ + || { rm -f "$out.part"; echo "BACKUP FAILED" >&2; false; } + +# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a +# running api holds locks that make those DROPs queue — abort the wait and you are left with a +# half-dropped database. Restoring with the app down also means nothing writes into a schema that +# is only partly back. +docker compose -f docker-compose.selfhost.yml stop api worker +docker compose -f docker-compose.selfhost.yml exec -T db \ + pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-.dump + +# Start it again only once the restore reported no errors — and, if you are rolling a release back, +# only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the +# newer image back here would re-apply the migrations you just restored away. Use `up -d`, not +# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and +# comes back on the image you are rolling back FROM. +docker compose -f docker-compose.selfhost.yml up -d +``` + +On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the +container and copy it out instead. Check the exit code between the two steps and use a timestamped +name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would +copy out the previous run's file and quietly overwrite your only backup with a stale one. + +Paste it as ONE block. `throw` only aborts the statement it is in, so as separate pasted lines the +copy would still run after a failed dump — and copy out the previous run's file: + +```powershell +& { + # Inside the block, because a scriptblock inherits the caller's preference — under a profile that + # sets SilentlyContinue, every `throw` below would be swallowed and the block would run on. + $ErrorActionPreference = "Stop" + $compose = "docker-compose.selfhost.yml" + New-Item -ItemType Directory -Force -Path backups | Out-Null + $out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump" + docker compose -f $compose exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump + $code = $LASTEXITCODE + # Remove the in-container dump on the failure path too: leaving it there is how a LATER failed run + # copies out this run's file and overwrites a good backup with a stale one. + if ($code -ne 0) { docker compose -f $compose exec -T db rm -f /tmp/s.dump; throw "BACKUP FAILED - pg_dump returned $code" } + docker compose -f $compose cp db:/tmp/s.dump $out + $code = $LASTEXITCODE + docker compose -f $compose exec -T db rm -f /tmp/s.dump + # Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump + # under the final name — the plausible-looking bad backup the .part dance above avoids. + if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" } + if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" } + "wrote $out" +} +``` + +## Upgrading + +**Take a backup first.** Every start runs any new database migrations automatically, and a migration +is not reversible by pulling the old image back — see rollback below. + +```bash +docker compose -f docker-compose.selfhost.yml pull +docker compose -f docker-compose.selfhost.yml up -d +``` + +That tracks `latest`, which moves whenever a release is cut. **To decide for yourself when you move, +pin a version** by putting it in `.env`: + +```bash +IMAGE_TAG=0.80.0 +``` + +Then `pull` + `up -d` stays on that version until you change the line. Released versions and what +changed in them are listed in the app itself, under **Release Notes** in the account menu, and in +[`CHANGELOG.md`](../CHANGELOG.md). + +**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is +"decide when you take the next hop", not "stay here forever": once you are several releases behind, +your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your +Docker images, or move to a new machine. A container that is already running is unaffected (Docker +keeps the image locally), and you can always go back to `latest` or build the image yourself from the +repository. If you want to sit on one version long term, keep your own copy: +`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`. + +### Rollback + +If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough +**as long as the release did not change the database schema**. If it did, the older image will refuse +to start against the newer schema. + +**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back +exactly where the old image expects it, in one step, with no revision arithmetic: + +```bash +# 1. stop the app (leave the database up) +docker compose -f docker-compose.selfhost.yml stop api worker +# 2. restore the dump you took before upgrading (see Backups above). Leave the app stopped: +# starting it here would run the NEWER image's migrations against the database you just restored. +docker compose -f docker-compose.selfhost.yml exec -T db \ + pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-.dump +# 3. pin the previous version in .env, then start again +docker compose -f docker-compose.selfhost.yml up -d +``` + +If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade +-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the +newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that +migration's data and still get a crash loop. Downgrade to the revision the target version expects, +which that version can tell you itself: + +```bash +# 1. stop the app (leave the database up) +docker compose -f docker-compose.selfhost.yml stop api worker +# 2. ask the version you are rolling back TO which schema revision it expects +IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads +# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image, +# because it is the one that ships the migrations being undone +docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade +# 4. pin the previous version in .env, then start again +docker compose -f docker-compose.selfhost.yml up -d +``` + +A downgrade drops the columns the newer version added, so anything stored only in those columns is +gone. That is the price of not having the dump. + +### A migration failed on start + +The container will keep restarting (`restart: unless-stopped`), and `worker` will sit waiting for an +`api` that never becomes healthy. Read the reason first: + +```bash +docker compose -f docker-compose.selfhost.yml logs api | tail -50 +``` + +Then either fix the cause and let it retry, or roll back: restore your pre-upgrade dump, pin the +previous `IMAGE_TAG`, and `up -d`. Do not delete the `siftlode_pgdata` volume to "start clean" unless +you have a dump you have actually tested restoring — that volume *is* your instance. diff --git a/frontend/package.json b/frontend/package.json index ad753e01..9801d062 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,7 @@ { "name": "siftlode-frontend", "private": true, + "license": "AGPL-3.0-only", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx new file mode 100644 index 00000000..a7e25382 --- /dev/null +++ b/frontend/src/components/AccountPanel.tsx @@ -0,0 +1,252 @@ +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 Modal from "./Modal"; +import { LoadingState, StateMessage } from "./QueryState"; +import { api } from "../lib/api"; +import { qk } from "../lib/queryKeys"; +import { notify } from "../lib/notifications"; +import { copyText } from "../lib/clipboard"; +import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers"; +import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; + +// The admin's answer to "why can this person not get in?", and the place every recovery action for +// one account lives (E12 · S5, design option C). +// +// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a +// "Pending approval" chip but not whether the address was confirmed, whether the password had been +// cleared by SEC-10, or which Google account was attached. Every support conversation started by +// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks +// one having read what is actually wrong. + +/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */ +function AccessRow({ + icon: Icon, + label, + value, + tone = "muted", +}: { + icon: typeof Mail; + label: string; + value: string; + tone?: "muted" | "warn" | "ok"; +}) { + const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted"; + return ( +
+ + {label} + {value} +
+ ); +} + +export default function AccountPanel({ + user, + onClose, +}: { + user: AdminUserRow; + onClose: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null); + const [copied, setCopied] = useState(false); + + const q = useQuery({ + queryKey: qk.adminUserAccess(user.id), + queryFn: () => api.adminUserAccess(user.id), + }); + + // Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error + // dialog on the promise that the caller reports the failure inline. Without an `onError` that + // promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this + // panel exists for, the admin would read silence as "the mail went out". + const issue = useMutation({ + mutationFn: () => api.createSetPasswordLink(user.id), + onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }), + onError: () => notify({ level: "error", message: t("users.account.linkFailed") }), + }); + + const resend = useMutation({ + mutationFn: () => api.resendUserVerification(user.id), + onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }), + onError: (err) => + notify({ + level: "error", + message: isEmailOff(err) + ? t("users.pendingAccess.resendEmailOff") + : t("users.pendingAccess.resendFailed"), + }), + }); + + const activate = useMutation({ + mutationFn: () => api.activateUser(user.id), + onSuccess: () => invalidateUserAccess(qc, user.id), + onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }), + }); + + const copy = async () => { + if (!link) return; + // Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a + // plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the + // minimal self-host this no-email link was built for. + if (await copyText(link.url)) { + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + return; + } + // The link is on screen and selectable, so a failed copy is a convenience that failed — not the + // action failing. Say so rather than leaving the button looking inert. + notify({ level: "warning", message: t("users.account.copyFailed") }); + }; + + const a: AdminUserAccess | undefined = q.data; + + return ( + +
{user.email}
+ + {q.isError && !a ? ( + q.refetch()}> + {t("common.loadError")} + + ) : !a ? ( + + ) : ( + <> +
+ {t("users.account.access")} +
+
+ + + + + {/* The gate that explains a Google sign-in silently becoming a fresh access request. */} + {!a.is_allowed && ( + + )} +
+ +
+ {t("users.account.recovery")} +
+
+ {a.can_activate && ( + + )} + + + + +
+ + {link && ( +
+
+
+ {link.url} +
+ +
+

{t("users.account.linkNote")}

+
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 64d094a0..d61b2737 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -24,6 +24,7 @@ import { type PendingAccessRow, } from "../lib/api"; import { notify } from "../lib/notifications"; +import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import Tooltip from "./Tooltip"; import InlineNotice from "./InlineNotice"; @@ -31,6 +32,7 @@ import { Section } from "./ui/form"; import SegmentedToggle from "./SegmentedToggle"; import { LANGUAGES, type LangCode } from "../i18n"; import { LoadingState, StateMessage } from "./QueryState"; +import AccountPanel from "./AccountPanel"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; import { PageToolbar } from "./PageShell"; @@ -107,6 +109,9 @@ function UsersRoles({ me }: { me: Me }) { // Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since // with per-row disabling the admin can now start actions on several rows concurrently. const [pendingIds, setPendingIds] = useState>(new Set()); + // The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so + // only one is ever mounted. + const [panelFor, setPanelFor] = useState(null); const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id)); const endPending = (id: number) => setPendingIds((s) => { @@ -192,6 +197,7 @@ function UsersRoles({ me }: { me: Me }) { return (
+ {panelFor && setPanelFor(null)} />}

{t("users.roles.intro")}

{q.isError && !q.data ? ( q.refetch()}> @@ -207,13 +213,17 @@ function UsersRoles({ me }: { me: Me }) { const self = u.id === me.id; return (
-
+
+ {u.role === "admin" && {t("users.roles.admin")}} {u.is_demo && {t("users.roles.demo")}} {u.is_suspended && {t("users.roles.suspended")}} @@ -316,12 +326,9 @@ function PendingAccess() { queryKey: qk.adminPendingAccess(), queryFn: () => api.adminPendingAccess(), }); - // Activating changes /api/me's badge count too, so refresh both or the number lies until reload. - const refresh = () => { - qc.invalidateQueries({ queryKey: qk.adminPendingAccess() }); - qc.invalidateQueries({ queryKey: qk.me() }); - qc.invalidateQueries({ queryKey: qk.adminUsers() }); - }; + // Activating changes /api/me's badge count too, so refresh all of them or the number lies until + // reload. Shared with the account panel's copy of this mutation (see lib/adminUsers). + const refresh = () => invalidateUserAccess(qc); const activate = useMutation({ mutationFn: (u: PendingAccessRow) => api.activateUser(u.id), @@ -343,10 +350,9 @@ function PendingAccess() { onError: (err) => notify({ level: "error", - message: - err instanceof HttpError && isEmailOff(err) - ? t("users.pendingAccess.resendEmailOff") - : t("users.pendingAccess.resendFailed"), + message: isEmailOff(err) + ? t("users.pendingAccess.resendEmailOff") + : t("users.pendingAccess.resendFailed"), }), }); @@ -380,12 +386,6 @@ function PendingAccess() { ); } -/** Did the resend fail because this instance has no working SMTP? */ -function isEmailOff(err: HttpError): boolean { - const d = err.detailData as { code?: string; reason?: string } | null | undefined; - return d?.code === "resend" && d.reason === "email_off"; -} - // One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same // reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone // in should see that the address has not been confirmed yet, because on a no-SMTP instance that is diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index d310fdb6..295986d1 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -25,11 +25,17 @@ export default function Modal({ onClose, children, maxWidth = "max-w-lg", + placement = "center", }: { title: ReactNode; onClose: () => void; children: ReactNode; maxWidth?: string; + /** `drawer` slides in from the right edge and runs full height; everything else about the shell — + * the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer + * is a placement, not a second dialog implementation: duplicating that behaviour is exactly how + * two shells drift apart. */ + placement?: "center" | "drawer"; }) { const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. @@ -106,9 +112,12 @@ export default function Modal({ }; }, [restoreTo]); + const drawer = placement === "drawer"; return createPortal(
{ pressedOnBackdrop.current = e.target === e.currentTarget; }} @@ -122,7 +131,11 @@ export default function Modal({

diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 9bb4c9d0..734a17cd 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -6,6 +6,8 @@ import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { useWizard } from "./WizardProvider"; import { LS, useAccountPersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; import { LoadingState, StateMessage } from "./QueryState"; @@ -51,6 +53,8 @@ export default function Stats() { function Overview({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); + // The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here. + const { openWizard } = useWizard(); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage }); const s = status.data; @@ -64,7 +68,12 @@ function Overview({ me }: { me: Me }) { message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }), }); }, - onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }), + // The same helper and the same wizard handle as the Channels page: it translates the 403 into + // "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare + // English detail under a Hungarian UI — and delegates everything else to notifyActionError so + // the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit + // rather than "Sync failed". + onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), diff --git a/frontend/src/components/VerifyBanner.tsx b/frontend/src/components/VerifyBanner.tsx index 0d99bab2..b38fae61 100644 --- a/frontend/src/components/VerifyBanner.tsx +++ b/frontend/src/components/VerifyBanner.tsx @@ -14,15 +14,39 @@ import type { VerifyEntry } from "../lib/useVerifyEntry"; // wrong-account case is why the address is named. export default function VerifyBanner({ entry }: { entry: VerifyEntry }) { const { t } = useTranslation(); + // A reset link opened here can only be ACTED on signed out, so say that rather than nothing — + // silence is precisely what made the verify half a bug. The token stays in the address bar, so + // signing out from this same tab picks it up. + // + // Tested on `resetPending` ALONE, not `!outcome && resetPending`. The two can both be set (a + // reset link pasted over an unread confirmation, or a slow verify resolving after one), and this + // one goes first because it is the banner with something to DO. `dismiss` then peels it and the + // outcome shows underneath — where `!outcome &&` would have hidden this one until the other was + // dismissed, and dismissing that used to clear both. + if (entry.resetPending) { + return ( + + {t("header.verify.resetSignedOut")} + + ); + } if (!entry.outcome) return null; - const ok = entry.outcome !== "invalid"; + const ok = entry.outcome === "ok" || entry.outcome === "ok_reset"; const text = entry.outcome === "invalid" ? t("header.verify.invalid") - : entry.email - ? t("header.verify.confirmedNamed", { email: entry.email }) - : t("header.verify.confirmed"); + : entry.outcome === "unreachable" + ? t("header.verify.unreachable") + : entry.email + ? t("header.verify.confirmedNamed", { email: entry.email }) + : t("header.verify.confirmed"); return ( )} + {/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply + could not reach the server — so pushing the user to request a fresh link would act on a + statement we know to be false (DX-16). Retrying the same link is the correct next move. */} + {verifyOutcome === "unreachable" && ( + {t("welcome.auth.verifyUnreachable")} + )} {verifyOutcome === "invalid" && ( {t("welcome.auth.verifyInvalid")}{" "} diff --git a/frontend/src/components/accountPanel.dom.test.tsx b/frontend/src/components/accountPanel.dom.test.tsx new file mode 100644 index 00000000..3de8c62e --- /dev/null +++ b/frontend/src/components/accountPanel.dom.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +// +// The admin account panel's RECOVERY actions, and specifically that a refusal is visible. +// +// All three call `quiet: true` endpoints, which suppress the global error dialog on the promise that +// the caller reports the failure inline. Without an `onError` that promise is broken and a refusal +// renders as nothing at all — and on the broken-SMTP instance this panel exists for, an admin reads +// silence as "the mail went out". These pin that each failure says something, and that the resend +// distinguishes "this instance has no working mail" from a genuine send failure. +// +// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are +// stubbed so the panel can be driven without a server. +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const notifyMock = vi.fn(); +vi.mock("../lib/notifications", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) }; +}); + +const access = { + email_verified: false, + email_configured: true, + is_active: false, + is_allowed: true, + can_activate: true, + has_password: false, + google_email: null, + invite_status: null, +}; + +const apiMock = { + adminUserAccess: vi.fn(async () => access), + createSetPasswordLink: vi.fn(), + resendUserVerification: vi.fn(), + activateUser: vi.fn(), +}; +vi.mock("../lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: apiMock }; +}); + +const { HttpError } = await import("../lib/api"); +const AccountPanel = (await import("./AccountPanel")).default; + +const user = { + id: 7, + email: "stuck@example.com", + display_name: null, + role: "user", + is_demo: false, + is_active: false, + is_suspended: false, +}; + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {}} /> + , + ); +} + +/** Click the recovery button whose label is `key`, once the readout has loaded. */ +async function clickAction(key: string) { + mount(); + await screen.findByText("users.account.recovery"); + fireEvent.click(screen.getByText(key)); +} + +beforeEach(() => { + notifyMock.mockClear(); + Object.values(apiMock).forEach((m) => m.mockClear()); + apiMock.adminUserAccess.mockResolvedValue(access); +}); +afterEach(cleanup); + +const errorMessages = () => + notifyMock.mock.calls + .map(([a]) => a as { level?: string; message?: string }) + .filter((a) => a.level === "error") + .map((a) => a.message); + +describe("the account panel reports a refused recovery action", () => { + it("says the link could not be minted instead of leaving the button inert", async () => { + apiMock.createSetPasswordLink.mockRejectedValue(new HttpError(404, "gone", null)); + + await clickAction("users.account.setPassword"); + + await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed")); + // …and no link block was rendered, so the toast is the only thing that happened. + expect(screen.queryByText("users.account.copy")).toBeNull(); + }); + + it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => { + apiMock.resendUserVerification.mockRejectedValue( + new HttpError(409, "email off", { code: "resend", reason: "email_off" }), + ); + + await clickAction("users.account.resend"); + + await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff")); + }); + + it("reports a genuine send failure as a failure, not as a sent mail", async () => { + apiMock.resendUserVerification.mockRejectedValue( + new HttpError(422, "send failed", { code: "resend", reason: "send_failed" }), + ); + + await clickAction("users.account.resend"); + + await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed")); + // The success toast must NOT also fire — that is the exact lie the backend guards against. + expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain( + "users.account.resendSent", + ); + }); + + it("reports a refused activation", async () => { + apiMock.activateUser.mockRejectedValue( + new HttpError(409, "not allowed", { code: "activate", reason: "not_allowed" }), + ); + + await clickAction("users.account.activate"); + + await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed")); + }); +}); diff --git a/frontend/src/components/authCardStates.dom.test.tsx b/frontend/src/components/authCardStates.dom.test.tsx index 9c03bdc4..59c37ade 100644 --- a/frontend/src/components/authCardStates.dom.test.tsx +++ b/frontend/src/components/authCardStates.dom.test.tsx @@ -11,7 +11,12 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import type { VerifyEntry } from "../lib/useVerifyEntry"; -const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} }; +const noVerify: VerifyEntry = { + outcome: null, + email: null, + resetPending: false, + dismiss: () => {}, +}; afterEach(() => { cleanup(); diff --git a/frontend/src/components/welcomeMode.render.dom.test.tsx b/frontend/src/components/welcomeMode.render.dom.test.tsx index 6b9b796e..2705bf23 100644 --- a/frontend/src/components/welcomeMode.render.dom.test.tsx +++ b/frontend/src/components/welcomeMode.render.dom.test.tsx @@ -32,7 +32,12 @@ import type { VerifyEntry } from "../lib/useVerifyEntry"; // App confirms the emailed token and hands the outcome down; these tests are about the card's // sub-views, so there is nothing to report. -const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} }; +const noVerify: VerifyEntry = { + outcome: null, + email: null, + resetPending: false, + dismiss: () => {}, +}; const back = () => act(() => { @@ -91,6 +96,7 @@ describe("the verification outcome handed down from App", () => { const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({ outcome, email: "someone@example.com", + resetPending: false, dismiss: () => {}, }); @@ -112,6 +118,15 @@ describe("the verification outcome handed down from App", () => { expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy(); }); + // DX-16: a transport failure is not a verdict on the token, so it must NOT offer a fresh link — + // that would act on a statement we know to be false. Retrying the same link is the way out. + it("does not offer a new link when the server was merely unreachable", async () => { + render(); + expect(await screen.findByText("welcome.auth.verifyUnreachable")).toBeTruthy(); + expect(screen.queryByText("welcome.auth.resendCta")).toBeNull(); + expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull(); + }); + it("shows no banner when there was no token to confirm", async () => { render(); await screen.findByText("welcome.auth.signinTitle"); diff --git a/frontend/src/i18n/locales/en/auditActions.json b/frontend/src/i18n/locales/en/auditActions.json index c569c299..2c03e60a 100644 --- a/frontend/src/i18n/locales/en/auditActions.json +++ b/frontend/src/i18n/locales/en/auditActions.json @@ -5,6 +5,7 @@ "user_delete": "User deleted", "user_activate": "Account activated", "user_verify_resend": "Confirmation link re-sent", + "user_setpw_link": "Set-password link issued", "invite_approve": "Access request approved", "invite_deny": "Access request denied", "invite_add": "Email whitelisted", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 6bd6082f..693adfb3 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -229,6 +229,14 @@ "tooLarge": "That file is too large (max {{size}}).", "empty": "That file is empty." }, + "urlBlocked": { + "scheme": "Only http:// and https:// links can be downloaded.", + "no_host": "That link doesn't contain a website address.", + "unresolvable": "That address couldn't be looked up. Check the link and try again.", + "private": "That address is on the server's own network, so it can't be downloaded from.", + "generic": "That address can't be downloaded from.", + "malformed": "That link isn't a valid web address." + }, "fail": { "title": "Download failed", "detailsLink": "details", @@ -273,6 +281,10 @@ "unknown": { "short": "Download failed", "body": "Something went wrong while downloading this item. The technical details below may help." + }, + "quota_exceeded": { + "short": "Storage full", + "body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again." } } }, diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 3a61b69b..d595a028 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -3,6 +3,7 @@ "generic": "The server couldn't carry out that action. Please try again.", "validation": "Some of the values you entered weren't valid. Please check them and try again.", "server": "Server error", + "syncCooldown": "You've synced a few times just now — give it a few minutes. The YouTube budget is shared with everyone on this instance, and background syncing keeps running either way.", "ok": "OK", "offline": { "title": "Connection lost", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index b5f7eb63..256a8888 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -61,6 +61,8 @@ "confirmed": "Email address confirmed.", "confirmedNamed": "Email address confirmed — {{email}}.", "invalid": "That verification link is no longer valid — it has expired or was already used.", + "unreachable": "Couldn't reach the server to confirm that link, so it hasn't been used up. Try opening it again in a moment.", + "resetSignedOut": "Password-reset links open in a signed-out window. Sign out here, or open the link in a private window, and it will pick up where it left off.", "reset": "The password this account registered with was cleared for security; set a new one before signing in." } } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 103f8c37..1c2e4ff4 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -59,5 +59,39 @@ "title": "Delete account?", "confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.", "done": "{{email}} deleted" + }, + "account": { + "access": "Access", + "activate": "Let this account in", + "alreadyConfirmed": "already confirmed", + "approval": "Admin approval", + "approvalDone": "let in", + "approvalNotRequested": "no approved invite yet", + "approvalWaitingOnYou": "waiting on you", + "copied": "Copied", + "copy": "Copy", + "copyFailed": "Couldn't copy — select the link and copy it by hand.", + "email": "Email", + "emailConfirmed": "confirmed", + "emailUnconfirmed": "not confirmed", + "google": "Google", + "googleAs": "connected as {{email}}", + "googleConnected": "connected", + "googleNone": "not connected", + "invite": "Invite", + "inviteMissing": "this address is not on the allow-list", + "linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.", + "mailOff": "no mail configured", + "open": "Open the account panel", + "password": "Password", + "passwordNone": "none — signs in with Google", + "passwordRevoked": "cleared for security", + "passwordSet": "set", + "recovery": "Recovery", + "resend": "Send a confirmation link", + "resendSent": "Confirmation link sent.", + "setPassword": "Create a set-password link", + "setPasswordAgain": "Create another link", + "linkFailed": "Couldn't create the link. Please try again." } } diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json index 0dfecd5b..4bd4a9a2 100644 --- a/frontend/src/i18n/locales/en/welcome.json +++ b/frontend/src/i18n/locales/en/welcome.json @@ -95,6 +95,7 @@ "verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.", "verifyOkResetCta": "Set a new password", "verifyInvalid": "That verification link is invalid or has expired.", + "verifyUnreachable": "Couldn't reach the server to confirm that link, so it hasn't been used up. Try opening it again in a moment.", "resendCta": "Send a new link", "resendDone": "If that email has an unconfirmed account, we've sent a new confirmation link. Check your inbox, and your spam folder if it hasn't arrived in a minute.", "oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.", diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json index 636e8a53..d7b99777 100644 --- a/frontend/src/i18n/locales/hu/auditActions.json +++ b/frontend/src/i18n/locales/hu/auditActions.json @@ -5,6 +5,7 @@ "user_delete": "Felhasználó törölve", "user_activate": "Fiók aktiválva", "user_verify_resend": "Megerősítő link újraküldve", + "user_setpw_link": "Jelszóbeállító link kiadva", "invite_approve": "Hozzáférési kérelem jóváhagyva", "invite_deny": "Hozzáférési kérelem elutasítva", "invite_add": "Email fehérlistázva", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 56ab3e54..f845cc2c 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -229,6 +229,14 @@ "tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).", "empty": "Ez a fájl üres." }, + "urlBlocked": { + "scheme": "Csak http:// és https:// hivatkozás tölthető le.", + "no_host": "Ez a hivatkozás nem tartalmaz webcímet.", + "unresolvable": "Ezt a címet nem sikerült feloldani. Ellenőrizd a hivatkozást, és próbáld újra.", + "private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla.", + "generic": "Erről a címről nem lehet letölteni.", + "malformed": "Ez a hivatkozás nem érvényes webcím." + }, "fail": { "title": "A letöltés nem sikerült", "detailsLink": "részletek", @@ -273,6 +281,10 @@ "unknown": { "short": "A letöltés nem sikerült", "body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek." + }, + "quota_exceeded": { + "short": "Betelt a tárhely", + "body": "Ez a letöltés leállt, mert a tárhelykereted már elfogyott. Törölj valamit a könyvtáradból, aztán próbáld újra." } } }, diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index a8847c5c..3fcd3f93 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -3,6 +3,7 @@ "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", "validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.", "server": "Szerverhiba", + "syncCooldown": "Az imént többször is szinkronizáltál — várj pár percet. A YouTube-keret közös az instance összes felhasználójával, a háttérszinkron pedig így is fut tovább.", "ok": "OK", "offline": { "title": "Megszakadt a kapcsolat", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index cb3b9333..005a09c2 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -61,6 +61,8 @@ "confirmed": "Megerősítettük az e-mail-címedet.", "confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}.", "invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták.", + "unreachable": "Nem sikerült elérni a szervert a link megerősítéséhez, így az nem is használódott el. Próbáld megnyitni újra egy pillanat múlva.", + "resetSignedOut": "A jelszó-visszaállító linkek kijelentkezett ablakban nyílnak meg. Jelentkezz ki itt, vagy nyisd meg a linket privát ablakban, és onnan folytatódik.", "reset": "A fiók regisztrációkori jelszavát biztonsági okból töröltük; belépés előtt állíts be újat." } } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 207fbf87..80a6df7e 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -59,5 +59,39 @@ "title": "Törlöd a fiókot?", "confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.", "done": "{{email}} törölve" + }, + "account": { + "access": "Hozzáférés", + "activate": "Fiók beengedése", + "alreadyConfirmed": "már megerősítve", + "approval": "Admin jóváhagyás", + "approvalDone": "beengedve", + "approvalNotRequested": "még nincs jóváhagyott meghívó", + "approvalWaitingOnYou": "rád vár", + "copied": "Másolva", + "copy": "Másolás", + "copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel.", + "email": "E-mail", + "emailConfirmed": "megerősítve", + "emailUnconfirmed": "nincs megerősítve", + "google": "Google", + "googleAs": "összekötve mint {{email}}", + "googleConnected": "összekötve", + "googleNone": "nincs összekötve", + "invite": "Meghívó", + "inviteMissing": "ez a cím nincs az engedélyezési listán", + "linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.", + "mailOff": "nincs beállítva levelezés", + "open": "Fiókpanel megnyitása", + "password": "Jelszó", + "passwordNone": "nincs — Google-lel lép be", + "passwordRevoked": "biztonsági okból törölve", + "passwordSet": "be van állítva", + "recovery": "Helyreállítás", + "resend": "Megerősítő link küldése", + "resendSent": "Megerősítő link elküldve.", + "setPassword": "Jelszóbeállító link készítése", + "setPasswordAgain": "Új link készítése", + "linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra." } } diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index 2bb1f998..6b8242c7 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -95,6 +95,7 @@ "verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.", "verifyOkResetCta": "Új jelszó beállítása", "verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.", + "verifyUnreachable": "Nem sikerült elérni a szervert a link megerősítéséhez, így az nem is használódott el. Próbáld megnyitni újra egy pillanat múlva.", "resendCta": "Új link küldése", "resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.", "oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.", diff --git a/frontend/src/index.css b/frontend/src/index.css index ca02eb27..8a5efe72 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -657,6 +657,24 @@ html[data-perf="1"] .overlay-backdrop { backdrop-filter: none; } +/* Right-edge drawer (Modal placement="drawer"). The slide is what makes it read as a panel that + came from the side rather than a dialog that happens to be right-aligned — so it is part of the + affordance, not decoration. Honours prefers-reduced-motion, like every other motion here. */ +.overlay-drawer { + animation: drawer-in 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both; +} +@keyframes drawer-in { + from { + transform: translateX(1.5rem); + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .overlay-drawer { + animation: none; + } +} + /* Skip layout/paint for off-screen feed cards so long lists stay smooth while scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar stays stable; `auto` lets the browser remember each card's real size after it diff --git a/frontend/src/lib/adminUsers.test.ts b/frontend/src/lib/adminUsers.test.ts new file mode 100644 index 00000000..75e88d41 --- /dev/null +++ b/frontend/src/lib/adminUsers.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +// +// jsdom because the shared module reaches lib/api, which pulls in i18n's localStorage probe. +import { describe, it, expect, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { invalidateUserAccess, isEmailOff } from "./adminUsers"; +import { HttpError } from "./api"; +import { qk } from "./queryKeys"; + +// The set matters, not the call: letting someone in changes three things a user can see at once — +// the row, the pending queue, and the /api/me badge — and the account panel shipped invalidating +// only the first two, so the badge went on claiming the person was stuck until a reload. Pinning the +// keys here is what stops the two call sites drifting apart again, since neither component test +// asserts on invalidation. +describe("invalidateUserAccess", () => { + it("refreshes the row, the pending queue and the badge", () => { + const qc = new QueryClient(); + const spy = vi.spyOn(qc, "invalidateQueries").mockImplementation(() => Promise.resolve()); + + invalidateUserAccess(qc); + + const keys = spy.mock.calls.map(([a]) => JSON.stringify(a?.queryKey)); + expect(keys).toEqual( + [qk.adminUsers(), qk.adminPendingAccess(), qk.me()].map((k) => JSON.stringify(k)), + ); + }); + + it("also refreshes one account's gate readout when given an id", () => { + const qc = new QueryClient(); + const spy = vi.spyOn(qc, "invalidateQueries").mockImplementation(() => Promise.resolve()); + + invalidateUserAccess(qc, 7); + + expect(spy.mock.calls.map(([a]) => JSON.stringify(a?.queryKey))).toContain( + JSON.stringify(qk.adminUserAccess(7)), + ); + }); +}); + +// The admin-side resend deliberately reports failures (the public one is silent), so it has to be +// able to say WHICH — a no-SMTP instance is a configuration answer, not a bug. +describe("isEmailOff", () => { + it("recognises the no-mail refusal", () => { + expect(isEmailOff(new HttpError(409, "x", { code: "resend", reason: "email_off" }))).toBe(true); + }); + + it("is false for any other failure, including a non-HttpError", () => { + expect(isEmailOff(new HttpError(422, "x", { code: "resend", reason: "send_failed" }))).toBe( + false, + ); + expect(isEmailOff(new HttpError(500, "x", null))).toBe(false); + expect(isEmailOff(new TypeError("Failed to fetch"))).toBe(false); + expect(isEmailOff(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/lib/adminUsers.ts b/frontend/src/lib/adminUsers.ts new file mode 100644 index 00000000..dea4b3ab --- /dev/null +++ b/frontend/src/lib/adminUsers.ts @@ -0,0 +1,36 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { HttpError } from "./api"; +import { qk } from "./queryKeys"; + +// Shared bits of the admin's user-management surface, extracted because the SAME two mutations +// (activate, resend-verification) are now offered from two places — the pending-access queue and +// the account panel — and the second copy quietly dropped half of what the first one does. +// +// Grouped invalidation lives per domain rather than in `qk` (see the contract note there); +// `invalidateDownloads` is the same pattern for the downloads domain. + +/** Everything that goes stale when an account is LET IN (activate, resend its confirmation). + * + * Suspend and delete move the same counters and are deliberately NOT wired here yet — they have + * their own pre-existing staleness, tracked separately, and widening this in passing would put an + * unreviewed change in a review-fix diff. + * + * `me` is in the set because the admin badge counts pending activations, and `admin-pending-access` + * because the queue is exactly the list of accounts this action removes one from — refreshing only + * `admin-users` leaves the badge and the queue asserting a person is still stuck after they were + * let in. One function so the two call sites cannot drift apart again. */ +export function invalidateUserAccess(qc: QueryClient, userId?: number) { + qc.invalidateQueries({ queryKey: qk.adminUsers() }); + qc.invalidateQueries({ queryKey: qk.adminPendingAccess() }); + qc.invalidateQueries({ queryKey: qk.me() }); + if (userId !== undefined) qc.invalidateQueries({ queryKey: qk.adminUserAccess(userId) }); +} + +/** Did an admin resend fail because the instance has no working mail, rather than for a real + * error? The admin-side resend deliberately reports failures (the public one is silent), so the + * message must be able to say WHICH — a no-SMTP instance would otherwise read as a bug. */ +export function isEmailOff(err: unknown): boolean { + if (!(err instanceof HttpError)) return false; + const d = err.detailData as { code?: string; reason?: string } | null | undefined; + return d?.code === "resend" && d.reason === "email_off"; +} diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index ce3749c7..a9ae05e7 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -75,10 +75,26 @@ export interface AdminUserRow { email_verified: boolean; is_demo: boolean; has_password: boolean; + /** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only + * account) from "we took it away" — different problems needing different help. */ + password_revoked_at: string | null; has_google: boolean; + /** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */ + google_email: string | null; created_at: string | null; } +/** The account panel's view of why someone can or cannot get in (E12 S5). */ +export interface AdminUserAccess extends AdminUserRow { + /** The address passes the invite gate — so Google sign-in will be let through, not filed as a + * fresh access request. */ + is_allowed: boolean; + can_activate: boolean; + /** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery + * actions are offered or shown as unavailable — rather than failing after the click. */ + email_configured: boolean; +} + export interface AuditRow { id: number; created_at: string | null; @@ -197,6 +213,14 @@ export const adminApi = { // because the caller is already an authenticated admin. resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> => req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }), + // What the account panel shows: the two computed gate answers, resolved for one user on open. + adminUserAccess: (id: number): Promise => req(`/api/admin/users/${id}/access`), + // Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the + // everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password. + createSetPasswordLink: ( + id: number, + ): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> => + req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }), adminInvites: (status?: string): Promise => req(`/api/admin/invites${status ? `?status=${status}` : ""}`), approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }), diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 31af97ee..74b6db05 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -72,6 +72,14 @@ function localizeDetail(d: unknown): string { if (o?.code === "upload_too_large") return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) }); if (o?.code === "upload_empty") return t("downloads.errors.upload.empty"); + // SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct — + // "we could not look that host up" is a different thing to tell someone than "we will not fetch + // from that address" — so an unknown reason falls back to the generic refusal rather than a key. + if (o?.code === "url_blocked") + return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic")); + // SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace + // limit, and the copy says so. + if (o?.code === "sync_cooldown") return t("errors.syncCooldown"); return t("errors.generic"); } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index fa635c03..d72a7ea1 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -85,6 +85,7 @@ export const qk = { // --- admin --- adminUsers: () => ["admin-users"] as const, + adminUserAccess: (id: number) => ["admin-user-access", id] as const, adminInvites: () => ["admin-invites"] as const, adminPendingAccess: () => ["admin-pending-access"] as const, adminConfig: () => ["admin-config"] as const, diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 4be9bf6f..0b42ad58 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,32 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.81.0", + date: "2026-09-05", + summary: + "If you run your own Siftlode, this one is mostly for you: there is now a way back from a bad upgrade, backup scripts that actually restore, and a way to let someone in on an instance that cannot send mail. If you just use Siftlode, the visible part is that a download can no longer quietly blow past your storage limit, and that the box refuses addresses on the server's own network.", + features: [ + "Admins get an account panel: click anyone in Users and it says, in one place, why that person can or cannot get in — whether their address is confirmed, whether an admin has approved them, whether they have a password, which Google account is attached, and whether their address is on the allow-list. Every recovery action for that account sits under the readout, so you pick one having read what is actually wrong instead of guessing.", + 'Someone locked out of an instance with no email setup can now be let back in. "Create a set-password link" gives you a one-time link to hand them however you like — chat, in person, on paper. It lasts 24 hours, works once, and signs out anyone already using that account. Issuing it changes nothing by itself: a misclick cannot lock anyone out.', + "Self-hosters can pin a version. Put `IMAGE_TAG=0.81.0` in your `.env` and Siftlode stays there until you change the line, so an upgrade happens when you decide it does. The docs now also carry a rollback recipe — and say plainly that restoring the dump you took first is the way back that does not lose data.", + "The backup and restore scripts work. On Windows they no longer corrupt the dump on the way out of the container, and on the documented self-host layout — the one where you download a single compose file — they now find it. The docs spell out that a backup is three things, not one: the database dump, your `.env` (without `TOKEN_ENCRYPTION_KEY` the restored secrets are unreadable), and your downloaded media.", + "Plex works on the public compose files. The read-only media mount and its empty-volume fallback are in both of them, with a short setup section — before, the feature existed but the file you were told to run could not satisfy it.", + "Siftlode is licensed, under AGPL-3.0.", + ], + fixes: [ + "A download can no longer exceed your storage quota. The limit was only checked when you queued a job, so a single large download could pass that check and then fill the disk — and once you were at your cap, the download running at that moment was the one with no ceiling at all. Downloads are now bounded by what you have left, and a job that finds nothing left says so instead of running anyway.", + "The download box refuses an address on the server's own network, and tells you why in your own language. Pasting an internal address used to make the server fetch it and hand the result back, which turned the box into a way to probe the machine Siftlode runs on. A malformed link is now refused as one rather than failing with a server error.", + "One person holding down the sync button no longer spends the shared YouTube budget for everyone on the instance. Sync and playlist pull now say, in plain words, that you have synced a few times just now and that background syncing keeps running either way — a pace limit rather than a failure.", + "A password-reset link opened in a tab you are already signed in to now says what to do with it. It used to do nothing at all: the page loaded, and the link appeared broken. The link itself stays in the address bar, so signing out from that same tab picks it up.", + "Admin actions that fail now say so. Sending a confirmation link on an instance whose mail is misconfigured used to produce nothing on screen at all, which reads exactly like it worked.", + "The row you click to open a user's account panel now looks clickable.", + ], + chores: [ + "E12, five sprints. S1: the backup/restore scripts honour COMPOSE_FILE and the PowerShell pair moves the dump with `docker compose cp` rather than through the pipeline (binary does not survive it on 5.1, and `-Encoding Byte` does not exist on 7); docs gained an Upgrading section, and `siftlode release` now tags `v$VERSION` and refuses on a stale CHANGELOG.md. S2: AGPL-3.0, the Plex mount and `init: true` on the worker in both public compose files. S3 (SEC-12/13/14): a URL guard that resolves the host and refuses loopback/private/link-local at enqueue AND again in the worker, a generalised network-class error so it stops working as a port oracle, playlist_items/max_downloads/max_filesize derived from the caller's remaining quota, and a per-user cooldown on the manual sync triggers. S4: the three E9.5 deferrals (DX-16, DX-17, canonical resolution in the setup wizard). S5 (SEC-15/16): the account panel and the admin-issued set-password link, after a mockup round.", + "The closing review ran six rounds across eight dimensions and converged 47 → 34 → 15 → 7 → 6 → 0. Round one's 47 deduplicated to 20 and 18 survived 60 adversarial verifier runs. Rounds two, four and five each found that a previous round's own fix had broken something — the ops half every time, because no gate can run shell scripts or a documented command. The three worth recording: the per-user quota refusal was writing onto a MediaAsset shared between users, so one person hitting their cap failed a second person's queued job for the same video with the first one's reason; the restore scripts' automatic restart re-ran `alembic upgrade head`, undoing the restore on the very rollback path the docs recommend; and the command printed in its place resolved to the other compose file, whose volumes differ, recreating the database empty. Two fixes were reverted rather than patched a third time. Round five also caught that the loop had been running seven of the gate's eight lanes. Residuals documented rather than closed: SEC-17 (redirects walk past the URL guard — the same socket-level fix as DNS rebinding), SEC-18 (the playlist push routes), SEC-19 (the edit path does not consult the byte cap).", + ], + }, { version: "0.80.0", date: "2026-09-05", diff --git a/frontend/src/lib/useVerifyEntry.ts b/frontend/src/lib/useVerifyEntry.ts index 11c06648..87a04c2f 100644 --- a/frontend/src/lib/useVerifyEntry.ts +++ b/frontend/src/lib/useVerifyEntry.ts @@ -1,16 +1,26 @@ import { useEffect, useRef, useState } from "react"; import { api } from "./api"; +import { HttpError } from "./api/core"; import { clearEntryUrl, entryHash } from "./entryUrl"; /** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this * account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials - * error — the user has to set a new one. */ -type VerifyOutcome = "ok" | "ok_reset" | "invalid"; + * error — the user has to set a new one. `unreachable` is a TRANSPORT failure, kept apart from + * `invalid` on purpose (DX-16): the fragment is already cleared by the time we know, so telling + * someone their link is spent when the server was merely restarting sends them to request a fresh one + * on the strength of a false statement. The same rule is already written one file away, for the + * reset-link probe: "a network blip must never be cached as 'this link is dead'". */ +type VerifyOutcome = "ok" | "ok_reset" | "invalid" | "unreachable"; export type VerifyEntry = { outcome: VerifyOutcome | null; /** The address that was confirmed, when the server named it. */ email: string | null; + /** A `#reset=` link opened in a tab that already holds a session. Only the signed-OUT auth card + * consumes a reset token, so nothing here can act on it — but silence is what made the verify half + * a bug, so this says out loud that the link needs a signed-out window. The fragment is deliberately + * left in the address bar (E9.5 review round 4), so signing out still recovers it. */ + resetPending: boolean; /** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */ dismiss: () => void; }; @@ -38,12 +48,24 @@ export function useVerifyEntry(): VerifyEntry { const [token, setToken] = useState(() => entryHash.get("verify")); const [outcome, setOutcome] = useState(null); const [email, setEmail] = useState(null); + const [resetPending, setResetPending] = useState(() => !!entryHash.get("reset")); const consumed = useRef(new Set()); useEffect(() => { const onHashChange = () => { - const next = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("verify"); + const hash = new URLSearchParams(window.location.hash.replace(/^#/, "")); + const next = hash.get("verify"); if (next) setToken(next); + // Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a + // same-document navigation the import-time snapshot never sees. The verify half got this + // listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17). + // + // Set from the live hash rather than one-way, because the flag must not outlive the token it + // describes: navigating away from a `#reset=` (a pasted verify link, the back button) leaves + // the banner promising that signing out will resume a reset whose token is gone — and, being + // checked first, hiding whatever arrived in its place. `clearEntryUrl`'s own replaceState + // fires no hashchange, so this cannot undo the deliberate keep-the-reset-fragment behaviour. + setResetPending(!!hash.get("reset")); }; // replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot // feed itself. @@ -67,8 +89,36 @@ export function useVerifyEntry(): VerifyEntry { setOutcome(r.password_reset_required ? "ok_reset" : "ok"); setEmail(r.email ?? null); }) - .catch(() => setOutcome("invalid")); + // Only the server's own verdict on the token means "invalid". `/auth/verify` refuses a spent + // or expired token with a 400 carrying {code:"verify"}; a 502 during a deploy, or a dropped + // connection (no HttpError at all), says nothing about the token and must not be reported as + // if it did. The token itself is unspent in that case — but the fragment has already been + // cleared, so the honest message is "we could not reach the server", not "request a new link". + .catch((e: unknown) => { + const spent = + e instanceof HttpError && + e.status === 400 && + (e.detailData as { code?: string } | undefined)?.code === "verify"; + setOutcome(spent ? "invalid" : "unreachable"); + }); }, [token]); - return { outcome, email, dismiss: () => setOutcome(null) }; + return { + outcome, + email, + resetPending, + // Dismiss peels ONE layer. The two states are independent — a reset link can arrive over an + // unread confirmation, and a slow /auth/verify can resolve after one did — so clearing both at + // once silently discards whichever the user had not read yet. The banner shows the reset + // guidance first (it is the one with something to do), and dismissing it reveals the outcome + // underneath rather than taking it with it. + dismiss: () => { + if (resetPending) { + setResetPending(false); + return; + } + setOutcome(null); + setEmail(null); + }, + }; } diff --git a/frontend/src/lib/verifyEntry.dom.test.tsx b/frontend/src/lib/verifyEntry.dom.test.tsx index 00d04db3..728c00c8 100644 --- a/frontend/src/lib/verifyEntry.dom.test.tsx +++ b/frontend/src/lib/verifyEntry.dom.test.tsx @@ -30,7 +30,13 @@ async function mountAt(url: string, verifyEmail: ReturnType) { function Probe() { const entry = useVerifyEntry(); - return
{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}
; + return ( + <> +
{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}
+
{entry.resetPending ? "reset-pending" : "-"}
+