mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 09:58:37 +00:00
9ca1981d00d29fb9bfda971beeff6032f4d1672f
168
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9ca1981d00 |
refactor: move the Socket.IO handlers into core/socket_handlers.py (#1638)
This one is small. The three Socket.IO handlers, connect, disconnect and request_status, move from `main.py` to `core/socket_handlers.py`, registered with `register_socket_handlers(socketio, ...)`. It is the Socket.IO item in the plan from Discussion #1536. They had no tests, so the first commit adds 9 that go through Flask-SocketIO's test client: what a client gets on connect with no auth, as an admin, as a user and as a visitor with no session, a failure reading the status, the manual status request, and the room bookkeeping on disconnect. They pass on current `main`. The second commit is the move. `resolve_status_scope` stays in `main.py` since the HTTP routes use it too, so it is passed in. One test line changed, the patch of `redact_status`, which now points at the new module. Log lines from the handlers now name `shelfmark.core.socket_handlers`. The URL map and the registered socket events are identical before and after, and the full suite, ruff and basedpyright are clean. I also started the app from the branch and the Socket.IO handshake answers. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Status requests now return a consistent empty update when access is denied and an error message when status retrieval fails. * Status visibility follows the requester’s access level, with non-admin results redacted. * **Refactor** * Connection, disconnection, and status-request handling now follows consistent room and connection-tracking behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
34c9e6dbd7 |
refactor: move the auth routes into core/auth_routes.py (#1625)
The auth routes are the next group from the plan in Discussion #1536: `/api/auth/login`, `/logout` and `/check`, plus the login lockout helpers, move from `main.py` to `core/auth_routes.py`, registered with `register_auth_routes(app, ...)`. `main.py` is about 370 lines shorter. This is the login path, so I kept it strictly to a move. Two commits. The first adds 59 tests that pin the current behaviour before anything moves: login in each mode (builtin, none, CWA and the OIDC password fallback), wrong passwords, the lockout (the warning when five attempts are left, the 429 and its text on the tenth failure, per-user isolation, expiry), the local-auth switches, logout in each mode and `/check`. They pass on current `main`, and I ran the same file against `main` and against this branch. The second commit is the move, and no existing test changed. The lockout state is one dict, now living in `auth_routes`, and `main.py` imports the same object so its other users (the password-attempt limiter for the self-user routes, and the tests) see it. Everything else that comes from `main` (the user database, the auth mode, the client IP, the CWA database path, the local-auth settings and the password check) is passed in as a callable evaluated per request, so patches on `main` still apply. Two things to know. `main.py` re-exports the lockout names and keeps three lines assigning the view functions (`api_login = app.view_functions["api_login"]` and the other two), because the e2e tests call `main_module.api_login()` directly and I did not change tests in this PR. If you would rather drop both, I can edit those e2e tests instead. The other is that the lockout dict is no longer reset by a reload of `main`, which only matters in tests, and they clear it themselves. The URL map is identical, all 77 rules, and the full suite, ruff and basedpyright are clean. I also ran the app with builtin auth on a scratch instance and went through it over HTTP: a wrong password gets a 401, login works and `/check` shows the session, logout clears it, the tenth failed attempt gets the 429, and the right password is refused while the account is locked. |
||
|
|
d1e959758d |
refactor: move the system routes into core/system_routes.py (#1624)
The system routes are the next group from the plan in Discussion #1536: `/api/config`, `/api/health`, `/api/debug`, `/api/restart` and the routes that serve the frontend (`/`, the SPA catch-all, `/assets/...`, the logo, the favicons and `theme-init.js`) move from `main.py` to `core/system_routes.py`, registered with `register_system_routes(app, login_required, ...)`. `main.py` is about 250 lines shorter. Same two-commit layout as before. The first commit adds 14 tests for the frontend-serving routes and the `/api/config` error paths, which had none, and they pass on current `main`. The second commit is the move, and no existing test changed. The helpers only these routes use moved with them (`admin_required`, built on the `login_required` that is passed in, plus `_base_href` and `_serve_index_html`). What `main.py` still owns, the base path, the frontend folder, the auth mode and the session user id, is passed in as callables evaluated per request, so the tests that patch them on `main` still work. The debug routes are only registered when debug is on, as before, with the stop-GUI and run-script hooks handed in. The thing to look at is the SPA catch-all, which is now registered earlier in `main.py` than it used to be (the old comment said it must be last). Werkzeug picks the most specific rule whatever the registration order, and I checked it: all 76 rule and method pairs in the URL map still match their own endpoint, on `main` and on this branch, and an unknown `/api/...` path still gets the JSON 404. The Flask URL map is identical before and after, all 77 rules, and with debug on (79). The full suite, ruff and basedpyright are clean. I also ran the app from this branch: health, config, the logo, the favicon and the theme script answer as before, a client-side path like `/library/books` gets the index page, an unknown `/api/` path gets the JSON 404, and the page loads in a browser. I did not exercise `/api/restart` or a real debug script run, since nothing tests them. |
||
|
|
d41fa98749 |
refactor: move the metadata routes into core/metadata_routes.py (#1623)
The metadata routes are the next group from the plan in Discussion #1536: the eight `/api/metadata/...` handlers (providers, config, search, field-options, book and the three targets routes) move from `main.py` to `core/metadata_routes.py`, registered with `register_metadata_routes(app, ...)`. `main.py` is about 490 lines shorter. Same two-commit layout as the settings move (#1595). The first commit adds 39 tests that pass on current `main` before anything moves. They cover the 401 on all eight routes, the shape of the providers list and the config, the search errors, the book lookup (200, 404, 400 and 503), and how target errors map to 400, 502 and 500. The second commit is the move, and no test changed in it. Three helpers moved with the routes because nothing else uses them. `_attach_library_ownership` stays in `main.py` since the releases routes need it too, and is passed in along with the login decorator and the auth mode, the way `activity_routes` takes them. The auth mode is a callable evaluated per request, so the one test that patches it on `main` still works. The Flask URL map (every rule, its methods and its endpoint name) is identical before and after, all 77 rules. The full suite, ruff and basedpyright are clean. I also ran the app from this branch and hit the routes over HTTP: providers and config answer 200, a book that doesn't exist gets a 404, and search answers 503 with the "no metadata provider configured" message on an instance with none enabled. I did not look at it in a browser or run the e2e tests. Log lines for these routes now come from `shelfmark.core.metadata_routes`, and the error tuples are local copies so the module doesn't import `main`. One thing to know when reading the diff: the register call sits just after `_attach_library_ownership` and not where the first route used to be, because that function has to be defined before it can be passed in. |
||
|
|
cd5a478831 |
refactor: move the onboarding routes into core/onboarding_routes.py (#1594)
First of the route moves from Discussion #1536, following the plan table there: the three onboarding routes (`GET` and `POST /api/onboarding`, `POST /api/onboarding/skip`) leave `main.py` for `core/onboarding_routes.py`, registered with `register_onboarding_routes(app, login_required)` the way `release_inspect_routes` takes it. `main.py` goes down by 72 lines and the handler bodies are the same code (I diffed them after de-indenting: the only difference is the local name of the error tuple). There are two commits so the move can be reviewed on its own. The first adds 15 tests for these routes, which had none, and they pass on current `main` before anything moves. They pin the behaviour I most wanted to keep: a 401 without a session when auth is on, a 403 for a signed-in non-admin (that check lives in `login_required` and goes by path, which is why the same decorator has to be passed in), the 400 on an empty body, the result passed through on success and failure, the 500 with the error text on an operational error, and open access when no auth is configured. The second commit is the move, and no test changed in it. What I checked on the move: - The Flask URL map (every rule, its methods and its endpoint name) is identical before and after: 77 rules, the three onboarding ones included. - Full suite, ruff and basedpyright are clean. - I ran the app from this branch and stepped through the setup wizard in a browser. It loads its steps from the moved route and goes from Search Mode to Metadata Provider, and `POST /api/onboarding/skip` and the empty-body 400 answer as before. Two small things that do change. The log lines for these routes now come from `shelfmark.core.onboarding_routes` instead of `main`, and the error tuple is a local copy (`_ONBOARDING_ERRORS`, the same classes as `_IMPORT_OPERATIONAL_ERRORS`) so the module does not import `main`. Nothing else is different. |
||
|
|
69cbf72698 |
refactor: move the settings routes into core/settings_routes.py (#1595)
Second route move from the plan in Discussion #1536: the four settings routes (`GET /api/settings`, `GET` and `PUT /api/settings/<tab>`, and `POST /api/settings/<tab>/action/<key>`) go from `main.py` to `core/settings_routes.py`, registered with `register_settings_routes(app, login_required)`. `main.py` is 143 lines shorter. These handlers call `settings_registry` directly, so nothing else has to be passed in. The handler bodies are the same code, and the only difference I found after de-indenting them is that `ruff format` wrapped one signature that no longer fits on a line. Same two-commit layout as the onboarding one (#1594). The first commit adds 27 tests for these routes and they pass on current `main` before anything moves. They pin the 404 for an unknown tab, the 400 when a `PUT` body is not a JSON object (a list, a string, a number, or no body at all), the "No changes to save" answer for an empty object, the unsaved form values reaching an action, the result passed through with a 400 on failure, the 500 with the error text on an operational error, a 401 without a session, a 403 for a signed-in non-admin on all four routes, and open access with no auth. The second commit is the move and no test changed in it. The admin check for these paths lives in `login_required`, so the same decorator is passed in. What I checked: - The Flask URL map (every rule, its methods and its endpoint name) is identical before and after, all 77 rules. - Full suite, ruff and basedpyright are clean. - I ran the app from this branch and opened Settings in a browser: the modal lists every tab and the General tab shows its values, which come from the moved `GET`. Over HTTP, a `PUT` of a new search page title saved and read back, a list body got the 400 and an unknown tab the 404. As with #1594, log lines for these routes now come from `shelfmark.core.settings_routes`, and the error tuple is a local copy (`_SETTINGS_ERRORS`) so the module does not import `main`. The two PRs touch neighbouring lines of `main.py` and I checked that they merge together without a conflict, in either order. |
||
|
|
7583f997bb |
fix(oidc): catch joserfc's InvalidClaimError so the issuer guidance shows (#1603)
A wrong OIDC issuer, which is the usual IdP setup mistake, sends the
user back to the login page with a bare "Authentication failed". The
callback has a handler that explains it ("OIDC issuer validation failed.
Verify your discovery URL and IdP issuer/external URL configuration."),
and it never runs.
Authlib validates the ID token with joserfc now, so the error that comes
out of `authorize_access_token()` is `joserfc.errors.InvalidClaimError`.
The handler catches the one in `authlib.jose.errors`, which is a
different class with a different base, so the isinstance check is False.
I confirmed it by validating a real `CodeIDToken` with a wrong `iss` on
Authlib 1.9.1. I haven't run it against a live IdP.
The existing tests passed because they raise the old class from a mock.
The new one raises the error from a real `CodeIDToken`, and it fails on
the old import with `'Authentication failed'` in place of the guidance.
Two small things come with it:
- The new class names its attribute `claim` where the old one used
`claim_name`, so that read changes.
- `authlib.jose` is deprecated in favour of joserfc and Authlib's own
warning says it goes in 2.0. This was the only place Shelfmark used it,
so the import moving also takes us off the module that is going away.
joserfc is already installed as an Authlib requirement, so I added it to
`pyproject.toml` (`>=1.6.8`, Authlib's own floor) and the lock gets the
one entry. No new package.
Ruff, basedpyright and the backend suite pass (5424 passed).
|
||
|
|
83e61975c4 |
fix(releases): apply the record policy to a provider named alongside a source (#1598)
Two small things I found while going back over what has landed since 1.5.0. `/api/releases` takes a `provider` and a `source`, and the policy check only looked at `source`. A user whose policy blocks one release source but not another could pass the blocked one as `provider` with the allowed one as `source`, and get the blocked source's record back. The record route already refuses that with a 403, so the listing route now applies the same refusal when `provider` names a source that is not being listed. The normal case, where provider and source are the same, is already gated and skips the extra check. The second commit is wording only. The Automatic Downloads setting and the Hardcover sync doc said "synced requests", but the pass picks up every pending Hardcover request, including ones a user made themselves with Request Book. The download is still queued as the user who asked (the admin is only the reviewer, same as a manual approval), and admins already see every user's requests. The text now says that, and a new test pins the behaviour for a user's own request. I opened a scratch instance and looked at the Hardcover Sync tab: the Automatic Downloads heading and the checkbox description read as intended and nothing else on the tab moved. Full suite, ruff and basedpyright pass, and the new policy test fails without the fix. |
||
|
|
cfba44b754 |
refactor: one module for taking credentials and links out of text (#1578)
Draft until #1571 merges: this is stacked on it, so the first two commits here are that PR's and only the last one is new. I will rebase once #1571 is in. Shelfmark strips credentials and links out of text in five places, written in different PRs with different patterns: the log formatter, `_safe_url` and `_redact_urls_in_text` in `torrent_utils.py`, `redact_status_message` in `viewer_redaction.py`, the `sed` rules in `genDebug.sh`, and the password handling in the settings registry. Between them there were three regexes that find a URL in text, two separate userinfo patterns and no shared list of what counts as a secret. #1571 made the gap visible, because every pattern added to the logger had to be remembered somewhere else too. This moves the text and URL part into `shelfmark/core/redaction.py`, which imports nothing from the app so the logger can depend on it: - `redact_secrets` (mask in place, for logs) moved from `core/logger.py`, patterns unchanged. The logger imports it. - `safe_url` and `redact_urls_in_text` (keep scheme, host and path, drop the query) moved from `torrent_utils.py` and are public now, so `alldebrid.py` no longer imports a private name from another module. - `strip_links` (remove links entirely) is what `redact_status_message` now calls. It and `redact_urls_in_text` share one link pattern, the one the viewer redaction already used. Two small behaviour changes come from sharing that pattern. A closing bracket or quote right after a URL is no longer swallowed into it when scrubbing an exception message. And the scheme-less path that requests prints after `url: ` now keeps only what is before its query in `redact_urls_in_text`, where before it passed through as it was (the log formatter masked the key, but the path and query stayed in the message). Also here: the two `sed` rules in `genDebug.sh` that mask userinfo now stop at `?` and `#` like the logger pattern does, with a test that fails on the old script. Not in this PR: the debug bundle zips the live log directory as it is, so log lines written before #1571 are not redacted in it. Fixing that means copying the logs to a staging folder and running them through this module, which I would do as a separate change. Tests: a new file covers `safe_url`, `redact_urls_in_text` and `strip_links` directly, and two checks that the viewer and the logger really use these. Full suite passes. |
||
|
|
8b80f32476 |
fix(sources): keep credentials out of a source-unavailable message (#1593)
Code scanning alert 106 (stack trace exposure on the release search 503) turned out to be worth a look. Jackett sends its API key as `?apikey=` in the query string, and when Jackett is unreachable the source builds its "unavailable" message from the requests exception, whose text includes the full request URL. `/api/releases` returns that message to whoever ran the search, so any signed-in user could read the Jackett key while Jackett was down. I reproduced it with Jackett pointed at a closed port: the 503 body had `apikey=<the key>` in it. Jackett was added in #1521 and isn't in a release yet, so nothing shipped with this. `SourceUnavailableError` now masks credentials in its message with the same `redact_secrets` the log formatter uses (query parameters like `apikey=`, `user:pass@` in URLs and so on). I did it in the exception rather than at the two Jackett sites that build the text, since its message is written for the user and any other source that ends up with a key in a URL would hit the same thing. The rest of the message stays readable, for example "Max retries exceeded with url: ...?apikey=(removed)&t=book". Prowlarr and slskd send their keys in headers so they didn't leak a key, but their messages do include the internal host and port. I left that alone here. Tests: two Jackett ones (a connection error on the search and on the indexer lookup, with the key in the exception text) and three on the exception itself. The four that check masking fail on current main. The full suite, ruff and basedpyright are clean. |
||
|
|
f02f0d7365 |
fix(notifications): say what applies on a user's own notification routes page (#1588)
This is the UI side of #1581. A non-admin looking at their own notification routes reads the same help text the admin does, including "or mailtos://...?to={Email} in the admin default". They can't set that default, a personal route can't use mailtos (personal routes are limited to ntfy, Gotify, Discord, Slack, Telegram, Pushover and PushBullet), and nothing on the page says whether an admin default exists. The default's URL is hidden from them on purpose since it can carry credentials, so the page just shows an empty All row, the same as when nothing is set up. What changes, on the user's own page only (the admin's view of a user keeps the full text): - The help text drops the admin default and mailtos part and lists the services a personal route can use. - The edit-context response now carries `adminDefaultRoutesSet`, a boolean that is true when an admin default route with a URL exists. The URL itself is still not sent, and the existing test that checks nothing from the admin's values leaks still passes. - When that is true, the help text adds a line saying a default applies until the user adds their own, and once they have, that theirs replace it. How routes are resolved is unchanged. A user's own routes still replace the default and don't merge with it. I didn't try to add mailtos for personal routes, since keeping SMTP credentials in a user's own URL is what the admin default avoids. Tests: two route tests (flag true with a URL in the admin default, false with an empty row), a vitest file for the description text, and the full frontend suite, oxlint, oxfmt, tsc, ruff and basedpyright are clean. I haven't looked at the page in a browser. |
||
|
|
46b9e7d8a3 |
fix(library): match a shelf title against the full title when the search title is only the series (#1587)
Fixes #1584. Thanks to @joeRob2468 for the write-up, which pinned this down to the line. Hardcover cuts a title like "Mistborn: The Final Empire" down to "Mistborn" for searching when it can't tell the series prefix from a title prefix, and search results don't carry the series. The library check then matched the shelf title "Mistborn: The Final Empire" against "Mistborn", saw "final empire" as extra words and took it for a longer, different work (the "Dune" vs "Dune Messiah" rule), so the book got no In library badge. `match_entries` now tries the search title first as before and, if that finds nothing and the full title is different, tries the full title too. The full title is the more specific of the two so it should rarely add false positives. Another book in the same series still doesn't match, there is a test for that, and the Dune and Dune Messiah test passes unchanged. The matching loop moved into a small helper to run it twice, nothing in it changed. This only touches the library check. The same short search title also feeds release searches and auto-download matching, which I left alone here because loosening or tightening those changes what gets searched and downloaded for everyone. Tests fail on current main without the change. The library tests, ruff and basedpyright are clean. |
||
|
|
47f0acdd31 |
fix(logging): mask userinfo and path-segment credentials, route stray loggers through the formatter (#1571)
Logs get pasted into issues, and the formatter from #1488 only masks `?key=value` query parameters. I went through what else commonly ends up in a log line and found these unmasked: - credentials in a URL's userinfo, like a proxy's `socks5://user:pass@host:1080` or `http://admin:secret@qbittorrent:8080` - a passkey as a path segment, like `/rss/<key>` or `/passkey/<key>/get.torrent`, which private trackers use - the token in a Telegram bot, Discord webhook or Slack webhook URL, which is what a notification URL looks like when it gets logged All three are masked now, in `redact_secrets`, so they apply to every handler. The path rule only fires after a segment that names a secret (`passkey`, `apikey`, `rsskey`, `authkey`, `token`, `secret`, `rss`, `torznab`) followed by 16 or more token characters. I left out generic words like `download` on purpose, because `/api/download/<md5>/cancel` appears in our own request log and the ids there are useful when debugging. That is the tradeoff: a passkey in a `/download/<passkey>/` path still gets through. Separately, four modules created their logger with plain `logging.getLogger`, so nothing they logged went through the formatter at all: `api/websocket.py`, `download/clients/__init__.py`, and the Jackett, Newznab and Prowlarr package inits. They use `setup_logger` now like everything else. Tests: ten cases, seven for the new shapes and three that must stay readable (an md5 route id, a short `/rss/latest`, an email address), plus two that check the stray modules now carry the redacting formatter. Nine of them fail without the change. Full suite passes. Not covered here: `/api/debug` still zips the existing log files as they are on disk, so anything logged before this change rides along in a bundle. That is a separate change in `genDebug.sh`. |
||
|
|
ef40a38546 |
fix: report a stopped download coordinator in /api/health (#1568)
If the download coordinator thread dies, every task sits at "Queued" and nothing says why. The rest of the app keeps answering, `/api/health` stays green and the container looks fine. #823 and #1166 were both that, and the loop's `except Exception` cannot catch everything that can end a thread. `/api/health` now reports it. The response gets a `coordinator` field, `running` or `stopped`, and when it is stopped the status is `error` and the HTTP code is 503. The Docker healthcheck in the Dockerfile and the one in the readme both use `curl -f`, so the container shows as unhealthy and anything watching health (Uptime Kuma, autoheal) can act on it. It needs no login, same as before, and shows no queue contents. That 503 is the one decision here. Keeping the 200 and only adding a `degraded` entry would not trip a status-code health check, which is the whole point, so I went with 503. Easy to flip if you would rather not. The orchestrator gets a small `coordinator_running()` that asks whether the thread is alive. `start()` already knows how to restart a dead thread, so restarting it automatically would be the true fix, but I would rather the failure be visible first and decide on that separately. Tests: three for the route (ok, 503, no login needed) and three for `coordinator_running` (never started, alive, dead). They fail without the change. One existing test hits `/api/health` through an app that never starts the coordinator, so it now patches that. The readme's health section has a sentence about the 503. Full suite passes, ruff and basedpyright are clean. |
||
|
|
973cf472a2 |
test(image-cache): flush window test no longer depends on float rounding (#1580)
`test_cache_hit_flushes_the_index_once_the_window_has_passed` (from #1544) failed on the first attempt of two of my PRs' Python Tests runs today (#1576, #1579) and passed on rerun, with nothing in either PR touching the image cache. The test sets the clock to exactly `last_saved + ACCESS_FLUSH_SECONDS` and expects a flush. `(now + 30) - now` isn't always 30.0 in floating point. For example `105.65207077218265 + 30 - 105.65207077218265` is `29.999999999999986`, so the `>=` check is false and the flush never happens. That only shows up when the monotonic value is small, which fits a freshly booted runner (on this box the uptime is in the millions of seconds and it never fails). I haven't been able to see the runner's actual uptime in the CI logs, so that part is an inference, but pinning `_last_saved` to that value reproduces the exact failure (`assert [] == [1]`) locally. The test now puts the clock one second past the window. The production check is fine, real elapsed time doesn't have this edge, so only the test changes. |
||
|
|
9011319ade |
refactor: one lookup for the supported formats, public names for two shared helpers (#1576)
Six places read the Supported Book Formats and Supported Audiobook Formats settings on their own: post-processing, the post-process scan, IRC, slskd, Prowlarr and direct download. For ebooks they already agreed, since they all fall back to `DEFAULT_EBOOK_FORMATS`. For audiobooks they did not. The settings registry and slskd fall back to every audio format plus zip and rar, while post-processing and IRC fell back to m4b and mp3 only. They now share `shelfmark/core/formats.py`, with `get_supported_formats`, `get_supported_audiobook_formats` and a content-type aware `get_supported_formats_for`, and a shared `DEFAULT_AUDIOBOOK_FORMATS` in `core/utils.py` next to the ebook one. `policy.py` re-exports the same functions, so nothing that imports them from there changes. It is not in `policy.py` itself because importing the `postprocess` package pulls in the router, which imports the release sources, so a source could not import from it without a cycle. One behaviour difference, and only in the fallback: with the audiobook setting unset, or emptied, post-processing and IRC used to accept m4b and mp3 and now accept the full default list like slskd and the settings page already did. A configured list is read exactly as before. Direct download's own helper used the raw configured list and now reads the same normalized one, which is identical for any list the settings page can save. Also public now: `parse_size` in the Prowlarr source (slskd and Newznab imported it with the underscore) and `coerce_completed_path_timeout_seconds` in the client base handler (slskd imported that too). Not changed on purpose: slskd's `_coerce_int` looks like a copy of `coerce_int` but is not. It reads `"12.0"` as 12 and `True` as 0, where `coerce_int` gives 0 and 1. And the `_parse_size` in the IRC source is a different function, string to bytes. Tests: a new set checks that post-processing, IRC and slskd all fall back to the shared defaults and that the settings default is the shared one, and the scan-archive and IRC tests now patch the shared config instead of the per-module copies. Full suite passes. |
||
|
|
70fe4b8c89 |
fix(hardcover): keep the scheduler alive on an unreadable interval, share one history connection per sync (#1569)
These are the follow-ups from the review notes on #1516 and #1534 that I said I would do once the series had merged. The scheduler waits for the next cycle outside the try that guards the cycle itself, so an interval setting that raised would end the thread and nothing would sync again until a restart. It now logs the error and tries again after six hours. `docs/hardcover-sync.md` says so. The history check in the sync opened a new SQLite connection for every book on the shelf. It opens one per sync now and shares it, which adds up on a slow disk or a small board. The two test nits from #1534 are in as well: a `parametrize` with a single case, and an unused argument on a helper. The thread surviving a failed wait and the shared connection each have a test that fails without the change. Ruff, basedpyright and the backend suite pass (5297 passed). |
||
|
|
d7a4b6b859 |
fix: keep exception text out of the status, config and release search responses (#1562)
Same idea as #1558, for three more places that hand `str(e)` to whoever is logged in. These are code scanning alerts 106, 142 and 143. `/api/status` and `/api/config` returned the raw exception text of an OSError, RuntimeError and so on with the 500. They now return "Could not read the download status" and "Could not load the configuration". The full error is still logged with its trace. For `/api/releases`, a source failing with an unexpected error put `source: <exception text>` into the error list that goes back to the caller. `search_source_releases` takes a new `include_error_detail` flag. The route passes True for admins (and when auth is off, where everyone is treated as an admin) and False for everyone else, who get `source: search failed`. The message of a `SourceUnavailableError` is written for the user, so it is kept either way, and the background auto-download caller keeps the default and sees the full text as before. Left alone on purpose: the `reason` in the release inspect response, since a test covers that it carries the error text and it looks intentional. Tests: two for the routes and two for the helper. All four fail without the change. Full suite passes (5295), ruff and basedpyright are clean. |
||
|
|
4f97fc9bec |
fix: keep server paths and URLs out of an error response and a debug log (#1558)
Two small leaks from going through the open CodeQL alerts (code scanning alerts 100 and 123). The local download route returned `str(e)` on an OSError, and for a missing or unreadable file that text is the file's path on the server (`[Errno 2] No such file or directory: '/audiobooks/Author/Title.epub'`). #1422 cuts a non-admin's download path down to the file name everywhere else, so this put it back. The route now answers "Could not read the file" and the detail stays in the log. `should_bypass_proxy` logged the whole request URL at debug when it could not be parsed. URLs can carry credentials, so it logs only the exception now. Each change has a test that fails without it. The full non-e2e suite, ruff and basedpyright pass. |
||
|
|
86fff32297 |
feat(hardcover): owned books, simpler queries, dead sources and failed releases (#1535)
A request whose book turns out to be in the library is closed with the note `[auto] Already in the library.` instead of sitting in Needs Review. A request an admin handled by hand while a pass was running is skipped. A series label in front of a Calibre title no longer hides an owned book, and a Calibre title marked as another edition (full cast, dramatized, GraphicAudio) is not the book. When no source matches, up to three simpler forms of the title are tried on each source under the same strict match. Optionally (off by default), an unmatched audiobook request is looked up on Audible US and UK through Audiobookshelf's metadata search and the request says what the store answered. It never declines anything. A Soulseek peer whose recent transfers all failed, and an AudiobookBay post whose comments say it has no seeders, are tried after the other matches and never excluded. A failed or stalled release is kept out for AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS and then tried again, and a torrent that died for one request is not offered to another. A request stuck on queued or downloading is settled from the download history. Optionally (off by default) a failed download is retried with another release within minutes. |
||
|
|
9d5b47d17b |
feat(hardcover): retries, error counting and a back-off for requests nothing matches (#1534)
A synced request whose download failed, was interrupted by a restart, or was cancelled by the stall timer goes back to pending after AUTO_DOWNLOAD_RETRY_DAYS (7 by default, 0 turns it off), a few per pass, oldest first. Only what never arrived is retried, the failed release is remembered, and a request is given up on after three failed retries. Downloads cancelled by a person and rejected requests are never retried. Errors count as errors: a search that failed in one source no longer reads as no match, a throttled Hardcover lookup is an error and not a skip, and a request that blows up doesn't abort the pass. Both summaries carry the error count. A request for the same Hardcover book and format is skipped when the history already has a finished or running download for it. A request that finds nothing is searched once a day after three no-match passes, then every two days, up to AUTO_DOWNLOAD_BACKOFF_MAX_DAYS (3 by default, 0 turns it off). |
||
|
|
4389548deb |
feat(hardcover): ebooks, audiobooks or both, and stricter matching for automatic downloads (#1533)
A shelf book can be requested as an ebook, an audiobook or both (one request per format), and each request is checked against its own library, source priority list and format guard. Ranking within a source prefers the most downloaded copy, then size, and one m4b is preferred to a set of chapter files unless it is much smaller than the other copies. Soulseek peers are ranked by free slot, queue length and speed. Automatic downloads refuse multi-book packs, sequels and other works named in the title, study guides, abridged or dramatised versions, two-in-one titles, releases in a language the requester didn't ask for, and an archive for an audiobook request. Matching folds accents on both sides, drops an alternate title for the search and the match, and reads small numbers the same in any form. AudiobookBay releases with no author in the listing title are judged on the authors credited on the detail page. |
||
|
|
ea69db3f8e |
feat(library): Audiobookshelf library check, and the sync fails closed when a library cannot be read (#1519)
Adds an Audiobookshelf provider for the library check, next to the Calibre one. It reads the server over its REST API (title, author, ISBN and ASIN per item), answers for audiobooks, and has its settings in General with a test button that uses the unsaved form values. Read only. The Hardcover sync and automatic downloads now fail closed when an enabled library cannot be read: the sync skips its cycle and an automatic download holds the request. A library that listed items at the last index and now lists none counts as a failed read. Badges still fail open. |
||
|
|
99a6aa206b |
feat(metadata): add Deutsche Nationalbibliothek (DNB) metadata provider (#1547)
## feat(metadata): add Deutsche Nationalbibliothek (DNB) metadata provider This adds the Deutsche Nationalbibliothek (DNB) as a metadata provider. It covers German editions far better than the existing providers. No API key, disabled by default, and the same pattern as the other providers. It uses DNB's public SRU interface and parses MARC21-XML. Thats one reason why this needs more code than the Google Books or Open Library providers. The others are the search (DNB has no relevance ranking) and the language filter, see below. ### Notes - **Search box.** Search-box text goes from precise to loose until a stage has hits: the complete title, then the title as a phrase, then every word in a title or a person's name, last without German articles. - **"Only show editions in my book languages"** (optional, on by default). DNB also catalogs a lot of translations. DNB filters by the searching user's Default Book Languages, else the system default. - **No covers.** DNB's cover service is behind Anubis bot protection, so the provider returns no cover URLs. - **Proxy support** already included like Open Library since #1526 ### Changes outside the provider - DNB is in the onboarding dropdown. - Calibre's `dnb-idn` identifier is mapped, so DNB results show library ownership - In the env-var docs I added only the DNB section. Regenerating the whole file on main currently also reorders unrelated sections and drops `SHELFMARK_API_KEY_READONLY` (and the Databáze knih section sits inside the Moly.hu block). ### Ideas - **Deduplication.** DNB lists every edition. Kafka's "Die Verwandlung" has around 200 German editions of the same text. Grouping them would touch the API, the frontend and release search. - **Language-aware metadata search** for all providers. The other providers ignore Default Book Languages in metadata search. The `user_id` added here could serve them too. |
||
|
|
48ae3dbef4 |
feat: optional Hardcover wishlist sync with automated downloading (1 of 5) (#1516)
This replaces #1047, which @InfiniteAvenger opened and calibrain offered to re-review. It is Caleb's feature commit with his authorship kept, rebased onto current main, with small fixes, tests and a docs page on top. Everything is opt-in and off by default, so nothing changes for anyone who doesn't turn it on. What it adds is a **Settings, Hardcover Sync** tab with three parts that can be switched on separately: - **Shelf sync**: a Hardcover shelf (Want to Read by default) is pulled into requests on a schedule, or with a Sync now button. Requests carry the Hardcover metadata and cover. - **Automatic downloads**: synced requests are approved and a release is picked from a source priority list. A strict title, author and format match (plus a seeder minimum for torrents) decides, so anything without a confident match stays pending for manual review. - **Skip books you already own**: sync and automatic downloads ask whichever libraries are enabled under Library Check (Calibre today) and skip a book that is already there. On top of Caleb's commit I made the request owner a lookup instead of assuming user id 1, sent synced cover URLs through the proxy when the request is created, got ruff and basedpyright clean, and added tests and `docs/hardcover-sync.md`. His commit also had its own Audiobookshelf settings and a Test library connection button, written before the library check was split into providers. The button would have raised a TypeError against the current `test_connection`, so those settings are gone from this tab and an Audiobookshelf provider comes as a separate PR, which is the part of #1047 that is missing here (for now anyways...). I've run this daily for about a couple of weeks. Ruff, basedpyright and the backend suite pass (4426 passed). This is 1 of 5. As calibrain suggested, the rest are stacked on this one, each saying which number it is. Closes https://github.com/calibrain/shelfmark/pull/1047#issuecomment-6302071588 --------- Co-authored-by: InfiniteAvenger <calebewest02@gmail.com> Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
72b5d04939 |
perf: cut repeated work on the status and activity paths (#1545)
Two small cuts to work that repeats on every poll or request. The delivery-state sync behind /api/status and the activity snapshot listed every fulfilled request (SELECT * plus a JSON parse of book_data and release_data per row) before it looked at the queue. That history is never purged, so an idle instance paid for it on every poll. It now returns straight away when no queue bucket holds a task, since nothing could map to a request then. Results and emitted updates are unchanged. The activity snapshot gains less from this, because its status merges download history rows, so it is usually not empty. In proxy auth mode the before-request hook looked the user up by name and by session id on every API call, cover images included, each on a new SQLite connection. UserDB.get_user_cached answers repeat lookups from memory for up to five seconds, bounded at 256 entries. create_user, update_user and delete_user drop the cache as soon as they commit, and a lookup that overlaps a write is not stored, so a role change or deletion made in the app applies on the next request. Changes made outside that UserDB instance (another process, or the separate instances some helpers open) show within the five seconds. Only the middleware uses the cached path; every other get_user caller is unchanged. Each change has a test that failed before it: one counts list_requests calls for an empty queue, the others count get_user calls across repeated lookups and middleware runs, check expiry with a patched clock, and check that writes and caller mutations do not leave a stale row behind. The full suite passes (4432 passed, 48 skipped, e2e excluded), as do ruff and basedpyright on the changed files. I also looked at throttling the full status_update broadcast when only the progress message changes during torrent and usenet polling, and left it out. Those handlers poll every 2 seconds and DOWNLOAD_PROGRESS_UPDATE_INTERVAL defaults to 1 second, so a throttle on that interval would skip nothing at the default setting. A longer window would slow the speed and ETA text in the UI, and without a trailing flush it would hide the last message of a quick burst, such as "Waiting for bot response" arriving just after "Requesting file from bot", for the whole wait that follows. --------- Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
237ddd53a2 |
fix(redaction): a non-admin's status message loses the links it quotes (#1541)
#1422 hid `source_id` and the download path from non-admins but left the status message alone. A failed add stores the client's exception text there, for example `401 Client Error: Unauthorized for url: <full url>`, and for a requests error that URL carries the path and query: an indexer key, a tracker passkey, a peer name. It reached the owner through `/api/status`, `/api/activity` and the `status_update` socket event. `redact_download_payload` now replaces every link in `status_message` with `(link removed)`. That covers the live queue, the socket push and the history snapshot, since all three go through it. Messages without a link are unchanged and admins still see the full text. Tests cover the payload, a whole status snapshot, and messages that must stay as they are. They fail on main and pass here. Found in a Claude pass over 1.5.0. --------- Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
a430341a93 |
perf: cut repeated work on search and cover paths (#1544)
I went through what Shelfmark does per search and per cover and measured the pieces that showed up. Four of them are cheap to fix, so this is one PR. Shelfmark runs on a single gevent worker, so any of these blocks every other request while it runs, and I'm assuming a lot of people run it on small hardware (a Pi, say). Cover cache. Every cache hit rewrote the whole `cache_index.json` just to bump the entry's last-access time. On my box that is 3.7 ms of CPU per hit at 500 entries and about 100 ms at 15,000 (2.2 MB written each time), and a results page asks for around 40 covers. The access time now stays in memory and the index is written at most every 30 seconds, or whenever something else saves it. A crash inside that window loses some access times, which only changes eviction order. Settings reload. `config.refresh()` rebuilt the cache by reading and parsing a tab's JSON file once per field, so about 250 parses per refresh, 100 ms of CPU, and the metadata search, providers and config routes each trigger it. It now reads each file once per rebuild (several tabs share `settings.json`, so the memo is keyed on the file path). Library match. When the library check is on, each search result is compared against every library entry, and the title was split into words again for every entry. The words are now computed once per result. I measured 33 ms per result at 5,000 library entries and 106 ms at 20,000, which is a second or more per page of 40 results. The matching itself is unchanged. Cover URL probe. `is_covers_cache_enabled()` creates and deletes a file in the config directory to check it is writable, and it runs for every cover, queue task and history row. The answer is remembered for 60 seconds. The probe is 0.045 ms on its own, but it is called a few hundred times per request in the worst case. Each change has a test that fails without it (call counts on the index saves, file opens during a refresh, tokenisation calls, and probe calls). Ruff, basedpyright on the changed files and the non-e2e suite pass. The timings are from a dev machine with an empty config, so the real numbers depend on cache and library size. |
||
|
|
08fdb7ea87 |
fix(startup): the reconcile guard catches the same errors the rest of main.py does (#1540)
The start-up reconcile runs at import, before the coordinator starts, inside a try that named five exception types and left out `TypeError`. `main.py` already defines `_IMPORT_OPERATIONAL_ERRORS` for this shape and the other module-level guards use it, so this switches to it. An unexpected `TypeError` from the reconcile would otherwise fail the import of `shelfmark.main`, and the worker would fail to boot on every restart. I didn't use `except Exception` because BLE001 is on and nothing in main.py does. The test reloads main with the reconcile raising `TypeError`, and with `OSError` as the control (that one already passes on main). Full non-e2e suite passes, ruff and basedpyright are clean. Found in a Claude pass over 1.5.0. |
||
|
|
f560b31ee4 |
fix(db): wait up to 30 s for a busy writer instead of SQLite's 5 s default (#1539)
The users, download history and activity view-state databases open their connections with SQLite's default busy timeout of 5 seconds. A start-up migration, the restart reconcile, or any statement that overlaps a backup or a second process raised "database is locked" after 5 seconds, and the reconcile finished only part of its work. One shared `DB_BUSY_TIMEOUT_SECONDS` (30) in `core/utils.py`, used by all three. The test reads SQLite's `busy_timeout` pragma back from each connection, so it doesn't need a real lock fight. I left `UserDB.initialize()` without a guard on purpose. With 30 seconds the wait covers a backup, and swallowing a schema failure there would hide a worker that can't run. Found in a Claude pass over 1.5.0. |
||
|
|
e27fed1d40 |
fix(queue): a late progress update no longer reopens a finished task (#1538)
#1429 made the first terminal status win, but the guard only refused terminal to terminal changes. A worker's poll that reports DOWNLOADING after a cancel still overwrote CANCELLED (the poll loop calls the status callback before it waits on the cancel flag), and the COMPLETE or ERROR that followed was then accepted as a first terminal status, so the terminal hook ran a second time. This refuses any status change away from a terminal status. `add()` and `enqueue_existing()` reset the status themselves, so retries are unaffected. The new test cancels a downloading task, sends DOWNLOADING and then COMPLETE, and expects CANCELLED and one hook call. It fails on main and passes here. Full non-e2e suite passes, ruff and basedpyright are clean. Found in a Claude pass over 1.5.0, same as my other small fixes from it. |
||
|
|
395c331b4a |
fix(search): give each provider its own slice of the search budget (#1537)
Fixes #1531. All Direct Download providers share the one search budget (`RELEASE_SEARCH_TIMEOUT`, 300s). Anna's Archive runs first, and when its bypass keeps failing the retries use up the whole budget. OceanofPDF then sees an expired deadline, skips its queries, and the search raises Anna's Archive's error even though OceanofPDF never got a turn. I didn't split the budget evenly, since the deadline module itself says a cold-start Anna's Archive solve can legitimately take 60 to 120s and a 150s or 37s slice would break setups that work today. Instead `search_deadline.unit_deadline(waiting=n)` opens a child deadline for one unit of work. It gets the parent's remaining time minus 30s for each unit still waiting, never less than 30s and never more than the parent has left. A single provider still gets everything, a fast provider's unused time rolls on to the next, and a stuck one stops at its own limit with the reserve intact. It's used in the provider loop in `DirectDownloadSource.search`, and in the source loop in `/api/releases` since it's the same shape and four lines. With several sources enabled the first one now gets 30s less per source behind it, which is still well above a cold solve for the usual two or three. Tests: a stuck first provider no longer starves the second (fails on main, passes here), a lone provider gets the full budget, and a child never outlives its parent. Two existing tests in `test_releases_api_budget.py` tripped the deadline in force from inside the source, which is now the child, so they use a short request budget instead. ruff, basedpyright on the changed files and the non-e2e suite are clean. OceanofPDF is inactive on my own instance (no mirror URL set), so I haven't reproduced this live. It's verified by reading the code and by the tests. I had Claude do the pass on this one. |
||
|
|
8e425da527 |
refactor: one default and one reader for the Supported Book Formats list (#1532)
The default for Supported Book Formats (`epub, mobi, azw3, fb2, djvu, cbz, cbr`) was written out in five places: the settings registry, `postprocess/policy.py`, `irc/parser.py`, Prowlarr's `source.py` (added in #1490) and slskd's `source.py` (added in #1428). Both of those two arrived in 1.5.0, and both are mine, so this is cleaning up after myself as much as anything. The setting was also read three different ways. Post-processing split on commas only. slskd split on commas and spaces and stripped a leading dot. IRC split on commas and kept the dot. Prowlarr split on commas and kept `""` as a format when the value was an empty string. So `.epub` or `epub mobi` worked in one source and not another. This puts `DEFAULT_EBOOK_FORMATS` and `normalize_format_list` in `core/utils.py` next to `AUDIOBOOK_FORMATS`, and the registry, post-processing, IRC, Prowlarr and slskd all use them. I took the most lenient reading of the three (comma or space split, leading dot dropped, empties ignored, default when nothing is left), so no existing config gets stricter. I put the reader in `core/utils.py` and not in `policy.py` because the release sources can't import `policy` without pulling in the whole post-processing package. A config with a sensible list reads exactly as before. The only differences are for malformed values: an emptied setting or an empty string now falls back to the default in every place, where IRC used to filter everything out and Prowlarr treated `""` as a format. The audiobook default is a separate story and I left it alone: the registry and slskd default to every processable audiobook format plus the archives, while post-processing and IRC fall back to `m4b, mp3`. They only differ when the setting is unset or emptied, but they do differ, and I'd rather ask which one you want than pick one here. Ruff, basedpyright on the changed files and the backend suite pass (4400 passed, e2e not run). New tests cover the reader and the two fallbacks. |
||
|
|
a11a63a29e |
feat(notifications): add {User}/{Email} placeholders to per-user routes (#1499)
Closes #1130. Per-user notification route URLs can now use `{Email}` and `{User}`, filled in from each user's account at send time, the same way `{User}` already works in destination paths. One default route like `mailtos://user:pass@smtp.example.com?to={Email}` now reaches every user at their own address, with no per-user setup. - Values are URL-encoded (`alice+books@example.com` keeps its `+`); placeholders are case-insensitive. - A route that needs a value the user doesn't have (e.g. no email on file) is skipped for that user; other routes still send. - The user lookup only runs when a route contains a placeholder. - The personal "Test notification" button expands the placeholders too, so testing a `{Email}` route works. - Added to the routes setting help, `docs/users-and-requests.md`, and tests. @calibrain ready for review. --------- Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
f837506017 |
Merge commit from fork
A signed-in user picks the release fields of POST /api/releases/download, and a record id with the "oceanofpdf:" prefix reached the provider without handles() or is_enabled(). The provider then fetched source_url, POSTed to the form action found on that page and fetched the meta-refresh target, with no check on any of them. The provider now refuses to run when it is switched off, and the book page, the form action and every POST redirect hop must be on the configured mirror (same scheme, host and port). The signed file link may sit on another host but must be http(s). network.ensure_public_destination refuses a host that resolves to a loopback, private, link-local or otherwise non-global address (IPv4-mapped IPv6 judged by its IPv4 address). Hosts the operator configured as mirrors and names that do not resolve pass as before. html_get_page and download_url call it on their first URL and check each redirect hop through a response hook. The image cache uses the shared address check too. Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
cb72ce807c |
Merge commit from fork
A user could set any value for their own overridable settings through
PUT /api/users/me. DESTINATION and DESTINATION_AUDIOBOOK accepted any
string, so a download (including one an admin fulfilled for that user)
could be written to any directory the container can reach, and
get_destination created it on demand.
A user's own destination must now resolve inside the global one (with
{User} expanded on both sides). Absolute paths outside it and any ".."
segment are rejected at save time, and get_destination applies the same
check when the path is used, so a stored or hand-written value outside
the global destination is ignored with a warning and the global
destination is used instead. A relative value is taken relative to the
global destination.
Other self-service values are checked as well: select fields must use
one of their options, EMAIL_RECIPIENT must be a plain address (the same
check the orchestrator and the downloads tab use, now shared from
request_helpers), and Grimmory library and path ids must be non-negative
integers. Admin saves on a user's behalf are unchanged.
The self-service 400 response now carries a message line so the reason
reaches the user in the toast.
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
|
||
|
|
c269230ef3 |
fix: review regressions in metadata, notifications, activity redaction, search options and genDebug (#1510)
- **Open Library (#1451): keep the work unless the search matched a translation.** Open Library shows an English edition whenever one matches the query. So "Mistborn" returned the 2023 "Mistborn" reissue (2023) instead of "The Final Empire" (2001), and "Children of Dune" returned "Children of Dune (Dune Chronicles, Book 3)" (1987). The matched edition's title, ISBNs, cover, language and year now win only when the edition is in another language and the query doesn't name the work's own title. Checked against live Open Library: "Children of Dune" and "Mistborn" give the v1.4.0 results again, and "Der Weg der Könige" and "Kinder des Nebels" still return the German editions. - **Google Books (#1434): the ISBN cache decorator is back on `search_by_isbn`.** It had moved to the new `search_paginated`. Failed searches were cached as empty pages, and ISBN lookups lost their cache. - **Notifications: Test notification shows the plugin's failure reason again.** Apprise 2 delivers on its own worker threads, even for a single service when that service has a deadline. `_AppriseLogCapture` filtered records by thread id and dropped all of them, so the UI only said "json: delivery failed". Apprise runs those workers in a copy of the caller's context, so the capture now filters on a context variable. That still keeps concurrent dispatches apart. `AppriseAsset(async_mode=False)` would not fix this, because the sequential path also uses the worker pool when a service has a deadline. - **Activity: `activity_update` no longer sends the raw task id to non-admins.** The owner's `user_<id>` room got the raw task id. For a request-linked download that id can hold a private tracker's passkey. It now goes through `viewer_download_id`, the same mapping `/api/status` and `download_progress` use. The admins room still gets the raw id. - **SearchBar (#1476): Escape and an outside click close the homepage's inline options panel again,** as they did in v1.4.0. - **genDebug.sh: packages are listed through `importlib.metadata`,** since #1404 removed pip from the image. The script also now redacts: - `PROWLARR_MAM_ID` - `user:pass@` credentials in URLs (proxies), in both the environment and the config files - notification route URLs, which carry their service's token (Telegram, Discord and others) - `make python-checks`, `make python-test`, `make frontend-checks` and `make frontend-test` pass on top of current `main`. - New tests cover: - Open Library edition selection - Google Books caching - log capture against a real Apprise 2 delivery to a local server that answers 500, plus a concurrent dispatch on another thread - the `activity_update` payload sent to each room - genDebug redaction and the package listing Each one fails on the old code. - I checked the SearchBar fix in the running app, because the frontend has no DOM test setup: Escape closes the panel, an outside click closes it, and a click inside keeps it open. - I ran the genDebug redaction under GNU sed (Debian image) and BSD sed. The package listing ran in the pip-less image. |
||
|
|
520b6b16d0 |
fix(activity): Clear Completed leaves out an item that is no longer finished instead of failing the batch (#1508)
Clear Completed can fail outright with "Only terminal requests can be dismissed". `dismiss-many` answers 409 and dismisses nothing when any one request or download in the batch is not terminal. The list a user clears from can be older than the server, so a request that was reopened or retried since, or a download that started again, takes the whole batch down with it. The earlier fix for pending requests (#1438) filters on the client, which only helps when the list is current. `dismiss-many` now skips such an item and carries on with the rest. The response adds `skipped_item_keys` when something was left out, and `count` is what was actually dismissed. A single `dismiss` of a non-terminal item still answers 409. I hit it with a request whose download had completed while the request itself was still pending, but any reopen between the list and the click does it. This touches the same handler as #1507, so whichever lands second needs a small rebase. Tests: a batch with a finished and a reopened request dismisses the finished one and reports the other, and a batch with a finished and a running download does the same. Ruff, basedpyright and the backend suite pass (3933 passed, e2e excluded). Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
61c7ad3c62 |
Merge commit from fork
* fix(torrent): check each redirect hop when fetching a torrent file The torrent file fetch followed redirects by hand without looking at where a hop pointed, so a download link could make the server request a loopback, link-local or LAN address. Each hop (the initial URL and every redirect Location) must now be http or https and resolve only to global addresses. The configured Prowlarr/Newznab origin is exempt, as it usually lives on the LAN. The API key still only goes to that origin. * fix(socketio): only accept websocket handshakes from the app's own origin The Socket.IO server allowed every origin. It now uses the same-origin check python-engineio applies by default (Host, or X-Forwarded-Proto and X-Forwarded-Host behind a proxy). SOCKETIO_ALLOWED_ORIGINS takes a comma separated list of extra origins for proxies that change Host without forwarding it. * fix(custom-script): do not pass a file name that starts with a dash as the first argument In relative path mode the script got the organized file's relative path as its first argument, which many scripts read as an option when the name starts with a dash. Such a name is now passed with a ./ prefix. Absolute mode is unchanged. |
||
|
|
6f65d6e20a |
Merge commit from fork
A release format such as /../../x was appended to the staging file name unchanged, so the direct-download and libgen handlers could write to, and on cancel unlink, a path outside TMP_DIR. Normalise the format to a plain extension at queue time and in build_filename, reject path-like source ids for the sources that use them as file names, and make both handlers resolve the staging path and refuse anything that is not a direct child of TMP_DIR. |
||
|
|
89663e32f6 |
Merge commit from fork
A user's personal notification routes were sent to any Apprise URL, from the server. Through the test endpoint or a saved route a signed-in user could make the server POST to loopback, link-local and LAN hosts, choosing the path and, with Apprise's + and : parameters, extra headers and body fields. User routes are now limited to the hosted services that talk to a fixed vendor host (discord, slack, tgram, pover, pbul) and to ntfy and gotify, whose host must resolve only to public addresses (checked on the host urlsplit reads and on the host Apprise reads, including IPv4-mapped and numeric forms). The check runs when a route is tested, when an event is queued and again when it is sent. Admin routes are unchanged. |
||
|
|
d34b200414 |
fix(activity): answer another user's item like a missing one, and skip foreign history rows (#1507)
Two small fixes in `activity_routes.py`. **Another user's item answers like a missing one.** Dismissing a task or request id answered 404 when it didn't exist and 403 when it belonged to someone else, so a user could test whether an arbitrary id exists. A non-admin now gets the same 404 for both. In dismiss-many a foreign key is reported with the missing ones and the rest carry on, and the owner is still written to the log. Admins are unchanged. **A foreign row in someone's viewer state no longer gives a 500.** If a non-admin's viewer state referenced another user's download or request row, building history raised a `RuntimeError`. The row is now skipped with a warning. Not changed: history still raises when a row that viewer state points to has been deleted. That is a different case, and I didn't want to widen this. Tests: the existing forbidden test now expects the 404 and the log context, and four new ones cover a foreign download and a foreign request answering like a missing one (including dismiss-many), an admin still dismissing a foreign row, and history skipping foreign rows. Ruff, basedpyright and the backend suite pass. |
||
|
|
8fd477bf50 |
fix(releases): apply the request policy to release listing, not only to download (#1506)
The request policy is applied when a user downloads (`/api/releases/download`) or requests (`/api/requests`), but not when they list releases. A user whose mode is `request_book` or `blocked` can still search releases and list a torrent's files through the shared Prowlarr and qBittorrent credentials. Only the UI hides the list. This applies the same gate to `/api/releases`, `/api/releases/inspect` and the release-source records route, with the same codes as the download route (`policy_blocked`, `policy_requires_request`, `required_mode`). - `blocked` and `request_book` are refused. `request_release` is allowed, because that mode lists releases so the user can pick one. The UI opens the request dialog directly under `request_book` and never calls `/api/releases` there. - `/api/releases` resolves the mode per source for the content type. A refused source is dropped from the search, so a user blocked on Prowlarr no longer reaches Prowlarr even when other sources are allowed. It answers 403 only when no source is left, or when the one source asked for is refused. - The records route doesn't know the content type before it fetches, so it refuses only when every content type the source supports is refused. - Admins, auth mode `none` and an instance with requests off bypass the gate, as they do on the download route. A user whose default is `blocked` now gets a 403 toast in the release modal, where they used to get a list of "Unavailable" rows. I made no frontend change. Tests (14, new file) cover each route for blocked, request_book, request_release, an admin, and auth mode none, the per-source drop, and the records route. Ruff, basedpyright and the backend suite pass. Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
b05bbc8c58 |
fix(covers): key the cover cache by the image URL, not the caller-supplied id (#1505)
`/api/covers/<id>?url=...` cached the fetched image under the id in the path, and the caller chooses that id. A request with another book's id and a different URL put its image in that book's cache entry, and every user then got it. The key is now derived on the server from the image URL (a hash of it), and the id in the path stays only so links keep their shape. The SSRF checks and `fetch_and_cache` are unchanged, and `transform_cover_url` links already carry the URL, so they keep working. The URL is decoded first now, so a missing one is a 404 and bad encoding a 400, both before any fetch. I made the key the URL alone, not URL plus id. Either stops the overwrite, and the URL alone lets one image be shared when the same cover appears under two ids (the viewer redaction rewrites ids and still hits the cache). Entries cached under the old keys are not read any more, since there is no telling which were overwritten. They age out of the LRU and the cache warms once after the upgrade. Tests: a request with another id can't replace the legitimate image, in either order; the normal miss then hit; the same URL under two ids hits; no URL gives 404; bad encoding gives 400 without a fetch; and a unit test for the key. Ruff, basedpyright and the backend suite pass. |
||
|
|
71cd030484 |
Merge commit from fork
GET /api/users/me/edit-context served every user-overridable key with the admin's global value in globalValues and effective, so any user could read the global destination paths, the admin's EMAIL_RECIPIENT, the Booklore library and path ids and the global USER_NOTIFICATION_ROUTES table, whose Apprise URLs embed tokens. build_user_preferences_payload gains a for_self_service mode that replaces a global value with None unless the user set the key themselves or the field is a checkbox, number, select or multi-select switch the form needs to lay itself out (BOOKLORE_LIBRARY_ID and BOOKLORE_PATH_ID are masked despite being selects). The self-service route uses it; the admin routes keep full data. |
||
|
|
386fdb313c |
Merge commit from fork
GET /api/debug returns a bundle with application logs, process listings and environment names, and GET /api/restart exits the process. Both are registered only when DEBUG is on, but any logged-in user could call them. Add an admin_required decorator built on login_required and use it for both routes. Non-admin sessions get 403 "Admin access required"; authentication mode "none" keeps working as before. |
||
|
|
77b80eb31a |
fix(metadata): only an admin may use the provider's own account, its lists and shelves (#1501)
Refs #981. Hardcover is signed in as one account, the instance owner's, and every logged-in user shares it. A non-admin can see the owner's lists and shelves in the List filter, browse one by asking for it, and add or remove books and change read status from search results, which is what #981 describes. Now only an admin may. With authentication set to none the one user keeps everything. - `GET` targets and the batch lookup return no options for a non-admin, so the buttons don't show and the account isn't asked. - `PUT` targets answers 403. - The List filter (`hardcover_list`) has no options for a non-admin, and a request that asks for it has the field dropped before the search. Providers name these fields in `account_search_fields`, and Hardcover lists `hardcover_list`. - Public things are unchanged: author, title and series suggestions, and a pasted list URL in the search box. That pasted URL is the one gap I know of. It resolves any list the account can see, so a user who knows the slug of one of the owner's private lists could still open it. Closing it means the provider has to know who is asking, which is a bigger change than this one, so I left it. Not included from the issue: a toggle for letting users view lists and limiting which lists they see. I kept this to the admin-only part so it stays small, and I can do the toggle as a follow-up if you want it. Tests cover the routes as an admin, a user and with no authentication (targets read, batch and write, list options, a crafted list search, and public fields still passing). Ruff, basedpyright and the backend suite pass. Co-authored-by: CaliBrain <calibrain@l4n.xyz> |
||
|
|
9ba5816b83 |
fix(requests): an older failed attempt no longer undoes a later delivery (#1493)
A request that was retried after a failure has two tasks in the status built from download history. `sync_delivery_states_from_queue_status` walks the status buckets in `QueueStatus` order and keeps the last one it sees for a request. Error comes after complete, so a request that was delivered by its retry is set back to error and, when no retry is left, reopened as pending. The next pass can then fetch the book again. When a request has more than one task, an attempt that is still running now decides its state, else a completed one, else the failure. Ties keep the old order, so a request with a single failed task still reopens as before. Tests: an earlier failure doesn't undo a later delivery, a running retry outranks an earlier failure, and a lone failure still reopens. It happened on my instance to a request whose first try failed and whose retry succeeded. Ruff and the backend suite pass. |
||
|
|
a1ac017487 |
feat(requests): let an admin reopen a rejected request (#1491)
Closes #1445, where you said you were open to it. A rejected request was final. `update_request` won't move a terminal status and the admin routes only fulfil or reject, so an admin who changed their mind had to ask the user to request the book again, and the user kept seeing the old rejected one. `POST /api/admin/requests/<id>/reopen` moves a rejected request back to pending and clears the review fields. What the user asked for (request level, release, note) is kept. It refuses anything that isn't rejected, and refuses when that user already has a pending request for the same book. Admins get a Reopen action on rejected cards in the activity sidebar and in History. A rejected request has usually been cleared from the list by the time anyone wants it back, so reopening also clears the dismissal, the way the failed-download reopen does. Without that the pending request stays hidden. Tests cover the service (state change, what is kept, the refusals, a non-string admin note) and the route, and the card model for the new action. Ruff, tsc, oxlint, vitest and the backend suite pass. It has been running on my instance since Oct 8. |
||
|
|
88cc734db2 |
fix(activity): a reused release follows the new request, and stale rows of a delivered request are closed (#1496)
Two things that go wrong when a second request picks a release a first request already used. A task id comes from the release, so the history row is reused. **The row stays with the first request.** Recording a download kept the first request's id, notes and state. On my instance a card for one book showed up under the request for a different book, with that request's note on it. Recording a download now moves the row to the request that queued it and restarts its queue time. A retry with no request keeps the request the row had. **Old rows of a delivered request show as failures forever.** The startup sweep leaves an old request-linked row alone so its retry stays, but one whose request already has its book (delivered, or another download of it finished) is now closed as "Superseded by a completed download" instead of showing as an interrupted failure for ever. Also in the first commit: History cards used the saved cover URL as it was, an external address the page's own-origin image policy blocks, while running downloads use the cover proxy. They use it too. The second commit is about restarts. After a restart nothing is in flight, but a request in downloading, resolving or locating whose row had moved to another request was stranded, because only queued was reopened. Those states are reopened too. Tests cover the row takeover and the retry case, the cover through the proxy (and a missing cover), the superseded cases (delivered request, another row finished, an undelivered request is left alone) and the in-flight states with no row. Ruff and the backend suite pass. |