fix: review round 3 — a new tab keeps its account's notices, a revoked tab account lands on the default

Deep-review round 3 over main..improvement/watch-background-audio.

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

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

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

Tests: the account's history and hints across the pin; a revoked and a
suspended pinned account; the per-viewer limit and the ceiling; the
limiter's exhausted(); the share page's announced failures; a share
link's feed filters persisted. Each mutation-checked RED for its own
reason.
This commit is contained in:
2026-09-15 02:25:00 +02:00
parent f569b19c49
commit e7f38396a2
26 changed files with 366 additions and 93 deletions
+31 -9
View File
@@ -284,7 +284,9 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
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).
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 []
@@ -303,6 +305,23 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
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:
return _session_user(request, db, *resolved_user_id(request))
@@ -312,19 +331,15 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
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.
@@ -341,7 +356,14 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
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 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`."""
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 _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:
+1 -1
View File
@@ -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_ip + the deploy docs.
trusted_proxy_ips: str = ""
# --- Outbound email (onboarding: access-request + approval notices) ---
+4 -3
View File
@@ -11,7 +11,7 @@ in prose):
is downloading into is that private, salted identity — so a cookie can never poison a shared
asset (the load-bearing cross-user privacy fix).
Both go through `cookie_salt` / `authed_format_sig` / `host_key_for_url` here, so they can't drift.
Both key the private identity on `cookie_salt` / `host_key_for_url` here, so they can't drift.
Security controls enforced here:
* **host scope (SEC-12)** — `host_key_for_url` matches the download URL's netloc against a curated
@@ -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))
+45 -1
View File
@@ -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 IP — `client_ip`), 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,34 @@ 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_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
+8 -40
View File
@@ -3,7 +3,6 @@
Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token
helpers) stays in app/auth.py, imported below."""
import ipaddress
import secrets
from datetime import datetime, timedelta, timezone
from typing import NamedTuple
@@ -40,7 +39,7 @@ from app.auth import (
from app.config import settings
from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
from app.ratelimit import RateLimiter, client_ip
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.
@@ -674,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_ip(request)):
return {"status": "pending"}
if is_allowed(db, email):
return {"status": "pending"} # already allowed — don't reveal it
@@ -691,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_ip(request)):
return {"authenticated": False}
email = (body.email or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email):
@@ -837,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_ip(request)):
return {"status": "ok"} # silently throttle; uniform response
# Do the existence check + account creation OFF the response path (a background task with its own
@@ -1005,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_ip(request)):
return {"status": "ok"} # silently throttle; uniform response
email = (body.email or "").strip().lower()
if valid_email(email):
@@ -1064,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_ip(request)):
raise _login_refused(429, "rate_limited")
email = (body.email or "").strip().lower()
password = body.password or ""
@@ -1095,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_ip(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
@@ -1144,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_ip(request)):
return {"ok": True}
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
+2 -3
View File
@@ -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,
@@ -80,9 +81,7 @@ def switch_account(
# switch list and refuse, rather than logging everyone out. (B-core-9)
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."
)
+14 -5
View File
@@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
@@ -17,13 +17,18 @@ from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.ratelimit import RateLimiter
from app.ratelimit import RateLimiter, client_ip
from app.security import verify_password
router = APIRouter(prefix="/api/public", tags=["public"])
# Slow password guessing per link (the token itself is 192-bit, so it needs no throttle).
# Slow password guessing on a link (the token itself is 192-bit, so it needs no throttle). Counted per
# viewer — the link AND the client — so someone who has the link but not its password can't lock out the
# viewers who do, and a busy link's own unlocks don't count against its next viewer.
_unlock_limit = RateLimiter(max_events=10, window_seconds=300)
# ...under a ceiling on WRONG passwords for the link from every client together, so guessing spread over
# many addresses stays slow too. Viewers who have the password never add to it.
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
def _link_or_404(db: Session, token: str) -> DownloadLink:
@@ -83,9 +88,12 @@ class WatchUnlockIn(BaseModel):
@router.post("/watch/{token}/unlock")
def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db)) -> dict:
def watch_unlock(
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
if not _unlock_limit.allow(token):
viewer = f"{token}|{client_ip(request)}"
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
link = _link_or_404(db, token)
if not link.password_hash:
@@ -96,6 +104,7 @@ def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db))
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token)
raise HTTPException(status_code=403, detail="Wrong password.")
job, asset = _ready_target(db, link)
link.view_count += 1
+1 -1
View File
@@ -11,7 +11,7 @@ echo "Starting Siftlode API..."
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
#
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. auth._client_ip trusts
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. ratelimit.client_ip trusts
# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those
# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and
# reopening the rate-limit bypass (SA3).
+26
View File
@@ -12,6 +12,7 @@ Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False
from datetime import timedelta
import pytest
from fastapi.testclient import TestClient
from app.auth import ACTIVE_ACCOUNT_HEADER
@@ -76,6 +77,31 @@ def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client,
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
client, make_user, db, ended
):
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
alice = make_user(email="alice4@example.com", password=_PW)
make_user(email="bob4@example.com", password=_PW)
assert _login(client, "alice4@example.com", _PW).status_code == 200
assert _login(client, "bob4@example.com", _PW).status_code == 200
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
assert client.get("/api/feed", headers=tab).status_code == 200
if ended == "revoked":
alice.session_epoch = (alice.session_epoch or 0) + 1
else:
alice.is_suspended = True
db.commit()
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
+59
View File
@@ -0,0 +1,59 @@
"""The password unlock of a shared /watch link (routes/public.py), DB-lane.
Guesses are slowed per viewer (the link AND the client), under a per-link ceiling on wrong passwords, so
someone who holds a password link without its password can't lock out the viewers who have it.
"""
import pytest
from fastapi.testclient import TestClient
from app.config import settings
from app.downloads import probe as probemod
from app.main import app
from app.ratelimit import RateLimiter
from app.routes import public
_PW = "open sesame"
@pytest.fixture
def unlock_url(as_user, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload", files={"file": ("clip.mp4", b"fake-video", "video/mp4")}
)
assert job.status_code == 200, job.text
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
assert link.status_code == 200, link.text
return f"/api/public/watch/{link.json()['token']}/unlock"
def _from(ip: str) -> TestClient:
# Its own socket peer: with no trusted proxy configured, that is the client's IP.
return TestClient(app, client=(ip, 50000))
def test_a_link_holder_without_the_password_cannot_lock_out_its_viewers(unlock_url):
# The limit used to be keyed on the link alone: ten wrong guesses every five minutes kept every
# viewer out, the ones with the password included.
guesser = _from("203.0.113.5")
for _ in range(10):
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 403
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 429
assert _from("198.51.100.7").post(unlock_url, json={"password": _PW}).status_code == 200
def test_wrong_passwords_from_many_clients_still_reach_the_links_ceiling(unlock_url, monkeypatch):
monkeypatch.setattr(public, "_unlock_failures", RateLimiter(max_events=3, window_seconds=300))
viewer = _from("198.51.100.7")
for _ in range(5): # unlocking with the password never adds to the ceiling
assert viewer.post(unlock_url, json={"password": _PW}).status_code == 200
for n in range(3):
wrong = _from(f"203.0.113.{n}").post(unlock_url, json={"password": "guess"})
assert wrong.status_code == 403
late = _from("203.0.113.9").post(unlock_url, json={"password": "guess"})
assert late.status_code == 429
+13
View File
@@ -57,6 +57,19 @@ def test_reset_clears_every_key():
assert lim.allow("b") is True
def test_exhausted_reports_a_full_window_without_counting_an_attempt():
# The asking half of a limit that counts only some outcomes (a share link's unlock counts only
# wrong passwords toward its ceiling): asking must never use up the window it asks about.
lim = RateLimiter(max_events=2, window_seconds=300)
for _ in range(5):
assert lim.exhausted("link") is False
assert lim.allow("link") is True
assert lim.exhausted("link") is False
assert lim.allow("link") is True
assert lim.exhausted("link") is True
def test_expired_attempts_fall_out_of_the_window():
# A zero-length window means every previous attempt is already outside it.
lim = RateLimiter(max_events=1, window_seconds=0)
+2 -2
View File
@@ -111,8 +111,8 @@ export default function App() {
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
localKey: LS.filterCollapsed,
+1 -1
View File
@@ -18,7 +18,7 @@ import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// applied together via one Save/Discard bar; nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = [
@@ -4,6 +4,9 @@
// seeded from the `me` cache at mount — BootGate mounts the tree only once it is there. The adopt effect
// this replaced applied the demo default after mounting; with it gone, a seed that did not read `me`
// would open the demo account on its empty subscriptions scope.
//
// A share link's filters are written to the account once at mount too, so the view the link opened
// survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -55,4 +58,11 @@ describe("FeedFiltersProvider seed", () => {
mount({ is_demo: false });
expect(scopes[0]).toBe("my");
});
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
window.history.replaceState(null, "", "/?scope=all");
mount({ is_demo: false });
expect(scopes[0]).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
});
});
+10 -2
View File
@@ -106,7 +106,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
{err && <div className="text-sm text-danger">{err}</div>}
{err && (
<div role="alert" className="text-sm text-danger">
{err}
</div>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={cancelReset} disabled={busy}>
{t("common.cancel")}
@@ -153,7 +157,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
{err && <div className="text-sm text-danger">{err}</div>}
{err && (
<div role="alert" className="text-sm text-danger">
{err}
</div>
)}
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
+10 -7
View File
@@ -24,8 +24,8 @@ import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// outcome. App owns the state + the save; this controller is
// how the panel reads/writes it. See App.tsx.
// outcome. PrefsProvider owns the state + the save; this controller
// is how the panel reads/writes it.
export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
@@ -128,8 +128,7 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
@@ -252,8 +251,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
function Notifications({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft (live preview via configureNotifications there); the
// panel's Save bar persists it. The "send test" button below still fires immediately.
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
// it. The "send test" button below fires immediately.
const { notif: cfg, setNotif } = prefs;
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
const autoOn = cfg.durationMs > 0;
@@ -498,7 +497,11 @@ function SignInMethods({ me }: { me: Me }) {
autoComplete="new-password"
className={inputCls}
/>
{err && <p className="text-xs text-danger">{err}</p>}
{err && (
<p role="alert" className="text-xs text-danger">
{err}
</p>
)}
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</Button>
+5 -1
View File
@@ -267,7 +267,11 @@ export default function SetupWizard() {
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
)}
{error && <p className="text-sm text-danger mt-3">{error}</p>}
{error && (
<p role="alert" className="text-sm text-danger mt-3">
{error}
</p>
)}
<div className="flex items-center justify-between mt-6">
<button
@@ -147,6 +147,7 @@ describe("WatchPage honest failures", () => {
const page = render(<WatchPage />);
const retry = await page.findByRole("button", { name: "Try again" });
expect(page.queryByText(GONE)).toBeNull();
expect(page.getByRole("alert").textContent).toContain("Couldn't load this link.");
fireEvent.click(retry);
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
@@ -188,12 +189,18 @@ describe("WatchPage honest failures", () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
await page.findByText("Too many attempts. Try again in a few minutes.");
expect(page.getByPlaceholderText("Enter password")).toBeTruthy();
const alert = page.getByRole("alert");
expect(alert.textContent).toBe("Too many attempts. Try again in a few minutes.");
expect(page.getByPlaceholderText("Enter password").getAttribute("aria-describedby")).toBe(
alert.id,
);
});
it("an unlock that gets no answer says so instead of doing nothing", async () => {
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
await page.findByText("Couldn't check the password. Try again.");
expect((await page.findByRole("alert")).textContent).toBe(
"Couldn't check the password. Try again.",
);
});
});
+14 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format";
@@ -143,6 +143,7 @@ export default function WatchPage() {
const [meta, setMeta] = useState<Meta | null>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
@@ -233,7 +234,9 @@ export default function WatchPage() {
</div>
{status === "loading" && (
<div className="flex-1 grid place-items-center text-muted">{T.loading}</div>
<div role="status" className="flex-1 grid place-items-center text-muted">
{T.loading}
</div>
)}
{status === "gone" && (
@@ -247,7 +250,8 @@ export default function WatchPage() {
{status === "error" && (
<div className="flex-1 grid place-items-center text-center">
<div>
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
<div role="alert">
<div className="text-lg font-semibold">{T.failed}</div>
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
<Button variant="primary" size="md" onClick={retry} className="mt-4">
@@ -270,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 && <div className="text-sm text-danger mt-2">{error}</div>}
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
{error && (
<div id={errorId} role="alert" className="text-sm text-danger mt-2">
{error}
</div>
)}
<Button
variant="primary"
size="md"
+5 -1
View File
@@ -686,7 +686,11 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
className={inputCls}
/>
)}
{error && <p className="text-xs text-danger">{error}</p>}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={busy}
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
//
// The notification and hints stores read their per-account localStorage when they are imported. In a new
// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they
// read as nobody's; they must read again on the pin. The notification store used to keep its empty,
// nobody's history, and the tab's first notice replaced the account's stored history with it.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, in a tab not yet pinned", () => {
it("keep the account's notification history and settings once the tab is pinned", async () => {
const older = {
id: 7,
level: "info",
message: "older",
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
};
localStorage.setItem("siftlode.notifications.5", JSON.stringify([older]));
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { setActiveAccount } = await import("./api/core");
notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe
setActiveAccount(5);
notifications.notify({ message: "after the pin" });
const stored = JSON.parse(localStorage.getItem("siftlode.notifications.5") ?? "[]") as {
message: string;
}[];
expect(stored.map((n) => n.message)).toEqual(["older", "before the pin", "after the pin"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
});
it("read the account's hints choice once the tab is pinned", async () => {
localStorage.setItem("siftlode.hints.5", "0");
const hints = await import("./hints");
const { setActiveAccount } = await import("./api/core");
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
setActiveAccount(5);
expect(hints.hintsEnabled()).toBe(false);
});
});
+20 -2
View File
@@ -3,12 +3,30 @@
// on it, without a circular import — the account id is a lower-level primitive than the storage
// helpers that consume it.
//
// Deliberately NOT reactive: BootGate pins the account before any per-account state is read, and a
// switch reloads the page, so nothing mounted ever sees the id change underneath it.
// Deliberately NOT reactive: BootGate pins the account before any provider mounts, and a switch reloads
// the page, so nothing mounted ever sees the id change underneath it. The one exception is state read at
// module import (the notification and hints stores), which in a new tab runs before the pin: it reads
// again through `onAccountPinned`.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned.
const pinListeners = new Set<() => void>();
export function onAccountPinned(reload: () => void): void {
pinListeners.add(reload);
}
export function pinActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
pinListeners.forEach((reload) => reload());
}
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
+1
View File
@@ -19,6 +19,7 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" }
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import {
+2 -8
View File
@@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../activeAccount";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, pinActiveAccount } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -262,13 +262,7 @@ export function beacon(url: string, body: Record<string, unknown>): void {
}
}
export function setActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
}
export const setActiveAccount = pinActiveAccount;
export function clearActiveAccount(): void {
try {
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
+3
View File
@@ -1,6 +1,7 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { onAccountPinned } from "./activeAccount";
import { createStore } from "./store";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
@@ -9,6 +10,8 @@ function load(): boolean {
}
const store = createStore<boolean>(load());
// Read at import — in a new tab, before BootGate pins the account — so read again once it is pinned.
onAccountPinned(() => store.set(load()));
export const hintsEnabled = store.get;
export const subscribeHints = store.subscribe;
+17
View File
@@ -2,6 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@@ -205,6 +206,22 @@ function emit() {
recompute();
// The history and settings above are read at import, which in a new tab is before BootGate pins its
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
// tab's first write would replace the account's stored history with its own empty one. A notice raised
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
let loadedFor = activeAccountId();
onAccountPinned(() => {
const unpinned = loadedFor == null ? items : [];
loadedFor = activeAccountId();
config = loadSettings();
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
const history = load().map((n) => ({ ...n, id: counter++ }));
items = [...history, ...unpinned].slice(-MAX_HISTORY);
recompute();
listeners.forEach((l) => l());
});
export function subscribe(listener: () => void): () => void {
listeners.push(listener);
return () => {