Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e804ff3b8e | ||
|
|
cd6f119883 | ||
|
|
321776480e | ||
|
|
7c0647f87a | ||
|
|
efd7b217ca | ||
|
|
3d7ef9e945 | ||
|
|
7fce7a7ce5 | ||
|
|
f5018bdcbc | ||
|
|
b82c22b888 | ||
|
|
f839a461e6 | ||
|
|
8525666913 | ||
|
|
3f24248e01 | ||
|
|
6b86673af4 | ||
|
|
d924c665f8 | ||
|
|
aa51644f5f | ||
|
|
378b7559e4 | ||
|
|
05121fbc99 | ||
|
|
bf518223e5 | ||
|
|
332d24a646 | ||
|
|
c5e022f056 | ||
|
|
4065ac7e6a | ||
|
|
ce08fc2aec | ||
|
|
ea6d829c29 | ||
|
|
6a7ff330f6 | ||
|
|
7e1aeeb9fe | ||
|
|
7d682c90d5 | ||
|
|
cc9fc3b544 | ||
|
|
c9101e3450 | ||
|
|
51daf3e388 | ||
|
|
09d351a0e7 |
@@ -1,8 +1,14 @@
|
||||
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
|
||||
# independent of any one machine. Two parallel jobs match the two local lanes:
|
||||
# independent of any one machine. Two jobs match the two local lanes:
|
||||
# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
|
||||
# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
|
||||
#
|
||||
# They are declared side by side but they DO NOT run in parallel: the lab has a single act_runner
|
||||
# with one slot, so they queue. Measured across runs #86-#91 — the backend job starts at the exact
|
||||
# second the frontend one ends, every time, and the pair costs ~4 + ~6 = **~10 minutes from the
|
||||
# push**. Anything that waits on this gate has to budget for the sum, not the max; the ops
|
||||
# `release` read-back timed out on its first real run for believing otherwise.
|
||||
#
|
||||
# Runner: the lab act_runner (LXC 115) mounts the Docker socket, so service containers work. The backend
|
||||
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose
|
||||
# bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned
|
||||
@@ -87,6 +93,22 @@ jobs:
|
||||
run: python -m ruff check --config backend/ruff.toml backend scripts
|
||||
- name: ruff format --check
|
||||
run: python -m ruff format --check --config backend/ruff.toml backend scripts
|
||||
# The ninth lane (E27 S3). Run from backend/ so it resolves exactly as the local lane does —
|
||||
# pyrightconfig.json's include/extraPaths are relative to the config, but the baseline is
|
||||
# found relative to the working directory. New errors fail; the ~200 pre-existing ones are in
|
||||
# .basedpyright/baseline.json and burn down epic by epic. Matching is per (file, rule, column
|
||||
# span) plus a count, so a new error of the SAME shape in a file where a sibling was just
|
||||
# fixed is absorbed — burn the hot files down first.
|
||||
#
|
||||
# The second step is not redundant. basedpyright TIGHTENS the baseline in place when the count
|
||||
# drops, with no --writebaseline, and CI throws its workspace away — so without this a
|
||||
# burn-down is green here and red on the developer's `siftlode check`, which has the same
|
||||
# guard. Two gates over one tree must not answer differently.
|
||||
- name: basedpyright
|
||||
run: python -m basedpyright --project pyrightconfig.json
|
||||
working-directory: backend
|
||||
- name: basedpyright baseline is committed
|
||||
run: git diff --exit-code -- backend/.basedpyright/baseline.json
|
||||
- name: pytest
|
||||
run: python -m pytest
|
||||
working-directory: backend
|
||||
|
||||
@@ -37,3 +37,7 @@ backups/
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane
|
||||
# reads or regenerates them, so a committed one is stale from the commit that adds it.
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -7,6 +7,40 @@ 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.95.0 — 2026-09-23
|
||||
|
||||
A file that is too big to upload says so before it is sent, instead of failing as if the server were down - and the server no longer writes half a gigabyte to disk for someone who never signed in.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Picking a file over the size limit used to end in "the server couldn't be reached" - after the whole file had been sent. The dialog now checks the size the moment you choose the file, tells you what the limit is, and does not start. If a refusal does come from the server, it reads as a sentence about the size rather than as a network failure.
|
||||
- A long name on a download no longer breaks it. Anything typed past the length of the field used to fail the whole operation with a server error - on an upload, after the entire file had already transferred. Names are simply shortened now, on every screen where one can be set.
|
||||
- While the file you picked is too large, the drop zone still lights up when you drag a replacement onto it. It used to stay red, which read as though drag-and-drop had stopped working - in exactly the moment you were trying to fix the problem.
|
||||
|
||||
### Internal
|
||||
|
||||
- An upload is now read only after the server knows who is sending it. Until now anyone at all could make the server write up to 500 MB to disk and only then be told they were not allowed to upload, because the file arrived before the password was ever checked. It is read a piece at a time, straight to disk, and stops the moment it passes the limit.
|
||||
- An upload no longer holds a database connection open while the file is transferring. One slow upload used to occupy a connection for its entire duration, and enough of them at once would have starved everything else on the server.
|
||||
- The parser for the old upload format is gone from the app entirely, along with the size allowance it required.
|
||||
- Three adversarial review rounds closed the work: 13 findings, then 28, then 8. The last round found almost nothing in the app itself - its findings were in the tests the round before had written, which is why it was the last. Four of its claims were disproved rather than fixed, one of them a proposed change that would have broken emoji in names. Every regression test was proven to fail with its own fix taken out.
|
||||
|
||||
## 0.94.0 — 2026-09-22
|
||||
|
||||
No new features, on purpose: a release spent entirely on the machinery underneath, so that whole classes of fault stop being possible rather than being fixed one at a time.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Typing an impossible number into a schedule - 0 minutes, 5000 minutes - used to close the editor and quietly keep the old value, as if it had been saved. The editor now says what the allowed range is, refuses to save until the number is inside it, and hands the keyboard back to the button you opened it from when you leave with Escape.
|
||||
|
||||
### Internal
|
||||
|
||||
- Every request is now measured against a size limit before anything else reads it - before a password is checked, before an address is even resolved. Until now a stranger could make the app swallow a 32 MB upload and only then be told the password was wrong. Each address carries its own ceiling, including the ones that legitimately receive a film.
|
||||
- The server's Python is type-checked by the release gate for the first time: about 36 000 lines carried type annotations that nothing had ever verified. The mistakes already there are written down so they can be worked off release by release, and a new one now fails the build.
|
||||
- The app moved to React 19 and its compiler. The compiler now does, automatically and correctly, the bookkeeping that was written by hand in about 150 places - and hand-written bookkeeping is where most of this year's stale-value and stale-click bugs came from.
|
||||
- The 45 places where the old code told that same checker to stop looking at a piece of work are gone. Each was either a real bug, work that should never have been in that part of the code, or a genuine exception that now states the rule it depends on in code rather than in a comment.
|
||||
- The engine versions the app is built and shipped with are now pinned and checked on both sides, the way the JavaScript side was fixed in the previous release.
|
||||
- Four adversarial review rounds closed the work: 22 findings, then 13, then 6, then 2 - and the last two were in the review's own tests, not in the app. Everything found was fixed here, and every regression test was proven to fail with its own fix taken out.
|
||||
|
||||
## 0.93.2 — 2026-09-20
|
||||
|
||||
An amount on a Plex film reads the same in every browser.
|
||||
|
||||
@@ -7,17 +7,18 @@ worse than no rule — if you change how the code works, change this file in the
|
||||
|
||||
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
||||
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation
|
||||
provider.
|
||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
||||
router-free navigation provider.
|
||||
|
||||
## The gate — never merge or ship red
|
||||
|
||||
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
|
||||
The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + typecheck (3 tsc projects) + eslint + prettier
|
||||
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||
|
||||
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`.
|
||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||
|
||||
## Workflow
|
||||
@@ -57,7 +58,7 @@ how it gets built. Concretely:
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`,
|
||||
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
@@ -74,29 +75,34 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
## Canonical patterns — when you have a choice, pick THIS
|
||||
|
||||
| Area | Use | Not |
|
||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||
| Area | Use | Not |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+), else a latest-value ref | an excused dep list asserting the same thing in prose |
|
||||
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
"""The global request-body cap (SEC-20).
|
||||
|
||||
**The hole.** FastAPI reads a declared body IN FULL before auth or any other dependency runs — the
|
||||
body parameter has to be parsed to be passed in. So `POST /auth/password-login` drains a 32 MiB
|
||||
upload from an anonymous caller and only then answers 401. 70 of the 72 body-declaring routes behave
|
||||
the same way: one unauthenticated client can spend the process's memory and time at will.
|
||||
|
||||
**Why a middleware and not `capped_body` on every route.** `routes/_common.py:capped_body` is the
|
||||
right tool where a route genuinely takes bulk bytes, and the two routes that use it use it
|
||||
correctly. But putting it on all 70 others would (a) fight the canonical "Request body → a Pydantic
|
||||
model" row, since capping means taking the body as raw bytes and parsing it by hand, and (b) leave
|
||||
the NEXT route added uncovered, because nothing would remind its author. A default that has to be
|
||||
opted into is not a default. This module inverts it: the cap applies to everything, and the few
|
||||
routes that need more opt OUT, in one list, next to the code that reads it.
|
||||
|
||||
**Why raw ASGI and not `@app.middleware("http")`.** Starlette's BaseHTTPMiddleware cannot wrap
|
||||
`receive`, so it can only read `Content-Length` — which a client may omit entirely (chunked) or
|
||||
simply understate. The bytes have to be counted as they arrive, which is the same rule `capped_body`
|
||||
already applies per-route, and counting needs the receive channel.
|
||||
|
||||
**Why the refusal is byte-identical to `capped_body`'s.** Two shapes for one fact is how the
|
||||
`cancelled`/`canceled` bug happened. The 413 body here is built through the same `JSONResponse`
|
||||
FastAPI renders `HTTPException(413, {"code": "too_large"})` with — not a hand-rolled `json.dumps`,
|
||||
whose default separators differ by two bytes — and `tests/test_body_limit.py` compares the RAW BYTES
|
||||
of the two against each other rather than against a literal, so they cannot drift apart. The same
|
||||
reasoning decides where the middleware is registered (`main.py`): just inside CORS, so this 413
|
||||
carries the same headers `capped_body`'s does.
|
||||
|
||||
**No admin knob, deliberately.** The ceiling is a constant, not a `sysconfig` value: an attacker-
|
||||
facing limit that an admin can raise from the UI is a limit that can be raised by whoever gets the
|
||||
admin session, and nobody has asked to tune it. If a self-hoster ever needs a different ceiling it
|
||||
belongs in `config.py` (env, set before the process starts), not in the database.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app import debugsession
|
||||
from app.config import settings
|
||||
|
||||
# 1 MiB. The largest body any route on the default ceiling legitimately takes is 512 KiB (the client
|
||||
# log sink, `routes/diagnostics.py:BODY_MAX_BYTES`), so this leaves 2x headroom — a ceiling that
|
||||
# refuses a legitimate request would be a worse bug than the one this module fixes. That ratio is
|
||||
# the load-bearing fact, so `test_body_limit.py` holds it rather than the figure above.
|
||||
DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
||||
|
||||
# The routes that need MORE than the default, as (method, path TEMPLATE) → their own ceiling. The
|
||||
# template, not a concrete path, so the list reads like the route decorators it mirrors and
|
||||
# `test_body_limit.py` can assert that each one still resolves to a real route: a renamed route
|
||||
# fails the gate instead of silently losing its allowance in prod.
|
||||
#
|
||||
# These are RAISED CEILINGS, NOT EXEMPTIONS, and the difference is the whole point. An exemption
|
||||
# would mean "this route is counted by something else" — which is only true when the route's own
|
||||
# limit applies to the bytes as they ARRIVE. That, in turn, is only true of a route with NO DECLARED
|
||||
# BODY PARAMETER: FastAPI parses and spools a declared body BEFORE the first dependency runs, so a
|
||||
# route that declares one has already stored the bytes by the time its own cap — or its auth — gets
|
||||
# a say.
|
||||
#
|
||||
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
|
||||
# and the owner check resolves first). The upload route did NOT until P1: it declared
|
||||
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
|
||||
# per-file allowance to temp before `require_human` refused them — this middleware bounded that
|
||||
# number but could not stop the writing. It now reads its own body from a dependency declared after
|
||||
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
|
||||
#
|
||||
# That rule is no longer only written here: `test_body_limit.py` parses the routes and fails the
|
||||
# gate if any route in this dict declares a body parameter. A comment could not have stopped the
|
||||
# defect it describes; the test can.
|
||||
#
|
||||
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
|
||||
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
|
||||
# default, so listing it could only widen what it accepts.
|
||||
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
||||
# Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for —
|
||||
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
|
||||
# `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an
|
||||
# admin can only ever LOWER the route's cap below the edge, never raise it past one.
|
||||
#
|
||||
# WHICH LAYER ANSWERS, measured rather than assumed (deep-review round 1):
|
||||
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so
|
||||
# `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own
|
||||
# figure, after draining the rest of the body so the client can read it.
|
||||
# * knob at the DEFAULT — the two numbers are EQUAL, and the edge always wins: a declared
|
||||
# Content-Length over the cap is refused here before a byte is read, and an undeclared body
|
||||
# trips this counter on the same chunk the route would have tripped on. The route's 422 is
|
||||
# unreachable in that configuration, and no amount of headroom would change it — the
|
||||
# declared-length check fires first for any body big enough to matter, and the route's drain
|
||||
# pushes the count past the edge for the rest.
|
||||
#
|
||||
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
|
||||
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
|
||||
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
|
||||
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
|
||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
||||
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
||||
}
|
||||
|
||||
# The refusal, byte-for-byte what `capped_body`'s `HTTPException(413, {"code": "too_large"})` renders
|
||||
# as — built through the same JSONResponse so the separators match too, and asserted against a real
|
||||
# route's raw bytes in `test_body_limit.py` rather than against a literal here.
|
||||
TOO_LARGE_BODY = JSONResponse({"detail": {"code": "too_large"}}, status_code=413).body
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{[^/{}]+\}")
|
||||
|
||||
|
||||
def template_to_regex(template: str) -> re.Pattern[str]:
|
||||
"""`/api/debug-sessions/{sid}/screenshots` → a regex matching one concrete path.
|
||||
|
||||
A path parameter matches `[^/]+` — one segment, never across a `/`, so `{sid}` cannot swallow a
|
||||
deeper path and hand an exemption to a route that was never granted one.
|
||||
"""
|
||||
literals = [re.escape(part) for part in _PATH_PARAM.split(template)]
|
||||
return re.compile("^" + "[^/]+".join(literals) + "$")
|
||||
|
||||
|
||||
def _declared_length(scope: Scope) -> int | None:
|
||||
"""The `Content-Length` header as an int, or None when absent or unparseable (a chunked body has
|
||||
none, and a malformed one is not a number we may trust — both fall through to counting)."""
|
||||
for name, value in scope.get("headers", ()):
|
||||
if name == b"content-length":
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
# silent-ok: a malformed Content-Length is the caller's doing, not a fault of ours,
|
||||
# and it is about to be answered anyway — returning None drops the request into the
|
||||
# counting path, which refuses on the real bytes. Logging it would hand an anonymous
|
||||
# client a free line in our log for every request it cares to malform.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def _send_too_large(send: Send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 413,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(TOO_LARGE_BODY)).encode()),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": TOO_LARGE_BODY})
|
||||
|
||||
|
||||
class BodyLimitMiddleware:
|
||||
"""Refuses any request body over its route's ceiling, before the application sees it.
|
||||
|
||||
Registered just inside CORS in `main.py` — so the 413 is decorated like every other response
|
||||
and a browser can actually read it — but outside the session, the setup gate and every auth
|
||||
dependency, none of which run for a request that is already too large.
|
||||
|
||||
Every request gets a finite ceiling: `ROUTE_MAX_BODY_BYTES` raises it for the two routes that
|
||||
genuinely take bulk bytes, and there is no path that skips counting altogether.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
*,
|
||||
limit: int = DEFAULT_MAX_BODY_BYTES,
|
||||
routes: dict[tuple[str, str], int] | None = None,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.limit = limit
|
||||
self._routes = tuple(
|
||||
(method, template_to_regex(path), cap)
|
||||
for (method, path), cap in (ROUTE_MAX_BODY_BYTES if routes is None else routes).items()
|
||||
)
|
||||
|
||||
def limit_for(self, method: str, path: str) -> int:
|
||||
"""This request's ceiling: the route's own if it has one, else the global default. Every
|
||||
request gets a finite number — there is no path through here that is simply not counted."""
|
||||
for m, rx, cap in self._routes:
|
||||
if m == method and rx.match(path):
|
||||
return cap
|
||||
return self.limit
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
limit = self.limit_for(scope.get("method", ""), scope.get("path", ""))
|
||||
|
||||
declared = _declared_length(scope)
|
||||
if declared is not None and declared > limit:
|
||||
# Refused on the declaration alone: nothing downstream is called, and not one byte of
|
||||
# the body is read.
|
||||
await _send_too_large(send)
|
||||
return
|
||||
|
||||
seen = 0
|
||||
refused = False
|
||||
started = False
|
||||
|
||||
async def counting_receive() -> Message:
|
||||
nonlocal seen, refused
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
return message
|
||||
seen += len(message.get("body", b""))
|
||||
if seen > limit:
|
||||
# The app is blocked on this receive, so it has not answered yet. Telling it the
|
||||
# client went away is the only in-band way to stop it reading: `request.stream()`
|
||||
# turns an `http.disconnect` into ClientDisconnect, which unwinds the handler. Our
|
||||
# own 413 goes out below, once the app has finished unwinding.
|
||||
refused = True
|
||||
return {"type": "http.disconnect"}
|
||||
return message
|
||||
|
||||
async def guarded_send(message: Message) -> None:
|
||||
nonlocal started
|
||||
if refused and not started:
|
||||
return # discard the app's answer to the disconnect; the 413 below is the response
|
||||
started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, counting_receive, guarded_send)
|
||||
except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below
|
||||
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
|
||||
# consequence of the disconnect we just faked, and the caller must get the 413 rather
|
||||
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
|
||||
# untouched.
|
||||
if not refused:
|
||||
raise
|
||||
|
||||
if refused and not started:
|
||||
await _send_too_large(send)
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal, get_args, get_origin
|
||||
|
||||
from pydantic import ValidationInfo, field_validator, model_validator
|
||||
from pydantic import Field, ValidationInfo, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||
@@ -247,7 +247,11 @@ class Settings(BaseSettings):
|
||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
||||
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
|
||||
# real limit of zero rather than the route's "no per-file cap" sentinel — an instance set to 0
|
||||
# would refuse every upload, including the admin page's own way back. To lift the cap in
|
||||
# practice, set a large number.
|
||||
download_upload_max_bytes: int = Field(default=524_288_000, ge=1) # 500 MiB
|
||||
# Retention: an asset expires this many days after its last access; the GC job then
|
||||
# deletes it (once no job references it) after an additional grace window.
|
||||
download_retention_days: int = 30
|
||||
|
||||
+48
-1
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
@@ -34,3 +34,50 @@ def get_db() -> Iterator[Session]:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def release_read_connection(db: Session) -> None:
|
||||
"""End a request's READ transaction so its pooled connection goes back BEFORE a long await.
|
||||
|
||||
SQLAlchemy 2.0 autobegins on the first SELECT, so a request that has merely authenticated is
|
||||
already holding one of the connections above — `idle in transaction` to Postgres, and nothing
|
||||
times it out. A route that then awaits something slow (a request body still arriving, an
|
||||
external fetch) pins it for the whole wait, which is precisely the invariant the pool is sized
|
||||
on in the comment above. Measured: the next checkout after this call reuses the very backend
|
||||
that was released, and a 1.8 s upload goes from one sustained `idle in transaction` to none.
|
||||
|
||||
Every route that reads its own body from a dependency needs this, and `test_body_limit.py`
|
||||
holds that — the three that do are the ones with a raised (or self-imposed) body ceiling, and
|
||||
they got here one at a time, which is how the upload route ended up the only one fixed.
|
||||
|
||||
The caller must have NOTHING PENDING, and that is checked rather than described. The session is
|
||||
shared with every dependency that ran before, so a rollback here would silently discard a write
|
||||
one of them staged — `service.enqueue` already learned that lesson the expensive way and uses a
|
||||
SAVEPOINT for it. CLAUDE.md: a rule stated in a comment must also be enforced in code.
|
||||
|
||||
TWO checks, because the obvious one is blind to the shapes this codebase actually writes in.
|
||||
`db.new`/`deleted`/`dirty` hold only UNFLUSHED unit-of-work: the moment a helper calls
|
||||
`db.flush()` — CLAUDE.md's canonical split is "the handler commits; helpers flush" — the object
|
||||
is persistent with no unflushed history and all three sets are empty again, and Core DML
|
||||
(`db.execute(update(...))`) never enters them at all. Measured: after `add`+`flush` the sets are
|
||||
empty, the old check passed, and the rollback lost the row. So the transaction is asked
|
||||
directly. Postgres assigns a transaction id on the first WRITE and never for a read, so
|
||||
`pg_current_xact_id_if_assigned()` is NULL for exactly the state this function is safe in — and
|
||||
it sees a raw `text("UPDATE …")` too, which no session-level bookkeeping would.
|
||||
"""
|
||||
if db.new or db.deleted or db.dirty:
|
||||
raise RuntimeError(
|
||||
"release_read_connection() with pending work: something ahead of this dependency "
|
||||
"staged a write, which ending the transaction would discard. Commit it first, or do "
|
||||
"it after the await."
|
||||
)
|
||||
if (
|
||||
db.in_transaction()
|
||||
and db.execute(text("SELECT pg_current_xact_id_if_assigned()")).scalar() is not None
|
||||
):
|
||||
raise RuntimeError(
|
||||
"release_read_connection() on a transaction that has already WRITTEN: something ahead "
|
||||
"of this dependency flushed a row or ran an UPDATE/INSERT/DELETE, which ending the "
|
||||
"transaction would discard. Commit it first, or do it after the await."
|
||||
)
|
||||
db.rollback()
|
||||
|
||||
@@ -25,7 +25,7 @@ from app.downloads.jobdb import (
|
||||
)
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
from app.titles import normalize_title
|
||||
|
||||
@@ -597,7 +597,7 @@ def _download(
|
||||
}
|
||||
if job and not job.display_name and meta.title:
|
||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||
fields["display_name"] = meta.title[:255]
|
||||
fields["display_name"] = meta.title[:DISPLAY_TEXT_MAX]
|
||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobStatus
|
||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
||||
|
||||
|
||||
class EditError(Exception):
|
||||
@@ -179,7 +179,7 @@ def enqueue(
|
||||
profile_id=profile_id,
|
||||
profile_snapshot=spec,
|
||||
display_name=display_name
|
||||
or (asset.title[:255] if asset.status == "ready" and asset.title else None),
|
||||
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
||||
status="queued",
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
@@ -198,10 +198,13 @@ def enqueue(
|
||||
|
||||
|
||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
||||
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
||||
# rather than run through `display_text` — blank here means "no title", not None.
|
||||
if display_name:
|
||||
return display_name[:255]
|
||||
return display_name[:DISPLAY_TEXT_MAX]
|
||||
base = source_title or "Clip"
|
||||
return f"{base} (clip)"[:255]
|
||||
return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
|
||||
|
||||
|
||||
def enqueue_edit(
|
||||
@@ -255,7 +258,7 @@ def enqueue_edit(
|
||||
source_asset_id=src_asset.id,
|
||||
edit_spec=spec,
|
||||
profile_snapshot={},
|
||||
display_name=display_name or asset.title,
|
||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||
status="queued",
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, probe, quota, service, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.models import DownloadJob, MediaAsset, display_text
|
||||
|
||||
|
||||
def _title_from_filename(filename: str) -> str:
|
||||
@@ -99,7 +99,7 @@ def ingest_upload(
|
||||
source_kind="upload",
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
display_name=(display_name or "").strip() or None,
|
||||
display_name=display_text(display_name),
|
||||
status="done",
|
||||
progress=100,
|
||||
queue_pos=service.next_queue_pos(db),
|
||||
|
||||
@@ -466,7 +466,6 @@ _THIRD_PARTY_LEVELS = {
|
||||
"httpx": "WARNING",
|
||||
"httpcore": "WARNING",
|
||||
"asyncio": "WARNING",
|
||||
"multipart": "WARNING",
|
||||
"PIL": "WARNING",
|
||||
"alembic": "INFO",
|
||||
# uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
|
||||
|
||||
+31
-10
@@ -23,6 +23,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.exc import DataError
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app import bodylimit
|
||||
from app import logsink
|
||||
from app import state
|
||||
from app import sysconfig
|
||||
@@ -189,15 +190,6 @@ app.add_middleware(
|
||||
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
|
||||
)
|
||||
|
||||
if settings.frontend_origin:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
|
||||
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
|
||||
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
|
||||
@@ -217,6 +209,34 @@ async def setup_gate(request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL
|
||||
# before the first dependency, so without this the anonymous login route is a free memory/time sink.
|
||||
#
|
||||
# WHERE it sits is the whole design, and it is pinned by `test_body_limit.py` rather than by this
|
||||
# comment, because `add_middleware` inverts the reading order (last registered = OUTERMOST) and a
|
||||
# stack described in prose drifts. Registered here it lands directly INSIDE CORS and directly
|
||||
# OUTSIDE the setup gate, which is the narrowest position that satisfies both constraints:
|
||||
#
|
||||
# * inside CORS and the security headers, so the 413 is decorated like every other response. A
|
||||
# refusal the browser drops for a missing Access-Control-Allow-Origin is no better than a hang,
|
||||
# and `capped_body`'s 413 one layer down IS decorated — two shapes for one fact is exactly what
|
||||
# bodylimit.py exists to avoid.
|
||||
# * outside the setup gate, the session and every auth dependency under the router — none of which
|
||||
# run for a body that is already too large. The setup gate matters specifically: on an instance
|
||||
# that is not yet configured it opens a DB session per request, which is precisely the anonymous
|
||||
# pre-auth work SEC-20 exists to remove.
|
||||
app.add_middleware(bodylimit.BodyLimitMiddleware)
|
||||
|
||||
if settings.frontend_origin:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def static_cache_headers(request, call_next):
|
||||
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
|
||||
@@ -259,7 +279,8 @@ async def security_headers(request, call_next):
|
||||
|
||||
# The access log (app/logs.py): request id, route template, status, duration — one line, never a
|
||||
# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
|
||||
# request, including what the three above do. Being outermost also puts it OUTSIDE SessionMiddleware,
|
||||
# request, including what every layer above does — the body cap included, so a 413 is still logged
|
||||
# with its request id and duration. Being outermost also puts it OUTSIDE SessionMiddleware,
|
||||
# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
|
||||
# place a session is actually resolved into an account) rather than from `request.session`.
|
||||
app.middleware("http")(log_requests)
|
||||
|
||||
+31
-3
@@ -23,6 +23,34 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
|
||||
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
|
||||
# columns below are declared with it rather than with a literal: a name a person typed goes into
|
||||
# `User.display_name` (their Google profile name), `DownloadJob.display_name` and
|
||||
# `DownloadJob.display_uploader`. A value wider than the column is a psycopg DataError, which
|
||||
# `main.py` re-raises for everything but the NUL case — i.e. a bare 500 for a name somebody pasted.
|
||||
DISPLAY_TEXT_MAX = 255
|
||||
|
||||
|
||||
def display_text(raw: str | None) -> str | None:
|
||||
"""A user-supplied display string on its way into one of those columns: stripped, truncated,
|
||||
blank -> None.
|
||||
|
||||
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
|
||||
`POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and
|
||||
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
|
||||
than refusing: the name is a label on work the user has already done — a file already
|
||||
transferred, an account already authenticated — and losing its tail is not worth failing the
|
||||
request over.
|
||||
|
||||
`test_upload.py` holds both halves: that every route assigning one of these fields goes through
|
||||
this helper (parsed, not grepped), and that each of those routes truncates rather than 500s. A
|
||||
sweep is exactly the kind of thing that gets done five-sixths of the way, which is how this
|
||||
ended up being written twice.
|
||||
"""
|
||||
return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None
|
||||
|
||||
|
||||
from app.utils import canonical_email
|
||||
|
||||
|
||||
@@ -114,7 +142,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
# not cleared eagerly — it simply stops counting).
|
||||
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
|
||||
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
|
||||
@@ -953,11 +981,11 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||
)
|
||||
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||
# Per-user display overrides + extras (like display_name — metadata only, never the file).
|
||||
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
|
||||
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
|
||||
display_uploader: Mapped[str | None] = mapped_column(String(255))
|
||||
display_uploader: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||
display_uploader_url: Mapped[str | None] = mapped_column(Text)
|
||||
extra_links: Mapped[list | None] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(
|
||||
|
||||
@@ -6,7 +6,7 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
|
||||
one existence-leak policy.
|
||||
"""
|
||||
|
||||
from typing import Any, TypeVar
|
||||
from typing import IO, Any, TypeVar
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -79,6 +79,47 @@ async def capped_body(request: Request, limit: int) -> bytes:
|
||||
return bytes(body)
|
||||
|
||||
|
||||
async def capped_stream_to(
|
||||
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
|
||||
) -> int:
|
||||
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
|
||||
straight to `out` instead of accumulated, returning the size.
|
||||
|
||||
Two deliberate differences from `capped_body`. It never builds a `bytearray` — the upload route
|
||||
admits 500 MiB by default, so holding the body would trade a disk problem for a memory one. And
|
||||
the refusal is the CALLER'S, passed in rather than assumed: `capped_body`'s flat 413 is right
|
||||
for a screenshot, while the upload route answers 422 `upload_too_large` carrying the admin
|
||||
knob's own number, which is what its dialog shows the user. One counting rule, two answers,
|
||||
neither of them a second copy of the loop.
|
||||
|
||||
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
|
||||
arrived before the limit was passed.
|
||||
|
||||
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
|
||||
Answering mid-upload and closing the connection is what the client sees as a network failure,
|
||||
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
|
||||
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
|
||||
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
|
||||
finish sending, read the 422 and say the true thing.
|
||||
|
||||
The bytes are discarded, never written, and the whole request is already bounded by
|
||||
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
|
||||
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
|
||||
size = 0
|
||||
over = False
|
||||
async for chunk in request.stream():
|
||||
size += len(chunk)
|
||||
if over:
|
||||
continue # draining — see the docstring; counted, not stored
|
||||
if size > limit:
|
||||
over = True
|
||||
continue
|
||||
out.write(chunk)
|
||||
if over:
|
||||
raise refusal
|
||||
return size
|
||||
|
||||
|
||||
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
|
||||
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
|
||||
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
|
||||
|
||||
@@ -39,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.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text
|
||||
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
|
||||
@@ -472,7 +472,7 @@ async def callback(
|
||||
if email:
|
||||
user.google_email = email
|
||||
_sync_google_email(db, user, email)
|
||||
user.display_name = userinfo.get("name")
|
||||
user.display_name = display_text(userinfo.get("name"))
|
||||
user.avatar_url = userinfo.get("picture")
|
||||
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
||||
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
||||
@@ -594,7 +594,7 @@ def _complete_link(
|
||||
if linked_email:
|
||||
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
||||
if not user.display_name:
|
||||
user.display_name = userinfo.get("name")
|
||||
user.display_name = display_text(userinfo.get("name"))
|
||||
if not user.avatar_url:
|
||||
user.avatar_url = userinfo.get("picture")
|
||||
_store_token(db, user, token)
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import Session
|
||||
from app import audit, debugsession, logs, logsink
|
||||
from app.audit import AuditAction
|
||||
from app.auth import require_human
|
||||
from app.db import get_db
|
||||
from app.db import get_db, release_read_connection
|
||||
from app.issues import bundle, gitea
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.refusal import refused
|
||||
@@ -335,7 +335,13 @@ def _upload_target(
|
||||
return owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
|
||||
|
||||
async def _image_body(request: Request) -> bytes:
|
||||
async def _image_body(request: Request, db: Session = Depends(get_db)) -> bytes:
|
||||
# The owner check above has already run, and it was a SELECT — so this session is holding a
|
||||
# pooled connection `idle in transaction` and would hold it for the whole 10 MiB upload, from a
|
||||
# client that may be on a phone. Hand it back first; the handler's own work re-takes one. The
|
||||
# `session` object the handler holds is expired by this and reloads on its next attribute read,
|
||||
# which is the correct moment to touch the database again.
|
||||
release_read_connection(db)
|
||||
return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES)
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import debugsession, diagnostics, logs, logsink
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.db import get_db, release_read_connection
|
||||
from app.models import DebugSession, LogEvent, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes._common import capped_body
|
||||
@@ -73,7 +73,7 @@ class ClientEventsIn(BaseModel):
|
||||
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
|
||||
|
||||
|
||||
async def _parsed_events(request: Request) -> ClientEventsIn:
|
||||
async def _parsed_events(request: Request, db: Session = Depends(get_db)) -> ClientEventsIn:
|
||||
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
|
||||
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
|
||||
|
||||
@@ -83,6 +83,10 @@ async def _parsed_events(request: Request) -> ClientEventsIn:
|
||||
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if media_type != "application/json" and not media_type.endswith("+json"):
|
||||
raise HTTPException(status_code=415, detail={"code": "json_required"})
|
||||
# `current_user` resolved above on a SELECT, so this session is holding a pooled connection.
|
||||
# Give it back before awaiting the body — the sink is a background beacon and its sender may be
|
||||
# on a link far slower than the 512 KiB suggests. See `db.release_read_connection`.
|
||||
release_read_connection(db)
|
||||
raw = await capped_body(request, BODY_MAX_BYTES)
|
||||
try:
|
||||
return ClientEventsIn.model_validate_json(raw)
|
||||
|
||||
+121
-47
@@ -9,11 +9,13 @@ serves finished files (range-aware, with the user's custom display name), and sh
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
@@ -22,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app import audit, sysconfig
|
||||
from app.auth import admin_user, require_human
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.db import get_db, release_read_connection
|
||||
from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
@@ -37,8 +39,9 @@ from app.models import (
|
||||
DownloadShare,
|
||||
MediaAsset,
|
||||
User,
|
||||
display_text,
|
||||
)
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
||||
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
|
||||
from app.security import hash_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
from app.utils import iso
|
||||
@@ -398,7 +401,7 @@ def enqueue_download(
|
||||
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
|
||||
display_name = display_text(body.display_name)
|
||||
try:
|
||||
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
||||
except quota.QuotaExceeded as e:
|
||||
@@ -406,61 +409,119 @@ def enqueue_download(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
||||
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
||||
_UPLOAD_CHUNK = 1024 * 1024
|
||||
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
|
||||
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
|
||||
# FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous
|
||||
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
|
||||
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
|
||||
# declared AFTER the auth one, is the only shape that closes it — the same one
|
||||
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
|
||||
# every route with a raised ceiling rather than leaving it to a comment like this one.
|
||||
#
|
||||
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
|
||||
# Hungarian filename would otherwise be unsendable.
|
||||
UPLOAD_FILENAME_HEADER = "x-upload-filename"
|
||||
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload_download(
|
||||
file: UploadFile = File(...),
|
||||
display_name: str | None = Form(None),
|
||||
@dataclass(frozen=True)
|
||||
class SpooledUpload:
|
||||
"""What the body dependency hands the handler: the bytes already on disk, plus the two names
|
||||
that came alongside them."""
|
||||
|
||||
path: Path
|
||||
size: int
|
||||
user_id: int
|
||||
filename: str
|
||||
display_name: str | None
|
||||
|
||||
|
||||
def _header_text(request: Request, name: str) -> str:
|
||||
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
|
||||
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
|
||||
own fallback names the file."""
|
||||
raw = request.headers.get(name, "")
|
||||
try:
|
||||
return unquote(raw, errors="strict").strip()
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
|
||||
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
|
||||
# a label; logging it would hand any client a free log line per request.
|
||||
return ""
|
||||
|
||||
|
||||
async def spool_upload(
|
||||
request: Request,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
|
||||
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
||||
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
|
||||
) -> AsyncIterator[SpooledUpload]:
|
||||
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
||||
|
||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
|
||||
Starlette spools the multipart body into."""
|
||||
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
|
||||
naming `require_human` here makes the auth check a precondition of reading the body at all — a
|
||||
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
|
||||
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
|
||||
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
|
||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
user_id = user.id
|
||||
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
|
||||
# for why, and for the precondition it enforces. Everything this dependency still needs is
|
||||
# already an int or a header in hand: touching an ORM attribute below would reload `user` and
|
||||
# check a connection straight back out.
|
||||
release_read_connection(db)
|
||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
size = 0
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while chunk := file.file.read(_UPLOAD_CHUNK):
|
||||
size += len(chunk)
|
||||
if cap and size > cap:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
||||
)
|
||||
out.write(chunk)
|
||||
if size == 0:
|
||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||
try:
|
||||
job = uploadmod.ingest_upload(
|
||||
db,
|
||||
user.id,
|
||||
filename=file.filename or "upload",
|
||||
src_path=tmp_path,
|
||||
size=size,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
size = await capped_stream_to(
|
||||
request,
|
||||
out,
|
||||
cap,
|
||||
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||
return _serialize_job(db, job)
|
||||
yield SpooledUpload(
|
||||
path=tmp_path,
|
||||
size=size,
|
||||
user_id=user_id,
|
||||
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
||||
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
||||
)
|
||||
finally:
|
||||
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
||||
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload_download(
|
||||
upload: SpooledUpload = Depends(spool_upload),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
|
||||
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
|
||||
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
|
||||
probes + places it and returns an already-`done` job.
|
||||
|
||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||
plus sync DB writes — so it must NOT run on the event loop. The streaming half is async because
|
||||
reading the body is; keeping them in separate callables is what lets each run where it belongs."""
|
||||
if upload.size == 0:
|
||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||
try:
|
||||
job = uploadmod.ingest_upload(
|
||||
db,
|
||||
upload.user_id,
|
||||
filename=upload.filename,
|
||||
src_path=upload.path,
|
||||
size=upload.size,
|
||||
display_name=upload.display_name,
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
class EnqueueEditIn(BaseModel):
|
||||
source_job_id: int = 0
|
||||
edit_spec: dict
|
||||
@@ -480,7 +541,7 @@ def enqueue_edit(
|
||||
if source_job.status != "done" or source_job.asset_id is None:
|
||||
raise refused(409, "edit_unfinished")
|
||||
spec = body.edit_spec
|
||||
display_name = (body.display_name or "").strip() or None
|
||||
display_name = display_text(body.display_name)
|
||||
try:
|
||||
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
||||
except quota.QuotaExceeded as e:
|
||||
@@ -516,7 +577,20 @@ def list_downloads(
|
||||
|
||||
@router.get("/usage")
|
||||
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
return quota.usage(db, user.id)
|
||||
"""The caller's download quota, plus the per-file upload cap.
|
||||
|
||||
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
|
||||
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
|
||||
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
|
||||
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
|
||||
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
|
||||
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
|
||||
boundary: the caps stay where they are and still refuse a client that ignores this.
|
||||
"""
|
||||
return {
|
||||
**quota.usage(db, user.id),
|
||||
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
|
||||
}
|
||||
|
||||
|
||||
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||
@@ -611,9 +685,9 @@ def update_download_meta(
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
sent = body.model_fields_set
|
||||
if "display_name" in sent:
|
||||
job.display_name = ((body.display_name or "").strip())[:255] or None
|
||||
job.display_name = display_text(body.display_name)
|
||||
if "display_uploader" in sent:
|
||||
job.display_uploader = ((body.display_uploader or "").strip())[:255] or None
|
||||
job.display_uploader = display_text(body.display_uploader)
|
||||
if "display_uploader_url" in sent:
|
||||
job.display_uploader_url = _clean_url(body.display_uploader_url)
|
||||
if "extra_links" in sent:
|
||||
|
||||
+54
-10
@@ -137,10 +137,29 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
min=1,
|
||||
max=100_000,
|
||||
),
|
||||
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
|
||||
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
|
||||
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
|
||||
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
|
||||
# min=1, NOT 0: a stored 0 used to read as "no per-file cap" (the cap was enforced as
|
||||
# `if cap and size > cap`, so a falsy value disabled it). Since P1 the route passes this number
|
||||
# straight to `capped_stream_to`, where 0 would refuse every upload instead — the opposite
|
||||
# failure, and the reason `config.download_upload_max_bytes` carries its own `ge=1`. Either way
|
||||
# a stored 0 is wrong, so it is floored at 1; the overall footprint is separately bounded by the
|
||||
# byte quota.
|
||||
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over exactly
|
||||
# this number at the edge, before the route's own check ever sees it. An admin allowed to raise
|
||||
# the knob past the edge would get 413s from a layer the admin page never mentions, so the two
|
||||
# are pinned together here and `tests/test_body_limit.py` holds them that way. Below the edge is
|
||||
# the useful direction and the one the knob exists for: a lowered knob is what makes the route's
|
||||
# own 422 — the one carrying this figure — the answer the user gets. `_coerce` also
|
||||
# REJECTS a stored value outside these bounds on read (falling back to the env default, with a
|
||||
# warning) — neither bound has always been what it is now, so a legal-when-written override can
|
||||
# be sitting in the database already.
|
||||
ConfigSpec(
|
||||
"download_upload_max_bytes",
|
||||
"int",
|
||||
"downloads",
|
||||
"download_upload_max_bytes",
|
||||
min=1,
|
||||
max=settings.download_upload_max_bytes,
|
||||
),
|
||||
ConfigSpec(
|
||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||
),
|
||||
@@ -236,9 +255,29 @@ def _choice(s: ConfigSpec, raw: object) -> str:
|
||||
return option
|
||||
|
||||
|
||||
class OutOfRange(ValueError):
|
||||
"""A stored int override that no longer satisfies its spec's bounds."""
|
||||
|
||||
|
||||
def _coerce(s: ConfigSpec, raw: str):
|
||||
if s.type == "int":
|
||||
return int(raw)
|
||||
# Bounds are checked on READ, not only in `set_value`. Bounds get ADDED to a spec
|
||||
# (download_upload_max_bytes gained a `max` when SEC-20 started enforcing the same number at
|
||||
# the edge; its `min` went 0 → 1 during E7) and env defaults get LOWERED, and either leaves
|
||||
# a stored value that was legal when it was written. Serving it is how one layer ends up
|
||||
# acting on a number another layer refuses, with the admin page showing a value outside the
|
||||
# range it renders.
|
||||
#
|
||||
# It raises rather than CLAMPING, and that distinction cost a round: clamping is a silent
|
||||
# rewrite, and silently rewriting is exactly what this module refuses to do elsewhere. The
|
||||
# case that settles it is a stored 0 for download_upload_max_bytes — legal when `min` was 0,
|
||||
# and meaning "no per-file cap" to the route that reads it. Clamped to `min=1` it becomes a
|
||||
# ONE-BYTE cap and uploads stop working with nothing in the log. Raising drops it into the
|
||||
# same fallback the uncoercible path uses: the env default, and one warning.
|
||||
v = int(raw)
|
||||
if (s.min is not None and v < s.min) or (s.max is not None and v > s.max):
|
||||
raise OutOfRange(f"{v} is outside [{s.min}, {s.max}]")
|
||||
return v
|
||||
if s.type == "bool":
|
||||
return raw == "true"
|
||||
if s.type == "choice":
|
||||
@@ -291,14 +330,19 @@ def get(db: Session, key: str):
|
||||
return _coerce(s, raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
# The admin set this in the UI and it is quietly not in force: the app runs on the env
|
||||
# default while the settings page goes on showing the value that was saved.
|
||||
# default while the settings page goes on showing the value that was saved. An OutOfRange is
|
||||
# the same situation arrived at from the other direction — the value was fine when it was
|
||||
# written and the spec moved under it — so it gets the same treatment and its own code.
|
||||
event = (
|
||||
"sysconfig.override_out_of_range"
|
||||
if isinstance(exc, OutOfRange)
|
||||
else "sysconfig.override_uncoercible"
|
||||
)
|
||||
if (s.key, raw) not in _coerce_warned:
|
||||
_coerce_warned.add((s.key, raw))
|
||||
log.warning(
|
||||
"a stored setting could not be coerced; the default is used instead",
|
||||
extra=logs.event(
|
||||
"sysconfig.override_uncoercible", key=s.key, reason=type(exc).__name__
|
||||
),
|
||||
"a stored setting is not usable; the default is used instead",
|
||||
extra=logs.event(event, key=s.key, reason=type(exc).__name__),
|
||||
)
|
||||
return _default(s)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ from app.downloads.jobdb import (
|
||||
_staging_dir,
|
||||
)
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
_stop = threading.Event()
|
||||
@@ -562,7 +562,7 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||
if not job.display_name and asset.title:
|
||||
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
||||
fields["display_name"] = asset.title[:255]
|
||||
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
# Atomic finalize: only marks the job done while it is still running, so a concurrent
|
||||
# cancel/pause is never resurrected (see service.finalize_if_running).
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// The backend type-check lane (E27 S3) — the gate's ninth lane, and the first thing that has ever
|
||||
// type-checked the ~36k lines of `app/`. Read as JSONC: pyright accepts comments here.
|
||||
//
|
||||
// basedpyright, not mypy: measured on this tree with the dependencies actually installed, both land
|
||||
// ~190-200 errors with the same distribution, so the choice was made on the things that differ —
|
||||
// basedpyright is ~2x faster, needs no MYPYPATH to resolve our `app.*` imports, and has a FIRST-CLASS
|
||||
// baseline (`--writebaseline`), which is the whole ratchet. mypy would have needed a third-party tool
|
||||
// for that.
|
||||
//
|
||||
// It runs in the pytest test image, never on the host: `requirements.lock` cannot be installed on
|
||||
// Windows at all (uvloop refuses to build there), so a host run would type-check against absent
|
||||
// third-party packages and report nonsense. The version is pinned in requirements-dev.txt, which the
|
||||
// image installs — so the lane's version IS the pin, with no host-vs-pin check to drift.
|
||||
//
|
||||
// `pythonVersion` is not free-standing: `/.python-version` is the single source of truth and
|
||||
// `tests/test_python_engine_contract.py` fails this gate if this file, the two Dockerfiles, the CI
|
||||
// container or ruff's target-version disagree with it.
|
||||
//
|
||||
// `include` covers `alembic` for the same reason the ruff lane was widened to it: migrations run on
|
||||
// every container start, so one that raises there crash-loops prod, and "it is generated code" is
|
||||
// not an argument when the generated code is what boots the system. `tests/` is deliberately OUT —
|
||||
// a test's job is to fail loudly when it is wrong, which is the opposite of the silent runtime
|
||||
// failure this lane exists to catch, and annotating fixtures to a checker's satisfaction is churn
|
||||
// the ratchet exists to avoid.
|
||||
{
|
||||
"include": ["app", "alembic"],
|
||||
"extraPaths": ["."],
|
||||
"typeCheckingMode": "basic",
|
||||
"pythonVersion": "3.12",
|
||||
"pythonPlatform": "Linux"
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
# backend lint lane runs on the host (the gate fails when the host's ruff is not this one). httpx2:
|
||||
# Starlette 1.x's TestClient is built on it (`import httpx2 as httpx`) and only falls back to the
|
||||
# app's own httpx with a deprecation warning on every run.
|
||||
# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type
|
||||
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it
|
||||
# writes is keyed to the diagnostics THIS version produces.
|
||||
basedpyright==1.40.1
|
||||
pytest==8.4.2
|
||||
ruff==0.15.21
|
||||
httpx2==2.13.0
|
||||
|
||||
@@ -47,7 +47,6 @@ pydantic-settings==2.15.0
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
python-dotenv==1.2.3
|
||||
python-multipart==0.0.32
|
||||
requests==2.34.2
|
||||
rich==15.0.0
|
||||
starlette==1.6.0
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
fastapi>=0.115,<1.0
|
||||
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
|
||||
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
|
||||
python-multipart>=0.0.9,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
|
||||
# `ruff format --check` alongside `ruff check`).
|
||||
line-length = 100
|
||||
# The interpreter this code actually runs on, so ruff judges syntax and upgrade hints against the
|
||||
# right one. Left unset it falls back to a default that has nothing to do with us. `/.python-version`
|
||||
# is the single source of truth; tests/test_python_engine_contract.py holds this to it.
|
||||
target-version = "py312"
|
||||
|
||||
[lint]
|
||||
# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it:
|
||||
|
||||
@@ -15,6 +15,7 @@ tests that COMMIT (the whole point of this lane — transaction-ownership behavi
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
@@ -248,6 +249,27 @@ def as_user(db, override_dep, _configured):
|
||||
return _as
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upload_headers():
|
||||
"""Build the headers `POST /api/downloads/upload` takes since P1.
|
||||
|
||||
Shared, because two test modules drive that route and a second copy of this is exactly the drift
|
||||
that lets the wire shape change in one place only. The body IS the file now; the names ride as
|
||||
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
|
||||
latin-1 and a Hungarian filename would not survive raw)."""
|
||||
|
||||
def _headers(filename: str, display_name: str | None = None) -> dict[str, str]:
|
||||
headers = {
|
||||
"content-type": "application/octet-stream",
|
||||
"x-upload-filename": quote(filename),
|
||||
}
|
||||
if display_name is not None:
|
||||
headers["x-upload-display-name"] = quote(display_name)
|
||||
return headers
|
||||
|
||||
return _headers
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiters():
|
||||
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
"""SEC-20 — the global request-body cap (`app/bodylimit.py`).
|
||||
|
||||
Two halves, deliberately kept apart:
|
||||
|
||||
* the MIDDLEWARE tests drive a toy ASGI app, so they pin the mechanism (declared length, counted
|
||||
bytes, the exemption match) with no router, no auth and no database in the way;
|
||||
* the APP tests drive the real application, so they pin that the middleware is actually wired in,
|
||||
sits ahead of the auth dependencies, and refuses in the same shape `capped_body` does.
|
||||
|
||||
The pair that matters most is "an oversized body is refused" together with "a normal body still
|
||||
gets through": the first alone would pass just as well if the cap refused *everything*.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
from app import bodylimit, debugsession, sysconfig
|
||||
from app.config import settings
|
||||
from app.models import SystemConfig
|
||||
from app.bodylimit import DEFAULT_MAX_BODY_BYTES, BodyLimitMiddleware
|
||||
from app.routes import diagnostics as diagnostics_routes
|
||||
|
||||
SMALL = b"x" * 16
|
||||
OVER = b"x" * (DEFAULT_MAX_BODY_BYTES + 1)
|
||||
|
||||
|
||||
async def _reading_app(scope, receive, send):
|
||||
"""A toy app that reads the whole body the way a route does, then reports its length.
|
||||
|
||||
It uses a real Starlette `Request`, so the faked `http.disconnect` reaches it as the real
|
||||
ClientDisconnect a real handler would see — the middleware's exception path is exercised, not
|
||||
stubbed around.
|
||||
"""
|
||||
body = await Request(scope, receive).body()
|
||||
payload = json.dumps({"read": len(body)}).encode()
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": payload})
|
||||
|
||||
|
||||
def _client(**kwargs) -> TestClient:
|
||||
return TestClient(BodyLimitMiddleware(_reading_app, **kwargs))
|
||||
|
||||
|
||||
def _chunks(total: int, size: int = 64 * 1024):
|
||||
"""A body with NO Content-Length: httpx sends an iterator chunked, which is exactly the shape a
|
||||
declared-length check cannot see."""
|
||||
sent = 0
|
||||
while sent < total:
|
||||
n = min(size, total - sent)
|
||||
yield b"x" * n
|
||||
sent += n
|
||||
|
||||
|
||||
# --- the mechanism ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_declared_length_over_the_cap_is_refused_without_reading_the_body():
|
||||
r = _client().post("/anything", content=OVER)
|
||||
assert r.status_code == 413
|
||||
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||
|
||||
|
||||
def test_a_body_under_the_cap_is_passed_through_untouched():
|
||||
# The mutation guard for the test above: if the cap refused everything, this would fail.
|
||||
r = _client().post("/anything", content=SMALL)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"read": len(SMALL)}
|
||||
|
||||
|
||||
def test_a_chunked_body_is_refused_on_the_COUNTED_bytes():
|
||||
# No Content-Length at all, so the only thing that can stop this is counting as it arrives.
|
||||
r = _client().post("/anything", content=_chunks(DEFAULT_MAX_BODY_BYTES + 64 * 1024))
|
||||
assert r.status_code == 413
|
||||
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||
|
||||
|
||||
def test_a_chunked_body_under_the_cap_still_arrives_whole():
|
||||
size = DEFAULT_MAX_BODY_BYTES - 1
|
||||
r = _client().post("/anything", content=_chunks(size))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"read": size}
|
||||
|
||||
|
||||
def test_an_understated_content_length_does_not_buy_a_pass():
|
||||
# The declared-length check is the cheap half; a client that lies must still be counted out.
|
||||
r = _client().post("/anything", content=OVER, headers={"content-length": "10"})
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_exactly_at_the_cap_is_allowed():
|
||||
size = DEFAULT_MAX_BODY_BYTES
|
||||
r = _client(limit=size).post("/anything", content=b"x" * size)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"read": size}
|
||||
|
||||
|
||||
# --- the per-route ceilings ---------------------------------------------------------------------
|
||||
|
||||
BIG = {("POST", "/api/things/{tid}/blob"): DEFAULT_MAX_BODY_BYTES * 4}
|
||||
|
||||
|
||||
def test_a_route_with_a_raised_ceiling_may_send_a_body_over_the_global_cap():
|
||||
r = _client(routes=BIG).post("/api/things/abc/blob", content=OVER)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"read": len(OVER)}
|
||||
|
||||
|
||||
def test_a_raised_ceiling_is_still_a_ceiling():
|
||||
"""The difference between this and an exemption, and the reason the list is a mapping: a route
|
||||
that needs more bytes does not thereby get unlimited bytes. Left uncounted, the upload route
|
||||
would let an ANONYMOUS caller stream without bound — FastAPI parses a declared body before the
|
||||
first dependency, so `require_human` has not run yet when the bytes land."""
|
||||
over = b"x" * (BIG[("POST", "/api/things/{tid}/blob")] + 1)
|
||||
r = _client(routes=BIG).post("/api/things/abc/blob", content=over)
|
||||
assert r.status_code == 413
|
||||
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||
|
||||
|
||||
def test_a_raised_ceiling_counts_a_chunked_body_too():
|
||||
cap = BIG[("POST", "/api/things/{tid}/blob")]
|
||||
r = _client(routes=BIG).post("/api/things/abc/blob", content=_chunks(cap + 64 * 1024))
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_the_raised_ceiling_is_scoped_to_its_method_and_its_path():
|
||||
client = _client(routes=BIG)
|
||||
# Same path, different method.
|
||||
assert client.put("/api/things/abc/blob", content=OVER).status_code == 413
|
||||
# A neighbour that merely starts the same way.
|
||||
assert client.post("/api/things/abc/blob/extra", content=OVER).status_code == 413
|
||||
|
||||
|
||||
def test_a_path_parameter_cannot_swallow_a_slash():
|
||||
# `{tid}` matching greedily across `/` would hand the raised ceiling to routes without one.
|
||||
mw = BodyLimitMiddleware(_reading_app, routes={("POST", "/a/{tid}/b"): 99})
|
||||
assert mw.limit_for("POST", "/a/one/b") == 99
|
||||
assert mw.limit_for("POST", "/a/one/two/b") == DEFAULT_MAX_BODY_BYTES
|
||||
|
||||
|
||||
def test_every_request_gets_a_finite_ceiling():
|
||||
"""There is no pass-through path. A route that is not listed gets the default; one that is
|
||||
listed gets its own; nothing is simply not counted."""
|
||||
mw = BodyLimitMiddleware(_reading_app)
|
||||
assert mw.limit_for("GET", "/api/anything") == DEFAULT_MAX_BODY_BYTES
|
||||
for (method, template), cap in bodylimit.ROUTE_MAX_BODY_BYTES.items():
|
||||
assert cap > DEFAULT_MAX_BODY_BYTES # the admission rule: raised, never lowered
|
||||
assert mw.limit_for(method, template.replace("{sid}", "abc")) == cap
|
||||
|
||||
|
||||
def _declared_routes(root) -> set[tuple[str, str]]:
|
||||
"""Every (method, path template) the app serves.
|
||||
|
||||
It has to RECURSE: since FastAPI 0.141 / Starlette 1.6 an `include_router` leaves an opaque
|
||||
`_IncludedRouter` in `app.routes` and keeps the real routes under its `original_router`, instead
|
||||
of flattening them into the parent as older versions did. Reading `app.routes` alone finds 35
|
||||
entries and none of ours; walking the tree finds all 252.
|
||||
"""
|
||||
found: set[tuple[str, str]] = set()
|
||||
stack = [root]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
for route in getattr(node, "routes", ()) or ():
|
||||
inner = getattr(route, "original_router", None)
|
||||
if inner is not None:
|
||||
stack.append(inner)
|
||||
continue
|
||||
for method in getattr(route, "methods", None) or ():
|
||||
found.add((method, route.path))
|
||||
return found
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES))
|
||||
def test_every_listed_template_still_resolves_to_a_real_route(method, template):
|
||||
"""The drift guard. The list names route templates that live in another file; a renamed route
|
||||
must fail the gate here, not lose its allowance silently in production."""
|
||||
from app.main import app
|
||||
|
||||
assert (method, template) in _declared_routes(app)
|
||||
|
||||
|
||||
def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
|
||||
"""The headroom the module's docblock claims, held as a RATIO rather than as a figure in prose.
|
||||
The client log sink caps itself at 512 KiB — below the default, which is why it is not listed:
|
||||
the global ceiling never fires on it and raising it could only widen what it accepts.
|
||||
|
||||
`<=` alone would not hold the claim. The docblock calls the 2x margin load-bearing precisely
|
||||
because a ceiling that refuses a legitimate request is a worse bug than the one this module
|
||||
fixes, and a margin of 1x is no margin at all.
|
||||
"""
|
||||
assert diagnostics_routes.BODY_MAX_BYTES * 2 <= DEFAULT_MAX_BODY_BYTES
|
||||
assert not any(
|
||||
path.startswith("/api/diagnostics") for _, path in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||
)
|
||||
|
||||
|
||||
# Every body-reading call site in the routers, as (module, function, limit expression). A route that
|
||||
# reads its own body declares the size it MEANS to accept; if that is above the global default and
|
||||
# its route is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have
|
||||
# taken — silently, in production, on a path nobody tested. The point of the list is the third entry.
|
||||
#
|
||||
# BOTH helpers, not just `capped_body`: P1 added `capped_stream_to` as its sibling and the registry
|
||||
# did not follow, so the upload route — the one this whole invariant was written for — was the one
|
||||
# site the drift guard could not see (deep-review round 1). The names come from `_common.py` itself
|
||||
# below, so a THIRD sibling cannot be added without this list noticing either — and a route that
|
||||
# skips the helpers altogether and awaits `request.body()` lands here as `<no ceiling declared>`
|
||||
# rather than vanishing (round 3).
|
||||
#
|
||||
# The module is the path relative to `app/routes/`, so a site under `plex/` is spelled
|
||||
# `plex/admin.py` and cannot collide with the top-level `admin.py`.
|
||||
CAPPED_BODY_SITES = {
|
||||
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
|
||||
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
|
||||
("downloads.py", "spool_upload", "capped_stream_to", "cap"),
|
||||
}
|
||||
|
||||
|
||||
def _body_readers() -> set[str]:
|
||||
"""The names of the body-reading helpers `_common.py` exports — asked of the module rather than
|
||||
listed here, so adding a fourth one cannot quietly leave the registry below behind."""
|
||||
common = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes" / "_common.py"
|
||||
tree = ast.parse(common.read_text("utf-8"), filename=str(common))
|
||||
return {
|
||||
node.name
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
|
||||
and node.name.startswith("capped_")
|
||||
}
|
||||
|
||||
|
||||
def test_the_registry_covers_every_body_reading_helper():
|
||||
"""The mutation guard for the tests below: if a helper were renamed or added, the site list
|
||||
would still match itself while the new one went uncovered."""
|
||||
assert _body_readers() == {"capped_body", "capped_stream_to"}, (
|
||||
"routes/_common.py grew or renamed a body-reading helper — add its call sites to "
|
||||
"CAPPED_BODY_SITES and this expectation."
|
||||
)
|
||||
|
||||
|
||||
# The ways a route can read its own body WITHOUT one of `_common.py`'s helpers. Every `Request`
|
||||
# parameter under `app/routes/` is named `request`, so the base name is an exact filter and not a
|
||||
# guess — the only other `.json()` in the package is `exc.json()` on a ValidationError.
|
||||
#
|
||||
# Without this set the three guards below know only the `capped_*` naming convention, which means
|
||||
# they are blind to the shape `_common.py` itself is written in. A route spelling `await
|
||||
# request.body()` inline declares no ceiling, hands no connection back, and would be invisible to
|
||||
# all three — reading as "no new sites" rather than as a failure, which is the exact way a drift
|
||||
# guard dies quietly (deep-review round 3).
|
||||
RAW_BODY_READS = {"body", "stream", "form", "json"}
|
||||
|
||||
|
||||
def _call_name(node: ast.Call) -> str | None:
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
return node.func.attr
|
||||
return getattr(node.func, "id", None)
|
||||
|
||||
|
||||
def _body_read_calls(fn: ast.AST, readers: set[str]) -> list[tuple[ast.Call, str]]:
|
||||
"""Every call inside `fn` that reads the request body, as (node, name) in source order."""
|
||||
out: list[tuple[ast.Call, str]] = []
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "request"
|
||||
and node.func.attr in RAW_BODY_READS
|
||||
):
|
||||
out.append((node, f"request.{node.func.attr}"))
|
||||
elif _call_name(node) in readers:
|
||||
out.append((node, _call_name(node) or ""))
|
||||
return sorted(out, key=lambda pair: (pair[0].lineno, pair[0].col_offset))
|
||||
|
||||
|
||||
def _body_reading_functions() -> dict[tuple[str, str], dict]:
|
||||
"""Every function under `app/routes/` that reads a request body itself, keyed by
|
||||
(module, function).
|
||||
|
||||
Parsed, not grepped. A regex over the call text has to guess how the arguments are spelled, and
|
||||
the shapes it guesses wrong — a bare `4 * 1024 * 1024`, a parameter not named `request` — are
|
||||
the natural ways the NEXT site gets written, so a miss reads as "no new sites" rather than as a
|
||||
failure. `ast` sees every call whatever it looks like.
|
||||
|
||||
Keyed by the ENCLOSING FUNCTION, not just the module: a set keyed by module and limit
|
||||
expression collapses two call sites that spell their limit the same way, which is exactly what
|
||||
a second route copied from the first would do.
|
||||
|
||||
The module half of the key is the path RELATIVE to `app/routes/`, not the basename: the scan
|
||||
recurses, and `admin.py`, `discover.py` and `playlists.py` each exist both at the top level and
|
||||
under `plex/`. Keying by basename would let the second of a colliding pair overwrite the first
|
||||
and take its `releases` flag out of the comparison with it — the same entry-collapse this
|
||||
registry was widened to close.
|
||||
|
||||
`_common.py` is skipped: it is where the helpers live, so its own `request.stream()` loops are
|
||||
the implementation of the rule rather than a violation of it.
|
||||
"""
|
||||
readers = _body_readers()
|
||||
routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes"
|
||||
found: dict[tuple[str, str], dict] = {}
|
||||
for py in sorted(routes_dir.rglob("*.py")):
|
||||
if py.name == "_common.py":
|
||||
continue
|
||||
module = py.relative_to(routes_dir).as_posix()
|
||||
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
|
||||
for fn in ast.walk(tree):
|
||||
if not isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)):
|
||||
continue
|
||||
calls = _body_read_calls(fn, readers)
|
||||
if not calls:
|
||||
continue
|
||||
names = [name for _, name in calls]
|
||||
limits = []
|
||||
for node, name in zip([n for n, _ in calls], names):
|
||||
if name.startswith("request."):
|
||||
limits.append("<no ceiling declared>")
|
||||
continue
|
||||
limit = next((kw.value for kw in node.keywords if kw.arg == "limit"), None)
|
||||
if limit is None:
|
||||
pos = 2 if name == "capped_stream_to" else 1
|
||||
limit = node.args[pos] if len(node.args) > pos else None
|
||||
limits.append(ast.unparse(limit) if limit is not None else "<none>")
|
||||
# POSITION, not presence. `any(...)` over the function accepted a release that ran
|
||||
# AFTER the await it exists to precede — the natural spelling for someone reading the
|
||||
# name as "release it when you're done" — while pinning a connection for the whole
|
||||
# transfer. The first body read is what the release has to beat.
|
||||
releases = [
|
||||
node
|
||||
for node in ast.walk(fn)
|
||||
if isinstance(node, ast.Call) and _call_name(node) == "release_read_connection"
|
||||
]
|
||||
first_read = calls[0][0].lineno
|
||||
found[(module, fn.name)] = {
|
||||
"helpers": names,
|
||||
"limits": limits,
|
||||
"releases": any(node.lineno < first_read for node in releases),
|
||||
"releases_late": bool(releases)
|
||||
and not any(node.lineno < first_read for node in releases),
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
def test_every_body_reading_site_is_known_and_fits_under_its_ceiling():
|
||||
found = {
|
||||
(module, fn, site["helpers"][i], site["limits"][i])
|
||||
for (module, fn), site in _body_reading_functions().items()
|
||||
for i in range(len(site["helpers"]))
|
||||
}
|
||||
assert found == CAPPED_BODY_SITES, (
|
||||
f"a body-reading call site appeared, moved or changed shape: {found ^ CAPPED_BODY_SITES}. "
|
||||
"Each one declares a body size; check it against bodylimit.DEFAULT_MAX_BODY_BYTES and, if "
|
||||
"it is larger, add its route to bodylimit.ROUTE_MAX_BODY_BYTES — then update this list."
|
||||
)
|
||||
# And the known ones really do satisfy the rule.
|
||||
assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES
|
||||
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
|
||||
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
|
||||
# than being a constant to compare against — that pairing is its own test below.
|
||||
assert ("POST", "/api/downloads/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||
|
||||
|
||||
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
|
||||
"""The helper itself: a SELECT has autobegun, and afterwards it has not."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import release_read_connection
|
||||
|
||||
db.execute(select(SystemConfig).limit(1))
|
||||
assert db.in_transaction(), "nothing autobegan, so this test would pass vacuously"
|
||||
release_read_connection(db)
|
||||
assert not db.in_transaction()
|
||||
|
||||
|
||||
def test_release_read_connection_refuses_to_discard_pending_work(db):
|
||||
"""The precondition, ENFORCED rather than described.
|
||||
|
||||
The session belongs to the whole request, not to the dependency calling this — so if something
|
||||
ahead of it ever stages a write, ending the transaction would throw that write away with
|
||||
nothing in the log. `service.enqueue` already learned that a whole-session rollback takes the
|
||||
caller's pending work with it, which is why it uses a SAVEPOINT. A comment cannot stop the next
|
||||
dependency from being added in the wrong place; this can.
|
||||
"""
|
||||
from app.db import release_read_connection
|
||||
|
||||
db.add(SystemConfig(key="probe_pending_write", value="1"))
|
||||
with pytest.raises(RuntimeError, match="pending work"):
|
||||
release_read_connection(db)
|
||||
db.rollback() # the staged row is this test's own
|
||||
|
||||
|
||||
def test_release_read_connection_refuses_to_discard_a_FLUSHED_write(db):
|
||||
"""The half the unit-of-work check cannot see.
|
||||
|
||||
`db.new`/`dirty`/`deleted` empty out the moment a helper flushes, and CLAUDE.md's canonical
|
||||
split is "the handler commits; helpers flush" — so the single most likely future violator is
|
||||
the one shape the obvious check is blind to. Measured before the fix: all three sets empty, the
|
||||
guard silent, the row gone after the rollback.
|
||||
"""
|
||||
from app.db import release_read_connection
|
||||
|
||||
db.add(SystemConfig(key="probe_flushed_write", value="1"))
|
||||
db.flush()
|
||||
assert not (db.new or db.dirty or db.deleted), (
|
||||
"the flush did not clear the unit-of-work, so this test would pass for the wrong reason"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="already WRITTEN"):
|
||||
release_read_connection(db)
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_release_read_connection_refuses_to_discard_a_core_dml_write(db):
|
||||
"""The other half: `db.execute(update(...))` never enters the unit-of-work at all, and
|
||||
`downloads/service.py` writes that way on purpose (a conditional `UPDATE … WHERE status=…` is
|
||||
the honest-states pattern)."""
|
||||
from sqlalchemy import insert
|
||||
|
||||
from app.db import release_read_connection
|
||||
|
||||
db.execute(insert(SystemConfig).values(key="probe_core_dml", value="1"))
|
||||
assert not (db.new or db.dirty or db.deleted), (
|
||||
"Core DML entered the unit-of-work, so this test would pass for the wrong reason"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="already WRITTEN"):
|
||||
release_read_connection(db)
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_every_body_reading_site_hands_its_pooled_connection_back_first():
|
||||
"""Reading the body yourself is what makes a request SLOW inside its own database session.
|
||||
|
||||
The auth (or ownership) dependency that must resolve first is a SELECT, so SQLAlchemy has
|
||||
autobegun and the session is holding one of the pool's connections; awaiting the body then pins
|
||||
it for as long as the client keeps sending — one connection per in-flight upload, `idle in
|
||||
transaction`, with nothing to time it out. `db.release_read_connection` ends that transaction,
|
||||
and every one of these sites needs it.
|
||||
|
||||
Held generically because the three sites arrived one at a time and only the newest was fixed:
|
||||
a fourth body-reading route gets the same requirement automatically.
|
||||
|
||||
BEFORE the read, not merely somewhere in the function. A release that runs after the await
|
||||
satisfies the letter of the rule and none of its purpose — the connection was held for exactly
|
||||
the span the rule exists to protect — so `releases_late` is reported as its own, louder case.
|
||||
"""
|
||||
sites = _body_reading_functions()
|
||||
late = [f"{module}:{fn}" for (module, fn), site in sites.items() if site["releases_late"]]
|
||||
missing = [
|
||||
f"{module}:{fn}"
|
||||
for (module, fn), site in sites.items()
|
||||
if not site["releases"] and not site["releases_late"]
|
||||
]
|
||||
assert not late, (
|
||||
"a route calls db.release_read_connection AFTER it reads the body, which holds the pooled "
|
||||
f"connection for exactly the transfer it was supposed to free: {late}. Move the call above "
|
||||
"the await."
|
||||
)
|
||||
assert not missing, (
|
||||
"a route reads its own request body while still inside its read transaction, pinning a "
|
||||
f"pooled connection for the whole transfer: {missing}. Call "
|
||||
"db.release_read_connection(db) before the await — see routes/downloads.py:spool_upload."
|
||||
)
|
||||
|
||||
|
||||
def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
|
||||
"""The upload route's in-handler cap is a `sysconfig` value an admin can change; the edge is a
|
||||
constant. If the knob could be set above the edge, an admin raising it would silently start
|
||||
getting 413s from a layer the admin page never mentions.
|
||||
|
||||
Since P1 the two count the SAME bytes — the body is the file, with no multipart envelope
|
||||
wrapped round it — so the old allowance is gone and the edge is the knob's own maximum,
|
||||
exactly. A gap reappearing means someone re-wrapped the upload in an envelope without saying so.
|
||||
|
||||
Equality also means the EDGE is what answers on a default install, where no override has lowered
|
||||
the knob: the declared-length check refuses before a byte is read. That is not a gap to close
|
||||
with headroom (measured: headroom only moves the boundary by its own size, and the route's drain
|
||||
pushes the count back over it) — it is a refusal the client has to be able to read, which is why
|
||||
`errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first.
|
||||
"""
|
||||
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
|
||||
assert spec.max == edge
|
||||
|
||||
|
||||
def _route_objects(root):
|
||||
"""`_declared_routes`' sibling, keeping the route OBJECT so its FastAPI dependant can be read."""
|
||||
found: dict[tuple[str, str], object] = {}
|
||||
stack = [root]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
for route in getattr(node, "routes", ()) or ():
|
||||
inner = getattr(route, "original_router", None)
|
||||
if inner is not None:
|
||||
stack.append(inner)
|
||||
continue
|
||||
for method in getattr(route, "methods", None) or ():
|
||||
found[(method, route.path)] = route
|
||||
return found
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES))
|
||||
def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, template):
|
||||
"""P1's invariant, and the reason this is a test rather than a comment.
|
||||
|
||||
A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE.
|
||||
FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model)
|
||||
BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only
|
||||
thing between an anonymous caller and the whole allowance, `require_human` never gets a say, and
|
||||
the route's in-handler cap measures bytes that have already landed. That is exactly what P1
|
||||
closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a
|
||||
`file: UploadFile = File(...)` body for one release.
|
||||
|
||||
Asked of FastAPI itself rather than parsed or grepped: the router already knows which parameters
|
||||
it will read the body for, and a shape a regex or an AST walk guesses wrong is precisely how the
|
||||
next route slips through.
|
||||
|
||||
`route.body_field`, NOT `route.dependant.body_params`, and the difference is the whole test.
|
||||
`body_params` holds only the parameters of the route's OWN callable; `body_field` is built from
|
||||
`_get_flat_body_params`, which recurses into sub-dependencies — and that is also what FastAPI
|
||||
itself consults when it decides to read the body. Measured on the pinned FastAPI: a body declared
|
||||
inside a sub-dependency leaves `body_params` EMPTY while the body is still read in full before
|
||||
auth, so the first version of this test was green over a reproduction of the very defect it
|
||||
exists to forbid (deep-review round 1). The upload route's own idiom — a body read from a
|
||||
`yield` dependency — is exactly the shape that would have slipped through."""
|
||||
from app.main import app
|
||||
|
||||
route = _route_objects(app).get((method, template))
|
||||
assert route is not None, f"{method} {template} is not a route any more"
|
||||
assert route.body_field is None, (
|
||||
f"{method} {template} declares a body ({route.body_field.name}) on the route or in one of "
|
||||
"its dependencies, so FastAPI reads it before the first dependency runs and the raised "
|
||||
"ceiling is all that stands between an anonymous caller and the whole allowance. Read the "
|
||||
"body yourself, from a dependency declared AFTER the auth one — see "
|
||||
"routes/downloads.py:spool_upload."
|
||||
)
|
||||
|
||||
|
||||
def test_a_stored_override_outside_the_bounds_falls_back_on_READ(db):
|
||||
"""`set_value` refuses an out-of-range int, but neither bound has always been what it is now —
|
||||
the upload knob had no `max` before SEC-20 and its `min` was 0 before E7 — so a value that was
|
||||
legal when it was written can be sitting in the database. Served as-is it is exactly the drift
|
||||
the test above forbids: the route would accept bytes the edge refuses.
|
||||
|
||||
The fallback is the env default and a warning, NOT a clamp. A clamp is a silent rewrite, and on
|
||||
the min side it would be a wrong one: a stored 0 meant "no per-file cap" to the route, and
|
||||
clamped to 1 it becomes a one-byte cap that kills uploads with nothing in the log.
|
||||
"""
|
||||
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||
assert spec.max is not None and spec.min is not None
|
||||
default = settings.download_upload_max_bytes
|
||||
|
||||
row = SystemConfig(key="download_upload_max_bytes", value=str(spec.max * 4))
|
||||
db.add(row)
|
||||
db.commit()
|
||||
assert sysconfig.get_int(db, "download_upload_max_bytes") == default
|
||||
|
||||
row.value = "0" # the legacy "no per-file cap", now below min
|
||||
db.commit()
|
||||
assert sysconfig.get_int(db, "download_upload_max_bytes") == default
|
||||
|
||||
# An override that IS in range must still be honoured — otherwise a bounds predicate written
|
||||
# backwards would make every override inert and the two assertions above could not tell.
|
||||
# Deliberately not `spec.max`: that equals the env default for this key, so the assertion would
|
||||
# be satisfied by the fallback it is meant to rule out.
|
||||
row.value = str(spec.max - 1)
|
||||
db.commit()
|
||||
assert spec.max - 1 != default
|
||||
assert sysconfig.get_int(db, "download_upload_max_bytes") == spec.max - 1
|
||||
|
||||
|
||||
def test_the_cap_sits_inside_the_response_decorators_and_outside_the_setup_gate():
|
||||
"""The registration order, held in code rather than in the comment beside it. `add_middleware`
|
||||
inverts the reading order (last registered = OUTERMOST), which is exactly the kind of thing a
|
||||
prose description gets backwards — it already did once.
|
||||
|
||||
The layers are named, not counted by class: all four `@app.middleware("http")` decorators —
|
||||
including the setup gate, which must be INSIDE — register as the same `BaseHTTPMiddleware`, so a
|
||||
class-shaped assertion passes for every arrangement anyone would plausibly get wrong.
|
||||
|
||||
Outside the cap: only response decorators, so the 413 is dressed like any other answer. Inside
|
||||
it: everything that does work on the caller's behalf — the setup gate opens a DB session per
|
||||
request while an instance is unconfigured, which is the anonymous pre-auth work SEC-20 exists to
|
||||
remove.
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
def layer(m) -> str:
|
||||
dispatch = m.kwargs.get("dispatch") if m.kwargs else None
|
||||
return dispatch.__name__ if dispatch is not None else getattr(m.cls, "__name__", str(m.cls))
|
||||
|
||||
names = [layer(m) for m in app.user_middleware] # outer → inner
|
||||
i = names.index("BodyLimitMiddleware")
|
||||
assert set(names[:i]) <= {
|
||||
"log_requests",
|
||||
"security_headers",
|
||||
"static_cache_headers",
|
||||
"CORSMiddleware",
|
||||
}, f"something that is not a response decorator sits outside the cap: {names[:i]}"
|
||||
assert "log_requests" in names[:i], "the access log must stay outside, so a 413 is logged"
|
||||
assert "setup_gate" in names[i + 1 :], "the setup gate must not run for an oversized body"
|
||||
assert "SessionMiddleware" in names[i + 1 :]
|
||||
|
||||
|
||||
def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces():
|
||||
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
|
||||
assert (
|
||||
bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/debug-sessions/{sid}/screenshots")]
|
||||
== debugsession.SCREENSHOT_MAX_BYTES
|
||||
)
|
||||
|
||||
|
||||
# --- the real application -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_global_refusal_carries_the_baseline_security_headers():
|
||||
"""The 413 is a response like any other, so it has to be dressed like one. The middleware sits
|
||||
INSIDE the security-header and CORS layers for exactly this reason: a refusal a browser drops
|
||||
for a missing header is indistinguishable from a network failure, and the user would be shown a
|
||||
generic error instead of the translated `too_large`.
|
||||
|
||||
A bare TestClient on purpose, not the `client` fixture: this is the only test that exercises the
|
||||
middleware ORDER, and that fixture is DB-gated — so on a host without Postgres the one assertion
|
||||
holding the move would have skipped while the file still reported green. The refusal never
|
||||
reaches a database; it is answered above the router.
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
r = TestClient(app).post(
|
||||
"/auth/password-login", content=OVER, headers={"content-type": "text/plain"}
|
||||
)
|
||||
assert r.status_code == 413
|
||||
assert r.headers.get("x-content-type-options") == "nosniff"
|
||||
assert "referrer-policy" in r.headers
|
||||
|
||||
|
||||
def test_the_login_route_is_refused_before_a_single_dependency_runs(client, monkeypatch):
|
||||
"""`/auth/password-login` is the reason SEC-20 exists: it is anonymous, it declares a body, and
|
||||
FastAPI reads that body in full before the first dependency. `_login_limiter.allow` is the very
|
||||
first statement of the handler, so it is the sharpest available proof that nothing ran."""
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
reached = False
|
||||
|
||||
def _record(*args, **kwargs):
|
||||
nonlocal reached
|
||||
reached = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth_routes._login_limiter, "allow", _record)
|
||||
|
||||
r = client.post("/auth/password-login", content=OVER, headers={"content-type": "text/plain"})
|
||||
assert r.status_code == 413
|
||||
assert not reached, "the handler ran — the cap is inside the router instead of ahead of it"
|
||||
|
||||
|
||||
def test_a_normal_login_body_still_reaches_the_handler(client, monkeypatch):
|
||||
"""The mutation check for the test above. If the cap refused every body, the 413 test would
|
||||
pass while the app was broken; this one would not."""
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
reached = False
|
||||
|
||||
def _record(*args, **kwargs):
|
||||
nonlocal reached
|
||||
reached = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth_routes._login_limiter, "allow", _record)
|
||||
|
||||
r = client.post("/auth/password-login", json={"email": "nobody@example.com", "password": "x"})
|
||||
assert r.status_code == 401
|
||||
assert reached
|
||||
|
||||
|
||||
def test_the_global_refusal_is_byte_identical_to_the_per_route_one(db, as_user, make_user):
|
||||
"""Two shapes for one fact is how the `cancelled`/`canceled` bug happened. The log sink caps
|
||||
itself with `capped_body`; its 413 and the middleware's must be the same bytes.
|
||||
|
||||
Signed in on purpose: `current_user` resolves before the body dependency, so an anonymous call
|
||||
would be answered 401 and never reach the per-route cap this compares against. The body is over
|
||||
the SINK's 512 KiB but under the global 1 MiB, so it is `capped_body` that refuses it.
|
||||
"""
|
||||
user = make_user()
|
||||
db.commit()
|
||||
over_the_sink = b"x" * (diagnostics_routes.BODY_MAX_BYTES + 1)
|
||||
assert len(over_the_sink) < DEFAULT_MAX_BODY_BYTES
|
||||
r = as_user(user).post(
|
||||
"/api/diagnostics/events",
|
||||
content=over_the_sink,
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
# RAW BYTES, not parsed JSON: the two used to differ by the two spaces `json.dumps` puts in by
|
||||
# default and `JSONResponse` does not, and a structural comparison could not see it — which is
|
||||
# the only kind of drift the docblock's promise was ever at risk from.
|
||||
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||
@@ -18,12 +18,12 @@ _PW = "open sesame"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch):
|
||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||
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")}
|
||||
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
||||
)
|
||||
assert job.status_code == 200, job.text
|
||||
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""The Python major is a contract, not a coincidence — the backend half (E27 S3).
|
||||
|
||||
`0.93.2` was released to fix exactly this bug on the JavaScript side: four machines built or checked
|
||||
the app and none of them agreed on which Node they ran, the server gate went red for 73 of its last
|
||||
90 runs, and nothing told anyone. The Python side had the same shape and nobody had looked: prod
|
||||
(`Dockerfile`), the test image (`Dockerfile.test`) and CI (`check.yml`) each spelled **3.12** as an
|
||||
independent literal, there was no `.python-version`, no `requires-python`, and ruff had no
|
||||
`target-version` — while the dev machine had no 3.12 installed at all, only 3.13 and 3.14.
|
||||
|
||||
**The contract is a chain, and it is split across two test files because no single runner can see
|
||||
every link.** This suite runs inside the test image, which mounts `backend/` and nothing else, so it
|
||||
cannot read the repo-root Dockerfile or the workflow. It therefore holds the link it CAN see, and the
|
||||
one that matters most here:
|
||||
|
||||
the interpreter actually running == backend/pyrightconfig.json == backend/ruff.toml
|
||||
|
||||
and `frontend/src/lib/pythonEngine.contract.test.ts` — which runs on the host, with the whole tree —
|
||||
holds the rest of the chain:
|
||||
|
||||
/.python-version == pyrightconfig.json == ruff.toml == Dockerfile == Dockerfile.test == check.yml
|
||||
|
||||
Together they close the loop. To move the version: edit `/.python-version`, then follow the failures
|
||||
in both files. Every place they name is a place that would otherwise have been forgotten.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _version_in(path: Path, pattern: str) -> tuple[int, int]:
|
||||
"""The (major, minor) the given file declares. A rename or a moved line lands here — re-point
|
||||
the test, the contract did not go away with it."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = re.search(pattern, text, re.MULTILINE)
|
||||
assert match is not None, f"{pattern!r} not found in {path.name}"
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
# pyrightconfig.json is JSONC (it carries comments), so it is read by regex rather than json.loads.
|
||||
PINNED = _version_in(BACKEND / "pyrightconfig.json", r'"pythonVersion"\s*:\s*"(\d+)\.(\d+)"')
|
||||
|
||||
|
||||
def test_pins_a_plausible_python_version():
|
||||
"""Guards against a typo agreeing with nothing at all."""
|
||||
assert PINNED >= (3, 12), PINNED
|
||||
|
||||
|
||||
def test_this_interpreter_is_the_pinned_one():
|
||||
"""The assertion that pays. A green suite on another minor does not mean the shipped image is
|
||||
green — it means the suite and the image have drifted and nobody noticed yet."""
|
||||
running = sys.version_info[:2]
|
||||
assert running == PINNED, (
|
||||
f"these tests are running on Python {running[0]}.{running[1]}, but the app is pinned to "
|
||||
f"{PINNED[0]}.{PINNED[1]} (backend/pyrightconfig.json, /.python-version). The suite runs in "
|
||||
f"the test image, so this failing means backend/Dockerfile.test has drifted from the pin."
|
||||
)
|
||||
|
||||
|
||||
def test_ruff_judges_the_pinned_version():
|
||||
"""Unset, ruff falls back to a default that has nothing to do with us, and its syntax and
|
||||
upgrade advice are then aimed at some other interpreter."""
|
||||
major, minor = _version_in(BACKEND / "ruff.toml", r'^target-version\s*=\s*"py(\d)(\d+)"')
|
||||
assert (major, minor) == PINNED
|
||||
+513
-26
@@ -12,15 +12,23 @@ parsing (image → file, cover-art skip, dims, duration) is covered directly by
|
||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import pathlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.routes._common import capped_stream_to
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadQuota, MediaAsset
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||
@@ -123,15 +131,143 @@ def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root,
|
||||
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
||||
|
||||
|
||||
# --- route: the auth-first body (P1) --------------------------------------------------------
|
||||
|
||||
|
||||
def _counting_body(chunks: int = 4, size: int = 1024):
|
||||
"""A request body that records how much of itself the server actually pulled.
|
||||
|
||||
httpx's ASGI transport feeds the app lazily, one `http.request` message per yielded chunk, so a
|
||||
chunk that was never yielded is a chunk the app never asked for. That is the whole assertion
|
||||
below: it is not "the upload was refused", it is "the refusal happened before the bytes moved".
|
||||
"""
|
||||
pulled: list[int] = []
|
||||
|
||||
def body():
|
||||
for _ in range(chunks):
|
||||
pulled.append(size)
|
||||
yield b"x" * size
|
||||
|
||||
return body(), pulled
|
||||
|
||||
|
||||
_BOUNDARY = "p1boundary"
|
||||
|
||||
|
||||
def _counting_multipart(chunks: int = 4, size: int = 1024):
|
||||
"""The same counter, wrapped in a real multipart envelope.
|
||||
|
||||
The content-type is the load-bearing part. Starlette's `request.form()` returns an EMPTY
|
||||
FormData without touching the stream when the content-type is not multipart/urlencoded — so a
|
||||
raw-body probe cannot tell "the route stopped reading strangers" apart from "there was nothing
|
||||
here to parse". Only a well-formed multipart body makes the old shape actually spool.
|
||||
"""
|
||||
pulled: list[int] = []
|
||||
head = (
|
||||
f"--{_BOUNDARY}\r\n"
|
||||
'Content-Disposition: form-data; name="file"; filename="clip.mp4"\r\n'
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode()
|
||||
tail = f"\r\n--{_BOUNDARY}--\r\n".encode()
|
||||
|
||||
def body():
|
||||
pulled.append(len(head))
|
||||
yield head
|
||||
for _ in range(chunks):
|
||||
pulled.append(size)
|
||||
yield b"x" * size
|
||||
pulled.append(len(tail))
|
||||
yield tail
|
||||
|
||||
return body(), pulled
|
||||
|
||||
|
||||
def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
|
||||
"""P1 — the defect E27 bounded but did not close.
|
||||
|
||||
While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body
|
||||
BEFORE the first dependency ran, so `require_human` could not gate it: an unauthenticated
|
||||
caller could make the server write the whole per-file allowance (500 MiB by default) to temp,
|
||||
per request. SEC-20's ceiling capped the number; it could not stop the writing.
|
||||
|
||||
The route now reads its own body from a dependency declared AFTER the auth one, the shape
|
||||
`POST /api/debug-sessions/{sid}/screenshots` already uses. The claim under test is the strong
|
||||
one — a stranger's bytes are never read WHATEVER they claim to send, so the probe sends the
|
||||
multipart the old route would have spooled, not the raw body the new one expects."""
|
||||
body, pulled = _counting_multipart()
|
||||
r = client.post(
|
||||
"/api/downloads/upload",
|
||||
content=body,
|
||||
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert pulled == [], f"the server read {sum(pulled)} bytes from an anonymous caller"
|
||||
|
||||
|
||||
def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _root, monkeypatch):
|
||||
"""The mutation check for the test above: a route that read NOBODY's body would pass it just as
|
||||
well as the fixed one. The same counting body, signed in, must be pulled in full."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
body, pulled = _counting_body()
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
content=body,
|
||||
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert sum(pulled) == 4096
|
||||
|
||||
|
||||
def test_capped_stream_to_drains_the_rest_before_refusing():
|
||||
"""Measured in a real browser, then pinned HERE rather than at the route.
|
||||
|
||||
The cap now fires while the client is still sending, and answering + closing mid-upload is what
|
||||
a browser reports as a NETWORK failure: the dialog said "the server couldn't be reached"
|
||||
instead of naming the limit, because XHR never got to read the 422. `capped_stream_to` reads
|
||||
the remainder to nowhere before raising, so the client finishes sending and reads the real
|
||||
answer.
|
||||
|
||||
It is asserted against the helper and not through TestClient on purpose — httpx's ASGI
|
||||
transport pumps the request body whether or not the app reads it, so a route-level version of
|
||||
this test passes just as happily with the drain deleted. It was written that way first and
|
||||
caught by its own mutation check.
|
||||
"""
|
||||
pulled = []
|
||||
|
||||
async def stream():
|
||||
for i in range(8):
|
||||
pulled.append(i)
|
||||
yield b"x" * 1024
|
||||
|
||||
class _Req:
|
||||
def stream(self):
|
||||
return stream()
|
||||
|
||||
out = io.BytesIO()
|
||||
refusal = HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": 2048})
|
||||
# `asyncio.run` rather than an async test: the suite has no async plugin, and adding one to
|
||||
# pin four lines of helper would mean a new dev dependency and a lock regeneration.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(capped_stream_to(_Req(), out, 2048, refusal))
|
||||
assert exc.value is refusal
|
||||
assert len(pulled) == 8, "the body was abandoned mid-stream, not drained"
|
||||
# Drained means DISCARDED: nothing past the limit is written.
|
||||
assert len(out.getvalue()) <= 2048
|
||||
|
||||
|
||||
# --- route ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
|
||||
def test_upload_route_generic_forces_download(
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
|
||||
content=b"<script>alert(1)</script>",
|
||||
headers=upload_headers("report.html"),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
@@ -145,7 +281,7 @@ def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeyp
|
||||
|
||||
|
||||
def test_upload_route_media_html_extension_not_served_as_html(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||
@@ -156,7 +292,8 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
|
||||
content=b"<script>alert(1)</script>",
|
||||
headers=upload_headers("evil.html"),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
@@ -177,7 +314,9 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
|
||||
def test_public_watch_file_forces_download_for_generic_file(
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
||||
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
||||
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
||||
@@ -186,7 +325,8 @@ def test_public_watch_file_forces_download_for_generic_file(as_user, make_user,
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
|
||||
content=b"<script>alert(1)</script>",
|
||||
headers=upload_headers("notes.html"),
|
||||
).json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
||||
@@ -197,7 +337,7 @@ def test_public_watch_file_forces_download_for_generic_file(as_user, make_user,
|
||||
|
||||
|
||||
def test_public_watch_file_serves_media_inline_with_real_type(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""A recognised media container IS still served inline with its real audio/video type (so it
|
||||
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
||||
@@ -206,7 +346,8 @@ def test_public_watch_file_serves_media_inline_with_real_type(
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
|
||||
content=b"fake-mp4-bytes",
|
||||
headers=upload_headers("clip.mp4"),
|
||||
).json()
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
@@ -215,7 +356,9 @@ def test_public_watch_file_serves_media_inline_with_real_type(
|
||||
assert "inline" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
|
||||
def test_upload_route_enforces_per_file_cap(
|
||||
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||
):
|
||||
from app import sysconfig
|
||||
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
||||
@@ -223,47 +366,391 @@ def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monke
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
|
||||
content=b"way over the cap",
|
||||
headers=upload_headers("big.bin"),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"]["code"] == "upload_too_large"
|
||||
|
||||
|
||||
def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more(
|
||||
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The boundary, which the test above cannot see: it sends 16 bytes against a cap of 5, so a
|
||||
`>` silently loosened to `>=` refuses it just the same and the mutation survives the whole
|
||||
suite. The cap is `size > limit`, so a file of exactly the cap must be ACCEPTED.
|
||||
"""
|
||||
from app import sysconfig
|
||||
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
payload = b"x" * 64
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", len(payload))
|
||||
db.commit()
|
||||
c = as_user(make_user())
|
||||
|
||||
at_the_cap = c.post(
|
||||
"/api/downloads/upload", content=payload, headers=upload_headers("exact.bin")
|
||||
)
|
||||
assert at_the_cap.status_code == 200, at_the_cap.text
|
||||
|
||||
over = c.post(
|
||||
"/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin")
|
||||
)
|
||||
assert over.status_code == 422
|
||||
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
|
||||
|
||||
|
||||
def test_a_non_ascii_filename_survives_the_header_round_trip(
|
||||
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The percent-decoding, which every other test in this file leaves vacuous.
|
||||
|
||||
Headers are latin-1 on the wire, so the client `encodeURIComponent`s the name and the route
|
||||
`unquote`s it. Every filename the suite sends is pure ASCII — and `quote()` is the IDENTITY
|
||||
function on ASCII — so deleting the `unquote` call changes nothing anywhere else. A Hungarian
|
||||
name is the only input that can tell the two apart: without the decode the sanitizer would be
|
||||
handed the literal `nyaral%C3%A1s.bin` and would keep the percent-escapes in the stored name.
|
||||
"""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
|
||||
assert asset is not None
|
||||
stored = Path(asset.rel_path).name
|
||||
assert "%" not in stored and "c3" not in stored.lower(), stored
|
||||
assert stored.startswith("nyaralas_osszel"), stored
|
||||
|
||||
|
||||
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The sweep, held end to end rather than at one site.
|
||||
|
||||
`display_text` was introduced for the upload path and applied to three of the five writers of
|
||||
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
|
||||
`(x or "").strip() or None` and would still have answered a bare 500 (a psycopg DataError,
|
||||
which main.py re-raises for everything but the NUL case) to a name a user simply pasted. Found
|
||||
by four independent readers in deep-review round 2 — a sweep is exactly the kind of thing that
|
||||
gets done four-fifths of the way, so this holds all of it at once.
|
||||
"""
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: None)
|
||||
long_name = "n" * 300
|
||||
bound = "n" * DISPLAY_TEXT_MAX
|
||||
c = as_user(make_user())
|
||||
|
||||
# 1. the upload route — the name arrives in a request header.
|
||||
up = c.post(
|
||||
"/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name)
|
||||
)
|
||||
assert up.status_code == 200, up.text
|
||||
assert up.json()["display_name"] == bound
|
||||
|
||||
# 2. the enqueue route — a plain JSON field, from the Add-download dialog. An inline `spec`
|
||||
# rather than a profile id, so the test does not have to seed the builtin profile table.
|
||||
enq = c.post(
|
||||
"/api/downloads",
|
||||
json={"source": "dQw4w9WgXcQ", "spec": {"kind": "video"}, "display_name": long_name},
|
||||
)
|
||||
assert enq.status_code == 200, enq.text
|
||||
assert enq.json()["display_name"] == bound
|
||||
|
||||
# 3. the edit route — the same field, from the editor.
|
||||
edit = c.post(
|
||||
"/api/downloads/edit",
|
||||
json={
|
||||
"source_job_id": up.json()["id"],
|
||||
"edit_spec": {"trim": {"start_s": 0, "end_s": 1}},
|
||||
"display_name": long_name,
|
||||
},
|
||||
)
|
||||
assert edit.status_code == 200, edit.text
|
||||
assert edit.json()["display_name"] == bound
|
||||
|
||||
# 4 + 5. the metadata PATCH's two fields.
|
||||
patched = c.patch(
|
||||
f"/api/downloads/{up.json()['id']}",
|
||||
json={"display_name": long_name, "display_uploader": long_name},
|
||||
)
|
||||
assert patched.status_code == 200, patched.text
|
||||
assert patched.json()["display_name"] == bound
|
||||
assert patched.json()["display_uploader"] == bound
|
||||
|
||||
|
||||
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||
"""Every place under `backend/app/` that puts a value into a display field, as
|
||||
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
|
||||
|
||||
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
|
||||
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
|
||||
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
|
||||
the site the guard could not see (deep-review round 3).
|
||||
|
||||
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
|
||||
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
|
||||
declarations of the field, not writes into it.
|
||||
|
||||
The whole of `app/`, not just `app/routes/`: a job row is constructed in `downloads/service.py`
|
||||
and `downloads/upload.py`, so a walk stopping at the HTTP boundary misses the writers that
|
||||
actually reach the column.
|
||||
"""
|
||||
app_dir = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
found: dict[tuple[str, str], set[str]] = {}
|
||||
for py in sorted(app_dir.rglob("*.py")):
|
||||
module = py.relative_to(app_dir).as_posix()
|
||||
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
|
||||
enclosing: dict[int, str] = {}
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)):
|
||||
for node in ast.walk(fn):
|
||||
enclosing.setdefault(id(node), fn.name)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
names = {
|
||||
t.id if isinstance(t, ast.Name) else t.attr
|
||||
for t in node.targets
|
||||
if isinstance(t, (ast.Name, ast.Attribute))
|
||||
}
|
||||
value = node.value
|
||||
elif isinstance(node, ast.keyword):
|
||||
names = {node.arg} if node.arg else set()
|
||||
value = node.value
|
||||
else:
|
||||
continue
|
||||
if not names & {"display_name", "display_uploader"}:
|
||||
continue
|
||||
key = (module, enclosing.get(id(node), "<module>"))
|
||||
found.setdefault(key, set()).add(ast.unparse(value))
|
||||
return found
|
||||
|
||||
|
||||
# Every write into a display field, with the expression each one writes. A varchar(255) column with
|
||||
# a request header behind it turns a too-long name into a psycopg DataError — a 500 AFTER the whole
|
||||
# file has transferred — so each of these has to be bounded, and each one here has been read.
|
||||
#
|
||||
# Bounded by `display_text` unless noted:
|
||||
# auth.get_or_create_demo_user a literal.
|
||||
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
|
||||
# one value that needs no bound, and a clear is still a write.
|
||||
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
|
||||
# slices the YouTube title to the same width.
|
||||
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
|
||||
#
|
||||
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
||||
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
|
||||
# `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails
|
||||
# when a site appears, when one disappears, and when one changes what it writes.
|
||||
DISPLAY_WRITE_SITES = {
|
||||
("auth.py", "get_or_create_demo_user"): {"'Demo'"},
|
||||
("downloads/service.py", "redownload"): {"None"},
|
||||
("downloads/service.py", "enqueue"): {
|
||||
"display_name or (asset.title[:DISPLAY_TEXT_MAX] "
|
||||
"if asset.status == 'ready' and asset.title else None)"
|
||||
},
|
||||
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
||||
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
||||
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
|
||||
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
|
||||
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
|
||||
("routes/downloads.py", "update_download_meta"): {
|
||||
"display_text(body.display_name)",
|
||||
"display_text(body.display_uploader)",
|
||||
},
|
||||
("routes/downloads.py", "upload_download"): {"upload.display_name"},
|
||||
("routes/downloads.py", "spool_upload"): {
|
||||
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_no_code_path_assigns_a_display_field_without_the_shared_helper():
|
||||
"""The drift guard for the test above: it holds five routes, this holds the rule.
|
||||
|
||||
Parsed, not grepped. Every value that reaches a display field takes a string a user typed (or
|
||||
YouTube supplied) and puts it on its way to a varchar(255) column, so every one of them must be
|
||||
bounded. A writer added tomorrow fails here instead of 500ing in production.
|
||||
"""
|
||||
found = _display_write_sites()
|
||||
moved = set(found) ^ set(DISPLAY_WRITE_SITES)
|
||||
changed = {
|
||||
key: (found[key], DISPLAY_WRITE_SITES[key])
|
||||
for key in set(found) & set(DISPLAY_WRITE_SITES)
|
||||
if found[key] != DISPLAY_WRITE_SITES[key]
|
||||
}
|
||||
assert found == DISPLAY_WRITE_SITES, (
|
||||
"a display-field write appeared, moved or changed shape. Every value reaching "
|
||||
"display_name/display_uploader must be bounded to models.DISPLAY_TEXT_MAX — normally by "
|
||||
"models.display_text — or a name longer than the column is a 500 rather than a truncation. "
|
||||
f"Sites that appeared or vanished: {moved}. Sites that changed: {changed}."
|
||||
)
|
||||
|
||||
|
||||
def test_the_display_text_bound_matches_the_columns_it_guards():
|
||||
"""A number living in two places is how the `cancelled`/`canceled` bug happened. `display_text`
|
||||
truncates to `DISPLAY_TEXT_MAX`; the columns it feeds are `String(255)`. Held against the model
|
||||
itself, so widening one without the other fails here rather than as a DataError in production."""
|
||||
for name in ("display_name", "display_uploader"):
|
||||
assert DownloadJob.__table__.c[name].type.length == DISPLAY_TEXT_MAX
|
||||
|
||||
|
||||
def test_the_display_name_header_reaches_the_job_and_is_bounded(
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The second header, which had no backend test at all — and the length nothing bounded.
|
||||
|
||||
Since P1 the display name arrives in a REQUEST HEADER rather than as a form field, so the only
|
||||
limit on it was the header size. The column is varchar(255), so a longer one reached Postgres as
|
||||
a DataError: a 500 AFTER the whole file had been transferred. It is truncated now, by the same
|
||||
helper the metadata PATCH uses.
|
||||
"""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
|
||||
ok = c.post(
|
||||
"/api/downloads/upload",
|
||||
content=b"data",
|
||||
headers=upload_headers("clip.bin", "Nyári vakáció"),
|
||||
)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["display_name"] == "Nyári vakáció"
|
||||
|
||||
long_name = "n" * 300
|
||||
bounded = c.post(
|
||||
"/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
|
||||
)
|
||||
assert bounded.status_code == 200, bounded.text
|
||||
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
|
||||
|
||||
|
||||
def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
||||
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""P1 moved the body read behind the auth dependency, which put the whole transfer INSIDE the
|
||||
request's database session: resolving the caller and reading the cap are SELECTs, SQLAlchemy 2.0
|
||||
autobegins on the first one, and the session then holds one of the twenty pooled connections —
|
||||
`idle in transaction` to Postgres, with no `idle_in_transaction_session_timeout` to end it — for
|
||||
as long as the client keeps sending. `db.py` sizes the pool on that not happening and
|
||||
`plex/playback.py` records the production incident behind the rule.
|
||||
|
||||
Asserted on the session rather than on `engine.pool.checkedout()`: the transaction is the cause,
|
||||
the checkout is its consequence (measured), and the pool counter is shared with whatever else
|
||||
the test process is holding.
|
||||
"""
|
||||
from app.routes import downloads as downloads_routes
|
||||
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
seen: dict[str, bool] = {}
|
||||
real_stream = downloads_routes.capped_stream_to
|
||||
real_release = downloads_routes.release_read_connection
|
||||
|
||||
def spy_release(session):
|
||||
# The PRECONDITION, so this test cannot pass vacuously. Asserting only "no transaction
|
||||
# while streaming" would stay green if the auth/cap reads stopped opening one at all —
|
||||
# and under `as_user` the auth SELECT is already overridden away, so that is a live risk
|
||||
# rather than a theoretical one.
|
||||
seen["before"] = bool(session.in_transaction())
|
||||
real_release(session)
|
||||
seen["after_release"] = bool(session.in_transaction())
|
||||
|
||||
async def spy_stream(request, out, limit, refusal):
|
||||
seen["while_streaming"] = bool(db.in_transaction())
|
||||
return await real_stream(request, out, limit, refusal)
|
||||
|
||||
monkeypatch.setattr(downloads_routes, "release_read_connection", spy_release)
|
||||
monkeypatch.setattr(downloads_routes, "capped_stream_to", spy_stream)
|
||||
c = as_user(make_user())
|
||||
r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin"))
|
||||
assert r.status_code == 200, r.text
|
||||
assert seen["before"] is True, (
|
||||
"nothing had opened a transaction before the release, so this test proves nothing — the "
|
||||
"reads it is meant to follow must have moved or been stubbed away"
|
||||
)
|
||||
assert seen["after_release"] is False
|
||||
assert seen["while_streaming"] is False, (
|
||||
"the request's session was inside a transaction when the transfer began, so a pooled "
|
||||
"connection is pinned for the whole upload"
|
||||
)
|
||||
|
||||
|
||||
def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db):
|
||||
"""The dialog refuses an over-cap file off this number, and it is the only layer that produces
|
||||
a refusal carrying the limit on a default install — so the field is load-bearing, not
|
||||
decorative. Nothing held it: the route had no test at all, and the frontend's `DownloadUsage`
|
||||
is an unvalidated cast, so dropping the key would have been green everywhere (round 2).
|
||||
"""
|
||||
from app import sysconfig
|
||||
|
||||
c = as_user(make_user())
|
||||
r = c.get("/api/downloads/usage")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
|
||||
# And it is the KNOB, not a constant: a lowered override must show through.
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
|
||||
db.commit()
|
||||
assert c.get("/api/downloads/usage").json()["upload_max_bytes"] == 4096
|
||||
|
||||
|
||||
def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
|
||||
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""`spool_upload` writes the body to a staging temp file as it arrives, so a refusal happens
|
||||
with a partial file already on disk — its `finally` is what removes it. Nothing asserted that:
|
||||
deleting the unlink left the whole suite green while every refused upload leaked its partial
|
||||
bytes onto the volume, one file per attempt, with no job to ever clean them up.
|
||||
"""
|
||||
from app import sysconfig
|
||||
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 8)
|
||||
db.commit()
|
||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||
c = as_user(make_user())
|
||||
|
||||
r = c.post("/api/downloads/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
|
||||
assert r.status_code == 422
|
||||
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
|
||||
assert list(staging.iterdir()) == [], "a refused upload left its partial file behind"
|
||||
|
||||
|
||||
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
||||
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
|
||||
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
|
||||
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||
"""download_upload_max_bytes floors at 1, and set_value must enforce it.
|
||||
|
||||
The falsy-0 footgun this originally guarded is gone — the route's old `if cap and size > cap`
|
||||
read a stored 0 as "no cap at all", while `capped_stream_to` compares unconditionally and a 0
|
||||
would refuse everything instead. Refusing everything is the safe direction, but it is still
|
||||
wrong, and the knob is also the number the admin page and the 422 `limit` advertise. Pins the
|
||||
min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||
from app import sysconfig
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
||||
|
||||
|
||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
|
||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
|
||||
)
|
||||
r = c.post("/api/downloads/upload", content=b"", headers=upload_headers("empty.bin"))
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["code"] == "upload_empty"
|
||||
|
||||
|
||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
|
||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
|
||||
def test_edit_rejects_a_generic_upload_source(
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
||||
r = c.post(
|
||||
"/api/downloads/edit",
|
||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||
|
||||
@@ -187,6 +187,28 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
|
||||
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
|
||||
// triaged rather than bulk-suppressed, because the two answers are different:
|
||||
//
|
||||
// react-hooks/refs 217 — OFF, see below.
|
||||
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
|
||||
// sprint and E27 S2 burned the file down to nothing: what survives is
|
||||
// an inline directive at the site, each naming the invariant it
|
||||
// protects. There is DELIBERATELY no suppressions file any more — a
|
||||
// bulk `--suppress-all` would hide the next violation instead of
|
||||
// answering it, and it cannot hold a warning-level rule anyway.
|
||||
//
|
||||
// `refs` is off because it fires on this app's popover architecture, not on its history, so
|
||||
// suppressing it would not help the next popover anyone writes. Measured over the 217:
|
||||
// 79 are `pop.refs.setReference` / `getReferenceProps` — @floating-ui/react's callback-ref
|
||||
// setters, which are the documented way to use it — and 74 more are passing the popover handle
|
||||
// down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
|
||||
// engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
|
||||
// a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
|
||||
// real (E27 S2 answered the ones the other rules could see, at their sites).
|
||||
// Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
|
||||
"react-hooks/refs": "off",
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/literal-a11y-text": "error",
|
||||
|
||||
Generated
+90
-64
@@ -18,9 +18,9 @@
|
||||
"hls.js": "^1.6.16",
|
||||
"i18next": "^23.11.5",
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^14.1.2"
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-i18next": "^15.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
@@ -28,12 +28,13 @@
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"globals": "16.5.0",
|
||||
"jsdom": "^25.0.1",
|
||||
@@ -2922,32 +2923,24 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
@@ -3469,6 +3462,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/babel-plugin-react-compiler": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -4084,16 +4087,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
||||
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-refresh": {
|
||||
@@ -4586,6 +4596,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hermes-parser": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
|
||||
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
@@ -4776,6 +4803,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -5279,18 +5307,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -5824,42 +5840,39 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz",
|
||||
"integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==",
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.2.3",
|
||||
"react": ">= 16.8.0"
|
||||
"i18next": ">= 23.4.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -5867,6 +5880,9 @@
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6016,13 +6032,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -6368,7 +6381,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -6836,6 +6849,19 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"hls.js": "^1.6.16",
|
||||
"i18next": "^23.11.5",
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^14.1.2"
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-i18next": "^15.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
@@ -44,12 +44,13 @@
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"globals": "16.5.0",
|
||||
"jsdom": "^25.0.1",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import reactCompiler from "babel-plugin-react-compiler";
|
||||
|
||||
// ONE React transform, shared by vite.config.ts and vitest.config.ts (E27 S1).
|
||||
//
|
||||
// It is a shared factory rather than two `react()` calls because the two configs must not drift:
|
||||
// if the compiler ran only in the production build, every vitest run would be exercising code the
|
||||
// user never receives, and a compiler-induced behaviour change would ship green. That is precisely
|
||||
// the defect `0.93.2` was released to fix — a gate and a build image that had quietly stopped being
|
||||
// the same thing — so the rule here is that the gate compiles the code the same way the build does.
|
||||
//
|
||||
// The React Compiler auto-memoises: it infers what `useCallback`/`useMemo` were being hand-written
|
||||
// for and applies the equivalent itself. It is enabled repo-wide with no opt-out list because it
|
||||
// bails out on its own for anything it cannot prove safe (its rules are also linted, via
|
||||
// eslint-plugin-react-hooks). `target` follows the installed React major and must move with it.
|
||||
//
|
||||
// The plugin is IMPORTED rather than named by the usual `"babel-plugin-react-compiler"` string. Two
|
||||
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
|
||||
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
|
||||
// while a bad import is a resolve error on the tsc lane.
|
||||
export const reactWithCompiler = () =>
|
||||
react({
|
||||
babel: {
|
||||
plugins: [[reactCompiler, { target: "19" }]],
|
||||
},
|
||||
});
|
||||
+70
-32
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { qk } from "./lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -131,6 +131,9 @@ export default function App() {
|
||||
});
|
||||
const needsSetup = setupQuery.data?.configured === false;
|
||||
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
|
||||
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
|
||||
const me = meQuery.data;
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
@@ -141,32 +144,40 @@ export default function App() {
|
||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||
useGlobalKeymap(
|
||||
!!meQuery.data,
|
||||
setPage,
|
||||
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||
);
|
||||
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
||||
|
||||
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
|
||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||
const diagnostics = meQuery.data?.diagnostics;
|
||||
const debugSession = meQuery.data?.debug_session;
|
||||
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
||||
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
||||
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
|
||||
// before that early return, which is the half we WANT re-run. The old dep list picked out four
|
||||
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
|
||||
// job to verify, not a comment's to assert, and it silently missed any other field the module
|
||||
// may come to read.
|
||||
const diagnostics = me?.diagnostics;
|
||||
const debugSession = me?.debug_session;
|
||||
useEffect(() => {
|
||||
applyDiagnostics(
|
||||
diagnostics,
|
||||
debugSession,
|
||||
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
||||
);
|
||||
}, [diagnostics?.level, diagnostics?.until, debugSession?.id, debugSession?.ended_at]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [diagnostics, debugSession, queryClient]);
|
||||
|
||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||
// requestIdleCallback, hence the timer).
|
||||
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
|
||||
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
|
||||
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
|
||||
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
|
||||
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
|
||||
// nothing; only the idle callback is re-scheduled.
|
||||
useEffect(() => {
|
||||
const me = meQuery.data;
|
||||
if (!me) return;
|
||||
const run = () => {
|
||||
preloadPages([...moduleOrder(me), "settings"]);
|
||||
@@ -179,7 +190,7 @@ export default function App() {
|
||||
}
|
||||
const id = window.setTimeout(run, 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [me]);
|
||||
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
@@ -197,9 +208,13 @@ export default function App() {
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
|
||||
// on an account switch was never the point — by then the params are already gone and a second
|
||||
// clearParams is a no-op.
|
||||
const signedIn = !!me;
|
||||
useEffect(() => {
|
||||
if (meQuery.data) clearParams("access", "verify", "reset");
|
||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (signedIn) clearParams("access", "verify", "reset");
|
||||
}, [signedIn]);
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||
@@ -218,12 +233,20 @@ export default function App() {
|
||||
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
||||
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
||||
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
||||
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
|
||||
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
|
||||
// toast when the user changes language. (The old `[]` got away with it only because clearParams
|
||||
// had already removed the param — an invariant held by a side effect two lines further down.)
|
||||
const linkAnswered = useRef(false);
|
||||
const { refetch: refetchMe } = meQuery;
|
||||
useEffect(() => {
|
||||
if (linkAnswered.current) return;
|
||||
const result = new URLSearchParams(window.location.search).get("link");
|
||||
if (!result) return;
|
||||
linkAnswered.current = true;
|
||||
if (result === "ok") {
|
||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||
void meQuery.refetch();
|
||||
void refetchMe();
|
||||
setAccountRaw(LS.settingsTab, "account");
|
||||
setPage("settings");
|
||||
} else {
|
||||
@@ -231,42 +254,59 @@ export default function App() {
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
clearParams("link");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [t, setPage, refetchMe]);
|
||||
|
||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
||||
const oauthAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (oauthAnswered.current) return;
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
oauthAnswered.current = true;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
clearParams("oauth");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [t]);
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
|
||||
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
|
||||
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
|
||||
// deep link was answered.
|
||||
const adminDeepLinkAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||
if (meQuery.data.role === "admin") {
|
||||
if (adminDeepLinkAnswered.current) return;
|
||||
if (adminDeepLink !== "access-requests" || !me) return;
|
||||
adminDeepLinkAnswered.current = true;
|
||||
if (me.role === "admin") {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [adminDeepLink, me, setPage]);
|
||||
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
||||
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
||||
// before — the notice stayed in the old language until the count changed.
|
||||
const role = me?.role;
|
||||
const pendingInvites = me?.pending_invites ?? 0;
|
||||
const pendingActivations = me?.pending_activations ?? 0;
|
||||
useEffect(() => {
|
||||
if (meQuery.data?.role !== "admin") return;
|
||||
if (role !== "admin") return;
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
|
||||
const pending = pendingInvites + pendingActivations;
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
@@ -285,8 +325,7 @@ export default function App() {
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
|
||||
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
||||
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
@@ -302,7 +341,7 @@ export default function App() {
|
||||
if (needsSetup) return <SetupWizard />;
|
||||
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
||||
// probe — so no data here means "not signed in" → the public landing.
|
||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
||||
if (!me) return <Welcome verify={verifyEntry} />;
|
||||
|
||||
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
||||
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
||||
@@ -323,16 +362,15 @@ export default function App() {
|
||||
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
|
||||
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
|
||||
// side rail shows behind it.
|
||||
const me = meQuery.data!;
|
||||
const pageGate = moduleDef(page).gate;
|
||||
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
|
||||
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
|
||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex text-fg">
|
||||
<NavSidebar
|
||||
me={meQuery.data!}
|
||||
me={me!}
|
||||
onOpenAbout={() => setAboutOpen(true)}
|
||||
onChangeLanguage={changeLanguage}
|
||||
language={i18n.language as LangCode}
|
||||
@@ -368,7 +406,7 @@ export default function App() {
|
||||
key={channelView.id}
|
||||
channelId={channelView.id}
|
||||
initialName={channelView.name}
|
||||
me={meQuery.data!}
|
||||
me={me!}
|
||||
view={view}
|
||||
setView={setView}
|
||||
onShowInFeed={() => {
|
||||
@@ -393,11 +431,11 @@ export default function App() {
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
{me!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
}
|
||||
@@ -417,9 +455,9 @@ export default function App() {
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
{me && !me.is_demo && <ChatDock meId={me.id} />}
|
||||
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||
{meQuery.data && !meQuery.data.is_demo && <DebugRecorder />}
|
||||
{me && !me.is_demo && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
<About
|
||||
|
||||
@@ -191,6 +191,26 @@ describe("BootGate", () => {
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
});
|
||||
|
||||
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
|
||||
// rather than tidy: a re-run reloads the page, so an effect that cannot tell "already adopted this
|
||||
// account" from "adopted a different one" turns an ordinary boot into a reload loop. The spy is
|
||||
// installed BEFORE mounting on purpose — the other reload tests stub it after the app is up, which
|
||||
// is exactly late enough to miss the re-run that setting `readyFor` triggers.
|
||||
it("a plain boot never reloads, however many times the adoption effect re-runs", async () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
|
||||
act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })));
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
const qc = mount();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
||||
@@ -83,23 +83,31 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
// • Adopt the account's language, which follows the account across devices — the local copy only
|
||||
// knows what this browser last showed.
|
||||
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
|
||||
// `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
|
||||
// account must not re-run the adoption over a language the user has just changed. That rule used
|
||||
// to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness —
|
||||
// and the failure mode of getting it wrong was a page-reload loop, not a stale value.
|
||||
const meRef = useRef(me);
|
||||
meRef.current = me;
|
||||
useLayoutEffect(() => {
|
||||
if (identity === undefined) return;
|
||||
if (readyFor !== undefined) {
|
||||
// The tree was built for another identity — see the header. The pin stays as it is: the tree
|
||||
// being torn down still runs its unmount saves before the page goes, and with the pin moved they
|
||||
// would land under the new account's local keys. The reloaded page's first probe corrects it.
|
||||
location.reload();
|
||||
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
|
||||
// see the header. The pin stays as it is: the tree being torn down still runs its unmount saves
|
||||
// before the page goes, and with the pin moved they would land under the new account's local
|
||||
// keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for
|
||||
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
|
||||
// instead of a reload.
|
||||
if (readyFor !== identity) location.reload();
|
||||
return;
|
||||
}
|
||||
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
|
||||
const lang = me?.preferences?.language;
|
||||
const account = meRef.current;
|
||||
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
||||
const lang = account?.preferences?.language;
|
||||
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
|
||||
setReadyFor(identity);
|
||||
// `me` is read for the identity it produced; a background refetch of the same account must not
|
||||
// re-run the adoption over a language the user has just changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity]);
|
||||
}, [identity, readyFor]);
|
||||
|
||||
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
|
||||
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
|
||||
|
||||
@@ -95,7 +95,13 @@ export default function ChannelPage({
|
||||
intervalMs: channelDetailPollMs,
|
||||
});
|
||||
const ch = detail.data;
|
||||
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
||||
// A fresh channel/banner gets the retries back, in the render that changes it: as an effect the
|
||||
// new banner was committed with the previous one's exhausted attempt count still in place.
|
||||
const [bannerFor, setBannerFor] = useState(ch?.banner_url);
|
||||
if (bannerFor !== ch?.banner_url) {
|
||||
setBannerFor(ch?.banner_url);
|
||||
setBannerAttempt(0);
|
||||
}
|
||||
// The poll above is also the ONLY signal that the running backfill has written videos, so it
|
||||
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
|
||||
// the PATCH response, before the background job has stored anything).
|
||||
@@ -137,6 +143,7 @@ export default function ChannelPage({
|
||||
if (ch.subscribed || ch.blocked) return;
|
||||
if (ch.explored && ch.stored_videos > 0) return;
|
||||
autoTried.current = channelId;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
|
||||
void runExplore();
|
||||
}, [ch, channelId, me.is_demo, runExplore]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
@@ -117,18 +117,28 @@ function SuggestedChipsLane({
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
const hidden = suggestions.length - visible;
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, suggestions.length - visible);
|
||||
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
|
||||
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
|
||||
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
|
||||
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
|
||||
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
|
||||
// the lane overflows. Convergent — the write falsifies its own condition.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
@@ -340,7 +350,12 @@ export function ChannelTagsControl({
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = attached.length - visible;
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, attached.length - visible);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const editPop = useAnchoredPopover({
|
||||
@@ -353,18 +368,18 @@ export function ChannelTagsControl({
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
|
||||
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
|
||||
// and the flag reset with it, or the card returns unasked on the next overflow.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
// If tags change so nothing is collapsed anymore, the "+N" trigger unmounts — force the reveal-all
|
||||
// popover's state closed so it can't linger open against a gone reference.
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
const pillClass =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function Channels() {
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel: onFocusChannel,
|
||||
consumeFocus,
|
||||
} = useChannelsActions();
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
@@ -281,10 +282,22 @@ export default function Channels() {
|
||||
},
|
||||
];
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
|
||||
// payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
|
||||
// overwriting whatever the user had since typed.
|
||||
//
|
||||
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
|
||||
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
|
||||
// which does not even change `page`. The provider then reports `focusName` as null, so a later
|
||||
// visit leaves the box exactly as the user left it.
|
||||
const focusChannelNameRef = useRef(focusChannelName);
|
||||
focusChannelNameRef.current = focusChannelName;
|
||||
useEffect(() => {
|
||||
if (focusChannelName) setSearch(focusChannelName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusChannelToken]);
|
||||
const name = focusChannelNameRef.current;
|
||||
if (!name) return;
|
||||
setSearch(name);
|
||||
consumeFocus();
|
||||
}, [focusChannelToken, setSearch, consumeFocus]);
|
||||
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
|
||||
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
|
||||
const resetRef = useRef(filtersResetToken);
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
||||
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
vi.mock("./navigationContext", () => ({
|
||||
useNavigation: () => ({ page: "channels" }),
|
||||
@@ -127,3 +127,85 @@ describe("ChannelsProvider — S8 shareable URL", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- the focus-a-channel intent is consumed, not masked -------------------------------------------
|
||||
// `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page
|
||||
// chunk: it unmounts on every navigation away, and on opening a channel page (which does not even
|
||||
// change `page`), and its seeding effect runs again on each mount. So the "already applied" marker
|
||||
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
|
||||
// from earlier in the session over whatever the user has typed into the search box since.
|
||||
describe("ChannelsProvider — the focus-a-channel intent", () => {
|
||||
/** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */
|
||||
function Manager() {
|
||||
const { focusName, focusToken } = useChannels();
|
||||
const { setSearch, consumeFocus } = useChannelsActions();
|
||||
const nameRef = useRef(focusName);
|
||||
nameRef.current = focusName;
|
||||
useEffect(() => {
|
||||
const name = nameRef.current;
|
||||
if (!name) return;
|
||||
setSearch(name);
|
||||
consumeFocus();
|
||||
}, [focusToken, setSearch, consumeFocus]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Harness({ mounted }: { mounted: boolean }) {
|
||||
return <>{mounted && <Manager />}</>;
|
||||
}
|
||||
|
||||
it("seeds the search box once, and a later visit does not re-seed it", () => {
|
||||
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
|
||||
function Probe() {
|
||||
api = { ...useChannels(), ...useChannelsActions() };
|
||||
return null;
|
||||
}
|
||||
const { rerender } = renderRoot(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
|
||||
act(() => api.focusChannel("Kurzgesagt"));
|
||||
expect(api.search).toBe("Kurzgesagt");
|
||||
|
||||
// The user looks for something else instead.
|
||||
act(() => api.setSearch("veritasium"));
|
||||
|
||||
// Leave the manager (the page chunk unmounts) and come back.
|
||||
rerender(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted={false} />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
rerender(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
|
||||
expect(api.search).toBe("veritasium");
|
||||
});
|
||||
|
||||
it("re-seeds when the channel is focused again — the token is the intent", () => {
|
||||
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
|
||||
function Probe() {
|
||||
api = { ...useChannels(), ...useChannelsActions() };
|
||||
return null;
|
||||
}
|
||||
renderRoot(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
|
||||
act(() => api.focusChannel("Kurzgesagt"));
|
||||
act(() => api.setSearch("veritasium"));
|
||||
act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
|
||||
expect(api.search).toBe("Kurzgesagt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
@@ -120,14 +120,18 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
|
||||
// this load started on, and the three setters are stable by construction (useFilterState,
|
||||
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
|
||||
// every value the body reads without the seed re-arming on a later tab switch.
|
||||
const [mountView] = useState(view);
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
@@ -175,10 +179,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
setPage("channels");
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
|
||||
useEffect(() => {
|
||||
if (page !== "channels") setFocusName(null);
|
||||
}, [page]);
|
||||
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
|
||||
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
|
||||
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
|
||||
// one render earlier than the effect used to make it.
|
||||
//
|
||||
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
|
||||
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
|
||||
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
|
||||
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
|
||||
// has since typed into the search box. Keyed on the token rather than the name, because
|
||||
// re-focusing the same channel is a new intent.
|
||||
const [focusApplied, setFocusApplied] = useState(0);
|
||||
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
|
||||
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
|
||||
|
||||
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
|
||||
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
|
||||
@@ -211,9 +225,19 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
}),
|
||||
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||
[
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
],
|
||||
);
|
||||
|
||||
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
|
||||
@@ -42,6 +42,10 @@ export default function ChatThread({
|
||||
{ intervalMs: POLL_MS, enabled: !needsKey },
|
||||
);
|
||||
const items = q.data?.items ?? [];
|
||||
// The two effects below take the FETCH as their trigger and read the payload through this ref, so
|
||||
// their dep lists name everything they touch without making the identity of `q.data` a trigger.
|
||||
const threadRef = useRef(q.data);
|
||||
threadRef.current = q.data;
|
||||
|
||||
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
|
||||
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
|
||||
@@ -56,7 +60,7 @@ export default function ChatThread({
|
||||
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
|
||||
let pub: string | null = null;
|
||||
let refreshed = false;
|
||||
for (const m of q.data?.items ?? []) {
|
||||
for (const m of threadRef.current?.items ?? []) {
|
||||
if (!m.ciphertext || !m.iv) continue;
|
||||
try {
|
||||
if (!pub) pub = await partnerPub(partnerId);
|
||||
@@ -84,7 +88,8 @@ export default function ChatThread({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
|
||||
// where keying this family on `q.data` itself produced a render loop.
|
||||
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
|
||||
|
||||
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
|
||||
@@ -101,13 +106,12 @@ export default function ChatThread({
|
||||
lastPartner.current = partnerId;
|
||||
lastIncoming.current = -1;
|
||||
}
|
||||
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||
const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||
if (incoming !== lastIncoming.current) {
|
||||
lastIncoming.current = incoming;
|
||||
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q.dataUpdatedAt, partnerId, meId, qc]);
|
||||
|
||||
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -59,10 +59,14 @@ export default function ConfigPanel() {
|
||||
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
|
||||
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
|
||||
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
|
||||
// The baseline is read through a ref, not named as a dependency: an unchanged refetch produces a
|
||||
// new object, and re-seeding on that is exactly the clobbering of an in-progress draft that CB2
|
||||
// was about. `dataVersion` (the config SHAPE) and the post-save token are the real triggers.
|
||||
const baselineRef = useRef(baseline);
|
||||
baselineRef.current = baseline;
|
||||
useEffect(() => {
|
||||
setDraft(baseline);
|
||||
setDraft(baselineRef.current);
|
||||
setSecretReset({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataVersion, reseedToken]);
|
||||
|
||||
const isDirty = (it: ConfigItem): boolean => {
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function DateFilterSelect({
|
||||
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
|
||||
// one stale "Custom range" frame between the external clear and this reset.
|
||||
useLayoutEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the reveal is NOT derivable from the range — the user opens it on an empty range and fills it in afterwards. This undoes it only when the range is cleared from OUTSIDE while the popover is shut, and it is a layout effect so the trigger never paints a stale "Custom range".
|
||||
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ afterEach(() => {
|
||||
|
||||
let composing: string | null = null;
|
||||
function ComposerProbe() {
|
||||
// eslint-disable-next-line react-hooks/globals -- a TEST probe: a module-level handle is how the test reads a hook's value from outside the tree without a wrapper that would change what is being measured.
|
||||
composing = useComposerSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ function useElapsed(since: string): string {
|
||||
const id = window.setInterval(() => tick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT.
|
||||
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function useRemaining(until: string | null): { h: number; m: number } | null {
|
||||
return () => window.clearInterval(id);
|
||||
}, [until]);
|
||||
if (!until) return null;
|
||||
// eslint-disable-next-line react-hooks/purity -- same as DebugRecorder's elapsed clock, and the comment above records the prod UAT finding that a state-held `now` produced: a level raised two minutes after opening Settings read "24 h 2 m" until the first tick.
|
||||
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
|
||||
return { h: Math.floor(minutes / 60), m: minutes % 60 };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -152,10 +152,10 @@ export default function DownloadCenter() {
|
||||
];
|
||||
// Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
|
||||
// — that would render a blank page with no active tab. Fall back to the default.
|
||||
useEffect(() => {
|
||||
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, me.role]);
|
||||
// Adjusted during render rather than from an effect: as an effect the blank page it exists to
|
||||
// prevent was committed and painted first. `setTab` is this component's own state (persisted per
|
||||
// account), so a render-phase write is allowed, and it converges — "queue" is always in the list.
|
||||
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
|
||||
|
||||
const saveBtn = (job: DownloadJob) => (
|
||||
<a
|
||||
|
||||
@@ -220,17 +220,21 @@ export default function Feed({
|
||||
if (qEmpty && parseSort(filters.sort).key === "relevance") {
|
||||
setFilters({ ...filters, sort: buildSort("date", "desc") });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qEmpty]);
|
||||
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
|
||||
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
|
||||
// guarding against a loop that the guard already prevents.
|
||||
}, [qEmpty, filters, setFilters]);
|
||||
|
||||
// Drop optimistic status overrides when filters change OR fresh server data
|
||||
// arrives — after a refetch the server is authoritative, so a stale override
|
||||
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||
// filtered out of the current view.
|
||||
useEffect(() => {
|
||||
const [overridesFor, setOverridesFor] = useState(filters);
|
||||
if (overridesFor !== filters) {
|
||||
setOverridesFor(filters);
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [filters]);
|
||||
}
|
||||
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
|
||||
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
|
||||
// Only a real refetch (page count doesn't grow) is authoritative.
|
||||
@@ -284,7 +288,7 @@ export default function Feed({
|
||||
message: v?.title
|
||||
? i18n.t("feed.hiddenNamed", { title: v.title })
|
||||
: i18n.t("feed.hidden"),
|
||||
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
|
||||
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
|
||||
meta: {
|
||||
kind: "video-hidden",
|
||||
videoId: id,
|
||||
@@ -299,7 +303,10 @@ export default function Feed({
|
||||
message: v?.title
|
||||
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
||||
: i18n.t("feed.markedWatched"),
|
||||
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
|
||||
action: {
|
||||
label: i18n.t("feed.unwatch"),
|
||||
onClick: () => onStateRef.current(id, "new"),
|
||||
},
|
||||
meta: {
|
||||
kind: "video-watched",
|
||||
videoId: id,
|
||||
@@ -328,6 +335,11 @@ export default function Feed({
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
|
||||
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
|
||||
const onStateRef = useRef(onState);
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
onStateRef.current = onState;
|
||||
|
||||
const onResetState = useCallback(
|
||||
(id: string) => {
|
||||
|
||||
@@ -67,10 +67,16 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// A share link's filters (captured at mount) win over the account's stored view and are persisted
|
||||
// to it once, so the view the link opened also survives a later plain reload.
|
||||
//
|
||||
// The mount-time filters are FROZEN into their own state rather than left to an empty dep list:
|
||||
// `filters` changes on every edit, so listing it honestly would re-persist — and setFilters
|
||||
// setting the state it depends on would spin. Frozen, all three deps are stable and the effect
|
||||
// means exactly what it says: write what the URL brought, once.
|
||||
const [urlSeededFilters] = useState(filters);
|
||||
useEffect(() => {
|
||||
if (initialHadUrlFilters) setFilters(filters);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters also WRITES to per-account localStorage, which is the point here — persisting what a share link brought. That is a side effect, not a value to derive, and the frozen seed above makes it happen exactly once.
|
||||
if (initialHadUrlFilters) setFilters(urlSeededFilters);
|
||||
}, [initialHadUrlFilters, urlSeededFilters, setFilters]);
|
||||
|
||||
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
|
||||
|
||||
|
||||
@@ -141,7 +141,10 @@ export default function GlassTuner() {
|
||||
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
|
||||
const enabled = usePrefs().theme.showTuner;
|
||||
const { t } = useTranslation();
|
||||
const initial = useMemo(loadSaved, []);
|
||||
// Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
|
||||
// a memo is a caching hint React may discard and recompute, while "the saved values as they were
|
||||
// when this mounted" is state — and the effect below applies them exactly once.
|
||||
const [initial] = useState(loadSaved);
|
||||
const [vars, setVars] = useState<Record<string, number>>(() => {
|
||||
const base: Record<string, number> = {};
|
||||
ALL.forEach((s) => (base[s.k] = s.def));
|
||||
@@ -167,6 +170,7 @@ export default function GlassTuner() {
|
||||
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
|
||||
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
|
||||
setSwatches(sw);
|
||||
}, [initial]);
|
||||
|
||||
|
||||
@@ -474,6 +474,7 @@ export default function Home() {
|
||||
hero: DiscoverPayload["hero"];
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- deliberately OUTLIVES the query: a failed reroll is terminal (retry:false) and must leave the previous answer on screen with the budget tag the SERVER echoed for it. Derived from pickQ it would vanish with the query.
|
||||
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
|
||||
}, [pickQ.data]);
|
||||
|
||||
@@ -695,6 +696,7 @@ export default function Home() {
|
||||
// `reroll`'s changing identity causes — and to a token minted for another history entry.
|
||||
const surpriseRequest = useSurpriseRequest();
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- consuming a cross-page token (lib/homeIntent) that was minted before this module's chunk existed. takeSurprise() clears it and answers false to every later pass, including StrictMode's second mount.
|
||||
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
|
||||
}, [surpriseRequest, reroll]);
|
||||
|
||||
|
||||
@@ -603,9 +603,11 @@ function Attachments({
|
||||
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
|
||||
const pick = useRef<HTMLInputElement>(null);
|
||||
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
useEffect(() => {
|
||||
const source = reviewed.current;
|
||||
if (!source) return;
|
||||
// eslint-disable-next-line react-hooks/immutability -- a ONE-SHOT token, consumed: the parent sets it to say "give this source's review button the focus back when the list re-renders", and clearing it here is what stops the focus being stolen on every later render. The ref is the caller's, which is why it is a dependency rather than a local.
|
||||
reviewed.current = null;
|
||||
reviewButtons.current[source]?.focus();
|
||||
}, [reviewed]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -85,15 +85,20 @@ function ConversationList({
|
||||
const items = q.data?.items ?? [];
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
|
||||
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
|
||||
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
|
||||
// (and setPreviews) every render while the query is loading.
|
||||
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
|
||||
// payload is read through a ref, which is what makes the dep list below complete without making
|
||||
// the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
|
||||
// new array every render, and anything handing back a fresh object per render (a stub, a caller
|
||||
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
|
||||
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
|
||||
const conversationsRef = useRef(q.data);
|
||||
conversationsRef.current = q.data;
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of q.data?.items ?? []) {
|
||||
for (const c of conversationsRef.current?.items ?? []) {
|
||||
const m = c.last_message;
|
||||
if (m.kind === "user" && m.ciphertext && m.iv) {
|
||||
try {
|
||||
@@ -109,7 +114,6 @@ function ConversationList({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q.dataUpdatedAt, ready]);
|
||||
|
||||
function previewText(c: Conversation): string {
|
||||
|
||||
@@ -33,6 +33,7 @@ function useLogEvents(): LogEvent[] {
|
||||
setList(events());
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- this IS the subscribe-to-an-external-store shape the rule's own text allows: the ring fills outside React, and a sync at subscribe time is what closes the gap between the first render and the listener. useSyncExternalStore would drop the rAF coalescing, which is the point at a few hundred events a second.
|
||||
setList(events()); // whatever arrived between the first render and the subscription
|
||||
return () => {
|
||||
unsubscribe();
|
||||
|
||||
@@ -153,10 +153,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
|
||||
useEffect(() => setNavigator(setPage), [setPage]);
|
||||
|
||||
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
|
||||
useEffect(() => {
|
||||
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
||||
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
||||
// after the new page had already committed, so for one frame a module rendered with a live search
|
||||
// still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has
|
||||
// to be permanent — a derived null would quietly come back when you returned to the feed.
|
||||
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
|
||||
if (pageSearchWasOn !== page) {
|
||||
setPageSearchWasOn(page);
|
||||
if (page !== "feed") setYtSearch(null);
|
||||
}, [page]);
|
||||
}
|
||||
|
||||
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
|
||||
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
|
||||
@@ -174,6 +180,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
document.title = label ? `${label} · ${brand}` : brand;
|
||||
}, [signedIn, page, channelView, i18n.language, t]);
|
||||
|
||||
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
|
||||
// entry: every later page change writes its own entry through setPage.
|
||||
const [mountPage] = useState(page);
|
||||
|
||||
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
|
||||
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||
// the popstate handler only needs to read history.state.
|
||||
@@ -185,12 +195,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||
const url = urlWithParams({ page: page === "feed" ? null : page });
|
||||
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
|
||||
if (st._yt && st._ytScrape) {
|
||||
window.history.replaceState({ ...st, sfPage: page }, "", url);
|
||||
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
|
||||
} else {
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||
window.history.replaceState({ ...rest, sfPage: page }, "", url);
|
||||
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
|
||||
}
|
||||
function onPop(e: PopStateEvent) {
|
||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||
@@ -207,8 +217,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [mountPage]);
|
||||
|
||||
const state = useMemo<NavigationState>(
|
||||
() => ({ page, channelView, ytSearch }),
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
meta: { errorShownInline: true },
|
||||
});
|
||||
|
||||
const { mutate: startImport } = importSubs;
|
||||
// First time we see read access with zero imported channels, pull the user's
|
||||
// subscriptions automatically so they don't have to hunt for a button.
|
||||
useEffect(() => {
|
||||
@@ -97,10 +98,11 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
!importTriggered.current
|
||||
) {
|
||||
importTriggered.current = true;
|
||||
importSubs.mutate();
|
||||
startImport();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me.can_read, myStatus.data?.channels_total]);
|
||||
// `mutate` is stable (react-query memoises it) while the mutation object around it is not, so
|
||||
// the effect can name what it calls. The ref keeps it to once regardless.
|
||||
}, [me.can_read, myStatus.data, startImport]);
|
||||
|
||||
// Closing without finishing the essential read step suppresses future auto-popups
|
||||
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
@@ -107,13 +107,16 @@ export default function PersonalViewsModal() {
|
||||
},
|
||||
});
|
||||
const resetDraft = filtersDraft.reset;
|
||||
// Re-seed only when the SELECTION changes, not on every same-view refetch — a refetch returns the
|
||||
// value we just wrote, and re-seeding on it would throw away the prune's undo history. The chosen
|
||||
// view is therefore read through a ref: `selectedId` is the trigger, `selected` is only the data.
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
useEffect(() => {
|
||||
resetDraft(selected?.filters ?? {});
|
||||
setNameDraft(selected?.name ?? "");
|
||||
// Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its
|
||||
// undo history. Save-current re-seeds explicitly in its own handler.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
const view = selectedRef.current;
|
||||
resetDraft(view?.filters ?? {});
|
||||
setNameDraft(view?.name ?? "");
|
||||
}, [selectedId, resetDraft]);
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows, read
|
||||
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
|
||||
|
||||
@@ -351,3 +351,53 @@ describe("PlayerModal — the dialog contract", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- the watch toggle belongs to the item it describes -------------------------------------------
|
||||
// A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the
|
||||
// commit that already advanced `active`, and within the finish margin it auto-marks that item
|
||||
// watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item
|
||||
// as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored.
|
||||
describe("PlayerModal — the watch toggle after a queue advance", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => {
|
||||
const onState = vi.fn();
|
||||
clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
await settle();
|
||||
|
||||
// A really is marked watched — that part is correct and must keep working.
|
||||
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
|
||||
// B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
|
||||
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
|
||||
expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("still reflects a watch toggled on the item you are actually on", async () => {
|
||||
const onState = vi.fn();
|
||||
clock.current = 0;
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
|
||||
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
|
||||
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
|
||||
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
@@ -170,7 +170,10 @@ export default function PlayerModal({
|
||||
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
|
||||
// modal adjusts volume (not just the small player area).
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusModal = () => cardRef.current?.focus();
|
||||
// Stable: it reads a ref and nothing else, and three hooks take it as a parameter (useYtVolume,
|
||||
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
|
||||
// dep lists too.
|
||||
const focusModal = useCallback(() => cardRef.current?.focus(), []);
|
||||
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
|
||||
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
|
||||
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
|
||||
@@ -231,22 +234,51 @@ export default function PlayerModal({
|
||||
setLiveData(null);
|
||||
};
|
||||
|
||||
// Local mirror of watch status so the toggle reacts instantly; changes are
|
||||
// propagated to the feed (and server) via onState.
|
||||
const [status, setStatus] = useState(active.status);
|
||||
const watched = status === "watched";
|
||||
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
|
||||
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
|
||||
// is ignored rather than displayed — that is the enforcement, and it has to be in code because
|
||||
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
|
||||
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
|
||||
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
|
||||
// item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
|
||||
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
|
||||
// onState(next, "new") for an item that was already new.
|
||||
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
|
||||
id: active.id,
|
||||
value: active.status,
|
||||
});
|
||||
const watched = (status.id === active.id ? status.value : active.status) === "watched";
|
||||
const setWatched = (on: boolean) => {
|
||||
const next = on ? "watched" : "new";
|
||||
setStatus(next);
|
||||
setStatus({ id: active.id, value: next });
|
||||
onState(active.id, next);
|
||||
};
|
||||
|
||||
// On queue advance (active changes), sync local state to the new item.
|
||||
useEffect(() => {
|
||||
setStatus(active.status);
|
||||
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
|
||||
// not one commit later. As an effect, the frame between still had it on the previous id, so the
|
||||
// "you have navigated away" affordance flashed on every advance. React discards this pass instead
|
||||
// of committing it, so there is no such frame; the guard falsifies itself immediately.
|
||||
//
|
||||
// The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
|
||||
// immune to the late writer this block cannot see (see that comment).
|
||||
const [syncedTo, setSyncedTo] = useState(active.id);
|
||||
if (syncedTo !== active.id) {
|
||||
setSyncedTo(active.id);
|
||||
setCurrentVideoId(active.id);
|
||||
}
|
||||
|
||||
// `currentIdRef` must NOT move into the block above, and the reason is EFFECT ORDERING, which the
|
||||
// committed test "resumes an item played earlier in the SAME session" measures. useYtProgress's
|
||||
// per-item effect cleanup persists the outgoing item's position, and its persist() is guarded on
|
||||
// `currentIdRef.current === id` so a video navigated to inside the player is never checkpointed
|
||||
// against the library item. React runs every cleanup BEFORE any setup, so in this effect the ref
|
||||
// still names the item being left while that cleanup runs, and flips to the new one just before
|
||||
// useYtProgress (declared below) sets up for it. Advanced to render time the flip happens first,
|
||||
// the outgoing checkpoint is skipped, and stepping next→prev back to an item resumes from the
|
||||
// frozen launch queue's stale position — which is the exact bug the session-position map exists
|
||||
// to prevent (748 instead of 830, in the test's numbers).
|
||||
useEffect(() => {
|
||||
currentIdRef.current = active.id;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active.id]);
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
@@ -412,7 +444,11 @@ export default function PlayerModal({
|
||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||
useEffect(() => {
|
||||
//
|
||||
// The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads
|
||||
// whatever is current at the moment a key arrives, which is what the disabled dep list used to
|
||||
// claim in prose and now is by construction.
|
||||
const onPlayerKey = useEffectEvent((e: KeyboardEvent) => {
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
|
||||
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
|
||||
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
|
||||
@@ -432,59 +468,61 @@ export default function PlayerModal({
|
||||
e.preventDefault();
|
||||
nudgeSeek(secs);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
|
||||
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
|
||||
// does not, so it must not switch the trap off.
|
||||
if (e.key === "Tab") {
|
||||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||||
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
|
||||
return;
|
||||
}
|
||||
dispatch(e, "ytPlayer", {
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
ev.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
fullscreen: (ev) => {
|
||||
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
|
||||
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
|
||||
// does not, so it must not switch the trap off.
|
||||
if (e.key === "Tab") {
|
||||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||||
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
|
||||
return;
|
||||
}
|
||||
dispatch(e, "ytPlayer", {
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
ev.preventDefault();
|
||||
toggleFullscreen();
|
||||
},
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
},
|
||||
volumeUp: (ev) => volume(ev, true),
|
||||
volumeDown: (ev) => volume(ev, false),
|
||||
seekBack: (ev) => seek(ev, -5),
|
||||
seekFwd: (ev) => seek(ev, 5),
|
||||
prevVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goPrev();
|
||||
},
|
||||
nextVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goNext();
|
||||
},
|
||||
});
|
||||
};
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
fullscreen: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleFullscreen();
|
||||
},
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
},
|
||||
volumeUp: (ev) => volume(ev, true),
|
||||
volumeDown: (ev) => volume(ev, false),
|
||||
seekBack: (ev) => seek(ev, -5),
|
||||
seekFwd: (ev) => seek(ev, 5),
|
||||
prevVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goPrev();
|
||||
},
|
||||
nextVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goNext();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => onPlayerKey(e);
|
||||
window.addEventListener("keydown", onKey);
|
||||
focusModal();
|
||||
// (The scroll lock is the shared, counted one in useDialogChrome.)
|
||||
@@ -492,11 +530,7 @@ export default function PlayerModal({
|
||||
window.removeEventListener("keydown", onKey);
|
||||
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
||||
};
|
||||
// Bound once per player on purpose: every helper the handler calls reads its live state through
|
||||
// a ref (playerRef, navRef, maximizedRef, isFullscreenRef, the volume hook's own ref), so
|
||||
// re-binding on each render would only churn the listener, never change what a key does.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onClose]);
|
||||
}, [focusModal]);
|
||||
|
||||
// Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at
|
||||
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
|
||||
|
||||
@@ -290,6 +290,9 @@ export default function Playlists() {
|
||||
});
|
||||
const detail = detailQuery.data;
|
||||
|
||||
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
|
||||
// the object around them is rebuilt every render (lib/useUndoable).
|
||||
const { reset: resetOrder, rebase: rebaseOrder } = order;
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
|
||||
@@ -297,7 +300,7 @@ export default function Playlists() {
|
||||
const key = `${selectedId}:${idsKey(detail.items)}`;
|
||||
if (key !== lastSetRef.current) {
|
||||
lastSetRef.current = key;
|
||||
order.reset(detail.items);
|
||||
resetOrder(detail.items);
|
||||
} else {
|
||||
// Same membership, fresher ROWS — a watch position the player just stored, a status. The
|
||||
// guard above looks at the id set only, so without this the list kept showing, and handing the
|
||||
@@ -305,17 +308,23 @@ export default function Playlists() {
|
||||
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
|
||||
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
|
||||
const fresh = new Map(detail.items.map((v) => [v.id, v]));
|
||||
order.rebase((list) => list.map((v) => fresh.get(v.id) ?? v));
|
||||
rebaseOrder((list) => list.map((v) => fresh.get(v.id) ?? v));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detail]);
|
||||
// `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing;
|
||||
// `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's.
|
||||
}, [detail, selectedId, resetOrder, rebaseOrder]);
|
||||
|
||||
// Reset the view when switching playlists (the stored order itself is per-playlist).
|
||||
useEffect(() => {
|
||||
// Reset the view when switching playlists (the stored order itself is per-playlist) — during the
|
||||
// render that switches, so the incoming playlist is never drawn once through the previous one's
|
||||
// sort, filters and grouping. All three are this component's own state, which is what makes a
|
||||
// render-phase write legal here.
|
||||
const [viewFor, setViewFor] = useState(selectedId);
|
||||
if (viewFor !== selectedId) {
|
||||
setViewFor(selectedId);
|
||||
setViewSort(STORED_SORT);
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
}, [selectedId]);
|
||||
}
|
||||
|
||||
const builtin = detail?.kind === "watch_later";
|
||||
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
|
||||
@@ -385,6 +394,7 @@ export default function Playlists() {
|
||||
// Read by the drag announcements, which must report the CURRENT positions without making the
|
||||
// announcement closures a render dependency of DndContext.
|
||||
const shownRef = useRef(shown);
|
||||
// eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
|
||||
shownRef.current = shown;
|
||||
|
||||
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
||||
@@ -595,6 +605,7 @@ export default function Playlists() {
|
||||
}
|
||||
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
|
||||
const removeRef = useRef(removeItem);
|
||||
// eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
|
||||
removeRef.current = removeItem;
|
||||
|
||||
async function moveItemTo(video: Video, target: Playlist) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -91,7 +91,9 @@ export default function PlaylistsRail({
|
||||
queryFn: () => api.playlists(),
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const playlists = listQuery.data ?? [];
|
||||
// Memoised so the effect and the sort below depend on a value that only changes with the query —
|
||||
// `?? []` would otherwise mint a new empty array on every render while the list is loading.
|
||||
const playlists = useMemo(() => listQuery.data ?? [], [listQuery.data]);
|
||||
|
||||
// Keep the selection valid: default to the first playlist, and if the selected one disappears
|
||||
// drop to the next available (or clear when none remain). Waits for the first load.
|
||||
@@ -104,10 +106,12 @@ export default function PlaylistsRail({
|
||||
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
|
||||
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlists, selectedId, listQuery.data]);
|
||||
}, [playlists, selectedId, listQuery.data, setSelectedId]);
|
||||
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
// useCallback because the two memos below sort by it: as a fresh closure each render it could not
|
||||
// be named in their dep lists, and `t` — which is what actually changes it, on a language switch —
|
||||
// was therefore invisible to them. A rename in the other language now re-sorts.
|
||||
const plName = useCallback((p: { kind: string; name: string }) => playlistName(p, t), [t]);
|
||||
|
||||
const sortedPlaylists = useMemo(() => {
|
||||
const sign = plSort.dir === "asc" ? 1 : -1;
|
||||
@@ -126,15 +130,13 @@ export default function PlaylistsRail({
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlists, plSort]);
|
||||
}, [playlists, plSort, plName]);
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const visiblePlaylists = useMemo(
|
||||
() =>
|
||||
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sortedPlaylists, q],
|
||||
[sortedPlaylists, q, plName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -112,14 +112,14 @@ export default function PlexBrowse() {
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
|
||||
// Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
|
||||
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. Keyed on the
|
||||
// view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing).
|
||||
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. `sub.open`
|
||||
// is referentially stable (lib/history memoises it), so naming it costs nothing and the dep list is
|
||||
// the honest one: the toggle is republished when the view KIND changes, and on nothing else.
|
||||
const publishView = usePublishPlexView();
|
||||
useLayoutEffect(() => {
|
||||
publishView({ kind: sub.view.kind, open: sub.open });
|
||||
return () => publishView(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sub.view.kind]);
|
||||
}, [sub.view.kind, sub.open, publishView]);
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
// clear the signal so it doesn't re-open.
|
||||
@@ -128,8 +128,7 @@ export default function PlexBrowse() {
|
||||
sub.open({ kind: "playlist", id: openPlaylist });
|
||||
setPlaylistOpen(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openPlaylist]);
|
||||
}, [openPlaylist, sub, setPlaylistOpen]);
|
||||
|
||||
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
||||
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
|
||||
@@ -150,8 +149,7 @@ export default function PlexBrowse() {
|
||||
);
|
||||
setJumpTo(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jumpTo]);
|
||||
}, [jumpTo, sub, setJumpTo]);
|
||||
|
||||
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
|
||||
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
|
||||
@@ -180,6 +178,17 @@ export default function PlexBrowse() {
|
||||
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
||||
// different title's info starts at the top.
|
||||
const infoScrollRef = useRef(0);
|
||||
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
|
||||
// rule the code holds rather than one spread over four call sites — and so the compiler's
|
||||
// complaint about it is answered once, here, instead of at each of them.
|
||||
//
|
||||
// The complaint is a false positive of a conservative analysis, not a hazard: the rule objects to a
|
||||
// ref being written outside the effect that reads it, but that IS the arrangement a scroll memory
|
||||
// needs — the navigation handlers record where the page was, and the restore layout effect above
|
||||
// reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
|
||||
// makes it safe, and nothing here reads the value during render.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
|
||||
// One comfort-pin menu for the titles grid (see ui/ContextMenu).
|
||||
const pinMenu = useContextMenu<PlexCard>();
|
||||
const scroller = () => document.querySelector("main");
|
||||
@@ -261,6 +270,11 @@ export default function PlexBrowse() {
|
||||
// then a second after the clear; the query waits for both to settle into a single fetch.
|
||||
useEffect(() => {
|
||||
if (committedLang !== lang) {
|
||||
// Not adjustable during render, which is where a state reset like this would otherwise
|
||||
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
|
||||
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
|
||||
// — the guard above is false immediately afterwards.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCommittedLang(lang);
|
||||
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
||||
}
|
||||
@@ -308,7 +322,7 @@ export default function PlexBrowse() {
|
||||
}
|
||||
function onInfo(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
infoScrollRef.current = 0; // fresh info page starts at the top
|
||||
rememberInfoScroll(0); // fresh info page starts at the top
|
||||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
// Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip).
|
||||
@@ -316,7 +330,7 @@ export default function PlexBrowse() {
|
||||
// it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a
|
||||
// later info page would wrongly restore this stale offset.
|
||||
function onOpenPerson(name: string) {
|
||||
if (sub.view.kind === "info") infoScrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
if (sub.view.kind === "info") rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||
sub.open({ kind: "person", name });
|
||||
}
|
||||
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
|
||||
@@ -361,7 +375,7 @@ export default function PlexBrowse() {
|
||||
}
|
||||
setFilters(merged as unknown as PlexFilters);
|
||||
if (patch.actors?.length || patch.directors?.length) setScope("both");
|
||||
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
|
||||
rememberInfoScroll(scroller()?.scrollTop ?? 0); // restore on Back to the info/show page
|
||||
sub.open({ kind: "grid" });
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,38 @@ const money = (n: number) =>
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
|
||||
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
|
||||
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
|
||||
// declared during render is a NEW component type on every render — React unmounts and remounts the
|
||||
// subtree rather than updating it, which throws away DOM state and, for this one, the keyboard focus
|
||||
// on the button while the info page re-renders around it.
|
||||
function Score({
|
||||
score,
|
||||
filterable,
|
||||
onFilterRating,
|
||||
}: {
|
||||
score: number;
|
||||
filterable: boolean;
|
||||
onFilterRating?: (score: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return filterable && onFilterRating ? (
|
||||
<button
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex items-center gap-1 hover:opacity-80"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
|
||||
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
|
||||
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
|
||||
@@ -65,29 +97,17 @@ function RatingsRow({
|
||||
const primary = imdbRating ?? audience ?? null; // the filterable score
|
||||
const chipCls =
|
||||
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
|
||||
const Score = ({ score, filterable }: { score: number; filterable: boolean }) =>
|
||||
filterable && onFilterRating ? (
|
||||
<button
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex items-center gap-1 hover:opacity-80"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
const has = (x?: number | null): x is number => x != null;
|
||||
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{has(imdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score score={imdbRating} filterable={imdbRating === primary} />
|
||||
<Score
|
||||
score={imdbRating}
|
||||
filterable={imdbRating === primary}
|
||||
onFilterRating={onFilterRating}
|
||||
/>
|
||||
<span className="text-2xs font-normal opacity-70">IMDb</span>
|
||||
{imdbUrl && (
|
||||
<a
|
||||
@@ -111,7 +131,7 @@ function RatingsRow({
|
||||
)}
|
||||
{has(audience) && imdbRating !== audience && (
|
||||
<span className={chipCls}>
|
||||
<Score score={audience} filterable={!has(imdbRating)} />
|
||||
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
|
||||
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -208,6 +208,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
|
||||
const barAutoHideRef = useRef(prefs.barAutoHide);
|
||||
barAutoHideRef.current = prefs.barAutoHide;
|
||||
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
|
||||
// a ref for the same reason `tRef` above does: naming them in that effect's deps would re-run it
|
||||
// when the user picks a track from the player's own menu, double-loading the session the picker
|
||||
// has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
|
||||
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
|
||||
const langPrefsRef = useRef({ audioLang: prefs.audioLang, subLang: prefs.subLang });
|
||||
langPrefsRef.current = { audioLang: prefs.audioLang, subLang: prefs.subLang };
|
||||
const seekStepRef = useRef(prefs.seekStep);
|
||||
seekStepRef.current = prefs.seekStep;
|
||||
const fineSeekStepRef = useRef(prefs.fineSeekStep);
|
||||
@@ -474,7 +481,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
restartsRef.current.at = now;
|
||||
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
|
||||
restartsRef.current.n += 1;
|
||||
loadSession(playhead(absRef, loadStartRef), !video.paused);
|
||||
// Through the ref, not by name: this is loadSession calling ITSELF from inside its own
|
||||
// useCallback, which the checker rightly reads as "accessed before it is declared".
|
||||
// The ref (assigned right below the callback) is the file's existing answer to that —
|
||||
// detachSessionRef and cancelAutoSkipRef do the same for the same reason.
|
||||
void loadSessionRef.current(playhead(absRef, loadStartRef), !video.paused);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -497,6 +508,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
},
|
||||
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
|
||||
);
|
||||
// The self-restart path above reaches the CURRENT loadSession through this, so the recursion is
|
||||
// not a forward reference to a binding that is still being defined.
|
||||
const loadSessionRef = useRef(loadSession);
|
||||
// eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly.
|
||||
loadSessionRef.current = loadSession;
|
||||
|
||||
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
|
||||
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
|
||||
@@ -552,16 +568,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
restartsRef.current = { n: 0, at: 0 };
|
||||
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
|
||||
// audio ref synchronously so the loadSession below picks the right track on the first request.
|
||||
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
|
||||
const { audioLang, subLang } = langPrefsRef.current;
|
||||
const aOrd = audioOrdForLang(detail.audio_streams ?? [], audioLang);
|
||||
audioRef.current = aOrd;
|
||||
setAudioOrd(aOrd);
|
||||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
|
||||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
|
||||
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||
loadSession(resume);
|
||||
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
|
||||
// double-load the session — the picker handlers already reload/toggle).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detail, id, loadSession]);
|
||||
void loadSession(resume);
|
||||
}, [detail, id, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -593,7 +607,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const v = videoRef.current;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the live counter, as above
|
||||
// The rule's advice — copy the ref into a variable and use the copy — is precisely wrong here.
|
||||
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
|
||||
// that its response no longer belongs to anything, and a value captured when the effect ran
|
||||
// would invalidate a generation that has since been superseded instead of the current one.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
sessGenRef.current++;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
@@ -839,7 +857,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setUpNextOpen(false);
|
||||
if (nextId) go(nextId);
|
||||
}, [nextId, go]);
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
// The "up next" card belongs to the item that was playing, so a queue advance closes it. Adjusted
|
||||
// during the render that changes `id` rather than in an effect: as an effect it closed one commit
|
||||
// after the new episode had already drawn behind it.
|
||||
const [upNextForId, setUpNextForId] = useState(id);
|
||||
if (upNextForId !== id) {
|
||||
setUpNextForId(id);
|
||||
setUpNextOpen(false);
|
||||
}
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
|
||||
|
||||
@@ -12,7 +12,10 @@ type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||
// panel owns no state of its own. `now` is the live wall clock (passed in, not a second ticker) used
|
||||
// only for the clock-date-style preview buttons.
|
||||
export function PlexPlayerSettings(props: {
|
||||
menuRef: RefObject<HTMLDivElement>;
|
||||
// `| null` is not optional under React 19's types: `useRef<T>(null)` is now `RefObject<T | null>`,
|
||||
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
|
||||
// about that and 18's types let it.
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
settingsTabs: { key: SettingsTab; label: string }[];
|
||||
activeTab: SettingsTab;
|
||||
setSettingsTab: (tab: SettingsTab) => void;
|
||||
|
||||
@@ -48,8 +48,14 @@ export default function PlexSearchBox() {
|
||||
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
||||
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
||||
|
||||
// A fresh query resets the highlight.
|
||||
useEffect(() => setHi(-1), [dq, activeField, scope]);
|
||||
// A fresh query resets the highlight — during THAT render, so no frame ever draws the new
|
||||
// suggestion list with the old row highlighted.
|
||||
const suggestKey = `${scope}|${activeField}|${dq}`;
|
||||
const [hiFor, setHiFor] = useState(suggestKey);
|
||||
if (hiFor !== suggestKey) {
|
||||
setHiFor(suggestKey);
|
||||
setHi(-1);
|
||||
}
|
||||
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
||||
useEffect(() => {
|
||||
if (hi < 0) return;
|
||||
|
||||
@@ -129,11 +129,15 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
||||
// and a feed modal shouldn't linger open on plex.
|
||||
useEffect(() => {
|
||||
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
|
||||
// source rather than from an effect, so the first plex render never commits carrying the feed's
|
||||
// sticky id — which is exactly long enough for a stale view chip to paint.
|
||||
const [prevSource, setPrevSource] = useState(source);
|
||||
if (prevSource !== source) {
|
||||
setPrevSource(source);
|
||||
setStickyId(null);
|
||||
setModalOpen(false);
|
||||
}, [source]);
|
||||
}
|
||||
|
||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
||||
@@ -146,17 +150,21 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
|
||||
// before this provider mounts.
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
// `cfg` re-memoizes on every filter edit, so it is read through a ref: the seed must fire on the
|
||||
// views query's first resolve for a source and never again, which `seededRef` enforces. Listing
|
||||
// cfg would re-arm nothing (the guard still holds) but would re-run the effect on every keystroke
|
||||
// in the filter bar. The ref keeps the dep list honest without that.
|
||||
const cfgRef = useRef(cfg);
|
||||
cfgRef.current = cfg;
|
||||
useEffect(() => {
|
||||
const data = viewsQuery.data;
|
||||
if (!data || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||
const c = cfgRef.current;
|
||||
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
||||
if (!def) return;
|
||||
cfg.setFilters(cfg.asFilters(def.filters));
|
||||
c.setFilters(c.asFilters(def.filters));
|
||||
setStickyId(def.id);
|
||||
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
|
||||
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewsQuery.data, source]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
@@ -168,9 +176,10 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
),
|
||||
[views, currentKey, stickyId, cfg],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (activeView) setStickyId(activeView.id);
|
||||
}, [activeView]);
|
||||
// The sticky id follows whichever view is active — also adjusted during render. It converges by
|
||||
// construction: preferSticky returns the sticky match when there is one, so the very write below
|
||||
// makes its own condition false on the next pass.
|
||||
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
|
||||
|
||||
const stickyView = useMemo(
|
||||
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to
|
||||
// hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep
|
||||
// a draft only while the field is being edited (E27 S2). That matters here more than elsewhere
|
||||
// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the
|
||||
// thing a poll can overwrite at the wrong moment.
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import Scheduler from "./Scheduler";
|
||||
import { api, type SchedulerStatus } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
|
||||
running_here: true,
|
||||
enabled: true,
|
||||
paused: false,
|
||||
jobs: [
|
||||
{
|
||||
id: "sync",
|
||||
interval_minutes: 30,
|
||||
next_run: null,
|
||||
running: false,
|
||||
status: "ok",
|
||||
last_started: null,
|
||||
last_finished: null,
|
||||
last_result: null,
|
||||
last_error: null,
|
||||
progress: null,
|
||||
},
|
||||
],
|
||||
queue: {
|
||||
channels_recent_pending: 0,
|
||||
channels_deep_pending: 0,
|
||||
deep_eta_seconds: 0,
|
||||
videos_pending_enrich: 0,
|
||||
videos_pending_shorts: 0,
|
||||
videos_live_refresh: 0,
|
||||
},
|
||||
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
|
||||
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
|
||||
...over,
|
||||
});
|
||||
|
||||
let qc: QueryClient;
|
||||
let current: SchedulerStatus;
|
||||
|
||||
async function mount(initial = status()) {
|
||||
current = initial;
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Scheduler />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("scheduler.maintenance.batchLabel");
|
||||
}
|
||||
|
||||
/** A poll landing, driven through the query the page actually makes. */
|
||||
async function poll(next: SchedulerStatus) {
|
||||
current = next;
|
||||
await act(() => qc.refetchQueries({ queryKey: qk.scheduler() }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const intervalBox = () => screen.queryByRole("spinbutton", { name: "" });
|
||||
const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel");
|
||||
|
||||
// The FIRST FOUR tests below are CHARACTERIZATION, not regression guards for E27 S2: the old
|
||||
// JobRow's re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while
|
||||
// editing, so each of those four passes against the pre-sweep code too. They are here because the
|
||||
// editor had no tests at all. The two after them — the range refusal and the focus return — ARE
|
||||
// regression guards, for the silent-discard fix, and they fail against the code this file shipped
|
||||
// with. The editor that genuinely lost the user's typing to a poll is the maintenance batch box
|
||||
// below: its old effect had no guard, and that one has its own test.
|
||||
describe("Scheduler · the job interval editor", () => {
|
||||
it("opens on the current interval and commits a new one", async () => {
|
||||
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
|
||||
await mount();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
||||
const box = intervalBox()!;
|
||||
expect(box).toHaveProperty("value", "30");
|
||||
|
||||
fireEvent.change(box, { target: { value: "45" } });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(intervalBox()).toBeNull(); // committing closes the editor at once
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45));
|
||||
});
|
||||
|
||||
it("drops what you typed on Escape and shows the server's value again", async () => {
|
||||
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
|
||||
await mount();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
||||
fireEvent.change(intervalBox()!, { target: { value: "999" } });
|
||||
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(intervalBox()).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
||||
expect(intervalBox()).toHaveProperty("value", "30");
|
||||
});
|
||||
|
||||
it("keeps what you are typing when a poll lands underneath", async () => {
|
||||
await mount();
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
||||
fireEvent.change(intervalBox()!, { target: { value: "45" } });
|
||||
|
||||
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] }));
|
||||
|
||||
expect(intervalBox()).toHaveProperty("value", "45");
|
||||
});
|
||||
|
||||
it("follows the server while the editor is closed", async () => {
|
||||
await mount();
|
||||
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
||||
expect(intervalBox()).toHaveProperty("value", "90");
|
||||
});
|
||||
|
||||
it("refuses a value outside the range instead of silently dropping it", async () => {
|
||||
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
|
||||
await mount();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
||||
fireEvent.change(intervalBox()!, { target: { value: "99999" } });
|
||||
fireEvent.keyDown(intervalBox()!, { key: "Enter" });
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
// Still open, still showing what was typed, and saying why.
|
||||
expect(intervalBox()).toHaveProperty("value", "99999");
|
||||
expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
|
||||
});
|
||||
|
||||
it("hands focus back to the trigger when the editor closes", async () => {
|
||||
await mount();
|
||||
const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ });
|
||||
fireEvent.click(trigger);
|
||||
expect(document.activeElement).toBe(intervalBox());
|
||||
|
||||
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: /scheduler.everyMin/ }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler · the maintenance batch editor", () => {
|
||||
it("keeps what you are typing when a poll changes the batch underneath", async () => {
|
||||
// THE regression this editor needed: its pre-E27 effect re-synced on every
|
||||
// `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced
|
||||
// what the admin had typed.
|
||||
await mount();
|
||||
fireEvent.change(batchBox(), { target: { value: "9000" } });
|
||||
|
||||
// The poll carries a new interval TOO, and the interval label follows the server unconditionally
|
||||
// — so waiting for it is how this test knows the new status has actually been rendered. Without
|
||||
// that anchor the assertion below passes on a page that simply has not polled yet.
|
||||
await poll(
|
||||
status({
|
||||
jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }],
|
||||
maintenance: { ...status().maintenance, revalidate_batch: 12000 },
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
|
||||
);
|
||||
|
||||
expect(batchBox()).toHaveProperty("value", "9000");
|
||||
});
|
||||
|
||||
it("refuses a value outside min..max instead of silently dropping it", async () => {
|
||||
const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({
|
||||
revalidate_batch: 5000,
|
||||
});
|
||||
await mount();
|
||||
|
||||
fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000
|
||||
fireEvent.keyDown(batchBox(), { key: "Enter" });
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(batchBox()).toHaveProperty("value", "10");
|
||||
expect(batchBox().getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
|
||||
expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("is not dirty until edited, and stops being dirty once saved", async () => {
|
||||
const save = vi
|
||||
.spyOn(api, "updateMaintenanceBatch")
|
||||
.mockResolvedValue({ revalidate_batch: 9000 });
|
||||
await mount();
|
||||
const saveBtn = () => screen.getByRole("button", { name: "common.save" });
|
||||
|
||||
expect(saveBtn()).toHaveProperty("disabled", true);
|
||||
fireEvent.change(batchBox(), { target: { value: "9000" } });
|
||||
expect(saveBtn()).toHaveProperty("disabled", false);
|
||||
|
||||
fireEvent.keyDown(batchBox(), { key: "Enter" });
|
||||
expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith(9000));
|
||||
});
|
||||
|
||||
it("follows a poll that changes the batch while nothing is being typed", async () => {
|
||||
await mount();
|
||||
expect(batchBox()).toHaveProperty("value", "5000");
|
||||
await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } }));
|
||||
await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000"));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../i18n";
|
||||
@@ -139,17 +139,33 @@ function JobRow({
|
||||
runDisabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [val, setVal] = useState(String(job.interval_minutes));
|
||||
// The box shows the server's value and holds a draft only while it is being edited, so there is
|
||||
// nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page
|
||||
// refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
|
||||
// previous value for a frame.
|
||||
const rangeId = useId();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const editing = draft !== null;
|
||||
const val = draft ?? String(job.interval_minutes);
|
||||
const next = secsUntil(job.next_run);
|
||||
useEffect(() => {
|
||||
if (!editing) setVal(String(job.interval_minutes));
|
||||
}, [job.interval_minutes, editing]);
|
||||
const m = parseInt(val, 10);
|
||||
const valid = Number.isFinite(m) && m >= 1 && m <= 1440;
|
||||
// Every way out of the editor unmounts the focused control, so hand focus back to the trigger that
|
||||
// opened it — otherwise Escape drops a keyboard user onto <body> and the next Tab restarts at the
|
||||
// top of the page. Same pattern as IssueComposer's review buttons.
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
function close() {
|
||||
setDraft(null);
|
||||
queueMicrotask(() => triggerRef.current?.focus());
|
||||
}
|
||||
|
||||
function save() {
|
||||
const m = parseInt(val, 10);
|
||||
if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m);
|
||||
setEditing(false);
|
||||
// A value outside the range is REFUSED, not swallowed: the draft stays so the user can see and
|
||||
// correct what they typed, and `aria-invalid` + the hint say why. Dropping it silently left the
|
||||
// field showing the server's number with no clue whether the save had happened.
|
||||
if (!valid) return;
|
||||
if (m !== job.interval_minutes) onSave(m);
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -170,34 +186,46 @@ function JobRow({
|
||||
max={1440}
|
||||
value={val}
|
||||
autoFocus
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
aria-invalid={!valid}
|
||||
aria-errormessage={valid ? undefined : rangeId}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
if (e.key === "Escape") close();
|
||||
}}
|
||||
className="w-16 bg-surface border border-border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
className={`w-16 bg-surface border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 ${
|
||||
valid
|
||||
? "border-border focus:ring-accent/40"
|
||||
: "border-danger focus:ring-danger/40"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
||||
<FocusSafeButton
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
disabled={saving || !valid}
|
||||
className="text-success hover:opacity-80 disabled:opacity-50"
|
||||
aria-label={t("common.save")}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
onClick={close}
|
||||
className="text-muted hover:text-fg"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{!valid && (
|
||||
<span id={rangeId} role="alert" className="text-2xs text-danger">
|
||||
{t("scheduler.range", { min: 1, max: 1440 })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint={t("scheduler.editInterval")}>
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
ref={triggerRef}
|
||||
onClick={() => setDraft(String(job.interval_minutes))}
|
||||
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||
>
|
||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
||||
@@ -256,12 +284,23 @@ function MaintenanceSettings({
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [val, setVal] = useState(String(m.revalidate_batch));
|
||||
useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]);
|
||||
// Same shape as the job row above: a draft while editing, the server's value otherwise. This is
|
||||
// the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no
|
||||
// `editing` guard at all.
|
||||
const rangeId = useId();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const val = draft ?? String(m.revalidate_batch);
|
||||
const n = parseInt(val, 10);
|
||||
const valid = Number.isFinite(n) && n >= m.min && n <= m.max;
|
||||
|
||||
function save() {
|
||||
const n = parseInt(val, 10);
|
||||
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
|
||||
// Out of range is REFUSED, not swallowed: keep the draft so the typed value stays visible and
|
||||
// correctable, and say why. Dropping it left the field showing the server's number with nothing
|
||||
// to tell the user whether the save had happened, been rejected, or been ignored.
|
||||
if (!valid) return;
|
||||
if (n !== m.revalidate_batch) onSave(n);
|
||||
// Dropping the draft is what lets the box follow the server again once the save lands.
|
||||
setDraft(null);
|
||||
}
|
||||
const dirty = String(m.revalidate_batch) !== val;
|
||||
|
||||
@@ -281,15 +320,27 @@ function MaintenanceSettings({
|
||||
max={m.max}
|
||||
step={1000}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") setDraft(null);
|
||||
}}
|
||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
aria-invalid={!valid}
|
||||
aria-errormessage={valid ? undefined : rangeId}
|
||||
className={`w-28 bg-surface border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 ${
|
||||
valid ? "border-border focus:ring-accent/40" : "border-danger focus:ring-danger/40"
|
||||
}`}
|
||||
/>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving || !valid}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{!valid && (
|
||||
<div id={rangeId} role="alert" className="text-2xs text-danger mt-1.5">
|
||||
{t("scheduler.range", { min: m.min, max: m.max })}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xs text-muted mt-1.5">
|
||||
{t("scheduler.maintenance.batchNote", {
|
||||
default: formatNumber(m.revalidate_batch_default),
|
||||
|
||||
@@ -20,17 +20,17 @@ export default function SetupWizard() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
|
||||
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
|
||||
const [tokenError, setTokenError] = useState(false);
|
||||
// "No token at all" is not a fetch outcome, it is a fact about the URL — so it is derived rather
|
||||
// than pushed into state by an effect, which had to run and commit before the error could show.
|
||||
const [infoFailed, setInfoFailed] = useState(false);
|
||||
const tokenError = !token || infoFailed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setTokenError(true);
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
api
|
||||
.setupInfo(token)
|
||||
.then(setInfo)
|
||||
.catch(() => setTokenError(true));
|
||||
.catch(() => setInfoFailed(true));
|
||||
}, [token]);
|
||||
|
||||
// Step state.
|
||||
|
||||
@@ -299,15 +299,16 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
|
||||
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
|
||||
// The autoTried ref is what makes it fire exactly once.
|
||||
const { mutate: createLink, isPending: createPending } = create;
|
||||
useEffect(() => {
|
||||
if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) {
|
||||
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
|
||||
autoTried.current = true;
|
||||
create.mutate();
|
||||
createLink();
|
||||
}
|
||||
// Only links.data (empty → auto-create once) should drive this; `create` is a fresh object every
|
||||
// render and would re-run the effect needlessly. The autoTried ref makes it fire exactly once.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [links.data]);
|
||||
}, [links.data, createLink, createPending]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
|
||||
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
|
||||
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
|
||||
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
|
||||
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
|
||||
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
|
||||
// second re-fit needs no user action at all.
|
||||
//
|
||||
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
|
||||
// measurement can only ever produce one answer.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const fit = { visible: 1 };
|
||||
vi.mock("../lib/useChipOverflowFit", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/useChipOverflowFit")>();
|
||||
return { ...actual, useChipOverflowFit: () => fit.visible };
|
||||
});
|
||||
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
|
||||
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
|
||||
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
|
||||
|
||||
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
|
||||
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
|
||||
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
|
||||
const menu = () => screen.queryByRole("menu", { hidden: true });
|
||||
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
fit.visible = 1;
|
||||
});
|
||||
|
||||
describe("TagFilterChips — the overflow menu", () => {
|
||||
it("does not come back on its own once the lane stops overflowing", () => {
|
||||
const { rerender } = render(<TagFilterChips chips={chips} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
|
||||
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
|
||||
// nothing is collapsed any more: both trigger and menu go.
|
||||
fit.visible = 3;
|
||||
rerender(<TagFilterChips chips={chips} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toBeNull();
|
||||
|
||||
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
|
||||
// returning trigger must not claim to be expanded.
|
||||
fit.visible = 1;
|
||||
rerender(<TagFilterChips chips={chips} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
it("does not come back when the chip set SHRINKS under the open menu", () => {
|
||||
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
|
||||
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
|
||||
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
|
||||
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
|
||||
// unclamped never fires, and the flag survives the one window it exists to close.
|
||||
//
|
||||
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
|
||||
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
|
||||
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
|
||||
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
|
||||
fit.visible = 3;
|
||||
const { rerender } = render(<TagFilterChips chips={five} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
|
||||
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
|
||||
expect(menu()).toBeNull();
|
||||
|
||||
// …and now the layout effect lands: two long names, only one of them fits.
|
||||
fit.visible = 1;
|
||||
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
it("still opens and closes on the trigger", () => {
|
||||
render(<TagFilterChips chips={chips} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
@@ -58,20 +58,33 @@ export default function TagFilterChips({
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = chips.length - visible;
|
||||
const [open, setOpen] = useState(false);
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, chips.length - visible);
|
||||
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
|
||||
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
|
||||
// contain a menu hanging off a reference that is already gone.
|
||||
//
|
||||
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
|
||||
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
|
||||
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
|
||||
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
|
||||
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
|
||||
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
|
||||
// flag is reset in RENDER against the same condition that hides it: convergent (the write
|
||||
// falsifies it) and, unlike an effect, gone before the frame is painted.
|
||||
const [openRaw, setOpen] = useState(false);
|
||||
if (openRaw && hidden === 0) setOpen(false);
|
||||
const open = openRaw && hidden > 0;
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
// If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
|
||||
// linger against a gone reference.
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && open) setOpen(false);
|
||||
}, [hidden, open]);
|
||||
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
|
||||
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
||||
downloadUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,7 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage },
|
||||
}));
|
||||
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
@@ -44,6 +49,8 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu
|
||||
beforeEach(() => {
|
||||
h.uploadDownload.mockClear();
|
||||
h.notify.mockClear();
|
||||
h.downloadUsage.mockClear();
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -76,4 +83,98 @@ describe("UploadDialog", () => {
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// `file.size > cap`, on the boundary in both directions — the cap is inclusive server-side
|
||||
// (backend test_upload.py pins that a body of EXACTLY the cap is accepted), so a dialog that
|
||||
// refused it would make a legal file un-uploadable with no way round. Two sizes either side of
|
||||
// one cap, because a single over-cap case passes just as happily with `>` loosened to `>=`.
|
||||
const CAP = 8;
|
||||
const pickFile = (bytes: number, name = "f.bin") =>
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x".repeat(bytes)], name)] },
|
||||
});
|
||||
|
||||
it("refuses a file one byte over the cap, naming the limit, instead of sending it", async () => {
|
||||
// Both server-side caps answer only after the transfer — and on a default install the one that
|
||||
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
|
||||
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
|
||||
// moves.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
// The drop zone carries the reason, because a natively disabled Submit is not in the tab order
|
||||
// to be asked for it.
|
||||
const zone = screen.getByText("big.bin").closest("button")!;
|
||||
expect(zone.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(zone.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id);
|
||||
});
|
||||
|
||||
it("still highlights as a drop target while the picked file is over the cap", async () => {
|
||||
// The state a user is in when they see the refusal is "replace this file". With a file
|
||||
// already picked the zone's label shows its NAME rather than `dropActive`, so the border is
|
||||
// the only feedback that a drag is over a live target — and a danger border that outranks the
|
||||
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
|
||||
// does not exist. The drop would have worked.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
const zone = screen.getByText("big.bin").closest("button")!;
|
||||
expect(zone.className).toContain("border-danger");
|
||||
|
||||
fireEvent.dragEnter(zone);
|
||||
expect(zone.className).toContain("border-accent");
|
||||
expect(zone.className).not.toContain("border-danger");
|
||||
});
|
||||
|
||||
it("swallows a Submit press that arrives while the button still holds focus", async () => {
|
||||
// The OTHER half of "never sent": the test above covers the natively disabled button (not
|
||||
// focused), this one covers the soft-disabled one. `ui/Button` is focus-safe, so a button that
|
||||
// turns unavailable while it HOLDS focus stays in the tab order and stays clickable, and it is
|
||||
// the primitive's own click guard that refuses — measured: deleting the `tooBig` term from
|
||||
// `submit()` changes nothing here, which is why that term is documented as belt and braces
|
||||
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
|
||||
// after dropping a bigger file does not start a transfer the server will refuse.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "fits.bin");
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
btn.focus();
|
||||
fireEvent.focus(btn);
|
||||
pickFile(CAP + 1, "toobig.bin");
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
||||
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
||||
fireEvent.click(btn);
|
||||
expect(h.uploadDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "exact.bin");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
|
||||
// Failing the same way again has to be announced again. The message names the CAP, not the
|
||||
// file, so two over-cap picks leave the node and its text identical — and a live region that
|
||||
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
|
||||
// the three other alerts in this app that can repeat.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "a.bin");
|
||||
const first = await screen.findByRole("alert");
|
||||
pickFile(CAP + 2, "b.bin");
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBe(first));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { isAborted } from "../lib/api/core";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
@@ -31,6 +33,26 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||
const dragDepth = useRef(0);
|
||||
const busy = pct !== null;
|
||||
// The per-file cap, so an over-cap file is refused HERE instead of after the user has pushed it up
|
||||
// the wire. Both server-side caps are still the boundary — this is only the pleasant half of it.
|
||||
// On a default install the one that answers is the edge ceiling (`bodylimit`), whose refusal
|
||||
// carries no number, so without this the user would be told "too large" and not what the limit is.
|
||||
// A cap that has not loaded yet blocks nothing — the server still refuses, honestly.
|
||||
const cap = useQuery({ queryKey: qk.downloadUsage(), queryFn: api.downloadUsage }).data
|
||||
?.upload_max_bytes;
|
||||
const tooBig = !!file && !!cap && file.size > cap;
|
||||
const refusalId = useId();
|
||||
// Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two
|
||||
// over-cap picks in a row leave the node and its text identical — the message names the cap, not
|
||||
// the file — and a live region that is not touched is not announced, so the second refusal was
|
||||
// silent to a screen reader. `KeyGate`, `SetupWizard` and `Welcome` all key their alerts for the
|
||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||
const [pickSeq, setPickSeq] = useState(0);
|
||||
const pick_ = (f: File) => {
|
||||
setFile(f);
|
||||
setName("");
|
||||
setPickSeq((n) => n + 1);
|
||||
};
|
||||
|
||||
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||
@@ -40,7 +62,11 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || busy) return;
|
||||
// `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the
|
||||
// button holds focus it swallows the activation itself, and while it does not it is natively
|
||||
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
|
||||
// described as the guard. It is here for the next caller of `submit`, not for the only one.
|
||||
if (!file || busy || tooBig) return;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
@@ -94,14 +120,28 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
if (f) pick_(f);
|
||||
}}
|
||||
disabled={busy}
|
||||
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
|
||||
// the drop zone reads as invalid and points at the sentence, so a reader who tabs back to
|
||||
// it hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported
|
||||
// list: Chromium's accessibility node for a native button does carry `invalid` from this
|
||||
// attribute.) The signal that is NOT colour is the sentence below, visible to everyone —
|
||||
// the border only reinforces it.
|
||||
aria-invalid={tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
// `dragOver` outranks `tooBig`: a drag is transient and the refusal repaints the instant
|
||||
// the drop lands, whereas a zone that stays red under the pointer tells the user their
|
||||
// drop target is dead — in the one state where they are trying to replace the file. With
|
||||
// a file picked, the label below shows its name rather than `dropActive`, so this border
|
||||
// is the ONLY feedback that the zone will accept the drop.
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
|
||||
dragOver
|
||||
? "border-accent bg-accent/5"
|
||||
: tooBig
|
||||
? "border-danger"
|
||||
: "border-border hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
@@ -125,22 +165,31 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
if (f) pick_(f);
|
||||
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||
}}
|
||||
/>
|
||||
|
||||
<label className="block text-sm font-medium mt-4 mb-1">
|
||||
{tooBig && (
|
||||
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
|
||||
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label htmlFor="upload-display-name" className="block text-sm font-medium mt-4 mb-1">
|
||||
{t("downloads.upload.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="upload-display-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("downloads.upload.namePlaceholder")}
|
||||
disabled={busy}
|
||||
// The column behind it is varchar(255) and the name rides in a REQUEST HEADER, where
|
||||
// nothing else bounds it; the server truncates to the same number. The number is not a
|
||||
// third copy: `downloads.contract.test.ts` holds DISPLAY_NAME_MAX against the backend's
|
||||
// own DISPLAY_TEXT_MAX.
|
||||
maxLength={DISPLAY_NAME_MAX}
|
||||
className={`${inputCls} mb-5`}
|
||||
/>
|
||||
|
||||
@@ -175,7 +224,19 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy}>
|
||||
{/* Plain `disabled` — `ui/Button` is a FocusSafeButton, which already expresses it as
|
||||
`aria-disabled` + a click guard for as long as the button HOLDS focus, so this cannot
|
||||
strand a keyboard user on <body> when the cap arrives late or a second file is dropped.
|
||||
`submit` refuses independently, which is what that soft state relies on. The REASON is
|
||||
reachable from the drop zone (aria-invalid + aria-describedby above) rather than from
|
||||
here, because a natively disabled button is not in the tab order to be asked. */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!file || busy || tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
>
|
||||
{t("downloads.upload.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -138,8 +138,10 @@ const T = STR[lang];
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
|
||||
// to load, and "loading" was committed and painted before the effect said so.
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
|
||||
"loading",
|
||||
token ? "loading" : "gone",
|
||||
);
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -166,10 +168,7 @@ export default function WatchPage() {
|
||||
// Bumped by Retry, which asks again.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
if (!token) return; // the seed above already says "gone"
|
||||
send(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||
.then((r) => {
|
||||
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
|
||||
|
||||
@@ -432,12 +432,17 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
|
||||
// — without this, Back out of a submitted form would show the old success message under the
|
||||
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
|
||||
useEffect(() => {
|
||||
// Cleared during the render that changes the view, not a commit later: the render is
|
||||
// `done ? msg : form`, so an effect meant Back out of a submitted form drew the old success
|
||||
// message under the new view's title for one frame — which is the exact symptom this guards.
|
||||
const [outcomeFor, setOutcomeFor] = useState(mode);
|
||||
if (outcomeFor !== mode) {
|
||||
setOutcomeFor(mode);
|
||||
setDone("");
|
||||
setError("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
}, [mode]);
|
||||
}
|
||||
|
||||
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||
// by the demo whitelist server-side.
|
||||
|
||||
@@ -39,6 +39,7 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (meId == null || canRead == null) return;
|
||||
const account = { can_read: canRead, is_demo: isDemo, google_enabled: googleEnabled };
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
|
||||
if (shouldAutoOpenOnboarding(account)) setOpen(true);
|
||||
}, [meId, canRead, canWrite, isDemo, googleEnabled]);
|
||||
|
||||
|
||||
@@ -126,6 +126,17 @@ describe("the upload dialog", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// The dialog reads the per-file cap on mount. Unstubbed it is a REAL fetch from jsdom, which
|
||||
// fails, retries once after 250 ms and then raises the "connection lost" notice — a delayed
|
||||
// toast that outlives the test that spawned it and lands as an unexpected error in whichever
|
||||
// test is running by then. The file is fast enough today that it always lands after the suite,
|
||||
// which is a coincidence, not a guarantee (deep-review round 2).
|
||||
vi.spyOn(api, "downloadUsage").mockResolvedValue({
|
||||
upload_max_bytes: 1_000_000,
|
||||
} as Awaited<ReturnType<typeof api.downloadUsage>>);
|
||||
});
|
||||
|
||||
it("names an upload the server refused", async () => {
|
||||
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
|
||||
// wipes it, and the dialog snapped back with no error and no job in the list.
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface ChannelsActions {
|
||||
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
|
||||
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
|
||||
focusChannel: (name: string) => void;
|
||||
// Mark the current focus intent as applied. The manager calls this the moment it seeds its name
|
||||
// filter, so the intent cannot be re-applied when the page chunk remounts on a later visit and
|
||||
// overwrite a search the user has typed since.
|
||||
consumeFocus: () => void;
|
||||
// Jump to the manager filtered to channels still missing full history (the header sync chip).
|
||||
goToFullHistory: () => void;
|
||||
}
|
||||
@@ -57,6 +61,7 @@ export const ChannelsActionsContext = createContext<ChannelsActions>({
|
||||
setLayout: () => {},
|
||||
setSort: () => {},
|
||||
focusChannel: () => {},
|
||||
consumeFocus: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pin, PinOff } from "lucide-react";
|
||||
@@ -100,9 +100,9 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides
|
||||
// WHEN the first fetch happens, not whether the answer survives the menu closing.
|
||||
const [everOpen, setEverOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open) setEverOpen(true);
|
||||
}, [open]);
|
||||
// Latched during render: the query below is gated on it, so an effect delayed the first fetch by
|
||||
// a commit for no reason. Convergent — the write falsifies its own condition.
|
||||
if (open && !everOpen) setEverOpen(true);
|
||||
|
||||
const shelf = useQuery({
|
||||
queryKey: qk.comfortPins(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
@@ -77,8 +77,15 @@ function Thumb({ job }: { job: DownloadJob }) {
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
||||
const primary = poster || remote;
|
||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
|
||||
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
|
||||
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
|
||||
// …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster
|
||||
// that 404'd because its file was still being written should be re-requested when the row comes
|
||||
// back, not written off for the life of the recycled slot. Convergent, so it belongs in render.
|
||||
const [failedPrimary, setFailedPrimary] = useState<string | null>(null);
|
||||
if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null);
|
||||
const src = failedPrimary && failedPrimary === primary ? remote : primary;
|
||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
|
||||
if (job.asset?.media_kind === "file") {
|
||||
@@ -97,7 +104,7 @@ function Thumb({ job }: { job: DownloadJob }) {
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
if (remote && src !== remote) setSrc(remote);
|
||||
if (remote && src !== remote) setFailedPrimary(primary);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useLayoutEffect } from "react";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// The failure state is keyed by URL rather than held as a boolean an effect resets, and the reason
|
||||
// is a frame the user could see: Avatar is recycled across items in the virtualized feed, so after
|
||||
// ONE image 429s (Google's avatar CDNs throttle bursts — see the component's own note), the next
|
||||
// item to land in that slot rendered with its new `src` and the previous item's `failed`. The
|
||||
// fallback initial was therefore committed and painted over a perfectly good image, and only the
|
||||
// post-paint effect put it right.
|
||||
//
|
||||
// `useLayoutEffect` is what makes that observable instead of a race: layout effects run against the
|
||||
// committed DOM BEFORE any passive effect, so the snapshot it takes is exactly the frame the browser
|
||||
// would paint — the one the old reset effect had not repaired yet.
|
||||
function Probe({ src, painted }: { src: string; painted: string[] }) {
|
||||
useLayoutEffect(() => {
|
||||
painted.push(document.body.innerHTML);
|
||||
});
|
||||
return <Avatar src={src} alt="avatar" fallback="Zed" />;
|
||||
}
|
||||
|
||||
describe("Avatar · a failed image must not follow the component to the next item", () => {
|
||||
it("shows the new src in the SAME commit that changes it, with no fallback frame between", () => {
|
||||
const painted: string[] = [];
|
||||
const { rerender } = render(<Probe src="https://cdn.test/a.jpg" painted={painted} />);
|
||||
|
||||
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
expect(screen.getByText("Z")).toBeTruthy();
|
||||
|
||||
painted.length = 0;
|
||||
rerender(<Probe src="https://cdn.test/b.jpg" painted={painted} />);
|
||||
|
||||
// The FIRST frame after the src change already carries the image. Reading the last snapshot
|
||||
// instead would let a passive-effect repair pass, which is precisely the bug.
|
||||
expect(painted.length).toBeGreaterThan(0);
|
||||
expect(painted[0]).toContain("https://cdn.test/b.jpg");
|
||||
expect(painted[0]).not.toContain(">Z<");
|
||||
});
|
||||
|
||||
// CHARACTERIZATION: the old boolean-plus-effect passes this too (the rerender is wrapped in act(),
|
||||
// so its reset effect has run by the time the src is read). The frame-level guard is the test
|
||||
// above, which the old shape fails; the retry guard is the test below, which it also fails.
|
||||
it("keeps the fallback for the URL that actually failed, not for whatever is current", () => {
|
||||
const { rerender } = render(
|
||||
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
|
||||
expect(screen.getByText("Z")).toBeTruthy();
|
||||
|
||||
rerender(<Avatar src="https://cdn.test/b.jpg" alt="avatar" fallback="Zed" />);
|
||||
expect(screen.getByRole("img", { name: "avatar" }).getAttribute("src")).toBe(
|
||||
"https://cdn.test/b.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => {
|
||||
// The instance is not remounted here on purpose: the player's channel avatar and the Messages
|
||||
// thread header both swap `src` on a live instance. A memory that outlived the reason for it
|
||||
// would pin a throttled channel to its initial for the rest of the session.
|
||||
const { rerender } = render(
|
||||
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
|
||||
expect(screen.getByText("Z")).toBeTruthy();
|
||||
|
||||
rerender(<Avatar src="https://cdn.test/b.jpg" alt="avatar" fallback="Zed" />);
|
||||
rerender(<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />);
|
||||
expect(screen.getByRole("img", { name: "avatar" }).getAttribute("src")).toBe(
|
||||
"https://cdn.test/a.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when there is no src at all", () => {
|
||||
render(<Avatar src={null} alt="avatar" fallback="Zed" />);
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
expect(screen.getByText("Z")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
// Avatar image with a graceful fallback.
|
||||
//
|
||||
@@ -20,8 +20,22 @@ export default function Avatar({
|
||||
fallback?: string; // a name/title; its first letter is shown when there's no image
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [src]);
|
||||
// The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes.
|
||||
// That distinction is visible: this component is recycled across items in the virtualized feed, so
|
||||
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
|
||||
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
|
||||
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
|
||||
//
|
||||
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
|
||||
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
|
||||
// swaps — the player's channel avatar stepping through a queue, the Messages thread header — would
|
||||
// show the initial for a throttled channel forever, never re-requesting even after the CDN
|
||||
// relented. Written in render because it is convergent (the write falsifies its own condition) and
|
||||
// because an effect would leave one committed frame in which the memory is stale, which is the
|
||||
// frame this whole component is about.
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
|
||||
const failed = !!src && failedSrc === src;
|
||||
|
||||
if (src && !failed) {
|
||||
return (
|
||||
@@ -30,7 +44,7 @@ export default function Avatar({
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
onError={() => setFailedSrc(src)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
useEffect(() => {
|
||||
const el = document.querySelector("main");
|
||||
scrollerRef.current = el;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading the DOM is the point — <main> is swapped by the page switch above us, and only after that commit can its scrollTop be read at all. Nothing derivable in render knows where the new page is scrolled to.
|
||||
setShow(!!el && el.scrollTop > threshold);
|
||||
}, [dep, threshold]);
|
||||
|
||||
|
||||
@@ -246,6 +246,56 @@ describe("Combobox", () => {
|
||||
expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended
|
||||
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
|
||||
});
|
||||
|
||||
// The typed query is discarded when the list closes. CHARACTERIZATION, not the regression guard:
|
||||
// the old effect-based clear satisfies these assertions too, because RTL wraps fireEvent in act()
|
||||
// and the passive effect has flushed by the time the value is read. What the move into the close
|
||||
// funnel actually fixed is measured by the next test, which the old shape fails. This one is here
|
||||
// because the behaviour is worth pinning: the list is for "find and pick", and a leftover filter
|
||||
// would hide everything worth seeing the next time it opens.
|
||||
it("discards the typed query when the list closes, so the next open shows everything", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "com" } });
|
||||
expect(input).toHaveProperty("value", "com");
|
||||
expect(screen.queryByText("Drama")).toBeNull(); // filtered away
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(input.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(input).toHaveProperty("value", "");
|
||||
|
||||
fireEvent.focus(input);
|
||||
expect(screen.getByText("Drama")).toBeTruthy(); // the whole list is back
|
||||
});
|
||||
|
||||
it("does not report a query change until something actually changes it", () => {
|
||||
// A clear driven by an effect on `open` fires at MOUNT too — `open` starts false — so a
|
||||
// controlled caller was told its own initial query had been cleared before the user touched
|
||||
// anything. Driving it from the close event instead means no event, no callback.
|
||||
const onQueryChange = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
options={OPTIONS}
|
||||
selected={[]}
|
||||
onChange={vi.fn()}
|
||||
query="com"
|
||||
onQueryChange={onQueryChange}
|
||||
onOpenChange={onOpenChange}
|
||||
clientFilter={false}
|
||||
/>,
|
||||
);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText("Action"));
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false);
|
||||
expect(onQueryChange.mock.calls).toEqual([[""]]);
|
||||
});
|
||||
});
|
||||
|
||||
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
|
||||
|
||||
@@ -106,6 +106,13 @@ export default function Combobox({
|
||||
const setOpenAnd = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
// Closing discards the typed query: the list is for "find and pick", and a leftover filter would
|
||||
// hide everything worth seeing on the next open. It belongs HERE, not in an effect on `open`,
|
||||
// because this is the single funnel every close goes through — floating-ui's dismiss, Escape and
|
||||
// outside-click all arrive as onOpenChange, and the single-select commit calls it directly
|
||||
// (verified: `setOpen` is called nowhere else). So the clear lands in the same commit as the
|
||||
// close, and the controlled caller's onQueryChange fires once, from an event rather than a render.
|
||||
if (!next) setQuery("");
|
||||
};
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
|
||||
// The input is the combobox reference for floating-ui AND our own focus target (the field's
|
||||
@@ -127,12 +134,6 @@ export default function Combobox({
|
||||
// would make Enter a no-op over a highlighted row.
|
||||
const { activeIndex, highlightIndex } = pop;
|
||||
|
||||
// Clear the typed query when the list closes.
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!clientFilter) return options;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
@@ -81,6 +81,15 @@ export default function CommandPalette({
|
||||
onClose: () => void;
|
||||
groups: CommandGroup[];
|
||||
}) {
|
||||
// Closed means UNMOUNTED, not merely hidden, and that one line replaces the effect that used to
|
||||
// blank the query and the picked row on every open: fresh state is what mounting already means.
|
||||
// It also stops the ranking memo below — which folds and scores every channel, playlist and saved
|
||||
// view — from re-running on every parent render while the palette is shut.
|
||||
if (!open) return null;
|
||||
return <CommandPaletteBody onClose={onClose} groups={groups} />;
|
||||
}
|
||||
|
||||
function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups: CommandGroup[] }) {
|
||||
const { t } = useTranslation();
|
||||
const listId = useId();
|
||||
const groupId = useId();
|
||||
@@ -94,15 +103,6 @@ export default function CommandPalette({
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
|
||||
// query from last time hides everything you might want now.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setPicked(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every
|
||||
// time — and the rows are ranked inside each group. The best row overall starts highlighted, so
|
||||
// Enter takes the best match even when it sits in a later group.
|
||||
@@ -144,6 +144,7 @@ export default function CommandPalette({
|
||||
const [announced, setAnnounced] = useState("");
|
||||
useEffect(() => {
|
||||
if (!summary) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the announcement is DEBOUNCED on purpose (ANNOUNCE_DELAY_MS above): a polite region rewritten per keystroke queues one utterance per letter. A timer's result cannot be derived in render, and the empty branch is the same timer's cancel.
|
||||
setAnnounced("");
|
||||
return;
|
||||
}
|
||||
@@ -151,8 +152,6 @@ export default function CommandPalette({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [summary]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
|
||||
// navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
|
||||
// a command that navigates closes whatever is underneath itself, one that does not (a theme) must
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
|
||||
import Pager from "./Pager";
|
||||
@@ -149,10 +149,19 @@ export default function DataTable<T>({
|
||||
// `filters` as a dep (which would re-run them on every filter change).
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
const commitFilters = (next: FilterMap) => {
|
||||
if (controlledFilters) onFiltersChange!(next);
|
||||
else setInternalFilters(next);
|
||||
};
|
||||
// …and the same trick for the CALLBACK, one line further: a controlled caller almost always
|
||||
// passes an inline arrow, so `onFiltersChange` is a fresh function on every render. Reading it
|
||||
// through a ref is what lets `commitFilters` be stable, and a stable commitFilters is what lets
|
||||
// the two effects below NAME it in their dep lists instead of switching the check off.
|
||||
const onFiltersChangeRef = useRef(onFiltersChange);
|
||||
onFiltersChangeRef.current = onFiltersChange;
|
||||
const commitFilters = useCallback(
|
||||
(next: FilterMap) => {
|
||||
if (controlledFilters) onFiltersChangeRef.current!(next);
|
||||
else setInternalFilters(next);
|
||||
},
|
||||
[controlledFilters],
|
||||
);
|
||||
|
||||
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
|
||||
// caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
|
||||
@@ -169,12 +178,16 @@ export default function DataTable<T>({
|
||||
);
|
||||
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
|
||||
|
||||
// Destructured so the deps are the values the body actually reads. `externalFilter` itself is an
|
||||
// object literal at every call site, so depending on it would re-apply the filter — and reset the
|
||||
// page — on every parent render.
|
||||
const externalKey = externalFilter?.key;
|
||||
const externalValue = externalFilter?.value;
|
||||
useEffect(() => {
|
||||
if (!externalFilter) return;
|
||||
commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
|
||||
if (externalKey === undefined || externalValue === undefined) return;
|
||||
commitFilters({ ...filtersRef.current, [externalKey]: externalValue });
|
||||
setPage(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externalFilter?.key, externalFilter?.value]);
|
||||
}, [externalKey, externalValue, commitFilters]);
|
||||
|
||||
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
|
||||
// so a navigation that bumped the token still clears even when it remounts this table fresh
|
||||
@@ -186,8 +199,7 @@ export default function DataTable<T>({
|
||||
resetRef.current = resetFiltersToken;
|
||||
commitFilters({});
|
||||
setPage(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetFiltersToken]);
|
||||
}, [resetFiltersToken, commitFilters]);
|
||||
|
||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useLayoutEffect } from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -32,3 +33,80 @@ describe("Pager · rows per page", () => {
|
||||
expect(onSizeChange.mock.calls).toEqual([[50], [0]]);
|
||||
});
|
||||
});
|
||||
|
||||
// The page box DERIVES the current page (E27 S2): it holds a draft only while being edited, so the
|
||||
// parent moving the page needs no re-sync effect. The layout-phase probe is what proves "derived"
|
||||
// rather than "re-synced after paint" — a passive effect could not have run yet when it reads.
|
||||
describe("Pager · the page box follows the page without an effect", () => {
|
||||
function Probe({ page, painted }: { page: number; painted: string[] }) {
|
||||
useLayoutEffect(() => {
|
||||
painted.push(
|
||||
(document.querySelector('input[type="number"]') as HTMLInputElement | null)?.value ?? "",
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Pager
|
||||
page={page}
|
||||
size={10}
|
||||
total={100}
|
||||
pageSizeOptions={[10, 25]}
|
||||
onPageChange={() => {}}
|
||||
onSizeChange={() => {}}
|
||||
sizeLabel="Rows per page"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it("shows the new page in the first frame after the parent moves it", () => {
|
||||
const painted: string[] = [];
|
||||
const { rerender } = render(<Probe page={0} painted={painted} />);
|
||||
expect(screen.getByLabelText("datatable.pager.pageLabel")).toHaveProperty("value", "1");
|
||||
|
||||
painted.length = 0;
|
||||
rerender(<Probe page={4} painted={painted} />);
|
||||
expect(painted[0]).toBe("5");
|
||||
});
|
||||
|
||||
it("keeps what you type until you commit it, then follows the page again", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(
|
||||
<Pager
|
||||
page={0}
|
||||
size={10}
|
||||
total={100}
|
||||
pageSizeOptions={[10, 25]}
|
||||
onPageChange={onPageChange}
|
||||
onSizeChange={() => {}}
|
||||
sizeLabel="Rows per page"
|
||||
/>,
|
||||
);
|
||||
const box = screen.getByLabelText("datatable.pager.pageLabel");
|
||||
fireEvent.change(box, { target: { value: "7" } });
|
||||
expect(box).toHaveProperty("value", "7");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(onPageChange).toHaveBeenCalledWith(6);
|
||||
// The parent in this test never moves, so dropping the draft must put page 1 back — proof the
|
||||
// box stopped holding its own copy rather than merely being overwritten.
|
||||
expect(box).toHaveProperty("value", "1");
|
||||
});
|
||||
|
||||
it("discards an unparseable entry instead of committing it", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(
|
||||
<Pager
|
||||
page={2}
|
||||
size={10}
|
||||
total={100}
|
||||
pageSizeOptions={[10, 25]}
|
||||
onPageChange={onPageChange}
|
||||
onSizeChange={() => {}}
|
||||
sizeLabel="Rows per page"
|
||||
/>,
|
||||
);
|
||||
const box = screen.getByLabelText("datatable.pager.pageLabel");
|
||||
fireEvent.change(box, { target: { value: "abc" } });
|
||||
fireEvent.blur(box);
|
||||
expect(onPageChange).not.toHaveBeenCalled();
|
||||
expect(box).toHaveProperty("value", "3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { hasPagerContent, pageCount } from "./pagerContent";
|
||||
@@ -34,9 +34,13 @@ export default function Pager({
|
||||
const totalPages = pageCount(total, size);
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
|
||||
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||
const [pageInput, setPageInput] = useState(String(safePage + 1));
|
||||
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
||||
// Local text for the editable "jump to page" box (committed on Enter/blur). `null` means "not
|
||||
// being edited", so the box simply DERIVES the current page instead of holding a copy that an
|
||||
// effect has to re-sync after every page change — one render instead of two, and no window in
|
||||
// which the box shows the page you just left. The draft is dropped on commit, which is the only
|
||||
// place the two can disagree: clicking any other pager control blurs the input first.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const pageInput = draft ?? String(safePage + 1);
|
||||
|
||||
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
|
||||
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
|
||||
@@ -51,8 +55,10 @@ export default function Pager({
|
||||
}
|
||||
function commitPageInput() {
|
||||
const n = parseInt(pageInput, 10);
|
||||
// Dropping the draft either way is what makes the box follow the real page again — on a valid
|
||||
// jump because the page moves to it, on garbage because there is nothing to commit.
|
||||
setDraft(null);
|
||||
if (!isNaN(n)) goToPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
||||
else setPageInput(String(safePage + 1));
|
||||
}
|
||||
|
||||
if (!hasPagerContent(total, size)) return null;
|
||||
@@ -75,7 +81,7 @@ export default function Pager({
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitPageInput}
|
||||
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
||||
aria-label={t("datatable.pager.pageLabel")}
|
||||
|
||||
@@ -89,10 +89,12 @@ export default function Toaster({
|
||||
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
|
||||
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
|
||||
// same value twice is harmless; that is the point of the ref.
|
||||
// eslint-disable-next-line react-hooks/globals -- the handover between toast hosts is module-level BY DESIGN and must be written during render — see the 12 lines above, which record why a useMemo produced the wrong answer under StrictMode. The write is idempotent, which is what makes a repeated render invocation safe.
|
||||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTop) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- arming is a TIMER, not derived state: the live region must be mounted and empty for a tick before content lands in it or a screen reader never announces the first toast. Deriving `isTop && armed` would let a host that regains top skip that tick.
|
||||
setArmed(false);
|
||||
return;
|
||||
}
|
||||
@@ -196,6 +198,10 @@ export default function Toaster({
|
||||
</div>
|
||||
{isTop && Docked && (
|
||||
<div className="pointer-events-auto w-max max-w-full">
|
||||
{/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks
|
||||
or undocks, and remounting is then the correct behaviour, not the hazard the rule
|
||||
warns about. */}
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<Docked />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -127,6 +127,15 @@ export default function VirtualGrid<T>({
|
||||
|
||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||
|
||||
// The React Compiler (E27 S1) SKIPS memoising this whole component, and says so: TanStack
|
||||
// Virtual's `useVirtualizer()` hands back functions that cannot be memoised without going stale,
|
||||
// so the compiler bails rather than risk a frozen viewport. That is the correct trade here — this
|
||||
// component's cost is the row window it mounts, not its render closures — and it is the ONE such
|
||||
// site in the app, which is why this is a targeted disable rather than the rule being turned off:
|
||||
// a second incompatible library anywhere else must still stop the gate. The caution in the rule's
|
||||
// own text applies, though: values taken from this API and handed to a memoised child can go
|
||||
// stale, so keep the virtualizer's output inside this component.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library -- see above; @tanstack/react-virtual
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollEl,
|
||||
|
||||
@@ -143,6 +143,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// menu opens; clear it whenever the surface closes.
|
||||
useEffect(() => {
|
||||
if (!opts.open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- both indices are also floating-ui's list-navigation state (passed to useListNavigation below), so they cannot be derived from `open` without lying to the library about where the cursor is. Clearing on close is what stops a stale row stealing the next open's initial focus.
|
||||
setActiveIndex(null);
|
||||
setHoverIndex(null);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
||||
},
|
||||
onDragOver: ({ active, over }) => {
|
||||
if (!over || (!moved && over.id === active.id)) return undefined;
|
||||
// `moved` is per-DRAG state, and the announcement closures capturing it is the point:
|
||||
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
|
||||
// the only thing separating that from a real move. A ref would be the wrong lifetime —
|
||||
// one drag, not the component.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
moved = true;
|
||||
return t("common.dnd.movedOver", landing(active.id, over.id));
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user