diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d76ad55..a8ab6c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ 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.87.0 — 2026-09-15 + +A shared or library video keeps playing when you switch apps on an iPad, and a new tab opens as the account it belongs to without flashing another page first. Facebook reels keep their titles and an mp3 stays an mp3. + +### Features + +- Background play on iPad (Brave): the shared-video page, the library player and the Plex player keep playing in another app and on the locked screen. +- The shared-video page has a Repeat toggle, on by default. +- Switching modules no longer flashes a loading screen: the pages load quietly in the background, and a slow answer shows its spinner only after a moment. + +### Fixes + +- A new tab honours your Startup choice and opens as its own account — no Home page flash, no other account's language or settings on the first frame. +- A signed-in Facebook reel keeps its real title instead of “Video” or “Facebook”, and an mp3 download stays an mp3 (it used to be wrapped into an mp4 an iPad would not play). +- A tab whose account was signed out, suspended or signed out everywhere acts as nobody and comes back as the account still signed in, instead of writing into another account or sticking on the sign-in page. +- Switching or adding an account no longer saves the video you were watching into the other account's resume position. +- A new tab keeps the account's notification history instead of replacing it with its own first notice. +- The shared-video page tells a failed load, a wrong password, too many attempts and an unreachable server apart, with Retry where it helps. Its password limit counts per viewer, so one person guessing cannot lock everyone else out. +- Form errors on sign-in, the message passphrase, password change and setup are read out by screen readers — again on a second identical failure — and the setup wizard names what is wrong instead of a generic error. + +### Internal + +- WebKit and iPad projects in the Playwright suite; the Vite dev proxy forwards websockets. +- Four deep-review rounds (12 → 8 → 8 → 8, the last one targeted), every regression test mutation-checked; half of the later rounds' findings were older defects, fixed here. + ## 0.86.0 — 2026-09-14 A failed download now tells you why it failed and what you can do about it, instead of repeating yt-dlp's raw error. Facebook comes first: downloads that used to die on “Cannot parse data” go through, and a Facebook group post downloads its own video instead of a different one from the page. diff --git a/VERSION b/VERSION index 63cd8847..359ee08a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.86.0 +0.87.0 diff --git a/backend/app/auth.py b/backend/app/auth.py index 95d027ec..225f3305 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -269,21 +269,29 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" -def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]: +def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]: """Which account this connection acts as, and whether that's the session's default account. Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this exact per-tab resolution instead of re-implementing it. The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser) - plus a default `user_id`. A caller may override the default with the X-Siftlode-Account header, - but ONLY for an account already in the wallet — so a tab can't impersonate an account that never - authenticated here. Everything else (WebSocket, plain navigations) keeps using the cookie default. + plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or + `?account=`), but ONLY an account in the wallet — so a tab can't impersonate an account that + never authenticated here. A connection that names no account acts as the cookie default. + + A connection naming an account NOT in the wallet acts as nobody (`None`) — typically a tab still + pinned to an account that was signed out in another tab. It must not fall back to the default: + that tab would go on acting as an account it never showed, its player checkpoints and pref saves + landing there and its Sign out signing THAT account out. Only the bootstrap /api/me probe passes + `strict=False` and gets the default, which is how such a tab learns who it now is (the frontend + re-pins it and reloads). An account whose session ended while it stayed in the wallet (deleted, + suspended, revoked) leaves the wallet when it is refused (`_session_user`), so a tab pinned to it + takes the same way back. """ default_id = conn.session.get("user_id") wallet = conn.session.get("account_ids") or [] # Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a - # plain file download). Both are wallet-gated below, so neither can impersonate an - # account that never signed into this browser. + # plain file download). Both are wallet-gated below. hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account") if hdr: try: @@ -292,28 +300,46 @@ def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]: hid = None if hid is not None and hid in wallet: return hid, hid == default_id + if strict: + return None, False return default_id, True +def drop_from_wallet(session: dict, user_id: int) -> None: + """Take one account out of this browser's wallet (`account_ids`); the session default is untouched.""" + session["account_ids"] = [a for a in (session.get("account_ids") or []) if a != user_id] + + +def _end_account_session(request: Request, user_id: int, is_default: bool) -> None: + """An account whose session here is no longer valid. Only nuke the whole session when it is the + SESSION DEFAULT; a per-tab account just leaves the wallet (other tabs' default may still be valid). + Leaving is what lets a tab pinned to it recover: its next /api/me names an account no longer signed + in here and gets the default. Left in, it answered "signed out" — the landing page, with the default + still signed in, and no way off it but closing the tab.""" + if is_default: + request.session.clear() + else: + drop_from_wallet(request.session, user_id) + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: - user_id, is_default = resolved_user_id(request) + return _session_user(request, db, *resolved_user_id(request)) + + +def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User: if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") user = db.get(User, user_id) if user is None or not user.is_active or user.is_suspended: - # Suspended/deactivated mid-session → drop the session so the block takes effect at once. - # But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale - # per-tab header account just 401s that one tab (other tabs' default may still be valid). - if is_default: - request.session.clear() + # Suspended/deactivated mid-session → end it so the block takes effect at once. + _end_account_session(request, user_id, is_default) raise HTTPException(status_code=401, detail="Not authenticated") # SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a # password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an # account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too. epochs = request.session.get("epochs") or {} if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0): - if is_default: - request.session.clear() + _end_account_session(request, user_id, is_default) raise HTTPException(status_code=401, detail="Not authenticated") # Always keep the session default in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. @@ -328,9 +354,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None: """Like `current_user` but returns None instead of raising 401 when there's no valid session. Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to - the browser console (a non-2xx fetch is a console error no matter how the app handles it).""" + the browser console (a non-2xx fetch is a console error no matter how the app handles it). + The one lenient resolution (`strict=False`): a tab naming an account no longer signed in here gets + the default account, which is how it learns who it now is — see `resolved_user_id`. That includes an + account refused right here, which `_session_user` has just taken out of the wallet.""" + user_id, is_default = resolved_user_id(request, strict=False) try: - return current_user(request, db) + return _session_user(request, db, user_id, is_default) + except HTTPException: + if is_default: + return None + try: + return _session_user(request, db, *resolved_user_id(request, strict=False)) except HTTPException: return None diff --git a/backend/app/config.py b/backend/app/config.py index ad8d13ea..0483011c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -57,7 +57,7 @@ class Settings(BaseSettings): # VPS Caddy front door. A request from any other peer (someone hitting the app port directly) # is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via # XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev - # deploy. Comma-separated. See routes.auth._client_ip + the deploy docs. + # deploy. Comma-separated. See ratelimit.client_key + the deploy docs. trusted_proxy_ips: str = "" # --- Outbound email (onboarding: access-request + approval notices) --- diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py index 7e4705db..16c2994d 100644 --- a/backend/app/downloads/adapters.py +++ b/backend/app/downloads/adapters.py @@ -61,6 +61,10 @@ class SourceAdapter: return False # --- metadata ----------------------------------------------------------------------------- + def title(self, info: dict) -> str | None: + """The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's.""" + return info.get("title") + def uploader_url(self, info: dict) -> str | None: """The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives `channel_url`), else None (reddit / a raw media URL have no derivable channel page).""" @@ -158,6 +162,23 @@ class FacebookAdapter(SourceAdapter): def retriable(self, error_text: str) -> bool: return FACEBOOK_PARSE_WALL in error_text.lower() + # Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an + # 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A + # signed-out read titles the page with that same text behind the engagement counts, cut short. + _PLACEHOLDER_TITLES = frozenset({"video", "facebook"}) + + def title(self, info: dict) -> str | None: + raw = info.get("title") + if (raw or "").strip().lower() not in self._PLACEHOLDER_TITLES: + return raw + line = next( + (ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), "" + ) + # The whole first line, never cut here with an ellipsis: normalize_title's NFKC turns "…" into + # "..." and then collapses it to ".", which read as a finished sentence ("… live at."). The + # stored title is unbounded; display_name and the file name clip it themselves. + return line or raw # no description either (the 18+ reel): nothing better on the page + def uploader_url(self, info: dict) -> str | None: # Facebook gives neither channel_url nor uploader_url, but exposes a numeric `uploader_id` # (the page id) that resolves at facebook.com/. diff --git a/backend/app/downloads/credentials.py b/backend/app/downloads/credentials.py index cb6fa3cc..7e87388a 100644 --- a/backend/app/downloads/credentials.py +++ b/backend/app/downloads/credentials.py @@ -11,7 +11,7 @@ in prose): is downloading into is that private, salted identity — so a cookie can never poison a shared asset (the load-bearing cross-user privacy fix). -Both go through `cookie_salt` / `authed_format_sig` / `host_key_for_url` here, so they can't drift. +Both key the private identity on `cookie_salt` / `host_key_for_url` here, so they can't drift. Security controls enforced here: * **host scope (SEC-12)** — `host_key_for_url` matches the download URL's netloc against a curated @@ -101,8 +101,9 @@ def cookie_salt(user_id: int, host_key: str) -> str: def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str: - """The salted `format_sig` an authed download for (user, host) resolves to — the private asset - identity. Both enqueue (to create it) and the worker (to recognise it) compute it HERE.""" + """The salted `format_sig` an authed download for (user, host) resolves to under the current + signature version — the private asset enqueue creates, and one of the identities `is_authed_asset` + recognises. Neither calls it; the tests use it to hold the two to each other.""" return formats.format_sig(spec, salt=cookie_salt(user_id, host_key)) @@ -118,14 +119,17 @@ def is_authed_asset(asset: MediaAsset, spec: dict, user_id: int, url: str | None """Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one kind of asset a cookie is ever attached to: the one enqueue creates (`authed_format_sig`), or a Re-download's rebuild of it (`rebuild_salt`). The worker's cookie gate asks it before a download, - and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie).""" + and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie). + Either signature version counts (`formats.format_sigs`): a job queued before a deploy that bumped + the version still downloads into the asset it was given.""" host_key = host_key_for_url(url) if host_key is None: return False - if asset.format_sig == authed_format_sig(spec, user_id, host_key): + salt = cookie_salt(user_id, host_key) + if asset.format_sig in formats.format_sigs(spec, salt=salt): return True - return asset.id is not None and asset.format_sig == formats.format_sig( - spec, salt=rebuild_salt(asset.id, cookie_salt(user_id, host_key)) + return asset.id is not None and asset.format_sig in formats.format_sigs( + spec, salt=rebuild_salt(asset.id, salt) ) diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 040eb6f4..cc05becf 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -57,6 +57,10 @@ _DEFAULTS = { # opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped # the v3 net — must re-derive through the current browser-safe guarantee. _SIG_VERSION = 4 +# Audio-only downloads version on their own, so a change to the audio bytes doesn't re-download every +# cached video. v5: an extracted .mp3/.m4a is stored as itself instead of remuxed into an .mp4 that +# iPad's WebKit refuses to play, so a cached remuxed audio asset must re-derive. +_AUDIO_SIG_VERSION = 5 _SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"] @@ -96,20 +100,34 @@ def normalize(spec: dict) -> dict: return s -def format_sig(spec: dict, salt: str | None = None) -> str: +def _sig_version(s: dict) -> int: + return _AUDIO_SIG_VERSION if s["mode"] == "a" else _SIG_VERSION + + +def format_sig(spec: dict, salt: str | None = None, *, version: int | None = None) -> str: """Stable short hash of the output-affecting spec — the MediaAsset cache key component. `salt` forks a distinct, non-dedup identity for the same spec (used by force-redownload so it - always re-fetches into its own asset instead of sharing a cached file with other jobs).""" + always re-fetches into its own asset instead of sharing a cached file with other jobs). + `version` hashes under a signature version other than the current one (see `format_sigs`).""" s = normalize(spec) data = {k: s[k] for k in _SIG_KEYS} - data["_v"] = _SIG_VERSION + data["_v"] = _sig_version(s) if version is None else version if salt is not None: data["_salt"] = salt payload = json.dumps(data, sort_keys=True, separators=(",", ":")) return hashlib.sha256(payload.encode()).hexdigest()[:24] +def format_sigs(spec: dict, salt: str | None = None) -> tuple[str, str]: + """`format_sig` under the current signature version and under the one before it. An asset keeps + the signature it was created with, so a job queued before a deploy that bumped the version and + run after it downloads into an asset carrying the previous one — which must still be recognised + as that user's private asset (`credentials.is_authed_asset`), or it downloads without its cookie.""" + previous = _sig_version(normalize(spec)) - 1 + return format_sig(spec, salt), format_sig(spec, salt, version=previous) + + def build_ydl_opts( spec: dict, outtmpl: str, diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index d7308661..758d2f4f 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -113,9 +113,10 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None: # `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty # (e.g. a title that was only a social engagement prefix); the completion path later sets # the final title with its own source_ref fallback. - asset.title = normalize_title(info.get("title")) or info.get("id") + adapter = adapters.resolve_adapter(None, None, info) + asset.title = normalize_title(adapter.title(info)) or info.get("id") asset.uploader = info.get("uploader") or info.get("channel") - asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info) + asset.uploader_url = adapter.uploader_url(info) asset.thumbnail_url = info.get("thumbnail") asset.duration_s = int(info["duration"]) if info.get("duration") else None asset.upload_date = _parse_upload_date(info.get("upload_date")) @@ -403,8 +404,12 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the - file to store (converted, or the original) and corrects `info`'s codec fields to match disk.""" - if spec.get("container") != "mp4" or spec.get("vcodec"): + file to store (converted, or the original) and corrects `info`'s codec fields to match disk. + + An audio download (mode "a") is never touched: yt-dlp already extracted it to .mp3 / .m4a, which + every browser plays as audio/mpeg / audio/mp4. Remuxing it into .mp4 served an mp3 as video/mp4, + which iPad's WebKit refuses to play (prod job 268).""" + if spec.get("mode") == "a" or spec.get("container") != "mp4" or spec.get("vcodec"): return produced vcodec, acodec = editmod.probe_codecs(produced) is_mp4 = produced.suffix.lower() == ".mp4" @@ -509,7 +514,7 @@ def _download( vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info) meta = storage.MediaMeta( video_id=info.get("id") or source_ref, - title=normalize_title(info.get("title")) or source_ref, + title=normalize_title(adapter.title(info)) or source_ref, uploader=info.get("uploader") or info.get("channel") or "Unknown Channel", upload_date=_parse_upload_date(info.get("upload_date")), uniqueid_type=adapter.nfo_uniqueid_type(), diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py index d88e22da..8a7d6ae1 100644 --- a/backend/app/ratelimit.py +++ b/backend/app/ratelimit.py @@ -1,15 +1,20 @@ """A tiny in-process sliding-window rate limiter. -Generic groundwork: keyed by an arbitrary string (e.g. a client IP), so it can throttle any +Generic groundwork: keyed by an arbitrary string (e.g. a client — `client_key`), so it can throttle any endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which is fine for abuse throttling (not for anything that must survive a deploy). Not shared across processes — if we ever run multiple workers, swap the backing store for Redis behind the same ``allow()`` interface. """ +import ipaddress import threading import time +from fastapi import Request + +from app.config import settings + class RateLimiter: def __init__(self, max_events: int, window_seconds: float): @@ -25,6 +30,14 @@ class RateLimiter: with self._lock: self._hits.clear() + def exhausted(self, key: str) -> bool: + """Whether ``key``'s window is already full (``allow`` would refuse it), WITHOUT counting an + attempt — for a limit that counts only some outcomes: ask it first, ``allow`` the ones that + count.""" + cutoff = time.monotonic() - self.window + with self._lock: + return sum(1 for t in self._hits.get(key, []) if t > cutoff) >= self.max_events + def allow(self, key: str) -> bool: """Record an attempt for ``key`` and return whether it is within the limit. @@ -49,3 +62,40 @@ class RateLimiter: else: del self._hits[k] return True + + +def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + """Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a + plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string.""" + try: + ip = ipaddress.ip_address(s.strip()) + except ValueError: + return None + return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip + + +def client_key(request: Request) -> str: + """Best-effort client identity for rate limiting: its IP, or for IPv6 the /64 the address sits in — a + subscriber is handed a whole /64, so keyed on the full address one client would have endless identities + to spread its attempts over. X-Forwarded-For is client-controlled, so we trust it + ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips + — the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS + the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a + client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the + proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity. + + SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT + `--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF — + do NOT add that flag, or the trust check below would compare against a forgeable value.""" + peer = request.client.host if request.client else "unknown" + client = peer + trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None} + if _norm_ip(peer) in trusted: + xff = request.headers.get("x-forwarded-for") + parts = [p.strip() for p in (xff or "").split(",") if p.strip()] + if parts: + client = parts[-1] + ip = _norm_ip(client) + if isinstance(ip, ipaddress.IPv6Address): + return str(ipaddress.IPv6Network((ip, 64), strict=False)) + return client diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index b96d4304..18c25e67 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -3,7 +3,6 @@ Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token helpers) stays in app/auth.py, imported below.""" -import ipaddress import secrets from datetime import datetime, timedelta, timezone from typing import NamedTuple @@ -40,7 +39,7 @@ from app.auth import ( from app.config import settings from app.db import SessionLocal, get_db from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User -from app.ratelimit import RateLimiter +from app.ratelimit import RateLimiter, client_key from app.security import encrypt, hash_password, hash_token, verify_password from app.utils import canonical_email, valid_email @@ -172,37 +171,6 @@ def is_demo_allowed(db: Session, email: str) -> bool: ) -def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: - """Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a - plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string.""" - try: - ip = ipaddress.ip_address(s.strip()) - except ValueError: - return None - return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip - - -def _client_ip(request: Request) -> str: - """Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it - ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips - — the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS - the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a - client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the - proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity. - - SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT - `--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF — - do NOT add that flag, or the trust check below would compare against a forgeable value.""" - peer = request.client.host if request.client else "unknown" - trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None} - if _norm_ip(peer) in trusted: - xff = request.headers.get("x-forwarded-for") - parts = [p.strip() for p in (xff or "").split(",") if p.strip()] - if parts: - return parts[-1] - return peer - - class InviteRequest(NamedTuple): """What an access request settled on. @@ -631,6 +599,8 @@ async def logout(request: Request): out in one tab doesn't disturb another tab running a different account. If the account being removed was the session default, promote the most recent remaining account (or clear the session when none remain).""" + # A tab still pinned to an account already signed out in another tab resolves to nobody, so it + # signs nobody out — it used to fall back to the default and sign THAT account out in its place. active, is_default = resolved_user_id(request) remaining = [a for a in (request.session.get("account_ids") or []) if a != active] request.session["account_ids"] = remaining @@ -672,7 +642,7 @@ async def request_access( email = (body.email or "").strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") - if not _request_access_limiter.allow(_client_ip(request)): + if not _request_access_limiter.allow(client_key(request)): return {"status": "pending"} if is_allowed(db, email): return {"status": "pending"} # already allowed — don't reveal it @@ -689,7 +659,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) - so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers exactly like a non-match (authenticated=false) so the field can't be hammered as an enumeration oracle. On a match it sets the session and the client reloads into the app.""" - if not _demo_limiter.allow(_client_ip(request)): + if not _demo_limiter.allow(client_key(request)): return {"authenticated": False} email = (body.email or "").strip().lower() if valid_email(email) and is_demo_allowed(db, email): @@ -835,7 +805,7 @@ def register( ) if not sysconfig.get_bool(db, "allow_registration"): raise HTTPException(status_code=403, detail="Registration is currently closed.") - if not _register_limiter.allow(_client_ip(request)): + if not _register_limiter.allow(client_key(request)): return {"status": "ok"} # silently throttle; uniform response # Do the existence check + account creation OFF the response path (a background task with its own @@ -1003,7 +973,7 @@ def verify_email_resend( and re-registering an already-registered email is a silent no-op, so a user who lost the first mail had no way back. Uniform response + timing regardless of whether the email has a pending account (the lookup runs off the response path), so it can't probe for registered emails.""" - if not _verify_resend_limiter.allow(_client_ip(request)): + if not _verify_resend_limiter.allow(client_key(request)): return {"status": "ok"} # silently throttle; uniform response email = (body.email or "").strip().lower() if valid_email(email): @@ -1062,7 +1032,7 @@ def password_login( """Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). Once the password is proven correct, the owner gets a specific reason if their account is still pending or suspended — that's not an enumeration leak (they already hold the password).""" - if not _login_limiter.allow(_client_ip(request)): + if not _login_limiter.allow(client_key(request)): raise _login_refused(429, "rate_limited") email = (body.email or "").strip().lower() password = body.password or "" @@ -1093,7 +1063,7 @@ def password_reset_request( ) -> dict: """Request a password-reset link. Uniform response + timing regardless of whether the email has a password account (the lookup runs off the response path), so it can't probe for registered emails.""" - if not _reset_limiter.allow(_client_ip(request)): + if not _reset_limiter.allow(client_key(request)): return {"status": "ok"} email = (body.email or "").strip().lower() # Do the account lookup + token issue + email OFF the response path (a background task with its @@ -1142,7 +1112,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends( told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot answer it must degrade to the old behaviour instead of declaring a live link dead. """ - if not _reset_check_limiter.allow(_client_ip(request)): + if not _reset_check_limiter.allow(client_key(request)): return {"ok": True} return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)} diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index ec625c5f..6f50f2dd 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -7,6 +7,7 @@ from app.auth import ( count_admins, count_pending_activations, current_user, + drop_from_wallet, google_enabled, has_read_scope, has_write_scope, @@ -65,24 +66,26 @@ def switch_account( target = body.user_id if target not in (request.session.get("account_ids") or []): raise HTTPException(status_code=403, detail="Not an account you've signed into here.") + # An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is + # refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session + # default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole + # wallet session, logging out every account here. (B-core-9) u = db.get(User, target) if u is None: + drop_from_wallet(request.session, target) raise HTTPException(status_code=404, detail="That account no longer exists.") if not is_allowed(db, u.email): raise HTTPException(status_code=403, detail="That account no longer has access.") + if not u.is_active: + drop_from_wallet(request.session, target) + raise HTTPException(status_code=403, detail="That account no longer has access.") if u.is_suspended: - # Don't make a suspended account the active one — the next request's current_user would - # see the suspension and clear the WHOLE wallet session, logging out every account here. + drop_from_wallet(request.session, target) raise HTTPException(status_code=403, detail="That account is suspended.") - # Same reasoning for a REVOKED session (a password reset/change or "log out everywhere" bumped the - # account's epoch): making it the session default would trip current_user's epoch check on the next - # request and clear the whole wallet. Mirror the suspend guard — drop the stale account from the - # switch list and refuse, rather than logging everyone out. (B-core-9) + # Revoked: a password reset/change or "log out everywhere" bumped the account's epoch. epochs = request.session.get("epochs") or {} if int(epochs.get(str(target), 0)) != (u.session_epoch or 0): - request.session["account_ids"] = [ - a for a in (request.session.get("account_ids") or []) if a != target - ] + drop_from_wallet(request.session, target) raise HTTPException( status_code=403, detail="That account was signed out elsewhere — sign in again." ) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index eb7463f0..c2c030a9 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -423,9 +423,10 @@ async def messages_ws(ws: WebSocket) -> None: """Live push channel: authenticated by the session cookie, registers the connection so sent messages reach this user's open tabs instantly. We don't consume client→server frames (sending goes through POST /api/messages); the receive loop only detects disconnect.""" - # Which signed-in account this socket acts as — the SHARED per-tab resolution (wallet-gated - # ?account= override, session default otherwise). A browser WebSocket can't send the - # X-Siftlode-Account header, so resolved_user_id falls through to the ?account= query param. + # Which signed-in account this socket acts as — the SHARED per-tab resolution (a wallet-gated + # ?account=, the session default when none is named, nobody for an account no longer signed in + # here). A browser WebSocket can't send the X-Siftlode-Account header, so resolved_user_id falls + # through to the ?account= query param. uid, _ = resolved_user_id(ws) if not uid: await ws.close(code=1008) diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index 05703008..e580a77c 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once a short-lived signed grant that the media URL carries (a `
@@ -205,9 +274,15 @@ export default function WatchPage() { onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && password && unlock()} placeholder={T.passwordPlaceholder} + aria-describedby={error ? errorId : undefined} className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40" /> - {error &&
{error}
} + {/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */} + {error && ( + + )}
+ {meta.allow_download && ( + + {T.download} + + )} +
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 8736e330..789cca04 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -398,6 +398,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + // Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear + // and a set in one handler commit together, leaving the node untouched). + const [attempt, setAttempt] = useState(0); // Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth // configured. Optimistic (most instances have it); the rare Google-less instance just sees it // disappear once the config loads. @@ -481,6 +484,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { async function onSubmit(e: React.FormEvent) { e.preventDefault(); + setAttempt((n) => n + 1); reset(); const mail = email.trim().toLowerCase(); if ( @@ -686,7 +690,11 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { className={inputCls} /> )} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )}