Compare commits

...
Author SHA1 Message Date
Alex 7a4c913809 Clarify Docker digest update group 2026-05-12 12:14:29 +01:00
Alex 230015ad57 Add Dependabot update cooldown 2026-05-12 12:09:27 +01:00
Alex 28bf26414d Run uv Dependabot checks daily 2026-05-12 11:30:29 +01:00
Alex b28ad55d46 Relax URL hardening (#984) 2026-05-12 08:47:53 +01:00
Alex 9c3af5793b Fail closed on unwritable config (#985) 2026-05-12 08:47:42 +01:00
Alex d1fd93f180 Harden Welib URL validation (#979) 2026-05-10 15:20:43 +01:00
Alex b67df083e6 Remove installer tooling from runtime images (#978) 2026-05-10 15:08:44 +01:00
Alex b038867d8d Validate Welib fallback URLs (#977) 2026-05-10 10:28:52 +01:00
Alex 472aae608b Restrict torrent prefetch to trusted origins (#976) 2026-05-10 10:22:49 +01:00
Alex 0e120abfaf Validate request policy release source (#975) 2026-05-10 09:46:31 +01:00
Alex f6357ead41 Contain remote path mappings (#974) 2026-05-10 09:29:49 +01:00
Alex d67eeace3c Harden qBittorrent fallback path joins (#973) 2026-05-10 09:19:31 +01:00
Alex e615797e69 Enforce release source availability before queueing (#971) 2026-05-10 08:52:17 +01:00
Alex 2aee1d587e Validate ABB detail URLs (#972) 2026-05-10 08:25:03 +01:00
Alex d1ab58411b Fix queue management ownership checks (#970) 2026-05-09 14:42:11 +01:00
Alex f4daf05d03 Pin Docker base image digests (#969) 2026-05-09 14:37:44 +01:00
Alex 5f9f47cc41 Redact release URLs safely (#968) 2026-05-09 14:22:25 +01:00
Alex 81b448bc9f Restrict SABnzbd NZB prefetch origins (#967) 2026-05-09 13:44:47 +01:00
Alex eee8ba0e83 Fix Tor healthcheck clear-net probe (#966) 2026-05-09 13:33:12 +01:00
Alex cecbae738e Move archive extraction from memory to stream (#965) 2026-05-09 13:19:44 +01:00
Alex f5fafd2265 Validate IRC DCC offers before download (#964) 2026-05-09 13:19:22 +01:00
Alex fbbff8f715 Require verified OIDC email for account linking (#963)
Fixes a security issue relying on plain email fields for OIDC user
linking. Requires verified email instead.
2026-05-09 07:24:31 +01:00
Alex 9b8402c9a7 Add DISABLE_LOCAL_AUTH env variable (#962)
Adds a new env var to disable local auth entirely when using OIDC
authentication

Fixes #922 #834
2026-05-08 22:11:14 +01:00
Alex 3305ec9e46 Open up hardlink requirement (#961) 2026-05-08 13:26:38 +01:00
Alex f03be02de0 Fix language string normalisation (#960)
Fixes #948
2026-05-08 12:14:44 +01:00
dependabot[bot]andAlex 7d589abc35 Bump the npm-deps group across 1 directory with 9 updates (#953)
Bumps the npm-deps group with 9 updates in the /src/frontend directory:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) |
`19.2.5` | `19.2.6` |
|
[react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom)
| `19.2.5` | `19.2.6` |
|
[react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom)
| `7.14.2` | `7.15.0` |
|
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
| `25.6.0` | `25.6.2` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.6.2` | `6.12.1` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.46.0` | `0.48.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.61.0` | `1.63.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.21.1`
| `0.22.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.0.10` | `8.0.11` |


Updates `react` from 19.2.5 to 19.2.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/facebook/react/releases">react's
releases</a>.</em></p>
<blockquote>
<h2>19.2.6 (May 6th, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Type hardening and performance improvements
(<a
href="https://redirect.github.com/facebook/react/pull/36425">#36425</a>
by <a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> and
<a
href="https://github.com/unstubbable"><code>@​unstubbable</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/facebook/react/commit/eaf3e95ca92be7a23d3c9cc8ffd6f199a40be401"><code>eaf3e95</code></a>
Version 19.2.6</li>
<li>See full diff in <a
href="https://github.com/facebook/react/commits/v19.2.6/packages/react">compare
view</a></li>
</ul>
</details>
<br />

Updates `react-dom` from 19.2.5 to 19.2.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/facebook/react/releases">react-dom's
releases</a>.</em></p>
<blockquote>
<h2>19.2.6 (May 6th, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Type hardening and performance improvements
(<a
href="https://redirect.github.com/facebook/react/pull/36425">#36425</a>
by <a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> and
<a
href="https://github.com/unstubbable"><code>@​unstubbable</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/facebook/react/commit/eaf3e95ca92be7a23d3c9cc8ffd6f199a40be401"><code>eaf3e95</code></a>
Version 19.2.6</li>
<li>See full diff in <a
href="https://github.com/facebook/react/commits/v19.2.6/packages/react-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `react-router-dom` from 7.14.2 to 7.15.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md">react-router-dom's
changelog</a>.</em></p>
<blockquote>
<h2>v7.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies:
<ul>
<li><a
href="https://github.com/remix-run/react-router/releases/tag/react-router@7.15.0"><code>react-router@7.15.0</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/remix-run/react-router/commit/97c8de79c38f107acd15f74d8295c7bf75894a5d"><code>97c8de7</code></a>
Release v7.15.0 (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom/issues/15018">#15018</a>)</li>
<li>See full diff in <a
href="https://github.com/remix-run/react-router/commits/react-router-dom@7.15.0/packages/react-router-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/node` from 25.6.0 to 25.6.2
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.6.2 to 6.12.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.12.1</h2>
<ul>
<li>fix: type-only imports in monorepos (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1715">#1715</a>)
(de33a2cb020f321f242bfb3884cdd597fb5f868c) - thanks <a
href="https://github.com/lishaduck"><code>@​lishaduck</code></a>!</li>
<li>Bump jiti to ^2.7.0 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1729">#1729</a>)
(0fe8dc33dc60b05a814828046aa5207051fc4b6d) - thanks <a
href="https://github.com/re-taro"><code>@​re-taro</code></a>!</li>
<li>Fix Vercel config detection (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1726">#1726</a>)
(370236d2e67058fb30c77a5f54d88b9774276eef) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Fix inferred declaration export references (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1728">#1728</a>)
(4dcd756f0903c1045a7600201243decbc7184715) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Remove stale root watch script (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1731">#1731</a>)
(2d555a18befc2576539491b5d66799e630689b38) - thanks <a
href="https://github.com/jeffrey-takuma"><code>@​jeffrey-takuma</code></a>!</li>
<li>Update sponsorships script/numbers
(c3dcc8f4fd923f87baad444c5f8e23fd7be15497)</li>
<li>Add orgs using knip (78fd581857c0b01fc2ab987bc86d888954e97a71)</li>
<li>Yolo (7e689bf60b39c6a4af46e8d68e9a6986df0e6f04)</li>
</ul>
<h2>Release 6.12.0</h2>
<ul>
<li>Use venz light/dark responsive svg img
(2354194043354b67ed9463b6998d40a8e8cbab81)</li>
<li>Fix types/path references
(4afc873801bcca933dbc71c47b5557cbab646c6b)</li>
<li>Move on to pnpm 11 (b1060652e85b8bf9a306135ca12ae22032099889)</li>
<li>Fix up ecosystem tests
(c226a72b8936397dab2fc6d30e27517c257c36ca)</li>
<li>Add shell binaries to global ignore list (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1716">#1716</a>)
(ddcf7debd820b9deac9f29d1ed904f340c0ee91e) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Fix declaration export regression and document (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1722">#1722</a>)
(3a2c22b52cda834b4d8a9956d9089b3dea9422bd)</li>
<li>Update snapshot after 3a2c22b
(8300078b75913d94ef19dbd1990e2073db8541d8)</li>
<li>Detect babel.plugins/presets in <code>@​vitejs/plugin-react</code>
via function-form defineConfig (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1723">#1723</a>)
(d56ee51c2162c29baf3564ded39639a1a258caa1)</li>
<li>Lift defineConfig-arg unwrapper to ast-helpers, route findCallArg
through it (7195b0a5f0986833a059c5d2cda9697d7d0abbf7)</li>
<li>Fix PostCSS detection for <code>@​tailwindcss/postcss</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1719">#1719</a>)
(60f84824eebeece47ec5d2683fe4db9aaa6e7d00) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Allow <code>&gt;</code> inside SFC <code>&lt;script&gt;</code>
attribute values (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1714">#1714</a>)
(9e5501f60150d0521bf7f2aa5a9af8db1285813b)</li>
<li>Resolve Cypress reporter set per testing type (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1724">#1724</a>)
(7cc4fc19ea12f4aefb55ef01a0ad5237b2dac8c4)</li>
<li>Add Vercel config plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1720">#1720</a>)
(10f97c10cd3203761c6ba2f4ee335c9719d81840) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Direct config hint title to stderr
(53236b5f7dc12c2e7e561448c276a0168a6367fc)</li>
<li>Some light housekeeping
(727f842709f2adf9fe7658b6ed1b66b11043d821)</li>
<li>Fix up ecosystem tests
(0db3300e4109cb184520863e98eff6c2c956a717)</li>
<li>Fix --no-exit-code condition for
<code>isTreatConfigHintsAsErrors</code>
(f27c3f4a556fdd18bfafb9b270fddc9b12c8033a)</li>
<li>A friendlier message (aab1e83baaa088b8f8730f03f0d8e6520fa48d64)</li>
<li>Mark plugin-name fallback binaries as optional in knownBinsOnly mode
(c709a5aaa473184d1a73f7cbcb8eaf0d73e072d4)</li>
</ul>
<h2>Release 6.11.0</h2>
<ul>
<li>Ignore &amp; remove uninteresting lines in snapshots
(767ebaf9b20d02f271d2a083404b60cba63119d7)</li>
<li>Flag stale <code>@internal</code> tags in production mode (resolve
<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1658">#1658</a>)
(dd1caeda80784d6070b028c781a3502b33e9765a)</li>
<li>Add compiler-extensions in tsc-files mode (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1708">#1708</a>)
(ea867ad4bb9195f556ff58dae010d0d77c57bb25)</li>
</ul>
<h2>Release 6.10.0</h2>
<ul>
<li>Ecosystem patches → snapshots
(1803f9f94d42ebe88730465c002098de54c6650a)</li>
<li>Fix unlisted pkg when only <code>@​types/pkg</code> listed (resolve
<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1707">#1707</a>)
(62082b69b382ea56d86e718da623ccadb9281a14)</li>
<li>Don't report imports matching engines.X as unlisted
(23582e0f4da7a98cfe50c065b63d38a75870911e)</li>
<li>Treat imports in .d.ts files as type-only
(84caac58d99f39fde56c664f42bad2c000d8943e)</li>
<li>Treat tsconfig compilerOptions.types as type-only
(ce18854b0a008f1060e30c660eee820eba1568e5)</li>
<li>Resolve hoisted deps from monorepo root in single-package mode
(resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1711">#1711</a>)
(23b756fb0becacaa19fbd71a0d9a0264f8647e88)</li>
</ul>
<h2>Release 6.9.0</h2>
<ul>
<li>Expose types for JSON reporter
(961b734f398b451bc26708f8d3bfa72a71983dcc)</li>
</ul>
<h2>Release 6.8.0</h2>
<ul>
<li>feat: add WXT plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1703">#1703</a>)
(9167557755a0aef81947aaedfa4745805ffaa571) - thanks <a
href="https://github.com/sebastianbreguel"><code>@​sebastianbreguel</code></a>!</li>
<li>Add support for pnpm@11 new commands (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1706">#1706</a>)
(c937697a68caaf1ac224627537993b32d37cc91b) - thanks <a
href="https://github.com/PatrykWalach"><code>@​PatrykWalach</code></a>!</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/b255195be57173284dd313da599e7e86718c9cdb"><code>b255195</code></a>
Release knip@6.12.1</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/4dcd756f0903c1045a7600201243decbc7184715"><code>4dcd756</code></a>
Fix inferred declaration export references (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1728">#1728</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/370236d2e67058fb30c77a5f54d88b9774276eef"><code>370236d</code></a>
Fix Vercel config detection (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1726">#1726</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/0fe8dc33dc60b05a814828046aa5207051fc4b6d"><code>0fe8dc3</code></a>
Bump jiti to ^2.7.0 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1729">#1729</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/de33a2cb020f321f242bfb3884cdd597fb5f868c"><code>de33a2c</code></a>
fix: type-only imports in monorepos (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1715">#1715</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/44829fe6a9ca5cc34ea345860b912dceb1a87c51"><code>44829fe</code></a>
Release knip@6.12.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/c709a5aaa473184d1a73f7cbcb8eaf0d73e072d4"><code>c709a5a</code></a>
Mark plugin-name fallback binaries as optional in knownBinsOnly
mode</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/f27c3f4a556fdd18bfafb9b270fddc9b12c8033a"><code>f27c3f4</code></a>
Fix --no-exit-code condition for
<code>isTreatConfigHintsAsErrors</code></li>
<li><a
href="https://github.com/webpro-nl/knip/commit/727f842709f2adf9fe7658b6ed1b66b11043d821"><code>727f842</code></a>
Some light housekeeping</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/53236b5f7dc12c2e7e561448c276a0168a6367fc"><code>53236b5</code></a>
Direct config hint title to stderr</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.12.1/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.46.0 to 0.48.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/5921a25e374e7ad233fddb06ac3151e2318bd6ca"><code>5921a25</code></a>
release(apps): oxlint v1.63.0 &amp;&amp; oxfmt v0.48.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22109">#22109</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d82f698000b0626795545c7c54278097bb9af684"><code>d82f698</code></a>
release(apps): oxlint v1.62.0 &amp;&amp; oxfmt v0.47.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/21838">#21838</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.48.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.61.0 to 1.63.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxlint's
releases</a>.</em></p>
<blockquote>
<h2>oxlint v1.27.0 &amp;&amp; oxfmt v0.12.0</h2>
<h1>Oxlint v1.27.0</h1>
<h3>🚀 Features</h3>
<ul>
<li>222a8f0 linter/plugins: Implement
<code>SourceCode#isSpaceBetween</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15498">#15498</a>)
(overlookmotel)</li>
<li>2f9735d linter/plugins: Implement
<code>context.languageOptions</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15486">#15486</a>)
(overlookmotel)</li>
<li>bc731ff linter/plugins: Stub out all <code>Context</code> APIs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15479">#15479</a>)
(overlookmotel)</li>
<li>5822cb4 linter/plugins: Add <code>extend</code> method to
<code>FILE_CONTEXT</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15477">#15477</a>)
(overlookmotel)</li>
<li>7b1e6f3 apps: Add pure rust binaries and release to github (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15469">#15469</a>)
(Boshen)</li>
<li>2a89b43 linter: Introduce debug assertions after fixes to assert
validity (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15389">#15389</a>)
(camc314)</li>
<li>ad3c45a editor: Add <code>oxc.path.node</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15040">#15040</a>)
(Sysix)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>6f3cd77 linter/no-var: Incorrect warning for blocks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15504">#15504</a>)
(Hamir Mahal)</li>
<li>6957fb9 linter/plugins: Do not allow access to
<code>Context#id</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15489">#15489</a>)
(overlookmotel)</li>
<li>7409630 linter/plugins: Allow access to <code>cwd</code> in
<code>createOnce</code> in ESLint interop mode (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15488">#15488</a>)
(overlookmotel)</li>
<li>732205e parser: Reject <code>using</code> / <code>await using</code>
in a switch <code>case</code> / <code>default</code> clause (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15225">#15225</a>)
(sapphi-red)</li>
<li>a17ca32 linter/plugins: Replace <code>Context</code> class (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15448">#15448</a>)
(overlookmotel)</li>
<li>ecf2f7b language_server: Fail gracefully when tsgolint executable
not found (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15436">#15436</a>)
(camc314)</li>
<li>3c8d3a7 lang-server: Improve logging in failure case for tsgolint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15299">#15299</a>)
(camc314)</li>
<li>ef71410 linter: Use jsx if source type is JS in fix debug assertion
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15434">#15434</a>)
(camc314)</li>
<li>e32bbf6 linter/no-var: Handle TypeScript declare keyword in fixer
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15426">#15426</a>)
(camc314)</li>
<li>6565dbe linter/switch-case-braces: Skip comments when searching for
<code>:</code> token (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15425">#15425</a>)
(camc314)</li>
<li>85bd19a linter/prefer-class-fields: Insert value after type
annotation in fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15423">#15423</a>)
(camc314)</li>
<li>fde753e linter/plugins: Block access to
<code>context.settings</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15394">#15394</a>)
(overlookmotel)</li>
<li>ddd9f9f linter/forward-ref-uses-ref: Dont suggest removing wrapper
in invalid positions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15388">#15388</a>)
(camc314)</li>
<li>dac2a9c linter/no-template-curly-in-string: Remove fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15387">#15387</a>)
(camc314)</li>
<li>989b8e3 linter/no-var: Only fix to <code>const</code> if the var has
an initializer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15385">#15385</a>)
(camc314)</li>
<li>cc403f5 linter/plugins: Return empty object for unimplemented
parserServices (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15364">#15364</a>)
(magic-akari)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>25d577e language_server: Start tools in parallel (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15500">#15500</a>)
(Sysix)</li>
<li>3c57291 linter/plugins: Optimize loops (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15449">#15449</a>)
(overlookmotel)</li>
<li>3166233 linter/plugins: Remove <code>Arc</code>s (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15431">#15431</a>)
(overlookmotel)</li>
<li>9de1322 linter/plugins: Lazily deserialize settings JSON (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15395">#15395</a>)
(overlookmotel)</li>
<li>3049ec2 linter/plugins: Optimize <code>deepFreezeSettings</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15392">#15392</a>)
(overlookmotel)</li>
<li>444ebfd linter/plugins: Use single object for
<code>parserServices</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15378">#15378</a>)
(overlookmotel)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>97d2104 linter: Update comment in lint.rs about default value for
tsconfig path (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15530">#15530</a>)
(Connor Shea)</li>
<li>2c6bd9e linter: Always refer as &quot;ES2015&quot; instead of
&quot;ES6&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15411">#15411</a>)
(sapphi-red)</li>
<li>a0c5203 linter/import/named: Update &quot;ES7&quot; comment in
examples (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15410">#15410</a>)
(sapphi-red)</li>
<li>3dc24b5 linter,minifier: Always refer as &quot;ES Modules&quot;
instead of &quot;ES6 Modules&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15409">#15409</a>)
(sapphi-red)</li>
<li>2ad77fb linter/no-this-before-super: Correct &quot;Why is this
bad?&quot; section (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15408">#15408</a>)
(sapphi-red)</li>
<li>57f0ce1 linter: Add backquotes where appropriate (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15407">#15407</a>)
(sapphi-red)</li>
</ul>
<h1>Oxfmt v0.12.0</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h2>[1.63.0] - 2026-05-05</h2>
<h3>📚 Documentation</h3>
<ul>
<li>cacbc4a linter: Fix jest settings docs. (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/22127">#22127</a>)
(connorshea)</li>
</ul>
<h2>[1.62.0] - 2026-04-27</h2>
<h3>🚀 Features</h3>
<ul>
<li>348f46c linter: Add <code>respectEslintDisableDirectives</code>
option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21384">#21384</a>)
(Christian Vuerings)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>8c425db linter: Allow string for jest version in config schema (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21649">#21649</a>)
(camc314)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/5921a25e374e7ad233fddb06ac3151e2318bd6ca"><code>5921a25</code></a>
release(apps): oxlint v1.63.0 &amp;&amp; oxfmt v0.48.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/22109">#22109</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/cacbc4a9d8e1a9a7230255573704dd62e2e52fc6"><code>cacbc4a</code></a>
docs(linter): Fix jest settings docs. (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/22127">#22127</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d177595909fb9e3be6306335dba4046a94314cab"><code>d177595</code></a>
chore(linter): Update oxlint-tsgolint peer dependency to the latest
release. ...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d82f698000b0626795545c7c54278097bb9af684"><code>d82f698</code></a>
release(apps): oxlint v1.62.0 &amp;&amp; oxfmt v0.47.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21838">#21838</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/348f46cfc17780c4700114ea3f2c62ef2d9fcc7f"><code>348f46c</code></a>
feat(linter): add <code>respectEslintDisableDirectives</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21384">#21384</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/8c425db635bc935dd23e49c7a5695caf5b243b50"><code>8c425db</code></a>
fix(linter): allow string for jest version in config schema (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21649">#21649</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.63.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint-tsgolint` from 0.21.1 to 0.22.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/tsgolint/releases">oxlint-tsgolint's
releases</a>.</em></p>
<blockquote>
<h2>v0.22.1</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: clarify <code>AGENTS.md</code> submodule guidance by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/909">oxc-project/tsgolint#909</a></li>
<li>feat(no-unsafe-enum-comparison): implement suggestion by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/910">oxc-project/tsgolint#910</a></li>
<li>feat(no-unnecessary-template-expression): implement fix by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/911">oxc-project/tsgolint#911</a></li>
<li>chore(deps): update dependency vitest to v4.1.5 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/912">oxc-project/tsgolint#912</a></li>
<li>chore(deps): update github-actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/913">oxc-project/tsgolint#913</a></li>
<li>fix(prefer-optional-chain): avoid access comparison false positive
by <a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/914">oxc-project/tsgolint#914</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1">https://github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1</a></p>
<h2>v0.22.0</h2>
<h2>What's Changed</h2>
<ul>
<li>chore: convert renovate config to json by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/893">oxc-project/tsgolint#893</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/895">oxc-project/tsgolint#895</a></li>
<li>ci: replace OXC_BOT_PAT with GitHub App tokens by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/894">oxc-project/tsgolint#894</a></li>
<li>ci: add security analysis workflow by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/898">oxc-project/tsgolint#898</a></li>
<li>chore(deps): update github-actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/899">oxc-project/tsgolint#899</a></li>
<li>chore(deps): update module github.com/dlclark/regexp2 to v1.12.0 by
<a href="https://github.com/renovate"><code>@​renovate</code></a>[bot]
in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/900">oxc-project/tsgolint#900</a></li>
<li>chore(deps): update dependency typescript to v6.0.3 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/901">oxc-project/tsgolint#901</a></li>
<li>ci: make security analysis required-check friendly by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/902">oxc-project/tsgolint#902</a></li>
<li>feat(require-await): implement suggestions by <a
href="https://github.com/younggglcy"><code>@​younggglcy</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/896">oxc-project/tsgolint#896</a></li>
<li>fix: add warning for unsupported tsgolint CLI entrypoint by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/903">oxc-project/tsgolint#903</a></li>
<li>fix: resolve ancestor tsconfig for excluded nearest config by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/904">oxc-project/tsgolint#904</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/905">oxc-project/tsgolint#905</a></li>
<li>fix: handle UTF-16 diagnostics by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/906">oxc-project/tsgolint#906</a></li>
<li>fix(no-useless-default-assignment): make default assignment removal
a suggestion by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/907">oxc-project/tsgolint#907</a></li>
<li>fix(no-unnecessary-type-arguments): preserve shadowed type arguments
by <a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/908">oxc-project/tsgolint#908</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0">https://github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/2b933a935b1f9ed66d28efd637fec50ed91c2d78"><code>2b933a9</code></a>
fix(prefer-optional-chain): avoid access comparison false positive (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/914">#914</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/f9cb483ad787829aa9b71bcde139562678481998"><code>f9cb483</code></a>
chore(deps): update github-actions (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/913">#913</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/10a7cc2579e9327b2a68c9c4774cebb10982bce2"><code>10a7cc2</code></a>
chore(deps): update dependency vitest to v4.1.5 (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/912">#912</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/73c15a285b957cfc8dd2adc01037d36b6587e785"><code>73c15a2</code></a>
feat(no-unnecessary-template-expression): implement fix (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/911">#911</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/bfa426fecdbe25adaec9d4fe2a8c8e0d9cd2fc14"><code>bfa426f</code></a>
feat(no-unsafe-enum-comparison): implement suggestion (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/910">#910</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/5985b4bf3b276c6b68ff2470b4d2e680b89aa7a2"><code>5985b4b</code></a>
fix: clarify <code>AGENTS.md</code> submodule guidance (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/909">#909</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/42a817b2ac39c364e8b054d00bd1fb0947d0aa3a"><code>42a817b</code></a>
fix(no-unnecessary-type-arguments): preserve shadowed type arguments (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/908">#908</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/05314ff06fd61cdb772903b972d801cc5edd0076"><code>05314ff</code></a>
fix(no-useless-default-assignment): make default assignment removal a
suggest...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/3858f4d55e0057501cca916272647c4c5524d257"><code>3858f4d</code></a>
fix: handle UTF-16 diagnostics (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/906">#906</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/dbfba7b88374f27bec64dd9a2738f25ece136950"><code>dbfba7b</code></a>
chore: update typescript-go submodule (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/905">#905</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.0.10 to 8.0.11
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.0.11</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.0.11/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.0.10...v8.0.11">8.0.11</a>
(2026-05-07)<!-- raw HTML omitted --></h2>
<h3>Features</h3>
<ul>
<li>update rolldown to 1.0.0-rc.18 (<a
href="https://redirect.github.com/vitejs/vite/issues/22360">#22360</a>)
(<a
href="https://github.com/vitejs/vite/commit/3f80524aa1fa40bfa831f1a1bf2641c3979ba396">3f80524</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22334">#22334</a>)
(<a
href="https://github.com/vitejs/vite/commit/672c96288fd5440bbecddc65551e713edeb8d403">672c962</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22382">#22382</a>)
(<a
href="https://github.com/vitejs/vite/commit/5c0cfcb83dde2c6e25b6c3215dd622956bf29631">5c0cfcb</a>)</li>
<li><strong>glob:</strong> align hmr matcher options with glob
enumeration (<a
href="https://redirect.github.com/vitejs/vite/issues/22306">#22306</a>)
(<a
href="https://github.com/vitejs/vite/commit/30028f94516fa06dd0212567373169b3b3f6e393">30028f9</a>)</li>
<li>make separate object instance for each environment (<a
href="https://redirect.github.com/vitejs/vite/issues/22276">#22276</a>)
(<a
href="https://github.com/vitejs/vite/commit/7c2aa3b40ba00ce1299e4f31932c7929f179a80a">7c2aa3b</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li><strong>create-vite:</strong> list react-compiler templates in
README (<a
href="https://redirect.github.com/vitejs/vite/issues/22347">#22347</a>)
(<a
href="https://github.com/vitejs/vite/commit/7c3a61f42da6445904e93f0e29e9a2a838fa684a">7c3a61f</a>)</li>
<li>explain mergeConfig skips null/undefined (<a
href="https://redirect.github.com/vitejs/vite/issues/22325">#22325</a>)
(<a
href="https://github.com/vitejs/vite/commit/2151f701dc98270c905c540b209fb6d23d53d3ad">2151f70</a>)</li>
<li>mention native config loader in CLI options (<a
href="https://redirect.github.com/vitejs/vite/issues/22348">#22348</a>)
(<a
href="https://github.com/vitejs/vite/commit/0420c5d37b6049476b6e6c16662be372575dd683">0420c5d</a>)</li>
<li>update evan's x handle (<a
href="https://github.com/vitejs/vite/commit/640202a2167b0c19b94e4d3b8ff87309ae1f44d0">640202a</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li><strong>deps:</strong> update dependency tsdown to ^0.21.10 (<a
href="https://redirect.github.com/vitejs/vite/issues/22333">#22333</a>)
(<a
href="https://github.com/vitejs/vite/commit/3b51e050214c5a817c163838ab8643fe34c7d0c3">3b51e05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22383">#22383</a>)
(<a
href="https://github.com/vitejs/vite/commit/555ff36de70a43b3b3dc22f958bf78fe75e11d67">555ff36</a>)</li>
<li><strong>deps:</strong> update transitive packages to fix npm audit
alerts (<a
href="https://redirect.github.com/vitejs/vite/issues/22316">#22316</a>)
(<a
href="https://github.com/vitejs/vite/commit/86aee6268aa879d74f68a890392c1dee973ebf05">86aee62</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li>devtools integration (<a
href="https://redirect.github.com/vitejs/vite/issues/22312">#22312</a>)
(<a
href="https://github.com/vitejs/vite/commit/3c8bf064ec76e311f2d8be3a37dcfdcdd4e4253c">3c8bf06</a>)</li>
<li>remove unnecessary async (<a
href="https://redirect.github.com/vitejs/vite/issues/22296">#22296</a>)
(<a
href="https://github.com/vitejs/vite/commit/b31fd355d93eb166573362bd09c07745b9f76755">b31fd35</a>)</li>
<li>show direct path type in bad character warning (<a
href="https://redirect.github.com/vitejs/vite/issues/22339">#22339</a>)
(<a
href="https://github.com/vitejs/vite/commit/0c162e96a6545c93808e7338b9adeca2636596fa">0c162e9</a>)</li>
</ul>
<h3>Tests</h3>
<ul>
<li><strong>create-vite:</strong> use short help alias (<a
href="https://redirect.github.com/vitejs/vite/issues/22389">#22389</a>)
(<a
href="https://github.com/vitejs/vite/commit/994ab66bc4dc872278d8353d710ffc4bbd881f8d">994ab66</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/66f3194aa8e59924562575f0a98e7f4ae0acdd89"><code>66f3194</code></a>
release: v8.0.11</li>
<li><a
href="https://github.com/vitejs/vite/commit/5c0cfcb83dde2c6e25b6c3215dd622956bf29631"><code>5c0cfcb</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22382">#22382</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/555ff36de70a43b3b3dc22f958bf78fe75e11d67"><code>555ff36</code></a>
chore(deps): update rolldown-related dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22383">#22383</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/b31fd355d93eb166573362bd09c07745b9f76755"><code>b31fd35</code></a>
refactor: remove unnecessary async (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22296">#22296</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/3c8bf064ec76e311f2d8be3a37dcfdcdd4e4253c"><code>3c8bf06</code></a>
refactor: devtools integration (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22312">#22312</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/7c2aa3b40ba00ce1299e4f31932c7929f179a80a"><code>7c2aa3b</code></a>
fix: make separate object instance for each environment (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22276">#22276</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/3f80524aa1fa40bfa831f1a1bf2641c3979ba396"><code>3f80524</code></a>
feat: update rolldown to 1.0.0-rc.18 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22360">#22360</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/0c162e96a6545c93808e7338b9adeca2636596fa"><code>0c162e9</code></a>
refactor: show direct path type in bad character warning (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22339">#22339</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/672c96288fd5440bbecddc65551e713edeb8d403"><code>672c962</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22334">#22334</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/30028f94516fa06dd0212567373169b3b3f6e393"><code>30028f9</code></a>
fix(glob): align hmr matcher options with glob enumeration (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22306">#22306</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite/commits/v8.0.11/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex <25013571+alexhb1@users.noreply.github.com>
2026-05-08 12:02:57 +01:00
dependabot[bot] 84c8142b24 Bump the python-deps group across 1 directory with 5 updates (#954)
Bumps the python-deps group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [authlib](https://github.com/authlib/authlib) | `1.7.0` | `1.7.2` |
| [apprise](https://github.com/caronc/apprise) | `1.9.9` | `1.10.0` |
| [seleniumbase](https://github.com/seleniumbase/SeleniumBase) |
`4.48.2` | `4.48.4` |
| [prek](https://github.com/j178/prek) | `0.3.10` | `0.3.13` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.11` | `0.15.12` |


Updates `authlib` from 1.7.0 to 1.7.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/authlib/authlib/releases">authlib's
releases</a>.</em></p>
<blockquote>
<h2>v1.7.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix the readme links by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/886">authlib/authlib#886</a></li>
<li>Allow non-recommended algorithms in ClientSecretJWT and PrivateKey
by <a href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/887">authlib/authlib#887</a></li>
<li>Validate BCP47 language tags with a regex by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/873">authlib/authlib#873</a></li>
<li>Fix RFC7523 signing with non RSA keys by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/884">authlib/authlib#884</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.7.1...v1.7.2">https://github.com/authlib/authlib/compare/v1.7.1...v1.7.2</a></p>
<h2>v1.7.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix authlib.jose deprecation warning poping from _joserfc_helpers by
<a href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/881">authlib/authlib#881</a></li>
<li>Fix redirecting to unvalidated <code>redirect_uri</code> on
<code>InvalidScopeError</code> in <code>OpenIDImplicitGrant</code> and
<code>OpenIDHybridGrant</code>.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.7.0...v1.7.1">https://github.com/authlib/authlib/compare/v1.7.0...v1.7.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/authlib/authlib/commit/a0b76fac3fa114d7759af2010546bfc332364b63"><code>a0b76fa</code></a>
chore: bump to 1.7.2</li>
<li><a
href="https://github.com/authlib/authlib/commit/c85c7f2b02faf2667f62b27aa60df042dbc9b4ab"><code>c85c7f2</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/884">#884</a>
from azmeuk/852-rfc7523-key-import</li>
<li><a
href="https://github.com/authlib/authlib/commit/a3b2adda43b4452ba9f384d224f95848974b4eb4"><code>a3b2add</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/873">#873</a>
from azmeuk/bcp47</li>
<li><a
href="https://github.com/authlib/authlib/commit/f2578eaa198aedbaaaf3bb00eccc451e15e45e3a"><code>f2578ea</code></a>
fix: Import RSAKey in auth.py for additional key support</li>
<li><a
href="https://github.com/authlib/authlib/commit/b57182cf46a53da19623208dd852270ddec8ebcc"><code>b57182c</code></a>
fix: fallback support RSAKey when client_secret is text</li>
<li><a
href="https://github.com/authlib/authlib/commit/4e7590292ca9f948a0766640b27025618750e6c1"><code>4e75902</code></a>
Merge branch 'main' into 852-rfc7523-key-import</li>
<li><a
href="https://github.com/authlib/authlib/commit/5eb4a860600ea2f3acc1a2a9c3e621a430da9d0f"><code>5eb4a86</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/887">#887</a>
from azmeuk/883-alg</li>
<li><a
href="https://github.com/authlib/authlib/commit/5633f37c47bd8352b962f982cb916bc8b95bb5d5"><code>5633f37</code></a>
fix: allow non-recommended algorithms in ClientSecretJWT and
PrivateKeyJWT</li>
<li><a
href="https://github.com/authlib/authlib/commit/4c8e7b381aaef68f60e2323a25ba96cdb346f82f"><code>4c8e7b3</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/886">#886</a>
from azmeuk/885-readme</li>
<li><a
href="https://github.com/authlib/authlib/commit/23b333e48a17ebf9f032a0e0f9eb3f990a06f97b"><code>23b333e</code></a>
docs: fix the readme links</li>
<li>Additional commits viewable in <a
href="https://github.com/authlib/authlib/compare/v1.7.0...v1.7.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `apprise` from 1.9.9 to 1.10.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/caronc/apprise/releases">apprise's
releases</a>.</em></p>
<blockquote>
<h2>v1.10.0</h2>
<h2>What's Changed</h2>
<p>The big wow factor of this release would be the huge effort put into
Matrix E2EE built into Apprise without adding overhead to the plugin
itself. Huge props to those that helped out. Other than that, a few more
services have been added (137 supported now 🚀 ).</p>
<p>The official documentation website (<a
href="https://appriseit.com">https://appriseit.com</a>) got a nice
cleanup; the Service listings are now searchable; some nice tweaks to
the URL Builder as well.</p>
<h3>📣 New Notification Services:</h3>
<ul>
<li>Opsgenie functionality ported to jira:// in <a
href="https://redirect.github.com/caronc/apprise/pull/1273">caronc/apprise#1273</a></li>
<li>Evolution API (WhatsApp) notification plugin by <a
href="https://github.com/opastorello"><code>@​opastorello</code></a> in
<a
href="https://redirect.github.com/caronc/apprise/pull/1579">caronc/apprise#1579</a></li>
<li>Added blink(1) support in <a
href="https://redirect.github.com/caronc/apprise/pull/1578">caronc/apprise#1578</a></li>
<li>Exotel Support in <a
href="https://redirect.github.com/caronc/apprise/pull/782">caronc/apprise#782</a></li>
<li>Added Octopush Support in <a
href="https://redirect.github.com/caronc/apprise/pull/622">caronc/apprise#622</a></li>
<li>Added Postmark support in <a
href="https://redirect.github.com/caronc/apprise/pull/1590">caronc/apprise#1590</a></li>
</ul>
<h3>🐞  Bugfixes</h3>
<ul>
<li>ntfy:// tags= changed to xtags= in <a
href="https://redirect.github.com/caronc/apprise/pull/1555">caronc/apprise#1555</a>
<ul>
<li>this allows tags to work again correctly for those dependant on it;
previously <code>tags=</code> conflicted with <code>tags=</code> in
Apprise)</li>
</ul>
</li>
<li>XMPP server hostname can differentiate to what is found in JID in <a
href="https://redirect.github.com/caronc/apprise/pull/1560">caronc/apprise#1560</a></li>
<li>fixed issue with mailto:// when using yahoo.com in <a
href="https://redirect.github.com/caronc/apprise/pull/1561">caronc/apprise#1561</a></li>
<li>Fluxer time dependant unittest assertion optimized for slower
systems in <a
href="https://redirect.github.com/caronc/apprise/pull/1566">caronc/apprise#1566</a></li>
<li>fixed templating references impacting url generation (on <a
href="https://appriseit.com">https://appriseit.com</a>) in <a
href="https://redirect.github.com/caronc/apprise/pull/1582">caronc/apprise#1582</a></li>
<li>fixed KeyError Exception thrown when certain emoji's specified in <a
href="https://redirect.github.com/caronc/apprise/pull/1592">caronc/apprise#1592</a></li>
</ul>
<h3>💡 Features</h3>
<ul>
<li>Migrate the Dot. (Quote/0) plugin from API v1 to API v2 by <a
href="https://github.com/HerbertGao"><code>@​HerbertGao</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1512">caronc/apprise#1512</a>
<ul>
<li>Updated Dot. plugin to better align with Apprise in <a
href="https://redirect.github.com/caronc/apprise/pull/1588">caronc/apprise#1588</a></li>
</ul>
</li>
<li>URLs that can not be loaded are more verbose for the reasoning in <a
href="https://redirect.github.com/caronc/apprise/pull/1568">caronc/apprise#1568</a></li>
<li>Webex wxteams:// Bot API Support in <a
href="https://redirect.github.com/caronc/apprise/pull/1567">caronc/apprise#1567</a></li>
<li>fixes parse_url() -&gt; url() -&gt; parse_url() inconsistency in <a
href="https://redirect.github.com/caronc/apprise/pull/1572">caronc/apprise#1572</a></li>
<li>Pushover Delivery Group Support in <a
href="https://redirect.github.com/caronc/apprise/pull/1563">caronc/apprise#1563</a></li>
<li>Matrix token template cleanup in <a
href="https://redirect.github.com/caronc/apprise/pull/1573">caronc/apprise#1573</a></li>
<li>fcm:// add apns-push-type header for reliable iOS delivery by <a
href="https://github.com/AlbertoLanaro"><code>@​AlbertoLanaro</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1577">caronc/apprise#1577</a></li>
<li>Home Assistant &quot;Service' Notification Support Added (extension
to what was already there) in <a
href="https://redirect.github.com/caronc/apprise/pull/1294">caronc/apprise#1294</a></li>
<li>Added attachment support to Mattermost in <a
href="https://redirect.github.com/caronc/apprise/pull/1583">caronc/apprise#1583</a></li>
<li>🔥 Matrix E2EE Support in <a
href="https://redirect.github.com/caronc/apprise/pull/1574">caronc/apprise#1574</a></li>
<li>Matrix Hookshot support added in <a
href="https://redirect.github.com/caronc/apprise/pull/1586">caronc/apprise#1586</a></li>
<li>Mastodon supports hashtag/user references in <a
href="https://redirect.github.com/caronc/apprise/pull/1587">caronc/apprise#1587</a></li>
<li>PushPlus refactored to support more options in <a
href="https://redirect.github.com/caronc/apprise/pull/1589">caronc/apprise#1589</a></li>
<li>Inproved azure:// error handling and message responses in <a
href="https://redirect.github.com/caronc/apprise/pull/1499">caronc/apprise#1499</a></li>
</ul>
<h3>❤️ Life-Cycle Support</h3>
<ul>
<li>Update LoC badge by <a
href="https://github.com/github-actions"><code>@​github-actions</code></a>[bot]
in <a
href="https://redirect.github.com/caronc/apprise/pull/1554">caronc/apprise#1554</a></li>
<li>Memory-Optimized Plugin Management in <a
href="https://redirect.github.com/caronc/apprise/pull/1557">caronc/apprise#1557</a></li>
<li>Ruff linter configuration updated and applied to entire codebase in
<a
href="https://redirect.github.com/caronc/apprise/pull/1562">caronc/apprise#1562</a></li>
<li>i18n(pt_BR): add Brazilian Portuguese translation by <a
href="https://github.com/opastorello"><code>@​opastorello</code></a> in
<a
href="https://redirect.github.com/caronc/apprise/pull/1580">caronc/apprise#1580</a></li>
<li>i18n(es): add Spanish translation by <a
href="https://github.com/opastorello"><code>@​opastorello</code></a> in
<a
href="https://redirect.github.com/caronc/apprise/pull/1581">caronc/apprise#1581</a></li>
</ul>
<h2>Installation</h2>
<p>Apprise is available <a href="https://pypi.org/project/apprise/">on
PyPI</a> through <em>pip</em>:</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/caronc/apprise/commit/343c960969477b60df580f70294ce966bb4b0ce2"><code>343c960</code></a>
bummped version to v1.10.0</li>
<li><a
href="https://github.com/caronc/apprise/commit/67b523077173bcf8c717b0e567b7d2e7251d21e1"><code>67b5230</code></a>
Inproved azure:// error handling and message responses (<a
href="https://redirect.github.com/caronc/apprise/issues/1499">#1499</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/755f17479ff20fd9473e1aa15ab1b190920041c3"><code>755f174</code></a>
bugfix: KeyError Exception thrown when certain emoji's specified (<a
href="https://redirect.github.com/caronc/apprise/issues/1592">#1592</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/a7bc56dd81ea60b1c9f59775d1e005e560a716b9"><code>a7bc56d</code></a>
PushPlus refactored to support more options (<a
href="https://redirect.github.com/caronc/apprise/issues/1589">#1589</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/9d0056b9cd4771bc752d4ff2936a1e4977e3fa81"><code>9d0056b</code></a>
Added Postmark support (<a
href="https://redirect.github.com/caronc/apprise/issues/1590">#1590</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/0617ef7ac63cc6f1e850913c055e280043360358"><code>0617ef7</code></a>
Updated Dot. plugin to better align with Apprise standards (<a
href="https://redirect.github.com/caronc/apprise/issues/1588">#1588</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/0fbe0946869fab49c330d6fd5fb89ea58cb9b6db"><code>0fbe094</code></a>
Added Octopush Support (<a
href="https://redirect.github.com/caronc/apprise/issues/622">#622</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/58d0f1e00a5e4daac928372d1b49a2886e7589f2"><code>58d0f1e</code></a>
Exotel Support (<a
href="https://redirect.github.com/caronc/apprise/issues/782">#782</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/2a8d62fad5dc1930d8f14e0775341692ff0c5aa5"><code>2a8d62f</code></a>
Mastodon supports hashtag/user references (<a
href="https://redirect.github.com/caronc/apprise/issues/1587">#1587</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/1382633788004b515240c0f17c5b0cfe0f111554"><code>1382633</code></a>
Matrix Hookshot support added (<a
href="https://redirect.github.com/caronc/apprise/issues/1586">#1586</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/caronc/apprise/compare/v1.9.9...v1.10.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.48.2 to 4.48.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.48.4 - CDP Mode: Patch 104</h2>
<h2>CDP Mode: Patch 104</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a756d91999539497c8215aff59f520da7ed14633">Update
CDP Mode</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0e8b32f9b894cbbac546256f33630b16b08be49a">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 104 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4339">seleniumbase/SeleniumBase#4339</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.48.3...v4.48.4">https://github.com/seleniumbase/SeleniumBase/compare/v4.48.3...v4.48.4</a></p>
<h2>4.48.3 - Update config and dependencies</h2>
<h2>Update config and dependencies</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e5b2ffde2a9131f9e813cd97c430219648cd48e7">Update
default config in Chrome</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/163ef1b5b955df2b3daa201ca5652bb613ee1af9">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Update config and dependencies by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4338">seleniumbase/SeleniumBase#4338</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.48.2...v4.48.3">https://github.com/seleniumbase/SeleniumBase/compare/v4.48.2...v4.48.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9ae2c55abae90b0402e019eca3a32bf5ee81c4f8"><code>9ae2c55</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4339">#4339</a>
from seleniumbase/cdp-mode-patch-104</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/854529fb1ecefd9a3c6c1cbdc3f21b2a00efb68d"><code>854529f</code></a>
Version 4.48.4</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0e8b32f9b894cbbac546256f33630b16b08be49a"><code>0e8b32f</code></a>
Update CDP Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a756d91999539497c8215aff59f520da7ed14633"><code>a756d91</code></a>
Update CDP Mode</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7e07f81ff31ab592debfe5333d4480c9025f1cdd"><code>7e07f81</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4338">#4338</a>
from seleniumbase/update-config-and-dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/dbf29799515f5d414396199514e6f082decf4c6d"><code>dbf2979</code></a>
Version 4.48.3</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/163ef1b5b955df2b3daa201ca5652bb613ee1af9"><code>163ef1b</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e5b2ffde2a9131f9e813cd97c430219648cd48e7"><code>e5b2ffd</code></a>
Update default config in Chrome</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ac638ca1b1a999430ed503eb9bfc87584af5e830"><code>ac638ca</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2fe80fe4fcba890ae28c46026a8b36a3b864e03c"><code>2fe80fe</code></a>
Update CDP Mode examples</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.48.2...v4.48.4">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.3.10 to 0.3.13
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/releases">prek's
releases</a>.</em></p>
<blockquote>
<h2>0.3.13</h2>
<h2>Release Notes</h2>
<p>Released on 2026-05-06.</p>
<h3>Bug fixes</h3>
<ul>
<li>Respect hook filters for message files (<a
href="https://redirect.github.com/j178/prek/pull/2049">#2049</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add Godot Engine to users in README (<a
href="https://redirect.github.com/j178/prek/pull/2047">#2047</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a href="https://github.com/Calinou"><code>@​Calinou</code></a></li>
</ul>
<h2>Install prek 0.3.13</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/j178/prek/releases/download/v0.3.13/prek-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://github.com/j178/prek/releases/download/v0.3.13/prek-installer.ps1
| iex&quot;
</code></pre>
<h3>Install prebuilt binaries via Homebrew</h3>
<pre lang="sh"><code>brew install prek
</code></pre>
<h2>Download prek 0.3.13</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-aarch64-apple-darwin.tar.gz">prek-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-x86_64-apple-darwin.tar.gz">prek-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-aarch64-pc-windows-msvc.zip">prek-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-i686-pc-windows-msvc.zip">prek-i686-pc-windows-msvc.zip</a></td>
<td>x86 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-x86_64-pc-windows-msvc.zip">prek-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-aarch64-unknown-linux-gnu.tar.gz">prek-aarch64-unknown-linux-gnu.tar.gz</a></td>
<td>ARM64 Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-i686-unknown-linux-gnu.tar.gz">prek-i686-unknown-linux-gnu.tar.gz</a></td>
<td>x86 Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-i686-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-riscv64gc-unknown-linux-gnu.tar.gz">prek-riscv64gc-unknown-linux-gnu.tar.gz</a></td>
<td>RISCV Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.3.13/prek-riscv64gc-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.3.13</h2>
<p>Released on 2026-05-06.</p>
<h3>Bug fixes</h3>
<ul>
<li>Respect hook filters for message files (<a
href="https://redirect.github.com/j178/prek/pull/2049">#2049</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add Godot Engine to users in README (<a
href="https://redirect.github.com/j178/prek/pull/2047">#2047</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a href="https://github.com/Calinou"><code>@​Calinou</code></a></li>
</ul>
<h2>0.3.12</h2>
<p>Released on 2026-05-05.</p>
<h3>Highlights</h3>
<p><code>auto_update.cooldown_days</code> is now available in both the
user-level global
config (<code>~/.config/prek/prek.toml</code> on Linux and macOS, or
<code>$XDG_CONFIG_HOME/prek/prek.toml</code> when set;
<code>%APPDATA%\prek\prek.toml</code> on
Windows) and project config. Set a user default for <code>prek
auto-update</code>, then
override it per project when a repository needs a different update
cadence.</p>
<pre lang="toml"><code>[auto_update]
cooldown_days = 7
</code></pre>
<h3>Enhancements</h3>
<ul>
<li>Add global auto-update cooldown config (<a
href="https://redirect.github.com/j178/prek/pull/2041">#2041</a>)</li>
<li>Add project auto-update cooldown config (<a
href="https://redirect.github.com/j178/prek/pull/2044">#2044</a>)</li>
<li>Support <code>language: dart</code> (<a
href="https://redirect.github.com/j178/prek/pull/1146">#1146</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Pass commit message file to workspace hooks (<a
href="https://redirect.github.com/j178/prek/pull/2043">#2043</a>)</li>
<li>Preserve non-UTF8 filenames from git (<a
href="https://redirect.github.com/j178/prek/pull/2023">#2023</a>)</li>
<li>ruby: put resolved Ruby's bin dir on <code>$PATH</code> for
<code>gem</code> invocations (<a
href="https://redirect.github.com/j178/prek/pull/2021">#2021</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Update docs with the new logo and icon (<a
href="https://redirect.github.com/j178/prek/pull/2025">#2025</a>)</li>
<li>Point schema docs to SchemaStore (<a
href="https://redirect.github.com/j178/prek/pull/2039">#2039</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/81b290ef0630d1e130dc37ae1916da0e51b8a002"><code>81b290e</code></a>
Bump version to 0.3.13 (<a
href="https://redirect.github.com/j178/prek/issues/2050">#2050</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/3f5e5c6e54bdd9b1640228cdd69a449e18ddf9f8"><code>3f5e5c6</code></a>
Respect hook filters for message files (<a
href="https://redirect.github.com/j178/prek/issues/2049">#2049</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1fab88775affde4fd0c75b2f9102a5df88195efc"><code>1fab887</code></a>
Add Godot Engine to users in README (<a
href="https://redirect.github.com/j178/prek/issues/2047">#2047</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/93a71e0b730d30d2fa56f9e47f2a293beb007988"><code>93a71e0</code></a>
Remove deleted <code>dist/post/index.cjs</code> from publish prek
version workflow (<a
href="https://redirect.github.com/j178/prek/issues/2046">#2046</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/56d6d0c13ef36a4ab6a705621fba41448f2b503d"><code>56d6d0c</code></a>
Bump version to 0.3.12 (<a
href="https://redirect.github.com/j178/prek/issues/2045">#2045</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/5f94f6f8ea8e5df0fb6604b4b8ec63072e1dc8f3"><code>5f94f6f</code></a>
Add project auto-update cooldown config (<a
href="https://redirect.github.com/j178/prek/issues/2044">#2044</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c52833e9991584d03bcd7e2b6fc01fa43e1f0d09"><code>c52833e</code></a>
Add global auto-update cooldown</li>
<li><a
href="https://github.com/j178/prek/commit/8af3054dc690fd06fe91b2b0c0ca4fe8e4418051"><code>8af3054</code></a>
Pass commit message file to workspace hooks (<a
href="https://redirect.github.com/j178/prek/issues/2043">#2043</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1bf54f5fa9638ad66a7d0b160170da75de1eec81"><code>1bf54f5</code></a>
Update Rust crate serde-saphyr to 0.0.25 (<a
href="https://redirect.github.com/j178/prek/issues/2036">#2036</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/8c9761a523bcb180ff35fcfcd8e85184f87eaceb"><code>8c9761a</code></a>
Bump rand to 0.9.4 (<a
href="https://redirect.github.com/j178/prek/issues/2040">#2040</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.3.10...v0.3.13">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.15.11 to 0.15.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.15.12</h2>
<h2>Release Notes</h2>
<p>Released on 2026-04-24.</p>
<h3>Preview features</h3>
<ul>
<li>Implement <code>#ruff:file-ignore</code> file-level suppressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23599">#23599</a>)</li>
<li>Implement <code>#ruff:ignore</code> logical-line suppressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23404">#23404</a>)</li>
<li>Revert preview changes to displayed diagnostic severity in LSP (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24789">#24789</a>)</li>
<li>[<code>airflow</code>] Implement
<code>task-branch-as-short-circuit</code> (<code>AIR004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23579">#23579</a>)</li>
<li>[<code>flake8-bugbear</code>] Fix
<code>break</code>/<code>continue</code> handling in
<code>loop-iterator-mutation</code> (<code>B909</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24440">#24440</a>)</li>
<li>[<code>pylint</code>] Fix <code>PLC2701</code> for type parameter
scopes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24576">#24576</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>pandas-vet</code>] Suggest <code>.array</code> as well in
<code>PD011</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24805">#24805</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Respect default Unix permissions for cache files (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24794">#24794</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>pylint</code>] Fix <code>PLR0124</code> description not to
claim self-comparison always returns the same value (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24749">#24749</a>)</li>
<li>[<code>pyupgrade</code>] Expand docs on reusable
<code>TypeVar</code>s and scoping (<code>UP046</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24153">#24153</a>)</li>
<li>Improve rules table accessibility (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24711">#24711</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/dylwil3"><code>@​dylwil3</code></a></li>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
<li><a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a></li>
<li><a
href="https://github.com/avasis-ai"><code>@​avasis-ai</code></a></li>
<li><a href="https://github.com/Dev-iL"><code>@​Dev-iL</code></a></li>
<li><a
href="https://github.com/denyszhak"><code>@​denyszhak</code></a></li>
<li><a
href="https://github.com/ShipItAndPray"><code>@​ShipItAndPray</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/augustelalande"><code>@​augustelalande</code></a></li>
<li><a
href="https://github.com/amyreese"><code>@​amyreese</code></a></li>
<li><a
href="https://github.com/majiayu000"><code>@​majiayu000</code></a></li>
</ul>
<h2>Install ruff 0.15.12</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/ruff/releases/download/0.15.12/ruff-installer.sh
| sh
</code></pre>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.15.12</h2>
<p>Released on 2026-04-24.</p>
<h3>Preview features</h3>
<ul>
<li>Implement <code>#ruff:file-ignore</code> file-level suppressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23599">#23599</a>)</li>
<li>Implement <code>#ruff:ignore</code> logical-line suppressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23404">#23404</a>)</li>
<li>Revert preview changes to displayed diagnostic severity in LSP (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24789">#24789</a>)</li>
<li>[<code>airflow</code>] Implement
<code>task-branch-as-short-circuit</code> (<code>AIR004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23579">#23579</a>)</li>
<li>[<code>flake8-bugbear</code>] Fix
<code>break</code>/<code>continue</code> handling in
<code>loop-iterator-mutation</code> (<code>B909</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24440">#24440</a>)</li>
<li>[<code>pylint</code>] Fix <code>PLC2701</code> for type parameter
scopes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24576">#24576</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>pandas-vet</code>] Suggest <code>.array</code> as well in
<code>PD011</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24805">#24805</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Respect default Unix permissions for cache files (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24794">#24794</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>pylint</code>] Fix <code>PLR0124</code> description not to
claim self-comparison always returns the same value (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24749">#24749</a>)</li>
<li>[<code>pyupgrade</code>] Expand docs on reusable
<code>TypeVar</code>s and scoping (<code>UP046</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24153">#24153</a>)</li>
<li>Improve rules table accessibility (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24711">#24711</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/dylwil3"><code>@​dylwil3</code></a></li>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
<li><a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a></li>
<li><a
href="https://github.com/avasis-ai"><code>@​avasis-ai</code></a></li>
<li><a href="https://github.com/Dev-iL"><code>@​Dev-iL</code></a></li>
<li><a
href="https://github.com/denyszhak"><code>@​denyszhak</code></a></li>
<li><a
href="https://github.com/ShipItAndPray"><code>@​ShipItAndPray</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/augustelalande"><code>@​augustelalande</code></a></li>
<li><a
href="https://github.com/amyreese"><code>@​amyreese</code></a></li>
<li><a
href="https://github.com/majiayu000"><code>@​majiayu000</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/66f93cf7ed4d36325f35a452e4afa28268fbcd28"><code>66f93cf</code></a>
Bump 0.15.12 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24815">#24815</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/476a4d02e8e3b6c157ac39979d8b698a1b6baa91"><code>476a4d0</code></a>
[ty] Complete support for more detailed diagnostics on possibly unbound
error...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ed669eab30095d6c51fe6cdef6050fb01276bcb3"><code>ed669ea</code></a>
Implement <code>#ruff:file-ignore</code> file-level suppressions (<a
href="https://redirect.github.com/astral-sh/ruff/issues/23599">#23599</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/e73d952e43feb51356ee740c5a973fce81396ff6"><code>e73d952</code></a>
[ty] Include inferred type in <code>invalid-key</code> concise
diagnostic for union/inte...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/80feb29b31cd98c093316df2e0407b0c70c01b55"><code>80feb29</code></a>
[ty] report only dead annotation-only locals as unused (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24811">#24811</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/0fbf2bc27336a3d17d39af52cf89b78dcda8c7c8"><code>0fbf2bc</code></a>
Drop deprecated license classifier (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24808">#24808</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/43b174cc7f2fcb0080bb1d4843cd4bf6b72bbe27"><code>43b174c</code></a>
[ty] Infer lambda parameter types with <code>Callable</code> type
context (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24317">#24317</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/4f449ae4a2377569330a5ab94799d389357b5a3f"><code>4f449ae</code></a>
[ty] Add error context for intersection types (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24772">#24772</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/5b4e753acb46e96ad408e4904c15308e33efe307"><code>5b4e753</code></a>
[ty] Add support for goto in literal enum member inlay hint (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24792">#24792</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/e7cc76275a758ce1c636ea1c2d091fd576aac794"><code>e7cc762</code></a>
[ty] Add error context for TypedDict assignments (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24790">#24790</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.15.11...0.15.12">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:29:07 +01:00
dependabot[bot] 9bd7eae2b5 Bump github/codeql-action from 4.35.2 to 4.35.4 in the gh-actions group (#952)
Bumps the gh-actions group with 1 update:
[github/codeql-action](https://github.com/github/codeql-action).

Updates `github/codeql-action` from 4.35.2 to 4.35.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.35.4</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>v4.35.3</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.19.3 and earlier. These versions of
CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise
Server 3.15, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3837">#3837</a></li>
<li>Configurations for private registries that use Cloudsmith or GCP
OIDC are now accepted. <a
href="https://redirect.github.com/github/codeql-action/pull/3850">#3850</a></li>
<li>Best-effort connection tests for private registries now use
<code>GET</code> requests instead of <code>HEAD</code> for better
compatibility with various registry implementations. For NuGet feeds,
the test is now always performed against the service index. <a
href="https://redirect.github.com/github/codeql-action/pull/3853">#3853</a></li>
<li>Fixed a bug where two diagnostics produced within the same
millisecond could overwrite each other on disk, causing one of them to
be lost. <a
href="https://redirect.github.com/github/codeql-action/pull/3852">#3852</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.3">2.25.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3865">#3865</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.19.3 and earlier. These versions of
CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise
Server 3.15, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3837">#3837</a></li>
<li>Configurations for private registries that use Cloudsmith or GCP
OIDC are now accepted. <a
href="https://redirect.github.com/github/codeql-action/pull/3850">#3850</a></li>
<li>Best-effort connection tests for private registries now use
<code>GET</code> requests instead of <code>HEAD</code> for better
compatibility with various registry implementations. For NuGet feeds,
the test is now always performed against the service index. <a
href="https://redirect.github.com/github/codeql-action/pull/3853">#3853</a></li>
<li>Fixed a bug where two diagnostics produced within the same
millisecond could overwrite each other on disk, causing one of them to
be lost. <a
href="https://redirect.github.com/github/codeql-action/pull/3852">#3852</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.3">2.25.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3865">#3865</a></li>
</ul>
<h2>4.35.2 - 15 Apr 2026</h2>
<ul>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable is deprecated and will be removed in May 2026. If you are
affected by this, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/3795">#3795</a></li>
<li>The Git version 2.36.0 requirement for improved incremental analysis
now only applies to repositories that contain submodules. <a
href="https://redirect.github.com/github/codeql-action/pull/3789">#3789</a></li>
<li>Python analysis on GHES no longer extracts the standard library,
relying instead on models of the standard library. This should result in
significantly faster extraction and analysis times, while the effect on
alerts should be minimal. <a
href="https://redirect.github.com/github/codeql-action/pull/3794">#3794</a></li>
<li>Fixed a bug in the validation of OIDC configurations for private
registries that was added in CodeQL Action 4.33.0 / 3.33.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3807">#3807</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.2">2.25.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3823">#3823</a></li>
</ul>
<h2>4.35.1 - 27 Mar 2026</h2>
<ul>
<li>Fix incorrect minimum required Git version for <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a>: it should have been 2.36.0, not 2.11.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3781">#3781</a></li>
</ul>
<h2>4.35.0 - 27 Mar 2026</h2>
<ul>
<li>Reduced the minimum Git version required for <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> from 2.38.0 to 2.11.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3767">#3767</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.1">2.25.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3773">#3773</a></li>
</ul>
<h2>4.34.1 - 20 Mar 2026</h2>
<ul>
<li>Downgrade default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.24.3">2.24.3</a>
due to issues with a small percentage of Actions and JavaScript
analyses. <a
href="https://redirect.github.com/github/codeql-action/pull/3762">#3762</a></li>
</ul>
<h2>4.34.0 - 20 Mar 2026</h2>
<ul>
<li>Added an experimental change which disables TRAP caching when <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> is enabled, since improved incremental analysis
supersedes TRAP caching. This will improve performance and reduce
Actions cache usage. We expect to roll this change out to everyone in
March. <a
href="https://redirect.github.com/github/codeql-action/pull/3569">#3569</a></li>
<li>We are rolling out improved incremental analysis to C/C++ analyses
that use build mode <code>none</code>. We expect this rollout to be
complete by the end of April 2026. <a
href="https://redirect.github.com/github/codeql-action/pull/3584">#3584</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.0">2.25.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3585">#3585</a></li>
</ul>
<h2>4.33.0 - 16 Mar 2026</h2>
<ul>
<li>
<p>Upcoming change: Starting April 2026, the CodeQL Action will skip
collecting file coverage information on pull requests to improve
analysis performance. File coverage information will still be computed
on non-PR analyses. Pull request analyses will log a warning about this
upcoming change. <a
href="https://redirect.github.com/github/codeql-action/pull/3562">#3562</a></p>
<p>To opt out of this change:</p>
<ul>
<li><strong>Repositories owned by an organization:</strong> Create a
custom repository property with the name
<code>github-codeql-file-coverage-on-prs</code> and the type
&quot;True/false&quot;, then set this property to <code>true</code> in
the repository's settings. For more information, see <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">Managing
custom properties for repositories in your organization</a>.
Alternatively, if you are using an advanced setup workflow, you can set
the <code>CODEQL_ACTION_FILE_COVERAGE_ON_PRS</code> environment variable
to <code>true</code> in your workflow.</li>
<li><strong>User-owned repositories using default setup:</strong> Switch
to an advanced setup workflow and set the
<code>CODEQL_ACTION_FILE_COVERAGE_ON_PRS</code> environment variable to
<code>true</code> in your workflow.</li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/68bde559dea0fdcac2102bfdf6230c5f70eb485e"><code>68bde55</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3885">#3885</a>
from github/update-v4.35.4-803d9e8c3</li>
<li><a
href="https://github.com/github/codeql-action/commit/9739ad2d182c072da0d01a6887f7f39620f71b1e"><code>9739ad2</code></a>
Update changelog for v4.35.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/803d9e8c3ca8b0dd2029a1da3b541a18b6bfb076"><code>803d9e8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3883">#3883</a>
from github/mbg/test/macro-wrapper</li>
<li><a
href="https://github.com/github/codeql-action/commit/0fd9c7d1358a7404e46ed8165f12262f56bd1434"><code>0fd9c7d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3882">#3882</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/922d6fb888d665134eb982b150b8912dbd48e21a"><code>922d6fb</code></a>
Use <code>makeMacro</code> instead of <code>test.macro</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/df77e87896689b5c736433984c5df14d86c63d56"><code>df77e87</code></a>
Update test macro snippet</li>
<li><a
href="https://github.com/github/codeql-action/commit/6e3f985e4fc409a188c7701b68c4dec158c9ced3"><code>6e3f985</code></a>
Add wrapper for <code>test.macro</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/e7a347dfb1bfb7a858347623fcb4f650effca6b5"><code>e7a347d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3881">#3881</a>
from github/update-bundle/codeql-bundle-v2.25.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/17eabb2500031486a71e00ecbcb72c73804a6c9f"><code>17eabb2</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/aaef09c48db2dd7f0100363de1785963a34cd706"><code>aaef09c</code></a>
Bump ruby/setup-ruby</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...68bde559dea0fdcac2102bfdf6230c5f70eb485e">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.35.2&new-version=4.35.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:28:53 +01:00
Alex d6590be551 Add seedtime preferences toggle + clean up logic (#959)
Clears up seedtime logic to use user-specified seedtime only, ignore the
indexer-defaults.
Adds a toggle to enable the seedtime feature, disabled by default. 

Fixes #955
2026-05-08 11:24:50 +01:00
Alex 4c782ca92d Fix Google Books search cache failures (#958)
Recognises google error 503s as invalid results and does not add to
results cache.

Fixes #945
2026-05-08 11:17:57 +01:00
Alex b10a5a35ca Fix FUSE copy fallback (#957)
Fixes #949
2026-05-08 11:17:49 +01:00
Alex 196578fb18 Fix: Prowlarr seedtime priority (#946) 2026-05-04 14:25:05 +01:00
Alex ba62771a53 Add tor network access and bypass rules (#944) 2026-05-03 12:42:37 +01:00
Alex 7a2de1ccdd Fix SSRF access (#943) 2026-05-03 10:47:06 +01:00
78 changed files with 3744 additions and 758 deletions
+17 -3
View File
@@ -1,10 +1,17 @@
version: 2
updates:
# Python dependencies
# Dependabot supports uv version updates, but GitHub currently lists uv
# security updates as "Not applicable"; daily checks keep uv.lock moving
# while repo-level Dependabot alerts/security updates cover supported ecosystems.
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
interval: "daily"
time: "05:00"
timezone: "Europe/London"
cooldown:
default-days: 3
open-pull-requests-limit: 10
groups:
python-deps:
@@ -16,20 +23,25 @@ updates:
directory: "/src/frontend"
schedule:
interval: "weekly"
cooldown:
default-days: 3
open-pull-requests-limit: 10
groups:
npm-deps:
patterns: ["*"]
update-types: ["minor", "patch"]
# Dockerfile base images
# Dockerfile base image digests. When a tag stays the same, Dependabot titles
# can only show digest prefixes, so keep the group name explicit.
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
docker-images:
docker-base-image-digests:
patterns: ["*"]
ignore:
# Node.js: block major-version bumps so dependabot never proposes
@@ -43,6 +55,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
gh-actions:
+3 -3
View File
@@ -25,14 +25,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3
with:
category: "/language:${{ matrix.language }}"
+18 -3
View File
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
ARG BUILDARCH
# Frontend build stage.
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-builder
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS frontend-builder
# Helpful debug output to see what platforms BuildKit thinks it's using
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
@@ -25,9 +25,9 @@ COPY src/frontend/ ./
RUN npm run build
# Use python-slim as the base image
FROM python:3.14-slim AS base
FROM python:3.14-slim@sha256:1697e8e8d39bf168e177ac6b5fdab6df86d81cfc24dae17dfb96cfc3ef76b4dd AS base
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
# Add build argument for version
ARG BUILD_VERSION
@@ -103,6 +103,15 @@ COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-default-groups
# Runtime dependencies are installed into /app/.venv during the build. Remove the
# base image's system pip so stale installer CVEs do not ship in the final image.
RUN rm -rf \
/usr/local/bin/pip \
/usr/local/bin/pip3 \
/usr/local/bin/pip3.* \
/usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info
# Copy application code *after* dependencies are installed
COPY . .
@@ -164,6 +173,9 @@ RUN apt-get update && \
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-default-groups --extra browser
# uv is only needed while building the image.
RUN rm -f /usr/bin/uv /usr/bin/uvx
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
@@ -180,4 +192,7 @@ FROM base AS shelfmark-lite
ENV USING_EXTERNAL_BYPASSER=true
# uv is only needed while building the image.
RUN rm -f /usr/bin/uv /usr/bin/uvx
CMD ["/app/entrypoint.sh"]
+35 -1
View File
@@ -31,7 +31,7 @@ This document lists all configuration options that can be set via environment va
## Bootstrap Configuration
These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.
These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
@@ -43,6 +43,9 @@ These environment variables are used at startup before the settings system loads
| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` |
| `SESSION_COOKIE_SECURE` | Enable secure cookies (requires HTTPS). | boolean | `false` |
| `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` |
| `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` |
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` |
| `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` |
| `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
@@ -105,6 +108,27 @@ Path to the Calibre-Web database for authentication integration.
- **Type:** string (path)
- **Default:** `/auth/app.db`
#### `HIDE_LOCAL_AUTH`
Hide the username/password login form when OIDC is active.
- **Type:** boolean
- **Default:** `false`
#### `DISABLE_LOCAL_AUTH`
Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed.
- **Type:** boolean
- **Default:** `false`
#### `OIDC_AUTO_REDIRECT`
Automatically redirect to the OIDC provider instead of showing the login page.
- **Type:** boolean
- **Default:** `false`
#### `DOCKERMODE`
Indicates the application is running inside a Docker container.
@@ -1073,6 +1097,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
<details>
<summary>Detailed descriptions</summary>
@@ -1124,6 +1149,15 @@ Automatically retry search without category filtering if no results are found
- **Type:** boolean
- **Default:** `false`
#### `PROWLARR_USE_SEED_PREFERENCES`
**Use Prowlarr seed preferences**
Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client
- **Type:** boolean
- **Default:** `false`
</details>
## Newznab
+2 -1
View File
@@ -39,9 +39,10 @@ These optional environment variables control login page behavior when OIDC is en
| Variable | Description | Default |
|----------|-------------|---------|
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `false` |
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies `HIDE_LOCAL_AUTH`; with `AUTH_METHOD=builtin`, everyone is locked out until auth env vars are changed. | `false` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
If both are enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
If `DISABLE_LOCAL_AUTH` and `OIDC_AUTO_REDIRECT` are both enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
## Troubleshooting
+24 -25
View File
@@ -322,6 +322,27 @@ require_writable_dir() {
fi
}
fail_unwritable_config_dir() {
local folder="$1"
local owner
owner=$(stat -c '%u:%g' "$folder" 2>/dev/null || echo "unknown")
echo ""
echo "========================================================"
echo "ERROR: Config directory is not writable!"
echo ""
echo "Config directory: $folder"
echo "Current owner: $owner"
echo "Configured runtime identity: ${RUN_UID}:${RUN_GID}"
echo ""
echo "To fix this permanently, run on your HOST machine:"
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
echo "========================================================"
echo ""
exit 1
}
resolve_runtime_home() {
local runtime_home
@@ -417,37 +438,15 @@ else
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
make_writable "${CONFIG_DIR:-/config}" tree
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
# Refuse to continue if the config directory is still not writable after repair.
CONFIG_PATH=${CONFIG_DIR:-/config}
set +e
test_write "$CONFIG_PATH" >/dev/null 2>&1
config_ok=$?
set -e
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
if [ "$config_owner" = "0" ]; then
echo ""
echo "========================================================"
echo "WARNING: Permission issue detected!"
echo ""
echo "Config directory is owned by root but PUID=$RUN_UID."
echo "This typically happens after upgrading from v0.4.0 where"
echo "PUID/PGID settings were not respected."
echo ""
echo "Falling back to running as root to prevent data loss."
echo ""
echo "To fix this permanently, run on your HOST machine:"
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
echo ""
echo "Then restart the container."
echo "========================================================"
echo ""
RUN_UID=0
RUN_GID=0
USERNAME=root
TARGET_USER_SPEC="0:0"
fi
if [ $config_ok -ne 0 ]; then
fail_unwritable_config_dir "$CONFIG_PATH"
fi
fi
+4 -4
View File
@@ -21,15 +21,15 @@ dependencies = [
"rarfile",
"qbittorrent-api",
"transmission-rpc",
"authlib>=1.7.0,<1.8",
"apprise>=1.9.0",
"authlib>=1.7.2,<1.8",
"apprise>=1.10.0",
]
[project.optional-dependencies]
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.48.2",
"seleniumbase==4.48.4",
"python-xlib",
]
@@ -40,7 +40,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.15.11",
"ruff==0.15.12",
"vulture>=2.14",
]
+19 -1
View File
@@ -172,6 +172,24 @@ def _generate_bootstrap_env_docs() -> list[str]:
"type": "string (path)",
"default": "/auth/app.db",
},
{
"name": "HIDE_LOCAL_AUTH",
"description": "Hide the username/password login form when OIDC is active.",
"type": "boolean",
"default": "false",
},
{
"name": "DISABLE_LOCAL_AUTH",
"description": "Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed.",
"type": "boolean",
"default": "false",
},
{
"name": "OIDC_AUTO_REDIRECT",
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
"type": "boolean",
"default": "false",
},
{
"name": "DOCKERMODE",
"description": "Indicates the application is running inside a Docker container.",
@@ -189,7 +207,7 @@ def _generate_bootstrap_env_docs() -> list[str]:
lines = [
"## Bootstrap Configuration",
"",
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
"These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
+1
View File
@@ -121,6 +121,7 @@ SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
SESSION_COOKIE_NAME = "shelfmark_session"
CWA_DB_PATH = _resolve_cwa_db_path()
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
+12 -6
View File
@@ -76,7 +76,7 @@ def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[
@register_settings("security", "Security", icon="shield", order=5)
def security_settings() -> list[SettingsField]:
"""Security and authentication settings."""
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.config.env import CWA_DB_PATH, DISABLE_LOCAL_AUTH
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
@@ -108,11 +108,17 @@ def security_settings() -> list[SettingsField]:
),
show_when=_auth_condition("builtin"),
),
CustomComponentField(
key="oidc_admin_requirement",
component="oidc_admin_hint",
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
*(
[]
if DISABLE_LOCAL_AUTH
else [
CustomComponentField(
key="oidc_admin_requirement",
component="oidc_admin_hint",
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
),
]
),
*(
[]
+2 -1
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
from shelfmark.config.env import DISABLE_LOCAL_AUTH
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
@@ -78,7 +79,7 @@ def on_save_security(
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
if auth_method == "oidc":
if not _has_local_password_admin():
if not DISABLE_LOCAL_AUTH and not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
missing_fields = _get_missing_oidc_required_fields(effective_values)
+6 -2
View File
@@ -71,14 +71,16 @@ def determine_auth_mode(
cwa_db_path: object | None,
*,
has_local_admin: bool = True,
disable_local_auth: bool = False,
) -> str:
"""Determine active auth mode from security config and runtime prerequisites."""
auth_mode = security_config.get("AUTH_METHOD", "none")
local_admin_available = has_local_admin or disable_local_auth
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
return AUTH_SOURCE_CWA
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
if auth_mode == AUTH_SOURCE_BUILTIN and local_admin_available:
return AUTH_SOURCE_BUILTIN
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
@@ -86,7 +88,7 @@ def determine_auth_mode(
if (
auth_mode == AUTH_SOURCE_OIDC
and has_local_admin
and local_admin_available
and security_config.get("OIDC_DISCOVERY_URL")
and security_config.get("OIDC_CLIENT_ID")
):
@@ -102,6 +104,7 @@ def load_active_auth_mode(
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
from shelfmark.config.env import DISABLE_LOCAL_AUTH
from shelfmark.core.config import config as app_config
security_config = {
@@ -114,6 +117,7 @@ def load_active_auth_mode(
security_config,
cwa_db_path,
has_local_admin=has_local_password_admin(user_db),
disable_local_auth=DISABLE_LOCAL_AUTH,
)
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
return "none"
+70 -21
View File
@@ -8,7 +8,7 @@ import time
from http import HTTPStatus
from io import BytesIO
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from urllib.parse import urljoin, urlparse
import requests
@@ -39,6 +39,7 @@ FETCH_HEADERS = {
# Maximum image size to fetch (5 MB)
MAX_IMAGE_SIZE = 5 * 1024 * 1024
MAX_REDIRECTS = 5
# Negative cache TTL (for failed fetches) - 1 hour
NEGATIVE_CACHE_TTL = 3600
@@ -482,29 +483,85 @@ class ImageCacheService:
}
@staticmethod
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
def _prepare_safe_url(url: str) -> str | None:
"""Prepare and validate a URL before fetching it."""
if "\\" in url or any(ord(char) < 32 for char in url):
return None
try:
parsed = urlparse(url)
prepared = requests.Request("GET", url).prepare()
prepared_url = prepared.url
if not isinstance(prepared_url, str):
return None
parsed = urlparse(prepared_url)
hostname = parsed.hostname
except ValueError:
return False
except requests.exceptions.RequestException, ValueError:
return None
if not prepared_url:
return None
if "\\" in prepared_url or any(ord(char) < 32 for char in prepared_url):
return None
netloc_lower = parsed.netloc.lower()
if "%2f" in netloc_lower or "%5c" in netloc_lower:
return None
if parsed.scheme not in ("http", "https"):
return False
return None
if not hostname:
return False
return None
try:
resolved = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False
return None
except socket.gaierror, ValueError:
return False
return None
return True
return prepared_url
@staticmethod
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
return ImageCacheService._prepare_safe_url(url) is not None
def _fetch_safe_response(self, url: str) -> requests.Response | None:
"""Fetch a URL after validating the initial URL and each redirect."""
current_url = self._prepare_safe_url(url)
if not current_url:
logger.warning("Blocked request to disallowed URL: %s", url)
return None
for _ in range(MAX_REDIRECTS + 1):
response = requests.get(
current_url,
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
verify=get_ssl_verify(current_url),
allow_redirects=False,
)
if not response.is_redirect:
return response
location = response.headers.get("location")
response.close()
if not location:
return None
redirect_url = urljoin(current_url, location)
next_url = self._prepare_safe_url(redirect_url)
if not next_url:
logger.warning("Blocked redirect to disallowed URL: %s", redirect_url)
return None
current_url = next_url
return None
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
"""Fetch an image from URL and cache it.
@@ -519,17 +576,9 @@ class ImageCacheService:
"""
cached_data: tuple[bytes, str] | None = None
try:
if not self._is_safe_url(url):
logger.warning("Blocked request to disallowed URL: %s", url)
response = self._fetch_safe_response(url)
if response is None:
return None
response = requests.get(
url,
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
verify=get_ssl_verify(url),
)
response.raise_for_status()
# Validate content type
+11 -1
View File
@@ -73,6 +73,16 @@ def _has_username_or_email(claims: dict[str, Any]) -> bool:
return False
def _is_email_verified(claims: dict[str, Any]) -> bool:
"""Return True when claims explicitly mark the email address as verified."""
email_verified = claims.get("email_verified")
if isinstance(email_verified, bool):
return email_verified
if isinstance(email_verified, str):
return email_verified.strip().lower() == "true"
return False
def _login_error_url(message: str) -> str:
"""Build a login URL (with script_root) that includes an OIDC error message."""
script_root = request.script_root.rstrip("/")
@@ -295,7 +305,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
if admin_group and use_admin_group:
is_admin = admin_group in groups
allow_email_link = bool(user_info.get("email"))
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
user = provision_oidc_user(
user_db,
user_info,
+49 -4
View File
@@ -10,7 +10,7 @@ A mapping rewrites a remote path prefix into a local path prefix.
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -50,6 +50,42 @@ def _normalize_host(host: str) -> str:
return str(host or "").strip().lower()
def _is_relative_to(path: Path, prefix: Path) -> bool:
try:
path.relative_to(prefix)
except ValueError:
return False
return True
def _join_contained_path(local_prefix: str, remainder: str) -> Path | None:
local_path = Path(local_prefix)
if remainder:
remainder_path = Path(remainder)
windows_remainder_path = PureWindowsPath(remainder)
if (
remainder_path.is_absolute()
or windows_remainder_path.is_absolute()
or ".." in remainder_path.parts
or ".." in windows_remainder_path.parts
):
return None
remapped = local_path / remainder_path
else:
remapped = local_path
resolved_local_path = local_path.resolve(strict=False)
resolved_remapped = remapped.resolve(strict=False)
if not _is_relative_to(resolved_remapped, resolved_local_path):
return None
return remapped
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
"""Parse configured remote-path mapping rows into normalized mappings."""
if not value or not isinstance(value, list):
@@ -81,8 +117,12 @@ def remap_remote_to_local_with_match(
mappings: Iterable[RemotePathMapping],
host: str,
remote_path: str | Path,
) -> tuple[Path, bool]:
"""Remap a remote path and report whether a configured mapping matched."""
) -> tuple[Path | None, bool]:
"""Remap a remote path and report whether a configured mapping matched.
Returns ``(None, True)`` when a mapping prefix matched but the remainder was
unsafe to join under the local prefix.
"""
host_normalized = _normalize_host(host)
remote_normalized = _normalize_prefix(str(remote_path))
@@ -119,7 +159,10 @@ def remap_remote_to_local_with_match(
remainder = remainder.removeprefix("/")
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
remapped = _join_contained_path(local_prefix, remainder)
if remapped is None:
return None, True
return remapped, True
return Path(remote_normalized), False
@@ -134,6 +177,8 @@ def remap_remote_to_local(
host=host,
remote_path=remote_path,
)
if remapped is None:
return Path(str(remote_path))
return remapped
+28
View File
@@ -220,6 +220,26 @@ def _normalize_release_result_request_payload(
return "release", normalized_release_data
def _validate_release_source_matches_policy_context(
*,
source: str,
release_data: object,
) -> None:
if not isinstance(release_data, dict):
return
release_source = normalize_source(release_data.get("source"))
if release_source in {"", "*"} or release_source == source:
return
msg = "Policy context source must match release_data.source"
raise RequestServiceError(
msg,
status_code=400,
code="policy_source_mismatch",
)
def _resolve_request_title(request_row: dict[str, Any]) -> str:
return _resolve_title_from_book_data(request_row.get("book_data"))
@@ -317,6 +337,10 @@ def _prepare_request_create_arguments(
content_type = normalize_content_type(
context.get("content_type") or data.get("content_type") or book_data.get("content_type")
)
_validate_release_source_matches_policy_context(
source=source,
release_data=release_data,
)
request_level, release_data = _normalize_release_result_request_payload(
source=source,
request_level=request_level,
@@ -324,6 +348,10 @@ def _prepare_request_create_arguments(
release_data=release_data,
content_type=content_type,
)
_validate_release_source_matches_policy_context(
source=source,
release_data=release_data,
)
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
user_db,
+24 -5
View File
@@ -1,18 +1,21 @@
"""Archive extraction utilities for downloaded book archives."""
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_write
from shelfmark.download.fs import atomic_move
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats,
)
logger = setup_logger(__name__)
_ARCHIVE_COPY_CHUNK_SIZE = 1024 * 1024
if TYPE_CHECKING:
import rarfile
@@ -208,9 +211,25 @@ def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[
logger.warning("Path traversal attempt blocked: %r", info.filename)
continue
with archive.open(info) as src:
data = src.read()
final_path = atomic_write(target_path, data)
temp_path: Path | None = None
try:
with (
archive.open(info) as src,
tempfile.NamedTemporaryFile(
dir=output_dir,
prefix=".shelfmark-extract-",
suffix=".tmp",
delete=False,
) as temp_file,
):
temp_path = Path(temp_file.name)
shutil.copyfileobj(src, temp_file, length=_ARCHIVE_COPY_CHUNK_SIZE)
final_path = atomic_move(cast("Path", temp_path), target_path)
except Exception:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
raise
extracted_files.append(final_path)
logger.debug("Extracted: %s", filename)
+25 -1
View File
@@ -276,7 +276,18 @@ class ExternalClientHandler(DownloadHandler, ABC):
remote_path=source_path_obj,
)
delete_path = remapped if matched_mapping else source_path_obj
if matched_mapping:
if remapped is None:
logger.warning(
"Refusing to delete download data for %s %s because remote path mapping rejected unsafe path: %s",
client.name,
download_id,
source_path_obj,
)
return
delete_path = remapped
else:
delete_path = source_path_obj
if str(delete_path) in ("", "/"):
logger.warning(
@@ -435,6 +446,19 @@ class ExternalClientHandler(DownloadHandler, ABC):
)
if matched_mapping:
if remapped is None:
message = (
f"Remote path mapping rejected unsafe path '{source_path_obj}'. "
f"Check Settings > Advanced > Remote Path Mappings."
)
failure_log = "Remote path mapping rejected unsafe path for %s (%s): %s"
failure_args = (client.name, download_id, source_path_obj)
if log_details:
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return None, message
remapped_exists, remapped_error = _probe_completed_path(remapped)
if log_details:
+37 -6
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
import os
import time
from http import HTTPStatus
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from types import SimpleNamespace
from typing import NoReturn, TypedDict
@@ -46,6 +47,13 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
_ONE_WEEK_IN_SECONDS = 604800
class _UnsafeQBittorrentPath:
pass
_UNSAFE_QBITTORRENT_PATH = _UnsafeQBittorrentPath()
class _QBittorrentAddKwargs(TypedDict, total=False):
rename: str
category: str
@@ -136,6 +144,24 @@ def _is_explicit_add_failure(raw_result: object) -> bool:
return normalized in {"fail", "fails", "error", "errors"}
def _build_qbittorrent_child_path(base_path: object, child_path: object) -> str | None:
"""Build a qBittorrent-reported child path without allowing escape from base."""
if not isinstance(base_path, str) or not base_path:
return None
if not isinstance(child_path, str) or not child_path:
return None
child = child_path.replace("\\", "/")
posix_child = PurePosixPath(child)
windows_child = PureWindowsPath(child_path)
if posix_child.is_absolute() or windows_child.is_absolute() or windows_child.drive:
return None
if any(part == ".." for part in posix_child.parts):
return None
return os.path.normpath(str(Path(base_path) / child))
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
@@ -629,16 +655,18 @@ class QBittorrentClient(DownloadClient):
download_id = getattr(torrent, "hash", "")
if isinstance(download_id, str) and download_id:
derived = self._derive_download_path_from_files(download_id)
if derived:
if derived and not isinstance(derived, _UnsafeQBittorrentPath):
return derived
# Legacy fallback: save_path + name (for older clients/emulators)
return self._build_path(
return _build_qbittorrent_child_path(
getattr(torrent, "save_path", ""),
getattr(torrent, "name", ""),
)
def _derive_download_path_from_files(self, download_id: str) -> str | None:
def _derive_download_path_from_files(
self, download_id: str
) -> str | _UnsafeQBittorrentPath | None:
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
This mirrors how common automation apps derive the path when
@@ -685,9 +713,12 @@ class QBittorrentClient(DownloadClient):
first_name_norm = first_name.replace("\\", "/")
top_level = first_name_norm.split("/", 1)[0]
if not top_level:
return None
return _UNSAFE_QBITTORRENT_PATH
return os.path.normpath(str(Path(save_path) / top_level))
derived = _build_qbittorrent_child_path(save_path, top_level)
if derived is None:
return _UNSAFE_QBITTORRENT_PATH
return os.path.normpath(derived)
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug(
"qBittorrent could not derive path from files: %s: %s",
+41 -6
View File
@@ -33,6 +33,24 @@ _SABNZBD_CLIENT_ERRORS = (
_SabnzbdRequestParam = str | int | float | bool
def _url_origin(value: str) -> tuple[str, str, int] | None:
try:
parsed = urlparse(value)
port = parsed.port
except ValueError:
return None
scheme = parsed.scheme.lower()
hostname = (parsed.hostname or "").lower()
if scheme not in {"http", "https"} or not hostname:
return None
if port is None:
port = 443 if scheme == "https" else 80
return scheme, hostname, port
def _parse_eta(eta_str: str) -> int | None:
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
if not eta_str or eta_str == "0:00:00":
@@ -220,6 +238,18 @@ class SABnzbdClient(DownloadClient):
response.raise_for_status()
return response.content
def _can_prefetch_nzb_url(self, url: str) -> bool:
target_origin = _url_origin(url)
if target_origin is None:
return False
for key in ("PROWLARR_URL", "NEWZNAB_URL"):
trusted_url = normalize_http_config_url(config.get(key, ""))
if trusted_url and _url_origin(trusted_url) == target_origin:
return True
return False
def _get_prowlarr_headers(self, url: str) -> dict:
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
@@ -326,15 +356,20 @@ class SABnzbdClient(DownloadClient):
try:
logger.debug("Adding NZB to SABnzbd: %s", name)
nzb_filename = self._build_nzb_filename(name, url)
nzb_content = self._fetch_nzb_content(url)
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
nzo_id = self._extract_nzo_id(result)
logger.info("Added NZB to SABnzbd: %s", nzo_id)
if self._can_prefetch_nzb_url(url):
nzb_filename = self._build_nzb_filename(name, url)
nzb_content = self._fetch_nzb_content(url)
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
nzo_id = self._extract_nzo_id(result)
logger.info("Added NZB to SABnzbd: %s", nzo_id)
else:
logger.info("Skipping SABnzbd addfile prefetch for untrusted NZB URL")
nzo_id = ""
except _SABNZBD_CLIENT_ERRORS as e:
logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e)
else:
return nzo_id
if nzo_id:
return nzo_id
try:
result = self._api_call(
+42 -1
View File
@@ -7,12 +7,13 @@ import hashlib
import re
from binascii import Error as BinasciiError
from dataclasses import dataclass
from urllib.parse import parse_qs, urljoin, urlparse
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
@@ -32,6 +33,7 @@ _TORRENT_FETCH_ERRORS = (
ValueError,
)
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
@@ -93,6 +95,9 @@ def extract_torrent_info(
# Not a magnet - try to fetch and parse the .torrent file
if not fetch_torrent:
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
if not _is_trusted_torrent_fetch_url(url):
logger.debug("Skipping torrent prefetch for untrusted URL: %s...", url[:80])
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
@@ -133,6 +138,12 @@ def extract_torrent_info(
is_magnet=True,
magnet_url=redirect_url,
)
if not _is_trusted_torrent_fetch_url(redirect_url):
logger.debug(
"Skipping torrent prefetch redirect to untrusted URL: %s...",
redirect_url[:80],
)
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
# Not a magnet redirect, follow it manually
logger.debug("Following redirect to: %s...", redirect_url[:80])
resp = requests.get(
@@ -172,6 +183,36 @@ def extract_torrent_info(
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
def _is_trusted_torrent_fetch_url(url: str) -> bool:
parsed = urlparse(url)
origin = _url_origin(parsed)
if origin is None:
return False
for key in _TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS:
configured_url = str(config.get(key, "") or "").strip()
if not configured_url:
continue
configured_origin = _url_origin(urlparse(normalize_http_url(configured_url)))
if configured_origin == origin:
return True
return False
def _url_origin(parsed_url: ParseResult) -> tuple[str, str, int] | None:
scheme = parsed_url.scheme.lower()
if scheme not in {"http", "https"}:
return None
hostname = parsed_url.hostname
if not hostname:
return None
default_port = 443 if scheme == "https" else 80
return (scheme, hostname.lower(), parsed_url.port or default_port)
def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
"""Parse Transmission URL into (protocol, host, port, path)."""
parsed = urlparse(url)
+16 -12
View File
@@ -229,6 +229,10 @@ def _is_permission_error(e: Exception) -> bool:
return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM)
def _should_fallback_to_content_copy(error: Exception) -> bool:
return _is_permission_error(error) or (isinstance(error, OSError) and error.errno == errno.EIO)
def _system_op(op: str, source: Path, dest: Path) -> None:
"""Execute system command (mv or cp) as final fallback."""
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
@@ -463,9 +467,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as copy_error:
if _is_permission_error(copy_error):
if _should_fallback_to_content_copy(copy_error):
logger.debug(
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
"copy2 failed during move-copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
copy_error,
@@ -583,7 +587,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
error=e,
)
if permission_error or _hardlink_not_supported(e):
logger.debug(
logger.warning(
"Hardlink failed (%s), falling back to copy: %s -> %s",
e,
source_path,
@@ -631,16 +635,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as e:
# Handle NFS permission errors immediately here
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
if _should_fallback_to_content_copy(e):
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
logger.debug(
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
"copy2 failed during copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
e,
+43 -12
View File
@@ -29,6 +29,7 @@ from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_clea
from shelfmark.download.postprocess.router import post_process_download
from shelfmark.release_sources import (
get_handler,
get_source,
get_source_display_name,
)
@@ -108,6 +109,13 @@ def _parse_release_search_mode(value: object) -> SearchMode:
raise ValueError(msg)
def _source_unavailable_message(source_name: str) -> str | None:
source = get_source(source_name)
if source.is_available():
return None
return f"{source.display_name} is unavailable. Enable and configure the source in Settings."
def _optional_number(value: object) -> float | None:
if isinstance(value, bool):
return float(value)
@@ -138,13 +146,6 @@ def _optional_positive_int(value: object) -> int | None:
return parsed if parsed > 0 else None
def _seed_time_seconds_to_minutes(value: object) -> int | None:
seed_time_seconds = _optional_positive_int(value)
if seed_time_seconds is None:
return None
return (seed_time_seconds + 59) // 60
def _config_float(value: object, default: float) -> float:
if isinstance(value, bool) or value is None:
return default
@@ -166,19 +167,34 @@ def _build_retry_resolution_fields(
if not isinstance(extra, dict):
extra = {}
retry_download_url = normalize_optional_text(release_data.get("download_url"))
protocol = normalize_optional_text(release_data.get("protocol"))
source = normalize_optional_text(release_data.get("source"))
if source is not None:
handler = get_handler(source)
source_retry_fields = handler.build_retry_resolution_fields(release_data)
retry_download_url = (
normalize_optional_text(source_retry_fields.get("retry_download_url"))
or retry_download_url
)
protocol = (
normalize_optional_text(source_retry_fields.get("retry_download_protocol")) or protocol
)
ratio_limit = _optional_number(release_data.get("ratio_limit"))
if ratio_limit is None:
ratio_limit = _optional_number(extra.get("minimum_ratio"))
if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
ratio_limit = _optional_number(extra.get("configured_ratio_limit"))
seeding_time_limit_minutes = _optional_positive_int(
release_data.get("seeding_time_limit_minutes")
)
if seeding_time_limit_minutes is None:
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time"))
if seeding_time_limit_minutes is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
seeding_time_limit_minutes = _optional_positive_int(
extra.get("configured_seed_time_minutes")
)
return {
"retry_download_url": normalize_optional_text(release_data.get("download_url")),
"retry_download_url": retry_download_url,
"retry_download_protocol": protocol.lower() if protocol is not None else None,
"retry_release_name": normalize_optional_text(release_data.get("title")),
"retry_expected_hash": normalize_optional_text(
@@ -199,6 +215,11 @@ def queue_release(
"""Add a release to the download queue. Returns (success, error_message)."""
try:
source = release_data["source"]
unavailable_message = _source_unavailable_message(source)
if unavailable_message:
logger.warning("Rejected queue request for unavailable source %s", source)
return False, unavailable_message
extra = release_data.get("extra", {})
raw_request_id = release_data.get("_request_id")
request_id: int | None = None
@@ -590,6 +611,16 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
logger.error("Task not found in queue: %s", task_id)
return None
unavailable_message = _source_unavailable_message(task.source)
if unavailable_message:
logger.warning("Task %s: source unavailable: %s", task_id, unavailable_message)
_capture_task_error(
task,
message=unavailable_message,
exc_type="SourceUnavailable",
)
return None
title_label = task.title or "Unknown title"
logger.info(
"Task %s: starting download (%s) - %s",
+11 -15
View File
@@ -13,7 +13,6 @@ from shelfmark.core.naming import (
build_library_path,
derive_primary_title,
parse_naming_template,
same_filesystem,
sanitize_filename,
)
from shelfmark.core.utils import is_audiobook as check_audiobook
@@ -39,10 +38,7 @@ _TRANSFER_PROCESS_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, Typ
def should_hardlink(task: DownloadTask) -> bool:
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
if task.source != "prowlarr":
return False
"""Check if hardlinking is enabled for this torrent-backed task."""
if not task.original_download_path:
return False
@@ -96,21 +92,21 @@ def resolve_hardlink_source(
if hardlink_enabled and task.original_download_path:
hardlink_source = Path(task.original_download_path)
hardlink_source_exists = run_blocking_io(hardlink_source.exists)
if (
destination
and hardlink_source_exists
and run_blocking_io(same_filesystem, hardlink_source, destination)
):
if hardlink_source_exists:
use_hardlink = True
source_path = hardlink_source
elif hardlink_source_exists:
logger.warning(
"Cannot hardlink: %s and %s are on different filesystems. Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination.",
logger.info(
"Hardlink enabled for task %s; attempting link from %s to %s",
task.task_id,
hardlink_source,
destination,
)
if status_callback:
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
else:
logger.warning(
"Hardlink enabled for task %s, but source path does not exist: %s",
task.task_id,
hardlink_source,
)
return TransferPlan(
source_path=source_path,
+106
View File
@@ -26,6 +26,7 @@ from shelfmark.config.env import (
BUILD_VERSION,
CONFIG_DIR,
CWA_DB_PATH,
DISABLE_LOCAL_AUTH,
FLASK_HOST,
FLASK_PORT,
HIDE_LOCAL_AUTH,
@@ -1477,6 +1478,43 @@ def _download_row_owned_by_actor(
return False
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin and (not can_access_status or db_user_id is None):
return (
is_admin,
db_user_id,
normalized_actor_username,
jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}),
)
return is_admin, db_user_id, normalized_actor_username, None
def _queue_task_visible_to_actor(
task_id: str,
*,
is_admin: bool,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
if is_admin:
return True
task = backend.book_queue.get_task(task_id)
if task is None:
return False
return _task_owned_by_actor(
task,
actor_user_id=actor_user_id,
actor_username=actor_username,
)
backend.book_queue.set_queue_hook(_record_download_queued)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
@@ -1586,6 +1624,7 @@ def api_local_download() -> Response | tuple[Response, int]:
@app.route("/api/covers/<cover_id>", methods=["GET"])
@login_required
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
"""Serve a cached book cover image.
@@ -1797,6 +1836,22 @@ def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data["priority"])
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to update priority or book not found"}), 404
if not is_admin and not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
success = backend.set_book_priority(book_id, priority)
if success:
@@ -1835,6 +1890,23 @@ def api_reorder_queue() -> Response | tuple[Response, int]:
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
owned_book_priorities = {}
for book_id in book_priorities:
task = backend.book_queue.get_task(str(book_id))
if task is None:
continue
if not _task_owned_by_actor(
task, actor_user_id=db_user_id, actor_username=actor_username
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
owned_book_priorities[book_id] = book_priorities[book_id]
book_priorities = owned_book_priorities
success = backend.reorder_queue(book_priorities)
if success:
@@ -1856,6 +1928,20 @@ def api_queue_order() -> Response | tuple[Response, int]:
"""
try:
queue_order = backend.get_queue_order()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
queue_order = [
item
for item in queue_order
if _queue_task_visible_to_actor(
str(item.get("id", "")),
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"queue": queue_order})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Queue order error: {e}")
@@ -1873,6 +1959,20 @@ def api_active_downloads() -> Response | tuple[Response, int]:
"""
try:
active_downloads = backend.get_active_downloads()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
active_downloads = [
task_id
for task_id in active_downloads
if _queue_task_visible_to_actor(
task_id,
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"active_downloads": active_downloads})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Active downloads error: {e}")
@@ -1955,6 +2055,9 @@ def api_login() -> Response | tuple[Response, int]:
if auth_mode == "proxy":
return jsonify({"error": "Proxy authentication is enabled"}), 401
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
return jsonify({"error": "Local authentication is disabled"}), 403
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
return jsonify({"error": "Local authentication is disabled"}), 403
@@ -2184,6 +2287,9 @@ def api_auth_check() -> Response | tuple[Response, int]:
if logout_url:
response_data["logout_url"] = logout_url
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
response_data["hide_local_auth"] = True
# Add custom OIDC button label and SSO enforcement flags if configured
if auth_mode == "oidc":
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
+19 -10
View File
@@ -47,6 +47,11 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1"
class _GoogleBooksRequestError(Exception):
"""Raised when Google Books does not return a usable API response."""
# Sort mapping - Google only supports "relevance" and "newest"
SORT_MAPPING: dict[SortOrder, str | None] = {
SortOrder.RELEVANCE: None, # Default, no param needed
@@ -117,7 +122,10 @@ class GoogleBooksProvider(MetadataProvider):
f"{options.query}:{options.search_type.value}:{options.sort.value}:"
f"{options.language}:{options.limit}:{options.page}:{fields_key}"
)
return self._search_cached(cache_key, options)
try:
return self._search_cached(cache_key, options)
except _GoogleBooksRequestError:
return []
@cacheable(
ttl_key="METADATA_CACHE_SEARCH_TTL",
@@ -166,18 +174,19 @@ class GoogleBooksProvider(MetadataProvider):
if options.language:
params["langRestrict"] = options.language
result = self._make_request("/volumes", params)
if result is None:
raise _GoogleBooksRequestError
books: list[BookMetadata] = []
try:
result = self._make_request("/volumes", params)
if result:
items = result.get("items", [])
items = result.get("items", [])
for item in items:
book = self._parse_volume(item)
if book:
books.append(book)
for item in items:
book = self._parse_volume(item)
if book:
books.append(book)
logger.info("Google Books search '%s' returned %s results", query, len(books))
logger.info("Google Books search '%s' returned %s results", query, len(books))
except Exception:
logger.exception("Google Books search error")
+4
View File
@@ -390,6 +390,10 @@ class DownloadHandler(ABC):
"""
return
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
"""Return private queue-time fields needed for restart-safe retry."""
return {}
@abstractmethod
def cancel(self, task_id: str) -> bool:
"""Cancel an in-progress download."""
@@ -24,6 +24,8 @@ if TYPE_CHECKING:
from shelfmark.core.models import DownloadTask
logger = setup_logger(__name__)
DEFAULT_ABB_HOSTNAME = "audiobookbay.lu"
ALLOWED_DETAIL_URL_SCHEMES = {"https"}
def _resolve_configured_hostname() -> str:
@@ -32,6 +34,23 @@ def _resolve_configured_hostname() -> str:
return normalize_hostname(configured_hostname if isinstance(configured_hostname, str) else "")
def _resolve_allowed_detail_hostname() -> str:
"""Return the ABB hostname allowed for queued detail URLs."""
return _resolve_configured_hostname() or DEFAULT_ABB_HOSTNAME
def _detail_url_matches_host(detail_url: str, hostname: str) -> bool:
"""Return True when a detail URL uses the allowed ABB scheme and host."""
parsed = urlparse(detail_url)
detail_hostname = normalize_hostname(parsed.hostname)
allowed_hostname = normalize_hostname(hostname).lower().rstrip(".")
return (
parsed.scheme.lower() in ALLOWED_DETAIL_URL_SCHEMES
and bool(detail_hostname)
and detail_hostname.lower().rstrip(".") == allowed_hostname
)
@register_handler("audiobookbay")
class AudiobookBayHandler(ExternalClientHandler):
"""Handler for AudiobookBay downloads via configured torrent client."""
@@ -69,9 +88,14 @@ class AudiobookBayHandler(ExternalClientHandler):
logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id)
return None
hostname = _resolve_configured_hostname()
if not hostname:
hostname = normalize_hostname(urlparse(detail_url).hostname)
hostname = _resolve_allowed_detail_hostname()
if not _detail_url_matches_host(detail_url, hostname):
status_callback("error", "Invalid AudiobookBay details URL")
logger.warning(
"Rejected AudiobookBay details URL with invalid scheme or host: %s",
detail_url,
)
return None
status_callback("resolving", "Extracting magnet link")
magnet_link = scraper.extract_magnet_link(detail_url, hostname)
+35 -4
View File
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Self
from shelfmark.core.logger import setup_logger
from .dcc import DCCOffer, parse_dcc_send
from .dcc import DCCError, DCCOffer, parse_dcc_send, validate_dcc_endpoint
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -400,6 +400,33 @@ class IRCClient:
self.send_notice(sender, f"\x01VERSION {self.version}\x01")
logger.debug("Sent VERSION to %s", sender)
@staticmethod
def _sender_nick(msg: IRCMessage) -> str | None:
"""Extract the nick from a message prefix."""
if not msg.prefix:
return None
return msg.prefix.split("!", maxsplit=1)[0]
def _is_allowed_dcc_sender(
self,
msg: IRCMessage,
expected_senders: set[str] | None,
) -> bool:
allowed_senders = expected_senders or self.online_servers
if not allowed_senders:
return True
sender = self._sender_nick(msg)
if sender is None:
logger.warning("Ignoring DCC offer without sender prefix")
return False
normalized_allowed = {nick.casefold() for nick in allowed_senders}
if sender.casefold() not in normalized_allowed:
logger.warning("Ignoring DCC offer from unexpected sender: %s", sender)
return False
return True
def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]:
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
for line in self._recv_lines():
@@ -422,6 +449,7 @@ class IRCClient:
timeout: float = 60.0,
*,
result_type: bool = False,
expected_senders: set[str] | None = None,
) -> DCCOffer | None:
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
@@ -433,12 +461,15 @@ class IRCClient:
return None
if msg.event == target_event:
if not self._is_allowed_dcc_sender(msg, expected_senders):
continue
try:
offer = parse_dcc_send(msg.raw)
validate_dcc_endpoint(offer)
logger.info("Received DCC offer: %s", offer.filename)
except Exception:
logger.exception("Failed to parse DCC")
return None
except DCCError:
logger.exception("Rejected DCC offer")
continue
else:
return offer
+47 -2
View File
@@ -7,6 +7,8 @@ import re
import socket
import struct
from dataclasses import dataclass
from ipaddress import ip_address
from pathlib import PureWindowsPath
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
@@ -59,6 +61,10 @@ class DCCConnectionError(DCCError):
"""Failed to connect to DCC sender."""
class DCCSecurityError(DCCError):
"""Rejected unsafe DCC offer metadata."""
def int_to_ip(ip_int: int) -> str:
"""Convert 32-bit integer (DCC format) to dotted IP notation."""
packed = struct.pack(">I", ip_int)
@@ -76,15 +82,53 @@ def parse_dcc_send(text: str) -> DCCOffer:
ip_int = int(match.group(2))
port = int(match.group(3))
size = int(match.group(4))
try:
ip = int_to_ip(ip_int)
except struct.error as e:
msg = f"Invalid DCC IP integer: {ip_int}"
raise DCCParseError(msg) from e
return DCCOffer(
filename=filename,
ip=int_to_ip(ip_int),
filename=safe_dcc_filename(filename),
ip=ip,
port=port,
size=size,
)
def safe_dcc_filename(filename: str) -> str:
"""Return a DCC filename that cannot escape its destination directory."""
safe_name = filename.strip()
windows_path = PureWindowsPath(safe_name)
if (
not safe_name
or safe_name in {".", ".."}
or "/" in safe_name
or "\\" in safe_name
or windows_path.drive
):
msg = f"Rejected unsafe DCC filename: {filename!r}"
raise DCCSecurityError(msg)
return safe_name
def validate_dcc_endpoint(offer: DCCOffer) -> None:
"""Reject DCC endpoints that can target local/internal network services."""
if not 1 <= offer.port <= 65535:
msg = f"Rejected invalid DCC port: {offer.port}"
raise DCCSecurityError(msg)
try:
address = ip_address(offer.ip)
except ValueError as e:
msg = f"Rejected invalid DCC IP address: {offer.ip}"
raise DCCSecurityError(msg) from e
if not address.is_global:
msg = f"Rejected non-public DCC endpoint: {offer.ip}"
raise DCCSecurityError(msg)
def download_dcc(
offer: DCCOffer,
dest_path: Path,
@@ -93,6 +137,7 @@ def download_dcc(
timeout: float = 30.0,
) -> None:
"""Download file via DCC protocol to dest_path. Raises DCCError on failure."""
validate_dcc_endpoint(offer)
logger.info("DCC connecting to %s:%s for %s", offer.ip, offer.port, offer.filename)
try:
+16 -3
View File
@@ -12,7 +12,7 @@ from shelfmark.core.logger import setup_logger
from shelfmark.release_sources import DownloadHandler, register_handler
from .connection_manager import connection_manager
from .dcc import DCCError, download_dcc
from .dcc import DCCError, download_dcc, safe_dcc_filename
if TYPE_CHECKING:
from collections.abc import Callable
@@ -23,6 +23,15 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
def _server_from_download_request(download_request: str) -> str | None:
"""Extract the expected IRC bot nick from a release request line."""
stripped = download_request.strip()
if not stripped.startswith("!"):
return None
server = stripped[1:].split(maxsplit=1)[0]
return server or None
def _config_text(key: str) -> str:
"""Read a string config value with whitespace trimmed."""
value = config.get(key, "")
@@ -72,6 +81,7 @@ class IRCDownloadHandler(DownloadHandler):
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
download_request = task.task_id
logger.info("IRC download: %s...", download_request[:60])
expected_server = _server_from_download_request(download_request)
# Get IRC settings
server = _config_text("IRC_SERVER")
@@ -123,7 +133,8 @@ class IRCDownloadHandler(DownloadHandler):
# Phase 3: Wait for DCC offer
status_callback("resolving", "Waiting for bot response")
offer = client.wait_for_dcc(timeout=120.0, result_type=False)
wait_kwargs = {"expected_senders": {expected_server}} if expected_server else {}
offer = client.wait_for_dcc(timeout=120.0, result_type=False, **wait_kwargs)
if not offer:
status_callback("error", "No response from bot")
@@ -137,7 +148,9 @@ class IRCDownloadHandler(DownloadHandler):
status_callback("downloading", "")
# Get file extension from offer filename
ext = Path(offer.filename).suffix.lstrip(".") or task.format or "epub"
ext = (
Path(safe_dcc_filename(offer.filename)).suffix.lstrip(".") or task.format or "epub"
)
# Stage to temp directory (lazy import to avoid circular import)
from shelfmark.download.staging import get_staging_path
+4 -3
View File
@@ -30,7 +30,7 @@ from shelfmark.release_sources import (
)
from .connection_manager import connection_manager
from .dcc import DCCError, download_dcc
from .dcc import DCCError, download_dcc, safe_dcc_filename
from .parser import SearchResult, extract_results_from_zip, parse_results_file
logger = setup_logger(__name__)
@@ -227,7 +227,8 @@ class IRCReleaseSource(ReleaseSource):
# Wait for results DCC - this is the long wait
_emit_status(f"Connected to #{channel} - Waiting for results...", phase="searching")
offer = client.wait_for_dcc(timeout=60.0, result_type=True)
wait_kwargs = {"expected_senders": {search_bot}} if search_bot else {}
offer = client.wait_for_dcc(timeout=60.0, result_type=True, **wait_kwargs)
if not offer:
logger.info("No search results received")
_emit_status("No results found", phase="complete")
@@ -247,7 +248,7 @@ class IRCReleaseSource(ReleaseSource):
# Download results file
_emit_status(f"Connected to #{channel} - Downloading results...", phase="downloading")
with tempfile.TemporaryDirectory() as tmpdir:
result_path = Path(tmpdir) / offer.filename
result_path = Path(tmpdir) / safe_dcc_filename(offer.filename)
download_dcc(offer, result_path, timeout=30.0)
# Parse results
@@ -8,6 +8,7 @@ if TYPE_CHECKING:
from shelfmark.core.models import DownloadTask
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
from shelfmark.download.clients.base_handler import (
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
@@ -83,6 +84,44 @@ class NewznabHandler(ExternalClientHandler):
def _completed_path_max_attempts(self) -> int:
return COMPLETED_PATH_MAX_ATTEMPTS
def build_retry_resolution_fields(self, release_data: dict) -> dict:
source_id = normalize_optional_text(release_data.get("source_id"))
if source_id is None:
return {}
result = get_release(source_id)
if result is None:
return {}
return {
"retry_download_url": normalize_optional_text(_get_download_url(result)),
"retry_download_protocol": normalize_optional_text(_get_protocol(result)),
}
@classmethod
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
retry_download_url = normalize_optional_text(getattr(task, "retry_download_url", None))
retry_download_protocol = normalize_optional_text(
getattr(task, "retry_download_protocol", None)
)
if retry_download_url is None or retry_download_protocol is None:
return None
protocol = retry_download_protocol.lower()
if protocol not in {"torrent", "usenet"}:
return None
return DownloadRequest(
url=retry_download_url,
protocol=protocol,
release_name=(
normalize_optional_text(getattr(task, "retry_release_name", None))
or task.title
or "Unknown"
),
expected_hash=normalize_optional_text(getattr(task, "retry_expected_hash", None)),
)
def _resolve_download(
self,
task: DownloadTask,
@@ -90,6 +129,10 @@ class NewznabHandler(ExternalClientHandler):
) -> DownloadRequest | None:
result = get_release(task.task_id)
if not result:
restored_request = self._restore_download_request_from_task(task)
if restored_request is not None:
logger.info("Restored Newznab download request for retry: %s", task.task_id)
return restored_request
logger.warning("Newznab release cache miss: %s", task.task_id)
status_callback("error", "Release not found in cache (may have expired)")
return None
+1 -2
View File
@@ -109,7 +109,6 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
if is_freeleech:
add_flag("FreeLeech")
download_url = str(result.get("downloadUrl") or "").strip()
info_url = result.get("infoUrl") or result.get("guid")
return Release(
@@ -120,7 +119,7 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
language=None,
size=_parse_size(size_bytes),
size_bytes=size_bytes,
download_url=download_url or None,
download_url=None,
info_url=info_url,
protocol=protocol,
indexer=indexer,
+60 -2
View File
@@ -3,7 +3,7 @@
from collections.abc import Mapping
from contextlib import suppress
from http import HTTPStatus
from typing import Any
from typing import Any, TypedDict
import requests
@@ -11,7 +11,7 @@ from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
from shelfmark.release_sources.prowlarr.utils import coerce_int_like
from shelfmark.release_sources.prowlarr.utils import coerce_float_like, coerce_int_like
logger = setup_logger(__name__)
@@ -27,6 +27,15 @@ _PROWLARR_CLIENT_ERRORS = (
)
class IndexerSeedSettings(TypedDict, total=False):
ratio_limit: float
seeding_time_limit_minutes: int
_INDEXER_FIELD_SEED_RATIO = "torrentBaseSettings.seedRatio"
_INDEXER_FIELD_SEED_TIME_MINUTES = "torrentBaseSettings.seedTime"
def _normalize_json_object(payload: object, *, context: str) -> dict[str, Any]:
"""Return a JSON object payload with string keys or raise on unexpected shapes."""
if not isinstance(payload, Mapping):
@@ -52,6 +61,19 @@ def _normalize_json_object_list(payload: object, *, context: str) -> list[dict[s
return [_normalize_json_object(item, context=context) for item in payload]
def _get_field_value(fields: object, name: str) -> object | None:
if not isinstance(fields, list):
return None
for field in fields:
if not isinstance(field, Mapping):
continue
if field.get("name") == name:
return field.get("value")
return None
class ProwlarrClient:
"""Client for interacting with the Prowlarr API."""
@@ -183,6 +205,42 @@ class ProwlarrClient:
return enriched_ids
def get_indexer_seed_settings(
self, *, restrict_to: list[int] | None = None
) -> dict[int, IndexerSeedSettings]:
"""Return configured per-indexer torrent share limits.
Prowlarr exposes seedTime in minutes, which is also the unit expected by
torrent clients.
"""
settings_by_indexer: dict[int, IndexerSeedSettings] = {}
for idx in self.get_enabled_indexers_detailed():
idx_id_int = coerce_int_like(idx.get("id"))
if idx_id_int is None:
continue
if restrict_to is not None and idx_id_int not in restrict_to:
continue
if str(idx.get("protocol") or "").lower() != "torrent":
continue
fields = idx.get("fields")
ratio_limit = coerce_float_like(_get_field_value(fields, _INDEXER_FIELD_SEED_RATIO))
seeding_time_limit = coerce_int_like(
_get_field_value(fields, _INDEXER_FIELD_SEED_TIME_MINUTES)
)
settings: IndexerSeedSettings = {}
if ratio_limit is not None and ratio_limit > 0:
settings["ratio_limit"] = ratio_limit
if seeding_time_limit is not None and seeding_time_limit > 0:
settings["seeding_time_limit_minutes"] = seeding_time_limit
if settings:
settings_by_indexer[idx_id_int] = settings
return settings_by_indexer
def get_enabled_indexers(self) -> list[dict[str, Any]]:
"""Get enabled indexers with book capability info."""
indexers = self.get_indexers()
+28 -21
View File
@@ -1,6 +1,6 @@
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
@@ -51,22 +51,11 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None:
"""Convert Prowlarr's minimum seed time from seconds to whole minutes."""
if raw_seed_time is None:
def _coerce_positive_minutes(raw_minutes: object) -> int | None:
minutes = coerce_int_like(raw_minutes)
if minutes is None:
return None
seed_time_seconds = coerce_int_like(raw_seed_time)
if seed_time_seconds is None:
logger.warning("Invalid Prowlarr minimumSeedTime value: %r", raw_seed_time)
return None
if seed_time_seconds < 0:
logger.warning("Ignoring negative Prowlarr minimumSeedTime value: %s", seed_time_seconds)
return None
# Round up so we never under-seed when a tracker uses a non-minute boundary.
return (seed_time_seconds + 59) // 60
return minutes if minutes > 0 else None
@register_handler("prowlarr")
@@ -90,6 +79,22 @@ class ProwlarrHandler(ExternalClientHandler):
def _completed_path_max_attempts(self) -> int:
return COMPLETED_PATH_MAX_ATTEMPTS
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
source_id = normalize_optional_text(release_data.get("source_id"))
if source_id is None:
return {}
prowlarr_result = get_release(source_id)
if prowlarr_result is None:
return {}
return {
"retry_download_url": normalize_optional_text(
get_preferred_download_url(prowlarr_result)
),
"retry_download_protocol": normalize_optional_text(get_protocol(prowlarr_result)),
}
@classmethod
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
"""Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone."""
@@ -157,12 +162,14 @@ class ProwlarrHandler(ExternalClientHandler):
release_name = prowlarr_result.get("title") or task.title or "Unknown"
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
# Seed criteria from the indexer (Torznab attributes)
raw_seed_time = prowlarr_result.get("minimumSeedTime")
raw_ratio = prowlarr_result.get("minimumRatio")
seeding_time_limit = None
ratio_limit = None
if config.get("PROWLARR_USE_SEED_PREFERENCES", False):
raw_configured_seed_time = prowlarr_result.get("configuredSeedTimeMinutes")
raw_configured_ratio = prowlarr_result.get("configuredRatioLimit")
seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time)
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time)
ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None
return DownloadRequest(
url=download_url,
@@ -190,4 +190,11 @@ def prowlarr_config_settings() -> list[SettingsField]:
description="Automatically retry search without category filtering if no results are found",
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
CheckboxField(
key="PROWLARR_USE_SEED_PREFERENCES",
label="Use Prowlarr seed preferences",
default=False,
description="Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client",
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
]
+36 -8
View File
@@ -27,12 +27,11 @@ from shelfmark.release_sources import (
SortOption,
register_source,
)
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
from shelfmark.release_sources.prowlarr.cache import cache_release
from shelfmark.release_sources.prowlarr.utils import (
coerce_float_like,
coerce_int_like,
get_preferred_download_url,
get_protocol,
)
@@ -407,7 +406,7 @@ def _prowlarr_result_to_release(
language=language_detected,
size=_parse_size(size_bytes),
size_bytes=size_bytes,
download_url=get_preferred_download_url(result),
download_url=None,
info_url=result.get("infoUrl") or result.get("guid"),
protocol=(
ReleaseProtocol.TORRENT
@@ -433,8 +432,8 @@ def _prowlarr_result_to_release(
"freeleech": is_freeleech,
"download_volume_factor": result.get("downloadVolumeFactor"),
"upload_volume_factor": result.get("uploadVolumeFactor"),
"minimum_ratio": result.get("minimumRatio"),
"minimum_seed_time": result.get("minimumSeedTime"),
"configured_ratio_limit": result.get("configuredRatioLimit"),
"configured_seed_time_minutes": result.get("configuredSeedTimeMinutes"),
"info_hash": result.get("infoHash"),
"formats": formats or None,
"formats_display": formats_display,
@@ -444,6 +443,27 @@ def _prowlarr_result_to_release(
)
def _apply_indexer_seed_settings(
result: dict,
indexer_seed_settings: dict[int, IndexerSeedSettings],
) -> dict:
indexer_id = _coerce_indexer_id(result.get("indexerId"))
if indexer_id is None:
return result
seed_settings = indexer_seed_settings.get(indexer_id)
if not seed_settings:
return result
enriched_result = dict(result)
if "ratio_limit" in seed_settings:
enriched_result["configuredRatioLimit"] = seed_settings["ratio_limit"]
if "seeding_time_limit_minutes" in seed_settings:
enriched_result["configuredSeedTimeMinutes"] = seed_settings["seeding_time_limit_minutes"]
return enriched_result
@register_source("prowlarr")
class ProwlarrSource(ReleaseSource):
"""Prowlarr release source for ebooks and audiobooks."""
@@ -762,6 +782,11 @@ class ProwlarrSource(ReleaseSource):
# Some indexers benefit from title+author queries and extra format detection.
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
enriched_indexer_ids_set = set(enriched_indexer_ids)
indexer_seed_settings = (
client.get_indexer_seed_settings(restrict_to=indexer_ids)
if config.get("PROWLARR_USE_SEED_PREFERENCES", False)
else {}
)
def _check_timeout() -> None:
if time.monotonic() > deadline:
@@ -839,15 +864,18 @@ class ProwlarrSource(ReleaseSource):
results: list[Release] = []
enriched_source_ids: set[str] = set()
for r in all_results:
idx_id = r.get("indexerId")
for raw_result in all_results:
result_with_seed_settings = _apply_indexer_seed_settings(
raw_result, indexer_seed_settings
)
idx_id = result_with_seed_settings.get("indexerId")
idx_id_int = _coerce_indexer_id(idx_id)
is_enriched = bool(
idx_id_int is not None and idx_id_int in enriched_indexer_ids_set
)
release = _prowlarr_result_to_release(
r,
result_with_seed_settings,
content_type,
enable_format_detection=is_enriched,
)
@@ -140,9 +140,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor"))
upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor"))
minimum_ratio = _coerce_float(attrs.get("minimumratio"))
minimum_seed_time = _coerce_int(attrs.get("minimumseedtime"))
cleaned_title = _strip_author_from_title(title, author)
results.append(
@@ -168,8 +165,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
"bookTitle": book_title,
"downloadVolumeFactor": download_volume_factor,
"uploadVolumeFactor": upload_volume_factor,
"minimumRatio": minimum_ratio,
"minimumSeedTime": minimum_seed_time,
# Pass through all torznab attributes for tooltip display
"torznabAttrs": attrs,
}
+1
View File
@@ -58,6 +58,7 @@
"react/jsx-no-useless-fragment": "error",
"react/self-closing-comp": "error",
"typescript/switch-exhaustiveness-check": "error",
"jsx-a11y/prefer-tag-over-role": "off",
"no-restricted-imports": [
"error",
{
+395 -416
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -18,23 +18,23 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/node": "^25.6.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"knip": "^6.6.2",
"oxfmt": "^0.46.0",
"oxlint": "^1.61.0",
"oxlint-tsgolint": "^0.21.1",
"knip": "^6.12.1",
"oxfmt": "^0.48.0",
"oxlint": "^1.63.0",
"oxlint-tsgolint": "^0.22.1",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite": "^8.0.11",
"vitest": "^4.1.5"
}
}
@@ -18,6 +18,10 @@ export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
{' '}
<span className="opacity-40"># Hide the local login form</span>
{'\n'}
{' '}- <span className="text-blue-400">DISABLE_LOCAL_AUTH</span>=
<span className="text-green-400">true</span>{' '}
<span className="opacity-40"># Disable username/password login</span>
{'\n'}
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=
<span className="text-green-400">true</span>
{' '}
+4 -4
View File
@@ -75,8 +75,8 @@ const hydrateSettingsResponse = (response: SettingsResponse): HydratedSettingsSt
});
const values = extractSettingsValues(tabs);
if (values.general && Object.prototype.hasOwnProperty.call(values.general, '_THEME')) {
values.general._THEME = getStoredThemePreference();
if (values.general && Object.prototype.hasOwnProperty.call(values.general, THEME_FIELD.key)) {
values.general[THEME_FIELD.key] = getStoredThemePreference();
}
return {
@@ -208,7 +208,7 @@ export function useSettings(): UseSettingsReturn {
const updateValue = useCallback(
(tabName: string, key: string, value: unknown) => {
if (key === '_THEME' && typeof value === 'string') {
if (key === THEME_FIELD.key && typeof value === 'string') {
setThemePreference(value);
setOriginalValues((prev) => ({
...prev,
@@ -277,7 +277,7 @@ export function useSettings(): UseSettingsReturn {
if (tab) {
for (const field of getValueBearingFields(tab.fields)) {
if (field.fromEnv) continue; // Skip env-locked fields
if (field.key === '_THEME') continue; // Skip client-side only theme field
if (field.key === THEME_FIELD.key) continue; // Skip client-side only theme field
const value = tabValues[field.key];
const originalValue = originalTabValues[field.key];
@@ -4,7 +4,9 @@ import type { Language } from '../types/index';
import {
LANGUAGE_OPTION_ALL,
LANGUAGE_OPTION_DEFAULT,
buildLanguageNormalizer,
getReleaseSearchLanguageParams,
releaseLanguageMatchesFilter,
} from '../utils/languageFilters';
const supportedLanguages: Language[] = [
@@ -35,4 +37,33 @@ describe('languageFilters release search params', () => {
expect(result).toEqual(['de', 'hu']);
});
it('normalizes legacy default language names when combined with explicit filters', () => {
const result = getReleaseSearchLanguageParams(
[LANGUAGE_OPTION_DEFAULT, 'de'],
supportedLanguages,
['english'],
);
expect(result).toEqual(['en', 'de']);
});
});
describe('releaseLanguageMatchesFilter', () => {
it('matches release language names against legacy default language names', () => {
const normalizer = buildLanguageNormalizer(supportedLanguages);
expect(releaseLanguageMatchesFilter('English', ['english'], normalizer)).toBe(true);
});
it('keeps English-only issue 948 fallback results with a legacy English default', () => {
const normalizer = buildLanguageNormalizer(supportedLanguages);
const issue948Languages = [...Array<string>(48).fill('en'), 'de, en', 'en, es'];
const visibleLanguages = issue948Languages.filter((language) =>
releaseLanguageMatchesFilter(language, ['english'], normalizer),
);
expect(visibleLanguages).toHaveLength(48);
});
});
+4 -4
View File
@@ -31,13 +31,13 @@ const resolveBasePath = (): string => {
};
// Lazy initialization to ensure DOM is ready when base path is resolved
let _basePath: string | null = null;
let cachedBasePath: string | null = null;
export const getBasePath = (): string => {
if (_basePath === null) {
_basePath = normalizeBasePath(resolveBasePath());
if (cachedBasePath === null) {
cachedBasePath = normalizeBasePath(resolveBasePath());
}
return _basePath;
return cachedBasePath;
};
export const withBasePath = (path: string): string => {
+14 -5
View File
@@ -48,8 +48,11 @@ export const getLanguageFilterValues = (
return null;
}
const supportedCodes = new Set(supportedLanguages.map((lang) => lang.code));
const defaultCodes = defaultLanguageCodes.filter((code) => supportedCodes.has(code));
const languageNormalizer = buildLanguageNormalizer(supportedLanguages);
const supportedCodes = new Set(supportedLanguages.map((lang) => lang.code.toLowerCase()));
const defaultCodes = defaultLanguageCodes
.map((code) => languageNormalizer.get(code.toLowerCase()) ?? code.toLowerCase())
.filter((code) => supportedCodes.has(code));
const resolved = new Set<string>();
uniqueSelection.forEach((code) => {
@@ -58,8 +61,9 @@ export const getLanguageFilterValues = (
return;
}
if (supportedCodes.has(code)) {
resolved.add(code);
const normalizedCode = languageNormalizer.get(code.toLowerCase()) ?? code.toLowerCase();
if (supportedCodes.has(normalizedCode)) {
resolved.add(normalizedCode);
}
});
@@ -130,6 +134,11 @@ export const releaseLanguageMatchesFilter = (
return part;
});
const selectedSet = new Set(selectedCodes.map((c) => c.toLowerCase()));
const selectedSet = new Set(
selectedCodes.map((code) => {
const normalizedCode = code.toLowerCase();
return languageNormalizer?.get(normalizedCode) ?? normalizedCode;
}),
);
return releaseCodes.every((code) => selectedSet.has(code));
};
+77
View File
@@ -120,6 +120,83 @@ class TestAudiobookBayHandlerDownload:
)
assert "resolving" in recorder.statuses
@patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link")
@patch("shelfmark.release_sources.audiobookbay.handler.get_client")
@patch("shelfmark.release_sources.audiobookbay.handler.config.get")
def test_download_rejects_source_url_host_mismatch(
self, mock_config_get, mock_get_client, mock_extract_magnet
):
"""Test hostile detail URLs are rejected before page fetch."""
mock_config_get.side_effect = lambda key, default="": (
"audiobookbay.lu" if key == "ABB_HOSTNAME" else default
)
handler = AudiobookBayHandler()
task = DownloadTask(
task_id="35f56a3e5734bfa69c3169ee8e605a60",
source="audiobookbay",
title="Test Book",
content_type="audiobook",
source_url="https://169.254.169.254/latest/meta-data/",
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "details url" in recorder.last_message.lower()
mock_extract_magnet.assert_not_called()
mock_get_client.assert_not_called()
@patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link")
@patch("shelfmark.release_sources.audiobookbay.handler.get_client")
@patch("shelfmark.release_sources.audiobookbay.handler.config.get")
def test_download_allows_configured_source_url_host(
self, mock_config_get, mock_get_client, mock_extract_magnet
):
"""Test configured ABB host remains allowed for queued release URLs."""
mock_config_get.side_effect = lambda key, default="": (
"https://audiobookbay.lu/" if key == "ABB_HOSTNAME" else default
)
mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id_123"
mock_get_client.return_value = mock_client
handler = AudiobookBayHandler()
task = DownloadTask(
task_id="35f56a3e5734bfa69c3169ee8e605a60",
source="audiobookbay",
title="Test Book",
content_type="audiobook",
source_url="https://audiobookbay.lu/abss/test-book/",
)
cancel_flag = Event()
recorder = ProgressRecorder()
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None):
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
mock_extract_magnet.assert_called_once_with(
"https://audiobookbay.lu/abss/test-book/", "audiobookbay.lu"
)
mock_client.add_download.assert_called_once()
@patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link")
@patch("shelfmark.release_sources.audiobookbay.handler.get_client")
def test_download_existing_complete(self, mock_get_client, mock_extract_magnet):
@@ -10,6 +10,9 @@ from pathlib import Path
ENTRYPOINT_PATH = Path(__file__).resolve().parents[2] / "entrypoint.sh"
ENTRYPOINT_LOCK_PATH = Path("/tmp/shelfmark_entrypoint_test.lock")
BASH_PATH = shutil.which("bash") or "/bin/bash"
ID_PATH = shutil.which("id") or "/usr/bin/id"
MKDIR_PATH = shutil.which("mkdir") or "/bin/mkdir"
STAT_PATH = shutil.which("stat") or "/usr/bin/stat"
@contextlib.contextmanager
@@ -55,6 +58,61 @@ exit 2
printf '%s' "$HOME" > "$ENTRYPOINT_GUNICORN_HOME_FILE"
printf '%s' "$*" > "$ENTRYPOINT_GUNICORN_ARGS_FILE"
exit 0
""",
)
_write_executable(
bin_dir / "id",
"""#!/bin/sh
if [ -n "${ENTRYPOINT_STUB_CURRENT_UID:-}" ]; then
if [ "$1" = "-u" ]; then
printf '%s\\n' "$ENTRYPOINT_STUB_CURRENT_UID"
exit 0
fi
if [ "$1" = "-g" ]; then
printf '%s\\n' "$ENTRYPOINT_STUB_CURRENT_GID"
exit 0
fi
fi
exec "$ENTRYPOINT_REAL_ID" "$@"
""",
)
_write_executable(
bin_dir / "gosu",
"""#!/bin/sh
shift
if [ "${ENTRYPOINT_STUB_GOSU_FAIL_WRITES:-false}" = "true" ] && [ "$1" = "sh" ] && [ "$2" = "-c" ]; then
exit 1
fi
exec "$@"
""",
)
_write_executable(
bin_dir / "mkdir",
"""#!/bin/sh
for arg in "$@"; do
if [ "$arg" = "/var/log/shelfmark" ]; then
exit 0
fi
done
exec "$ENTRYPOINT_REAL_MKDIR" "$@"
""",
)
_write_executable(
bin_dir / "stat",
"""#!/bin/sh
if [ "$1" = "-c" ]; then
if [ "$2" = "%u:%g" ]; then
printf '%s\\n' "${ENTRYPOINT_STUB_STAT_OWNER:-0:0}"
exit 0
fi
fi
exec "$ENTRYPOINT_REAL_STAT" "$@"
""",
)
_write_executable(
bin_dir / "chown",
"""#!/bin/sh
exit 0
""",
)
@@ -65,6 +123,8 @@ def _run_entrypoint(
tmp_path: Path,
*,
extra_env: dict[str, str] | None = None,
simulate_root_startup: bool = False,
fail_gosu_writes: bool = False,
stub_home: Path | str | None = None,
) -> tuple[subprocess.CompletedProcess[str], Path, Path, Path]:
runtime_home = tmp_path / "runtime-home"
@@ -86,6 +146,9 @@ def _run_entrypoint(
"ENABLE_LOGGING": "false",
"ENTRYPOINT_GUNICORN_ARGS_FILE": str(runtime_args_file),
"ENTRYPOINT_GUNICORN_HOME_FILE": str(runtime_home_file),
"ENTRYPOINT_REAL_ID": ID_PATH,
"ENTRYPOINT_REAL_MKDIR": MKDIR_PATH,
"ENTRYPOINT_REAL_STAT": STAT_PATH,
"ENTRYPOINT_STUB_GID": str(os.getgid()),
"ENTRYPOINT_STUB_HOME": str(stub_home),
"ENTRYPOINT_STUB_UID": str(os.getuid()),
@@ -99,6 +162,18 @@ def _run_entrypoint(
"USING_EXTERNAL_BYPASSER": "true",
}
)
if simulate_root_startup:
env.update(
{
"ENTRYPOINT_STUB_CURRENT_GID": "0",
"ENTRYPOINT_STUB_CURRENT_UID": "0",
"ENTRYPOINT_STUB_STAT_OWNER": "0:0",
"PGID": str(os.getgid()),
"PUID": str(os.getuid()),
}
)
if fail_gosu_writes:
env["ENTRYPOINT_STUB_GOSU_FAIL_WRITES"] = "true"
if extra_env:
env.update(extra_env)
@@ -168,3 +243,17 @@ def test_entrypoint_non_root_mode_requires_writable_config_dir(tmp_path):
f"Config directory is not writable in non-root mode: {readonly_config_dir}" in result.stdout
)
assert "Prepare ownership outside the container" in result.stdout
def test_entrypoint_root_bootstrap_fails_closed_when_config_repair_fails(tmp_path):
result, _, _, _ = _run_entrypoint(
tmp_path,
simulate_root_startup=True,
fail_gosu_writes=True,
)
assert result.returncode == 1
assert "ERROR: Config directory is not writable!" in result.stdout
assert f"Configured runtime identity: {os.getuid()}:{os.getgid()}" in result.stdout
assert f"chown -R {os.getuid()}:{os.getgid()} /path/to/config" in result.stdout
assert "Startup mode: root" not in result.stdout
+32
View File
@@ -381,6 +381,16 @@ class TestSecuritySettings:
assert "inactive" in hint.label.lower()
assert "local admin" in hint.label.lower()
def test_oidc_admin_requirement_hint_absent_when_local_auth_is_disabled(self):
"""OIDC mode should not show the local-admin warning when local auth is disabled."""
from shelfmark.config.security import security_settings
with patch("shelfmark.config.env.DISABLE_LOCAL_AUTH", True):
fields = security_settings()
hint = next((f for f in fields if f.key == "oidc_admin_requirement"), None)
assert hint is None
def test_builtin_option_label_is_local(self):
"""Builtin auth option should be labeled Local."""
from shelfmark.config.security import security_settings
@@ -429,6 +439,28 @@ class TestSecurityOnSave:
assert result["error"] is True
assert "local admin" in result["message"].lower()
def test_on_save_allows_oidc_without_local_admin_when_local_auth_is_disabled(
self, tmp_path, monkeypatch
):
from shelfmark.config.security import _on_save_security
_set_config_dir(monkeypatch, tmp_path)
UserDB(str(tmp_path / "users.db")).initialize()
with patch("shelfmark.config.security_handlers.DISABLE_LOCAL_AUTH", True):
result = _on_save_security(
{
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": (
"https://auth.example.com/.well-known/openid-configuration"
),
"OIDC_CLIENT_ID": "shelfmark",
"OIDC_CLIENT_SECRET": "secret123",
}
)
assert result["error"] is False
def test_on_save_blocks_oidc_when_client_id_is_missing(self, tmp_path, monkeypatch):
from shelfmark.config.security import _on_save_security
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from pathlib import Path
TOR_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "tor.sh"
def _generated_tor_healthcheck_script() -> str:
script = TOR_SCRIPT_PATH.read_text()
start = script.index("cat <<'HC' > /app/tor_healthcheck.sh")
content_start = script.index("\n", start) + 1
content_end = script.index("\nHC", content_start)
return script[content_start:content_end]
def _tor_script_rule_lines() -> list[str]:
return [
line.strip()
for line in TOR_SCRIPT_PATH.read_text().splitlines()
if line.strip().startswith("iptables ")
]
def _line_index(lines: list[str], needle: str) -> int:
return next(index for index, line in enumerate(lines) if needle in line)
def test_tor_nat_rules_bypass_private_networks_before_tcp_redirect():
lines = _tor_script_rule_lines()
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
for cidr in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"):
rule_index = _line_index(lines, f"-d {cidr} -j RETURN")
assert rule_index < tcp_redirect_index
def test_tor_nat_rules_exempt_tor_process_before_dns_and_tcp_redirects():
lines = _tor_script_rule_lines()
owner_index = _line_index(lines, "-m owner --uid-owner")
udp_dns_index = _line_index(lines, "-p udp --dport 53")
tcp_dns_index = _line_index(lines, "-p tcp --dport 53")
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
assert owner_index < udp_dns_index
assert owner_index < tcp_dns_index
assert owner_index < tcp_redirect_index
def test_tor_nat_rules_handle_dns_before_tcp_redirect():
lines = _tor_script_rule_lines()
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
assert _line_index(lines, "-p udp --dport 53") < tcp_redirect_index
assert _line_index(lines, "-p tcp --dport 53") < tcp_redirect_index
def test_tor_healthcheck_uses_local_tor_state_without_clear_net_probe():
healthcheck_script = _generated_tor_healthcheck_script()
assert "google.com" not in healthcheck_script
assert "curl " not in healthcheck_script
assert "supervisorctl status tor" in healthcheck_script
assert "Bootstrapped 100%" in healthcheck_script
+28
View File
@@ -126,6 +126,21 @@ class TestLoginSemantics:
assert response.status_code == 403
assert response.get_json()["error"] == "Local authentication is disabled"
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
def test_login_rejects_password_auth_when_local_auth_is_disabled(
self, main_module, client, auth_mode
):
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
response = client.post(
"/api/auth/login",
json={"username": "alice", "password": "wrong", "remember_me": False},
)
assert response.status_code == 403
assert response.get_json()["error"] == "Local authentication is disabled"
assert main_module.failed_login_attempts == {}
def test_auth_check_none_mode_reports_full_access(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
response = client.get("/api/auth/check")
@@ -138,6 +153,19 @@ class TestLoginSemantics:
"is_admin": True,
}
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
def test_auth_check_hides_local_auth_when_disabled(self, main_module, client, auth_mode):
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
response = client.get("/api/auth/check")
assert response.status_code == 200
body = response.get_json()
assert body["auth_mode"] == auth_mode
assert body["auth_required"] is True
assert body["authenticated"] is False
assert body["hide_local_auth"] is True
def test_auth_check_includes_display_name_for_authenticated_user(
self, main_module, client, temp_user_db, monkeypatch
):
+28
View File
@@ -0,0 +1,28 @@
"""Cover proxy API security tests."""
from __future__ import annotations
import importlib
from unittest.mock import patch
import pytest
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
def test_cover_proxy_requires_authentication(main_module) -> None:
client = main_module.app.test_client()
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.get("/api/covers/test-id")
assert response.status_code == 401
assert response.get_json() == {"error": "Unauthorized"}
+201
View File
@@ -856,3 +856,204 @@ class TestStatusEndpointGuardrails:
assert resp.status_code == 200
assert observed["user_id"] is None
class TestQueueManagementEndpointGuardrails:
def test_non_owner_cannot_set_priority(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
actor = _create_user(main_module, prefix="actor")
_set_authenticated_session(
client,
user_id=actor["username"],
db_user_id=actor["id"],
is_admin=False,
)
task = DownloadTask(
task_id="owned-priority-1",
source="direct_download",
title="Owned Task",
user_id=owner["id"],
username=owner["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "set_book_priority") as mock_set_priority:
resp = client.put("/api/queue/owned-priority-1/priority", json={"priority": 1})
assert resp.status_code == 403
assert resp.get_json()["code"] == "download_not_owned"
mock_set_priority.assert_not_called()
def test_owner_can_set_priority(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
task = DownloadTask(
task_id="reader-priority-1",
source="direct_download",
title="Reader Task",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(
main_module.backend, "set_book_priority", return_value=True
) as mock_set_priority:
resp = client.put("/api/queue/reader-priority-1/priority", json={"priority": 2})
assert resp.status_code == 200
assert resp.get_json() == {
"status": "updated",
"book_id": "reader-priority-1",
"priority": 2,
}
mock_set_priority.assert_called_once_with("reader-priority-1", 2)
def test_non_owner_cannot_reorder_other_users_task(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
actor = _create_user(main_module, prefix="actor")
_set_authenticated_session(
client,
user_id=actor["username"],
db_user_id=actor["id"],
is_admin=False,
)
owned_task = DownloadTask(
task_id="actor-reorder-1",
source="direct_download",
title="Actor Task",
user_id=actor["id"],
username=actor["username"],
)
other_task = DownloadTask(
task_id="owner-reorder-1",
source="direct_download",
title="Owner Task",
user_id=owner["id"],
username=owner["username"],
)
def fake_get_task(task_id):
return {
"actor-reorder-1": owned_task,
"owner-reorder-1": other_task,
}.get(task_id)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module.backend.book_queue, "get_task", side_effect=fake_get_task
):
with patch.object(main_module.backend, "reorder_queue") as mock_reorder:
resp = client.post(
"/api/queue/reorder",
json={
"book_priorities": {
"actor-reorder-1": 1,
"owner-reorder-1": 0,
}
},
)
assert resp.status_code == 403
assert resp.get_json()["code"] == "download_not_owned"
mock_reorder.assert_not_called()
def test_non_admin_queue_order_is_scoped_to_owned_tasks(self, main_module, client):
user = _create_user(main_module, prefix="reader")
other = _create_user(main_module, prefix="other")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
user_task = DownloadTask(
task_id="reader-order-1",
source="direct_download",
title="Reader Task",
user_id=user["id"],
username=user["username"],
)
other_task = DownloadTask(
task_id="other-order-1",
source="direct_download",
title="Other Task",
user_id=other["id"],
username=other["username"],
)
def fake_get_task(task_id):
return {
"reader-order-1": user_task,
"other-order-1": other_task,
}.get(task_id)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module.backend,
"get_queue_order",
return_value=[
{"id": "reader-order-1", "title": "Reader Task", "priority": 0},
{"id": "other-order-1", "title": "Other Task", "priority": 1},
],
):
with patch.object(
main_module.backend.book_queue, "get_task", side_effect=fake_get_task
):
resp = client.get("/api/queue/order")
assert resp.status_code == 200
assert resp.get_json()["queue"] == [
{"id": "reader-order-1", "title": "Reader Task", "priority": 0}
]
def test_non_admin_active_downloads_are_scoped_to_owned_tasks(self, main_module, client):
user = _create_user(main_module, prefix="reader")
other = _create_user(main_module, prefix="other")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
user_task = DownloadTask(
task_id="reader-active-1",
source="direct_download",
title="Reader Task",
user_id=user["id"],
username=user["username"],
)
other_task = DownloadTask(
task_id="other-active-1",
source="direct_download",
title="Other Task",
user_id=other["id"],
username=other["username"],
)
def fake_get_task(task_id):
return {
"reader-active-1": user_task,
"other-active-1": other_task,
}.get(task_id)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module.backend,
"get_active_downloads",
return_value=["reader-active-1", "other-active-1"],
):
with patch.object(
main_module.backend.book_queue, "get_task", side_effect=fake_get_task
):
resp = client.get("/api/downloads/active")
assert resp.status_code == 200
assert resp.get_json() == {"active_downloads": ["reader-active-1"]}
+20
View File
@@ -240,6 +240,26 @@ class TestAtomicCopy:
assert result.exists()
assert result.read_text() == "content"
def test_copy_falls_back_when_copy2_hits_fuse_eio(self, tmp_path):
"""Fall back to content copy when FUSE rejects xattr metadata reads."""
import errno
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
with patch(
"shelfmark.download.fs.shutil.copy2",
side_effect=OSError(errno.EIO, "Input/output error"),
):
result = _atomic_copy(source, dest)
assert result == dest
assert result.exists()
assert result.read_text() == "content"
def test_copy_tolerates_post_publish_estale(self, tmp_path, monkeypatch):
"""Treat ESTALE on the final destination as a successful NFS publish."""
import errno
+51 -7
View File
@@ -28,7 +28,6 @@ def _run_organize_post_process(
task,
library: Path,
hardlink_enabled: bool = True,
same_fs: bool = True,
):
from shelfmark.download.postprocess.router import (
post_process_download as _post_process_download,
@@ -37,10 +36,7 @@ def _run_organize_post_process(
status_cb = MagicMock()
cancel_flag = Event()
with (
patch("shelfmark.core.config.config") as mock_config,
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_fs),
):
with patch("shelfmark.core.config.config") as mock_config:
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(
side_effect=lambda key, default=None, **_kwargs: {
@@ -407,6 +403,32 @@ class TestAtomicMove:
assert mock_copy.called
assert mock_fallback.called
def test_cross_filesystem_move_falls_back_when_copy2_hits_fuse_eio(self, tmp_path, monkeypatch):
"""Falls back to content copy when FUSE rejects xattr metadata reads."""
import errno
from shelfmark.download.fs import atomic_move as _atomic_move
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
def _raise_exdev(*_args, **_kwargs):
raise OSError(errno.EXDEV, "Cross-device link")
monkeypatch.setattr(os, "rename", _raise_exdev)
with patch(
"shelfmark.download.fs.shutil.copy2",
side_effect=OSError(errno.EIO, "Input/output error"),
):
result = _atomic_move(source, dest)
assert result == dest
assert not source.exists()
assert dest.exists()
assert dest.read_text() == "content"
def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent(
self, tmp_path, monkeypatch
):
@@ -696,7 +718,6 @@ class TestHardlinkDecisionLogic:
task=sample_task,
library=library,
hardlink_enabled=True,
same_fs=True,
)
assert result is not None
@@ -750,6 +771,29 @@ class TestHardlinkDecisionLogic:
assert result is not None
assert not staged.exists()
def test_non_prowlarr_torrent_with_original_path_can_hardlink(self, tmp_path, sample_task):
"""Torrent-backed sources such as AudiobookBay can hardlink client files."""
library = tmp_path / "library"
library.mkdir()
source = tmp_path / "downloads" / "book.m4b"
source.parent.mkdir()
source.write_bytes(b"content")
sample_task.source = "audiobookbay"
sample_task.content_type = "audiobook"
sample_task.format = "m4b"
sample_task.original_download_path = str(source)
result, _ = _run_organize_post_process(
temp_file=source,
task=sample_task,
library=library,
hardlink_enabled=True,
)
assert result is not None
assert Path(result).stat().st_ino == source.stat().st_ino
class TestHardlinkInodeVerification:
"""Tests that verify hardlinks share the same inode."""
@@ -1321,7 +1365,7 @@ class TestTorrentSourceCleanupProtection:
from shelfmark.download.postprocess.pipeline import transfer_file_to_library
# Simulate by directly calling transfer_file_to_library with use_hardlink=False
# (this is what happens after same_filesystem check fails)
# (this is what happens when hardlinking is disabled before transfer)
downloads = tmp_path / "downloads"
downloads.mkdir()
torrent_file = downloads / "book.epub"
+54 -2
View File
@@ -5,15 +5,67 @@ import requests
from shelfmark.core.image_cache import ImageCacheService
def test_fetch_and_cache_rejects_backslash_authority_bypass_before_request(
tmp_path, monkeypatch
) -> None:
cache = ImageCacheService(tmp_path)
calls = []
def fake_get(url, **_kwargs):
calls.append(url)
raise AssertionError("unsafe URL should not be requested")
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
assert cache.fetch_and_cache("cover-ssrf", "http://127.0.0.1:6666\\@1.1.1.1") is None
assert calls == []
assert "cover-ssrf" not in cache._index
def test_is_safe_url_rejects_encoded_separator_in_authority() -> None:
assert ImageCacheService._is_safe_url("http://127.0.0.1:6666%5c@1.1.1.1") is False
assert ImageCacheService._is_safe_url("http://127.0.0.1:6666%2f@1.1.1.1") is False
def test_is_safe_url_rejects_invalid_ipv6_url() -> None:
assert ImageCacheService._is_safe_url("http://[") is False
def test_fetch_and_cache_blocks_unsafe_redirect(tmp_path, monkeypatch) -> None:
cache = ImageCacheService(tmp_path)
def fake_getaddrinfo(hostname, *_args, **_kwargs):
addresses = {
"example.com": "93.184.216.34",
"127.0.0.1": "127.0.0.1",
}
return [(None, None, None, None, (addresses[hostname], 0))]
class RedirectResponse:
is_redirect = True
headers = {"location": "http://127.0.0.1/cover.jpg"}
def close(self):
return None
calls = []
def fake_get(url, **_kwargs):
calls.append(url)
return RedirectResponse()
monkeypatch.setattr("shelfmark.core.image_cache.socket.getaddrinfo", fake_getaddrinfo)
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
assert cache.fetch_and_cache("cover-redirect", "https://example.com/cover.jpg") is None
assert calls == ["https://example.com/cover.jpg"]
assert "cover-redirect" not in cache._index
def test_fetch_and_cache_returns_none_on_request_exception(tmp_path, monkeypatch) -> None:
cache = ImageCacheService(tmp_path)
monkeypatch.setattr(cache, "_is_safe_url", lambda _url: True)
def fake_get(*args, **kwargs):
def fake_get(*_args, **_kwargs):
raise requests.exceptions.TooManyRedirects("too many redirects")
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
+27
View File
@@ -2,6 +2,8 @@
import sqlite3
import pytest
from shelfmark.core.auth_modes import (
determine_auth_mode,
get_auth_check_admin_status,
@@ -63,6 +65,31 @@ class TestDetermineAuthMode:
}
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
@pytest.mark.parametrize(
("auth_mode", "config"),
[
("builtin", {"AUTH_METHOD": "builtin"}),
(
"oidc",
{
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
},
),
],
)
def test_disable_local_auth_keeps_configured_mode_without_admin(self, auth_mode, config):
assert (
determine_auth_mode(
config,
cwa_db_path=None,
has_local_admin=False,
disable_local_auth=True,
)
== auth_mode
)
def test_load_active_auth_mode_reads_env_backed_cwa_setting(self, monkeypatch, tmp_path):
from shelfmark.core.config import config as app_config
+57
View File
@@ -445,6 +445,7 @@ class TestOIDCCallbackEndpoint:
"userinfo": {
"sub": "oidc-alice-sub",
"email": "alice@example.com",
"email_verified": True,
"preferred_username": "alice_oidc",
"groups": [],
}
@@ -548,6 +549,7 @@ class TestOIDCCallbackEndpoint:
"userinfo": {
"sub": "oidc-new-sub",
"email": "shared@example.com",
"email_verified": True,
"preferred_username": "oidcuser",
"groups": [],
}
@@ -574,6 +576,7 @@ class TestOIDCCallbackEndpoint:
"userinfo": {
"sub": "oidc-nomatch",
"email": "different@example.com",
"email_verified": True,
"preferred_username": "newuser",
"groups": [],
}
@@ -589,6 +592,60 @@ class TestOIDCCallbackEndpoint:
original = user_db.get_user(username="existing")
assert original["oidc_subject"] is None
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_does_not_link_with_unverified_email(self, mock_get_client, client, user_db):
"""OIDC login should not link by email when email_verified is false."""
user_db.create_user(username="existing", email="shared@example.com", password_hash="hash")
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "oidc-unverified",
"email": "shared@example.com",
"email_verified": False,
"preferred_username": "attackeruser",
"groups": [],
}
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == "attackeruser"
original = user_db.get_user(username="existing")
assert original["oidc_subject"] is None
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_rejects_unverified_email_link_when_no_provision(
self, mock_get_client, client, user_db
):
"""OIDC login should not link by unverified email when creation is disabled."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
user_db.create_user(username="existing", email="shared@example.com", password_hash="hash")
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "oidc-unverified-no-provision",
"email": "shared@example.com",
"email_verified": False,
"preferred_username": "attackeruser",
"groups": [],
}
}
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
error = _get_oidc_error(resp)
assert error is not None
assert "Account not found" in error
original = user_db.get_user(username="existing")
assert original["oidc_subject"] is None
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_no_email_link_when_oidc_has_no_email(self, mock_get_client, client, user_db):
"""OIDC login without email in claims should not attempt email linking."""
+59
View File
@@ -0,0 +1,59 @@
from shelfmark.core.path_mappings import (
RemotePathMapping,
remap_remote_to_local_with_match,
)
def test_remap_rejects_parent_directory_remainder(tmp_path):
mapping = RemotePathMapping(
host="qbittorrent",
remote_path="/remote/downloads",
local_path=str(tmp_path / "local" / "downloads"),
)
remote_path = "/remote/downloads/../outside/book.epub"
remapped, matched = remap_remote_to_local_with_match(
mappings=[mapping],
host="qbittorrent",
remote_path=remote_path,
)
assert matched is True
assert remapped is None
def test_remap_rejects_path_that_resolves_outside_local_prefix(tmp_path):
local_prefix = tmp_path / "local" / "downloads"
mapping = RemotePathMapping(
host="qbittorrent",
remote_path="/remote/downloads",
local_path=str(local_prefix),
)
remote_path = "/remote/downloads/subdir/../../outside/book.epub"
remapped, matched = remap_remote_to_local_with_match(
mappings=[mapping],
host="qbittorrent",
remote_path=remote_path,
)
assert matched is True
assert remapped is None
def test_remap_allows_normal_child_path_under_local_prefix(tmp_path):
local_prefix = tmp_path / "local" / "downloads"
mapping = RemotePathMapping(
host="qbittorrent",
remote_path="/remote/downloads",
local_path=str(local_prefix),
)
remapped, matched = remap_remote_to_local_with_match(
mappings=[mapping],
host="qbittorrent",
remote_path="/remote/downloads/author/book.epub",
)
assert matched is True
assert remapped == local_prefix / "author" / "book.epub"
+21 -12
View File
@@ -1,7 +1,9 @@
"""Integration tests for real filesystem processing flows."""
import errno
import os
import zipfile
from contextlib import nullcontext
from pathlib import Path
from threading import Event
from unittest.mock import MagicMock, patch
@@ -46,6 +48,15 @@ def _sync_config(mock_config, mock_core):
mock_core.CUSTOM_SCRIPT = mock_config.CUSTOM_SCRIPT
def _hardlink_support_patch(supported: bool):
if supported:
return nullcontext()
return patch(
"shelfmark.download.fs.os.link",
side_effect=OSError(errno.EXDEV, "Invalid cross-device link"),
)
def test_direct_download_rename_moves_file(tmp_path):
from shelfmark.download.postprocess.router import (
post_process_download as _post_process_download,
@@ -366,7 +377,7 @@ def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_pa
with (
patch("shelfmark.core.config.config") as mock_config,
patch("shelfmark.config.env.TMP_DIR", staging),
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False),
_hardlink_support_patch(False),
):
mock_config.get = _build_config(ingest, organization="none", hardlink=True)
mock_config.CUSTOM_SCRIPT = None
@@ -385,7 +396,7 @@ def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_pa
# Most importantly: hardlink-setting-enabled fallback to copy should NOT extract.
assert list(ingest.glob("*.epub")) == []
assert any(msg.startswith("Copying") for _, msg in statuses)
assert any(msg.startswith("Hardlinking") for _, msg in statuses)
def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_supported(tmp_path):
@@ -422,7 +433,7 @@ def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_
with (
patch("shelfmark.core.config.config") as mock_config,
patch("shelfmark.config.env.TMP_DIR", staging),
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False),
_hardlink_support_patch(False),
):
mock_config.get = _build_config(
ingest,
@@ -1028,20 +1039,20 @@ def test_postprocess_folder_blackbox_matrix(
@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
@pytest.mark.parametrize("organization", ["none", "organize"])
@pytest.mark.parametrize("hardlink_enabled", [False, True])
@pytest.mark.parametrize("same_filesystem", [True, False])
@pytest.mark.parametrize("hardlink_supported", [True, False])
def test_postprocess_torrent_blackbox_matrix(
tmp_path,
input_kind: str,
content_kind: str,
organization: str,
hardlink_enabled: bool,
same_filesystem: bool,
hardlink_supported: bool,
):
"""Torrent-like (original_download_path set) black-box test matrix.
This exercises:
- hardlink enabled/disabled
- same-filesystem hardlink vs copy fallback
- successful hardlink vs copy fallback
- content type differences (book vs audiobook)
Assertions focus on invariants:
@@ -1087,7 +1098,7 @@ def test_postprocess_torrent_blackbox_matrix(
source_file.write_text("content")
task = DownloadTask(
task_id=f"torrent-matrix-{input_kind}-{content_kind}-{organization}-{hardlink_enabled}-{same_filesystem}",
task_id=f"torrent-matrix-{input_kind}-{content_kind}-{organization}-{hardlink_enabled}-{hardlink_supported}",
source="prowlarr",
title=title,
author=author,
@@ -1100,9 +1111,7 @@ def test_postprocess_torrent_blackbox_matrix(
with (
patch("shelfmark.core.config.config") as mock_config,
patch("shelfmark.config.env.TMP_DIR", staging),
patch(
"shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_filesystem
),
_hardlink_support_patch(hardlink_supported),
):
mock_config.get = _build_config(
ingest,
@@ -1131,8 +1140,8 @@ def test_postprocess_torrent_blackbox_matrix(
assert result_path.parent == ingest
assert result_path.name == f"random.{extension}"
# Hardlink only when enabled and same filesystem.
if hardlink_enabled and same_filesystem:
# Hardlink only when enabled and supported by the filesystem.
if hardlink_enabled and hardlink_supported:
assert os.stat(source_file).st_ino == os.stat(result_path).st_ino
else:
assert os.stat(source_file).st_ino != os.stat(result_path).st_ino
+182 -6
View File
@@ -354,6 +354,166 @@ class TestRequestRoutes:
mock_notify_admin.assert_not_called()
mock_notify_user.assert_not_called()
def test_download_policy_rejects_mismatched_context_and_release_source(
self, main_module, client
):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
policy = _policy(
default_ebook="download",
rules=[{"source": "direct_download", "content_type": "*", "mode": "blocked"}],
)
payload = {
"book_data": {
"title": "Policy Source Mismatch",
"author": "Shelfmark",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "policy-source-mismatch-1",
},
"context": {
"source": "prowlarr",
"content_type": "ebook",
"request_level": "release",
},
"release_data": {
"source": "direct_download",
"source_id": "blocked-release-1",
"title": "Blocked Release.epub",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module, "load_users_request_policy_settings", return_value=policy
):
with patch(
"shelfmark.core.request_routes.load_users_request_policy_settings",
return_value=policy,
):
with patch.object(main_module.backend, "queue_release") as mock_queue:
resp = client.post("/api/requests", json=payload)
assert resp.status_code == 400
assert resp.json["code"] == "policy_source_mismatch"
assert resp.json["error"] == "Policy context source must match release_data.source"
assert main_module.user_db.list_requests(user_id=user["id"]) == []
mock_queue.assert_not_called()
def test_release_result_source_rejects_mismatch_before_normalization(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
policy = _policy(
default_ebook="download",
rules=[{"source": "prowlarr", "content_type": "*", "mode": "blocked"}],
)
payload = {
"book_data": {
"title": "Release Result Source Mismatch",
"author": "Shelfmark",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "release-result-mismatch-1",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "release",
},
"release_data": {
"source": "prowlarr",
"source_id": "blocked-prowlarr-release-1",
"title": "Blocked Prowlarr Release.epub",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module, "load_users_request_policy_settings", return_value=policy
):
with patch(
"shelfmark.core.request_routes.load_users_request_policy_settings",
return_value=policy,
):
with patch.object(main_module.backend, "queue_release") as mock_queue:
resp = client.post("/api/requests", json=payload)
assert resp.status_code == 400
assert resp.json["code"] == "policy_source_mismatch"
assert resp.json["error"] == "Policy context source must match release_data.source"
assert main_module.user_db.list_requests(user_id=user["id"]) == []
mock_queue.assert_not_called()
def test_batch_rejects_release_result_source_mismatch_before_creating_any_requests(
self, main_module, client
):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
policy = _policy(
default_ebook="request_release",
rules=[{"source": "prowlarr", "content_type": "*", "mode": "blocked"}],
)
payloads = [
{
"book_data": {
"title": "Batch Valid Direct",
"author": "Shelfmark",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "batch-valid-direct-1",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "release",
},
"release_data": {
"source": "direct_download",
"source_id": "batch-valid-direct-release-1",
"title": "Batch Valid Direct.epub",
},
},
{
"book_data": {
"title": "Batch Release Result Mismatch",
"author": "Shelfmark",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "batch-release-result-mismatch-1",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "release",
},
"release_data": {
"source": "prowlarr",
"source_id": "batch-blocked-prowlarr-release-1",
"title": "Batch Blocked Prowlarr.epub",
},
},
]
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module, "load_users_request_policy_settings", return_value=policy
):
with patch(
"shelfmark.core.request_routes.load_users_request_policy_settings",
return_value=policy,
):
with patch.object(main_module.backend, "queue_release") as mock_queue:
resp = client.post("/api/requests/batch", json={"requests": payloads})
assert resp.status_code == 400
assert resp.json["code"] == "policy_source_mismatch"
assert resp.json["error"] == "Policy context source must match release_data.source"
assert main_module.user_db.list_requests(user_id=user["id"]) == []
mock_queue.assert_not_called()
def test_batch_download_policy_queues_releases_without_creating_requests(
self, main_module, client
):
@@ -1532,6 +1692,12 @@ class TestRequestRoutes:
def test_admin_fulfil_uses_real_queue_and_preserves_requesting_identity(
self, main_module, client
):
class AvailableSource:
display_name = "Direct Download"
def is_available(self):
return True
user = _create_user(main_module, prefix="reader")
other_user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
@@ -1567,13 +1733,23 @@ class TestRequestRoutes:
"shelfmark.core.request_routes.load_users_request_policy_settings",
return_value=policy,
):
create_resp = client.post("/api/requests", json=create_payload)
request_id = create_resp.json["id"]
with patch.object(
main_module.backend,
"get_source",
return_value=AvailableSource(),
):
create_resp = client.post("/api/requests", json=create_payload)
request_id = create_resp.json["id"]
_set_session(
client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True
)
fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={})
_set_session(
client,
user_id=admin["username"],
db_user_id=admin["id"],
is_admin=True,
)
fulfil_resp = client.post(
f"/api/admin/requests/{request_id}/fulfil", json={}
)
assert fulfil_resp.status_code == 200
assert fulfil_resp.json["status"] == "fulfilled"
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
from shelfmark.download import archive as archive_mod
class _FakeZipInfo:
filename = "book.epub"
flag_bits = 0
def is_dir(self) -> bool:
return False
class _ChunkOnlyStream:
def __init__(self, content: bytes) -> None:
self._content = content
self._offset = 0
self.whole_read_called = False
def __enter__(self) -> _ChunkOnlyStream:
return self
def __exit__(self, *_args: object) -> None:
return None
def read(self, size: int = -1) -> bytes:
if size < 0:
self.whole_read_called = True
msg = "archive member was read into memory"
raise AssertionError(msg)
chunk = self._content[self._offset : self._offset + size]
self._offset += len(chunk)
return chunk
class _FakeZipFile:
stream: _ChunkOnlyStream
def __init__(self, _path: Path, _mode: str) -> None:
self.stream = _ChunkOnlyStream(b"streamed archive content")
def __enter__(self) -> _FakeZipFile:
return self
def __exit__(self, *_args: object) -> None:
return None
def infolist(self) -> list[_FakeZipInfo]:
return [_FakeZipInfo()]
def testzip(self) -> None:
return None
def open(self, _info: _FakeZipInfo) -> _ChunkOnlyStream:
return self.stream
def test_extract_archive_raw_streams_members_without_whole_read(
tmp_path: Path, monkeypatch
) -> None:
fake_archives: list[_FakeZipFile] = []
def fake_zip_file(path: Path, mode: str) -> _FakeZipFile:
archive = _FakeZipFile(path, mode)
fake_archives.append(archive)
return archive
monkeypatch.setattr(archive_mod.zipfile, "ZipFile", fake_zip_file)
extracted_files, warnings = archive_mod.extract_archive_raw(tmp_path / "book.zip", tmp_path)
assert warnings == []
assert [path.name for path in extracted_files] == ["book.epub"]
assert extracted_files[0].read_bytes() == b"streamed archive content"
assert fake_archives[0].stream.whole_read_called is False
+16
View File
@@ -4,10 +4,26 @@ from pathlib import Path
from threading import Event
from unittest.mock import MagicMock
import pytest
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.core.queue import BookQueue
class _AvailableSource:
display_name = "Test Source"
def is_available(self):
return True
@pytest.fixture(autouse=True)
def source_available_by_default(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
def test_retry_download_requeues_error_task(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
@@ -1,6 +1,40 @@
from threading import Event
from unittest.mock import MagicMock
from shelfmark.core.models import SearchMode
import pytest
from shelfmark.core.models import DownloadTask, SearchMode
class _AvailableSource:
display_name = "Test Source"
def is_available(self):
return True
class _UnavailableSource:
display_name = "Direct Download"
def is_available(self):
return False
@pytest.fixture(autouse=True)
def source_available_by_default(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
def enable_prowlarr_seed_preferences(monkeypatch, orchestrator):
monkeypatch.setattr(
orchestrator.config,
"get",
lambda key, default=None, user_id=None: (
True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
),
)
def test_queue_release_uses_user_specific_books_output_mode(monkeypatch):
@@ -76,6 +110,28 @@ def test_queue_release_preserves_direct_search_mode_from_payload(monkeypatch):
assert captured["task"].search_mode == SearchMode.DIRECT
def test_queue_release_rejects_unavailable_source(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _UnavailableSource())
monkeypatch.setattr(orchestrator.book_queue, "add", MagicMock())
success, error = orchestrator.queue_release(
{
"source": "direct_download",
"source_id": "release-disabled-direct",
"title": "Disabled Direct Release",
"content_type": "ebook",
},
user_id=42,
username="alice",
)
assert success is False
assert error == "Direct Download is unavailable. Enable and configure the source in Settings."
orchestrator.book_queue.add.assert_not_called()
def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
@@ -114,6 +170,41 @@ def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch):
assert task.output_args == {}
def test_download_task_rejects_unavailable_source_before_handler(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="disabled-task",
source="direct_download",
title="Disabled Direct Release",
)
status_messages: list[tuple[str, str]] = []
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _UnavailableSource())
monkeypatch.setattr(orchestrator, "get_handler", MagicMock())
monkeypatch.setattr(orchestrator.book_queue, "get_task", lambda _task_id: task)
monkeypatch.setattr(
orchestrator.book_queue,
"update_status_message",
lambda task_id, message: status_messages.append((task_id, message)),
)
result = orchestrator._download_task("disabled-task", Event())
assert result is None
assert task.last_error_type == "SourceUnavailable"
assert task.last_error_message == (
"Direct Download is unavailable. Enable and configure the source in Settings."
)
assert status_messages == [
(
"disabled-task",
"Direct Download is unavailable. Enable and configure the source in Settings.",
)
]
orchestrator.get_handler.assert_not_called()
def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
@@ -125,6 +216,7 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
enable_prowlarr_seed_preferences(monkeypatch, orchestrator)
success, error = orchestrator.queue_release(
{
@@ -135,8 +227,8 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
"protocol": "torrent",
"indexer": "MyIndexer",
"extra": {
"minimum_ratio": 1.25,
"minimum_seed_time": 5400,
"configured_ratio_limit": 1.25,
"configured_seed_time_minutes": 90,
"info_hash": "ABC123",
},
},
@@ -156,6 +248,114 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
assert task.can_retry_without_staged_source is True
def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
enable_prowlarr_seed_preferences(monkeypatch, orchestrator)
success, error = orchestrator.queue_release(
{
"source": "prowlarr",
"source_id": "prowlarr-release-configured-seed-time",
"title": "Queued Prowlarr Release",
"download_url": "magnet:?xt=urn:btih:abc123",
"protocol": "torrent",
"extra": {
"configured_ratio_limit": 2,
"configured_seed_time_minutes": 7200,
"minimum_ratio": 1,
"minimum_seed_time": 259200,
},
},
user_id=42,
username="alice",
)
assert success is True
assert error is None
task = captured["task"]
assert task.retry_ratio_limit == 2.0
assert task.retry_seeding_time_limit_minutes == 7200
def test_queue_release_ignores_configured_seed_time_when_disabled_for_retry(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_release(
{
"source": "prowlarr",
"source_id": "prowlarr-release-configured-seed-time-disabled",
"title": "Queued Prowlarr Release",
"download_url": "magnet:?xt=urn:btih:abc123",
"protocol": "torrent",
"extra": {
"configured_ratio_limit": 2,
"configured_seed_time_minutes": 7200,
},
},
user_id=42,
username="alice",
)
assert success is True
assert error is None
task = captured["task"]
assert task.retry_ratio_limit is None
assert task.retry_seeding_time_limit_minutes is None
def test_queue_release_ignores_torznab_minimum_seed_criteria_for_retry(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_release(
{
"source": "prowlarr",
"source_id": "prowlarr-release-minimum-only",
"title": "Queued Prowlarr Release",
"download_url": "magnet:?xt=urn:btih:abc123",
"protocol": "torrent",
"extra": {
"minimum_ratio": 1,
"minimum_seed_time": 259200,
},
},
user_id=42,
username="alice",
)
assert success is True
assert error is None
task = captured["task"]
assert task.retry_ratio_limit is None
assert task.retry_seeding_time_limit_minutes is None
def test_queue_release_returns_error_for_operational_queue_failure(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
+84
View File
@@ -0,0 +1,84 @@
from ipaddress import IPv4Address
from shelfmark.release_sources.irc.client import IRCClient, IRCEvent, IRCMessage
def _dcc_raw(sender: str, filename: str, ip: str, port: int = 443) -> str:
ip_int = int(IPv4Address(ip))
return f':{sender}!user@example.test PRIVMSG reader :\x01DCC SEND "{filename}" {ip_int} {port} 1\x01'
def test_wait_for_dcc_ignores_unexpected_sender(monkeypatch) -> None:
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
client.online_servers = {"BookBot"}
messages = [
IRCMessage(
raw=_dcc_raw("Mallory", "evil.epub", "8.8.8.8"),
prefix="Mallory!user@example.test",
event=IRCEvent.BOOK_RESULT,
),
IRCMessage(
raw=_dcc_raw("BookBot", "book.epub", "8.8.8.8"),
prefix="BookBot!user@example.test",
event=IRCEvent.BOOK_RESULT,
),
]
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
assert offer is not None
assert offer.filename == "book.epub"
def test_wait_for_dcc_uses_expected_sender_over_online_server_list(monkeypatch) -> None:
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
client.online_servers = {"OtherBot"}
messages = [
IRCMessage(
raw=_dcc_raw("BookBot", "book.epub", "8.8.8.8"),
prefix="BookBot!user@example.test",
event=IRCEvent.BOOK_RESULT,
)
]
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
offer = client.wait_for_dcc(
timeout=1.0,
result_type=False,
expected_senders={"BookBot"},
)
assert offer is not None
assert offer.filename == "book.epub"
def test_wait_for_dcc_ignores_unsafe_offer_and_keeps_waiting(monkeypatch) -> None:
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
client.online_servers = {"BookBot"}
messages = [
IRCMessage(
raw=_dcc_raw("BookBot", "../outside.epub", "8.8.8.8"),
prefix="BookBot!user@example.test",
event=IRCEvent.BOOK_RESULT,
),
IRCMessage(
raw=_dcc_raw("BookBot", "internal.epub", "127.0.0.1"),
prefix="BookBot!user@example.test",
event=IRCEvent.BOOK_RESULT,
),
IRCMessage(
raw=_dcc_raw("BookBot", "book.epub", "8.8.8.8"),
prefix="BookBot!user@example.test",
event=IRCEvent.BOOK_RESULT,
),
]
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
assert offer is not None
assert offer.filename == "book.epub"
+82
View File
@@ -0,0 +1,82 @@
import socket
import pytest
from shelfmark.release_sources.irc.dcc import (
DCCOffer,
DCCParseError,
DCCSecurityError,
download_dcc,
parse_dcc_send,
safe_dcc_filename,
validate_dcc_endpoint,
)
def test_safe_dcc_filename_allows_plain_filenames() -> None:
assert safe_dcc_filename("results.txt") == "results.txt"
assert safe_dcc_filename("Author - Title.epub") == "Author - Title.epub"
@pytest.mark.parametrize(
"filename",
[
"",
".",
"..",
"../outside.txt",
"/tmp/outside.txt",
r"..\outside.txt",
r"C:\temp\outside.txt",
],
)
def test_safe_dcc_filename_rejects_paths(filename: str) -> None:
with pytest.raises(DCCSecurityError):
safe_dcc_filename(filename)
@pytest.mark.parametrize(
"ip",
[
"127.0.0.1",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"0.0.0.0",
],
)
def test_validate_dcc_endpoint_rejects_non_public_ips(ip: str) -> None:
with pytest.raises(DCCSecurityError):
validate_dcc_endpoint(DCCOffer(filename="book.epub", ip=ip, port=1234, size=1))
@pytest.mark.parametrize("port", [0, 65536])
def test_validate_dcc_endpoint_rejects_invalid_ports(port: int) -> None:
with pytest.raises(DCCSecurityError):
validate_dcc_endpoint(DCCOffer(filename="book.epub", ip="8.8.8.8", port=port, size=1))
def test_validate_dcc_endpoint_allows_public_endpoint() -> None:
validate_dcc_endpoint(DCCOffer(filename="book.epub", ip="8.8.8.8", port=443, size=1))
def test_parse_dcc_send_rejects_out_of_range_ip_integer() -> None:
with pytest.raises(DCCParseError):
parse_dcc_send('DCC SEND "book.epub" 999999999999999999 443 1')
def test_download_dcc_rejects_private_endpoint_before_socket_connect(
monkeypatch,
tmp_path,
) -> None:
def fail_socket(*_args: object, **_kwargs: object) -> socket.socket:
raise AssertionError("download should reject the endpoint before opening a socket")
monkeypatch.setattr(socket, "socket", fail_socket)
with pytest.raises(DCCSecurityError):
download_dcc(
DCCOffer(filename="book.epub", ip="127.0.0.1", port=1234, size=1),
tmp_path / "book.epub",
)
+53
View File
@@ -1,6 +1,59 @@
import requests
from shelfmark.core.cache import get_metadata_cache
from shelfmark.metadata_providers import MetadataSearchOptions
from shelfmark.metadata_providers.googlebooks import GoogleBooksProvider
class _GoogleBooksResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class _FlakyGoogleBooksSession:
def __init__(self):
self.calls = 0
def get(self, *args, **kwargs):
self.calls += 1
if self.calls == 1:
raise requests.Timeout
return _GoogleBooksResponse(
{
"items": [
{
"id": "volume-1",
"volumeInfo": {
"title": "Recovered Book",
"authors": ["Alice Author"],
},
}
]
}
)
def test_googlebooks_search_does_not_cache_request_failures():
get_metadata_cache().clear()
provider = GoogleBooksProvider(api_key="test-key")
session = _FlakyGoogleBooksSession()
provider.session = session
options = MetadataSearchOptions(query="Recovered Book")
assert provider.search(options) == []
result = provider.search(options)
assert session.calls == 2
assert [book.title for book in result] == ["Recovered Book"]
class TestGoogleBooksParseVolume:
def test_parse_volume_returns_metadata_for_valid_payload(self):
provider = GoogleBooksProvider(api_key="test-key")
+22
View File
@@ -96,6 +96,28 @@ class TestGetDownloadUrl:
class TestHandlerErrors:
def test_cache_miss_uses_persisted_retry_fields(self):
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
handler = NewznabHandler()
task = DownloadTask(
task_id="retryable",
source="newznab",
title="Book",
retry_download_url="https://indexer.example.com/nzb/42?apikey=secret",
retry_download_protocol="usenet",
retry_release_name="Book Release",
retry_expected_hash="abc123",
)
recorder = ProgressRecorder()
result = handler._resolve_download(task, recorder.status_callback)
assert result is not None
assert result.url == "https://indexer.example.com/nzb/42?apikey=secret"
assert result.protocol == "usenet"
assert result.release_name == "Book Release"
assert result.expected_hash == "abc123"
assert recorder.status_updates == []
def test_cache_miss_returns_error(self):
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
handler = NewznabHandler()
+44 -1
View File
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock
import pytest
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import ReleaseProtocol
@@ -13,6 +15,20 @@ from shelfmark.release_sources.newznab.source import (
# ── fixtures / helpers ─────────────────────────────────────────────────────────
class _AvailableSource:
display_name = "Newznab"
def is_available(self):
return True
@pytest.fixture(autouse=True)
def source_available_by_default(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
def _make_book(**kwargs) -> BookMetadata:
defaults = {
"provider": "hardcover",
@@ -60,7 +76,7 @@ class TestResultToRelease:
assert r.size_bytes == 2097152
assert r.indexer == "MyIndexer"
assert r.source_id == "https://indexer.example.com/nzb/42"
assert r.download_url == "https://indexer.example.com/nzb/42?apikey=secret"
assert r.download_url is None
def test_torrent_result_has_torrent_protocol(self):
r = _newznab_result_to_release(
@@ -134,6 +150,33 @@ class TestResultToRelease:
assert r.extra["book_title"] == "Dune"
assert r.extra["info_hash"] == "abc123"
def test_redacted_result_still_builds_private_retry_payload(self, monkeypatch):
import shelfmark.download.orchestrator as orchestrator
secret_download_url = "https://indexer.example.com/nzb/42?apikey=secret"
release = _newznab_result_to_release(_make_result(downloadUrl=secret_download_url))
assert release.download_url is None
captured_tasks = []
def fake_add(task):
captured_tasks.append(task)
return True
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(
orchestrator.config, "get", lambda key, default=None, user_id=None: default
)
success, error = orchestrator.queue_release(release.__dict__)
assert success is True
assert error is None
assert captured_tasks[0].retry_download_url == secret_download_url
assert captured_tasks[0].retry_download_protocol == "usenet"
assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0])
# ── NewznabSource.is_available ─────────────────────────────────────────────────
+53 -16
View File
@@ -236,7 +236,7 @@ class TestProwlarrHandlerDownloadErrors:
class TestProwlarrHandlerSeedCriteria:
"""Tests for seed criteria passed through from Prowlarr."""
def test_resolve_download_converts_seed_time_seconds_to_minutes(self):
def test_resolve_download_ignores_torznab_minimum_seed_criteria(self):
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
@@ -257,22 +257,28 @@ class TestProwlarrHandlerSeedCriteria:
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.seeding_time_limit == 4320
assert request.ratio_limit == 1.0
assert request.seeding_time_limit is None
assert request.ratio_limit is None
def test_resolve_download_rounds_seed_time_up_to_next_minute(self):
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"minimumSeedTime": 61,
},
def test_resolve_download_uses_configured_seed_time_minutes(self):
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"configuredSeedTimeMinutes": 7200,
"configuredRatioLimit": 2,
"minimumSeedTime": 259200,
"minimumRatio": 1,
},
),
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="seed-time-round-up",
task_id="configured-seed-time",
source="prowlarr",
title="Test Book",
)
@@ -280,7 +286,35 @@ class TestProwlarrHandlerSeedCriteria:
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.seeding_time_limit == 2
assert request.seeding_time_limit == 7200
assert request.ratio_limit == 2.0
def test_resolve_download_ignores_configured_seed_time_when_disabled(self):
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"configuredSeedTimeMinutes": 7200,
"configuredRatioLimit": 2,
},
),
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=False),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="configured-seed-time-disabled",
source="prowlarr",
title="Test Book",
)
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.seeding_time_limit is None
assert request.ratio_limit is None
def test_download_passes_seed_limits_to_client(self):
mock_client = MagicMock()
@@ -295,8 +329,10 @@ class TestProwlarrHandlerSeedCriteria:
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"configuredSeedTimeMinutes": 7200,
"configuredRatioLimit": 1.25,
"minimumSeedTime": 259200,
"minimumRatio": 1.25,
"minimumRatio": 1,
},
),
patch(
@@ -306,6 +342,7 @@ class TestProwlarrHandlerSeedCriteria:
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
patch.object(
ProwlarrHandler,
"_poll_and_complete",
@@ -329,7 +366,7 @@ class TestProwlarrHandlerSeedCriteria:
)
call_kwargs = mock_client.add_download.call_args.kwargs
assert call_kwargs["seeding_time_limit"] == 4320
assert call_kwargs["seeding_time_limit"] == 7200
assert call_kwargs["ratio_limit"] == 1.25
+152
View File
@@ -406,6 +406,158 @@ class TestQBittorrentClientGetStatus:
assert status.complete is True
assert status.file_path == "/downloads/Some Torrent"
@pytest.mark.parametrize(
"file_name",
["/escape/book.epub", "../escape/book.epub", "C:\\escape\\book.epub"],
)
def test_get_status_falls_back_after_unsafe_derived_file_path(self, monkeypatch, file_name):
"""Unsafe file-derived paths do not block the safe legacy fallback."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_torrent = MockTorrent(
hash_val="abc123",
progress=1.0,
state="uploading",
content_path="/downloads",
name="Some Torrent",
)
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
def response(kind: str):
r = MagicMock()
r.status_code = 200
r.raise_for_status = MagicMock()
if kind == "info":
r.json.return_value = [info_payload]
elif kind == "properties":
r.json.return_value = {"save_path": "/downloads"}
elif kind == "files":
r.json.return_value = [{"name": file_name}]
else:
raise AssertionError("unknown")
return r
mock_client_instance = MagicMock()
def get_side_effect(url, params=None, timeout=None):
if url.endswith("/api/v2/torrents/info"):
return response("info")
if url.endswith("/api/v2/torrents/properties"):
return response("properties")
if url.endswith("/api/v2/torrents/files"):
return response("files")
raise AssertionError(f"unexpected url: {url}")
mock_client_instance._session.get.side_effect = get_side_effect
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
status = client.get_status("abc123")
path = client.get_download_path("abc123")
assert status.complete is True
assert status.file_path == "/downloads/Some Torrent"
assert path == "/downloads/Some Torrent"
@pytest.mark.parametrize("torrent_name", ["/escape", "../escape", "C:\\escape"])
def test_get_status_rejects_unsafe_legacy_name_path(self, monkeypatch, torrent_name):
"""Legacy save_path/name fallback must reject absolute and traversal names."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_torrent = MockTorrent(
hash_val="abc123",
progress=1.0,
state="uploading",
content_path="/downloads",
name=torrent_name,
)
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
mock_client_instance = MagicMock()
mock_client_instance._session.get.side_effect = [
create_mock_session_response([info_payload], status_code=200),
create_mock_session_response([], status_code=404),
]
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
status = client.get_status("abc123")
assert status.complete is True
assert status.file_path is None
def test_get_status_accepts_safe_legacy_name_path(self, monkeypatch):
"""Normal relative torrent names still build under save_path."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_torrent = MockTorrent(
hash_val="abc123",
progress=1.0,
state="uploading",
content_path="/downloads",
name="Some Torrent",
)
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
mock_client_instance = MagicMock()
mock_client_instance._session.get.side_effect = [
create_mock_session_response([info_payload], status_code=200),
create_mock_session_response([], status_code=404),
]
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
status = client.get_status("abc123")
assert status.complete is True
assert status.file_path == "/downloads/Some Torrent"
def test_get_status_not_found(self, monkeypatch):
"""Test status for non-existent torrent."""
config_values = {
@@ -410,3 +410,83 @@ def test_windows_path_case_insensitive_matching():
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_resolve_download_path_rejects_unsafe_mapping_remainder_even_if_original_exists(
tmp_path,
):
remote_dir = tmp_path / "remote" / "downloads"
local_dir = tmp_path / "local" / "downloads"
escaped_file = tmp_path / "escape" / "book.epub"
remote_dir.mkdir(parents=True)
local_dir.mkdir(parents=True)
escaped_file.parent.mkdir(parents=True)
escaped_file.write_text("escaped content")
raw_path = f"{remote_dir}/../../escape/book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = raw_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with patch("shelfmark.download.clients.base_handler.config.get", side_effect=config_get):
handler = ProwlarrHandler()
resolved_path, error = handler._resolve_download_path_once(
mock_client,
"download_id",
log_details=False,
)
assert Path(raw_path).exists()
assert resolved_path is None
assert error is not None
assert "rejected unsafe path" in error
def test_delete_local_download_data_skips_unsafe_mapping_remainder_even_if_original_exists(
tmp_path,
):
remote_dir = tmp_path / "remote" / "downloads"
local_dir = tmp_path / "local" / "downloads"
escaped_file = tmp_path / "escape" / "book.epub"
remote_dir.mkdir(parents=True)
local_dir.mkdir(parents=True)
escaped_file.parent.mkdir(parents=True)
escaped_file.write_text("escaped content")
raw_path = f"{remote_dir}/../../escape/book.epub"
mock_client = MagicMock()
mock_client.name = "nzbget"
mock_client.get_download_path.return_value = raw_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "nzbget",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with patch("shelfmark.download.clients.base_handler.config.get", side_effect=config_get):
handler = ProwlarrHandler()
handler._delete_local_download_data(mock_client, "download_id")
assert Path(raw_path).exists()
assert escaped_file.exists()
assert escaped_file.read_text() == "escaped content"
+34
View File
@@ -476,6 +476,7 @@ class TestSABnzbdClientAddDownload:
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "books",
"PROWLARR_URL": "https://example.com",
}
monkeypatch.setattr(
"shelfmark.download.clients.sabnzbd.config.get",
@@ -512,6 +513,7 @@ class TestSABnzbdClientAddDownload:
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "books",
"PROWLARR_URL": "https://example.com",
}
monkeypatch.setattr(
"shelfmark.download.clients.sabnzbd.config.get",
@@ -551,6 +553,7 @@ class TestSABnzbdClientAddDownload:
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "books",
"PROWLARR_URL": "https://example.com",
}
monkeypatch.setattr(
"shelfmark.download.clients.sabnzbd.config.get",
@@ -592,6 +595,7 @@ class TestSABnzbdClientAddDownload:
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "books",
"PROWLARR_URL": "https://example.com",
}
monkeypatch.setattr(
"shelfmark.download.clients.sabnzbd.config.get",
@@ -620,6 +624,36 @@ class TestSABnzbdClientAddDownload:
assert result == "SABnzbd_nzo_fallback"
assert mock_api_call.call_args[0][0] == "addurl"
def test_add_download_does_not_prefetch_untrusted_nzb_url(self, monkeypatch):
"""Untrusted NZB URLs should be handed to SABnzbd without backend prefetch."""
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "books",
"PROWLARR_URL": "https://prowlarr.example",
}
monkeypatch.setattr(
"shelfmark.download.clients.sabnzbd.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_response = MagicMock()
mock_response.json.return_value = {"status": True, "nzo_ids": ["SABnzbd_nzo_addurl"]}
with patch(
"shelfmark.download.clients.sabnzbd.requests.get",
return_value=mock_response,
) as mock_get:
from shelfmark.download.clients.sabnzbd import SABnzbdClient
client = SABnzbdClient()
result = client.add_download("https://attacker.example/download.nzb", "Test Book")
assert result == "SABnzbd_nzo_addurl"
called_urls = [call.args[0] for call in mock_get.call_args_list]
assert "https://attacker.example/download.nzb" not in called_urls
assert called_urls == ["http://localhost:8080/api"]
class TestSABnzbdClientRemove:
"""Tests for SABnzbdClient.remove()."""
+214 -2
View File
@@ -5,7 +5,10 @@ Tests the utility functions for parsing release metadata.
"""
# Import the functions to test
import pytest
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
from shelfmark.release_sources.prowlarr.source import (
ProwlarrSource,
_detect_content_type_from_categories,
@@ -15,6 +18,20 @@ from shelfmark.release_sources.prowlarr.source import (
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
class _AvailableSource:
display_name = "Prowlarr"
def is_available(self):
return True
@pytest.fixture(autouse=True)
def source_available_by_default(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
class TestParseSize:
"""Tests for the _parse_size function."""
@@ -210,9 +227,12 @@ class TestDetectContentType:
class FakeTorznabClient:
def __init__(self):
def __init__(self, search_results=None, seed_settings=None):
self.calls: list[tuple[str, object]] = []
self.queries: list[str] = []
self.seed_settings_calls: list[object] = []
self.search_results = search_results or []
self.seed_settings = seed_settings or {}
def get_enabled_indexers_detailed(self):
return [
@@ -241,12 +261,47 @@ class FakeTorznabClient:
del indexer_id, search_type, limit, offset
self.calls.append((query, categories))
self.queries.append(query)
return []
return self.search_results
def get_enriched_indexer_ids(self, restrict_to=None):
del restrict_to
return []
def get_indexer_seed_settings(self, restrict_to=None):
self.seed_settings_calls.append(restrict_to)
return self.seed_settings
class TestProwlarrIndexerSeedSettings:
def test_get_indexer_seed_settings_reads_prowlarr_minutes_field(self, monkeypatch):
client = ProwlarrClient("http://prowlarr:9696", "apikey")
monkeypatch.setattr(
client,
"get_enabled_indexers_detailed",
lambda: [
{
"id": 13,
"protocol": "torrent",
"fields": [
{"name": "torrentBaseSettings.seedRatio", "value": "2.5"},
{"name": "torrentBaseSettings.seedTime", "value": "7200"},
],
},
{
"id": 14,
"protocol": "usenet",
"fields": [
{"name": "torrentBaseSettings.seedRatio", "value": "3"},
{"name": "torrentBaseSettings.seedTime", "value": "9999"},
],
},
],
)
assert client.get_indexer_seed_settings() == {
13: {"ratio_limit": 2.5, "seeding_time_limit_minutes": 7200}
}
class TestProwlarrLocalizedQueries:
def test_manual_query_still_applies_content_type_categories(self, monkeypatch):
@@ -309,6 +364,163 @@ class TestProwlarrLocalizedQueries:
assert fake_client.calls == [("my custom", None)]
def test_search_attaches_configured_seed_time_minutes_to_release(self, monkeypatch):
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
"PROWLARR_USE_SEED_PREFERENCES": True,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeTorznabClient(
search_results=[
{
"guid": "mam-result-1",
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"indexerId": 1,
"indexer": "MyAnonamouse",
"minimumSeedTime": 259200,
"minimumRatio": 1,
}
],
seed_settings={1: {"ratio_limit": 2.0, "seeding_time_limit_minutes": 7200}},
)
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Anything",
authors=["Someone"],
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en"])
releases = source.search(book, plan, content_type="ebook")
assert len(releases) == 1
assert fake_client.seed_settings_calls == [None]
assert releases[0].extra["configured_ratio_limit"] == 2.0
assert releases[0].extra["configured_seed_time_minutes"] == 7200
assert "minimum_seed_time" not in releases[0].extra
assert "minimum_ratio" not in releases[0].extra
def test_search_ignores_configured_seed_time_when_disabled(self, monkeypatch):
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
"PROWLARR_USE_SEED_PREFERENCES": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeTorznabClient(
search_results=[
{
"guid": "mam-result-1",
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"indexerId": 1,
"indexer": "MyAnonamouse",
}
],
seed_settings={1: {"ratio_limit": 2.0, "seeding_time_limit_minutes": 7200}},
)
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Anything",
authors=["Someone"],
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en"])
releases = source.search(book, plan, content_type="ebook")
assert len(releases) == 1
assert fake_client.seed_settings_calls == []
assert releases[0].extra["configured_ratio_limit"] is None
assert releases[0].extra["configured_seed_time_minutes"] is None
def test_redacted_search_result_still_builds_private_retry_payload(self, monkeypatch):
import shelfmark.download.orchestrator as orchestrator
import shelfmark.release_sources.prowlarr.source as prowlarr_source
secret_download_url = "https://prowlarr.example.com/1/download?apikey=secret"
def fake_get(key: str, default=None, user_id=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
"PROWLARR_USE_SEED_PREFERENCES": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
monkeypatch.setattr(orchestrator.config, "get", fake_get)
fake_client = FakeTorznabClient(
search_results=[
{
"guid": "secret-prowlarr-release",
"protocol": "usenet",
"title": "Secret Bearing Release",
"downloadUrl": secret_download_url,
}
]
)
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Anything",
authors=["Someone"],
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en"])
releases = source.search(book, plan, content_type="ebook")
assert len(releases) == 1
assert releases[0].download_url is None
captured_tasks = []
def fake_add(task):
captured_tasks.append(task)
return True
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
success, error = orchestrator.queue_release(releases[0].__dict__)
assert success is True
assert error is None
assert captured_tasks[0].retry_download_url == secret_download_url
assert captured_tasks[0].retry_download_protocol == "usenet"
assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0])
def test_search_uses_localized_titles_when_available(self, monkeypatch):
import shelfmark.release_sources.prowlarr.source as prowlarr_source
+117
View File
@@ -10,6 +10,7 @@ Tests:
import base64
import hashlib
from unittest.mock import MagicMock
import pytest
@@ -18,6 +19,7 @@ from shelfmark.download.clients.torrent_utils import (
bencode_encode,
extract_hash_from_magnet,
extract_info_hash_from_torrent,
extract_torrent_info,
parse_transmission_url,
)
@@ -356,6 +358,121 @@ class TestExtractInfoHash:
assert extract_info_hash_from_torrent(torrent_bytes) == expected
class TestExtractTorrentInfo:
"""Tests for extracting torrent info from user-supplied URLs."""
def test_does_not_fetch_untrusted_http_torrent_url(self, monkeypatch):
"""Arbitrary HTTP torrent URLs are passed through without backend prefetch."""
expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
monkeypatch.setattr(
"shelfmark.download.clients.torrent_utils.config.get",
lambda key, default="": "",
)
mock_get = MagicMock()
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
result = extract_torrent_info(
"https://attacker.example/book.torrent",
fetch_torrent=True,
expected_hash=expected_hash,
)
assert result.info_hash == expected_hash
assert result.torrent_data is None
assert result.is_magnet is False
mock_get.assert_not_called()
def test_fetches_configured_prowlarr_torrent_url(self, monkeypatch):
"""Configured Prowlarr download URLs can still be prefetched and parsed."""
info_dict = {
b"name": b"trusted.txt",
b"length": 100,
b"piece length": 16384,
b"pieces": b"\x00" * 20,
}
torrent_data = bencode_encode({b"info": info_dict})
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
config_values = {
"PROWLARR_URL": "https://prowlarr.example",
"PROWLARR_API_KEY": "secret",
}
monkeypatch.setattr(
"shelfmark.download.clients.torrent_utils.config.get",
lambda key, default="": config_values.get(key, default),
)
response = MagicMock(status_code=200, content=torrent_data)
response.raise_for_status = MagicMock()
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
result = extract_torrent_info(
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
fetch_torrent=True,
)
assert result.info_hash == expected_hash
assert result.torrent_data == torrent_data
assert result.is_magnet is False
mock_get.assert_called_once()
def test_normalizes_configured_origin_before_trusting_torrent_url(self, monkeypatch):
"""Configured Prowlarr URLs match the same normalization used by the source."""
info_dict = {
b"name": b"trusted.txt",
b"length": 100,
b"piece length": 16384,
b"pieces": b"\x00" * 20,
}
torrent_data = bencode_encode({b"info": info_dict})
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
config_values = {
"PROWLARR_URL": "prowlarr.example:9696/",
"PROWLARR_API_KEY": "secret",
}
monkeypatch.setattr(
"shelfmark.download.clients.torrent_utils.config.get",
lambda key, default="": config_values.get(key, default),
)
response = MagicMock(status_code=200, content=torrent_data)
response.raise_for_status = MagicMock()
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
result = extract_torrent_info(
"http://prowlarr.example:9696/1/download?apikey=secret&indexer=7",
fetch_torrent=True,
)
assert result.info_hash == expected_hash
assert result.torrent_data == torrent_data
mock_get.assert_called_once()
def test_does_not_follow_trusted_torrent_url_redirect_to_untrusted_host(self, monkeypatch):
"""Trusted HTTP prefetch does not continue through arbitrary redirects."""
expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
monkeypatch.setattr(
"shelfmark.download.clients.torrent_utils.config.get",
lambda key, default="": "https://prowlarr.example" if key == "PROWLARR_URL" else "",
)
response = MagicMock(status_code=302)
response.headers = {"Location": "https://attacker.example/book.torrent"}
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
result = extract_torrent_info(
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
fetch_torrent=True,
expected_hash=expected_hash,
)
assert result.info_hash == expected_hash
assert result.torrent_data is None
assert result.is_magnet is False
mock_get.assert_called_once()
class TestExtractHashFromMagnet:
"""Tests for extracting hash from magnet links."""
+18 -6
View File
@@ -155,11 +155,14 @@ wait_for_tor() {
return 1
}
tor_is_healthy() {
supervisorctl status tor | grep -q "RUNNING" &&
grep -q "Bootstrapped 100%" /var/log/tor/notices.log 2>/dev/null
}
FAIL_COUNT=0
while true; do
# Try to resolve/connect to google.com (timeout 10s)
if curl -s --head --max-time 10 https://google.com > /dev/null; then
# Success
if tor_is_healthy; then
FAIL_COUNT=0
else
FAIL_COUNT=$((FAIL_COUNT+1))
@@ -213,20 +216,29 @@ echo "[*] Setting up iptables rules..."
iptables -F
iptables -t nat -F
TOR_UID=$(id -u debian-tor)
# Allow loopback
iptables -t nat -A OUTPUT -o lo -j RETURN
# Redirect all TCP to Tor's TransPort
iptables -t nat -A OUTPUT -p tcp --syn -j REDIRECT --to-ports 9040
# Allow Tor itself to reach the network
iptables -t nat -A OUTPUT -m owner --uid-owner "$TOR_UID" -j RETURN
# For UDP DNS queries
iptables -t nat -A OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j DNAT --to-destination 127.0.0.1:53
# For TCP DNS queries (some DNS queries may use TCP)
iptables -t nat -A OUTPUT -p tcp --dport 53 ! -d 127.0.0.1 -j DNAT --to-destination 127.0.0.1:53
# Bypass Tor for local/private networks
iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN
iptables -t nat -A OUTPUT -d 10.0.0.0/8 -j RETURN
iptables -t nat -A OUTPUT -d 172.16.0.0/12 -j RETURN
iptables -t nat -A OUTPUT -d 192.168.0.0/16 -j RETURN
# Redirect all TCP to Tor's TransPort
iptables -t nat -A OUTPUT -p tcp --syn -j REDIRECT --to-ports 9040
echo "[✓] Transparent Tor routing enabled."
sleep 5
Generated
+68 -68
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.14"
[[package]]
name = "apprise"
version = "1.9.9"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -15,9 +15,9 @@ dependencies = [
{ name = "requests-oauthlib" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822, upload-time = "2026-03-21T17:49:14.041Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/74/9c16829d3e7e45ce7daf1b704687fa4fde7ea00d72eafe8de18c72bf5995/apprise-1.10.0.tar.gz", hash = "sha256:b768f32d99e45ed5f4c3eef1f67903e803c97f97ba61a531a5d0a45d40df90a8", size = 2188611, upload-time = "2026-04-26T14:23:51.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940, upload-time = "2026-03-21T17:49:11.847Z" },
{ url = "https://files.pythonhosted.org/packages/69/f9/177a73589d34e676d10bc4c6a8328710e28af5907234e9f25bb149a04eec/apprise-1.10.0-py3-none-any.whl", hash = "sha256:e685303d3568bb7a057d6ddeafd27ee12fff183ca36483ad4bacc0b9b4efa82c", size = 1632292, upload-time = "2026-04-26T14:23:49.28Z" },
]
[[package]]
@@ -31,15 +31,15 @@ wheels = [
[[package]]
name = "authlib"
version = "1.7.0"
version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" }
sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" },
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
]
[[package]]
@@ -101,11 +101,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.2.25"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
@@ -498,11 +498,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
@@ -666,11 +666,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.1"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
@@ -720,11 +720,11 @@ wheels = [
[[package]]
name = "pip"
version = "26.0.1"
version = "26.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
{ url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" },
]
[[package]]
@@ -747,26 +747,26 @@ wheels = [
[[package]]
name = "prek"
version = "0.3.10"
version = "0.3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/31/3e68cd8f45cb5f3f81009ff42d3a3cde0887b83f214a4eb0ded9ae239cc2/prek-0.3.10.tar.gz", hash = "sha256:f4e9c533612bbaa9f89eca0e80ab6e59a05b0fd15ca7c6642a35bb303731ad6f", size = 425926, upload-time = "2026-04-21T11:29:37.122Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/59/0a279983f96bd5d538b4975f0a23121082aa3b8560b6649fdf61f8011b07/prek-0.3.13.tar.gz", hash = "sha256:c48586ee3708bfbf3df80121f55583e9a7d0fa166b08172c091fe5971e92a0ac", size = 444848, upload-time = "2026-05-05T18:07:09.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/b7/306d57aaaf503be1ee58929f9e58e812fbde9bea52c1a578e4996d82425a/prek-0.3.10-py3-none-linux_armv6l.whl", hash = "sha256:7250661f003d902b7b601141d64c7015f93eeeeeb1c485f714374582714d7c51", size = 5416913, upload-time = "2026-04-21T11:29:33.716Z" },
{ url = "https://files.pythonhosted.org/packages/93/ec/4b4b60d17c5eb639779a059d7f66a8fff5b38c7cb26c9c5b3a853cc6a9c8/prek-0.3.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9da699368bb11e85fc58b57ae4c1ccc703364b1d95b595bd17a27b27fa6df50d", size = 5784725, upload-time = "2026-04-21T11:29:38.356Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/6d4dcc9446a1630a888e3f105c4775536afe58c20f18833fec8e0d841ac0/prek-0.3.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f245fa9935cee6ea7d5ba1c6e7dd7546ccf6f01fb7d604bb6a344c8e3b49a9c2", size = 5360071, upload-time = "2026-04-21T11:29:10.743Z" },
{ url = "https://files.pythonhosted.org/packages/36/2d/d1f63ca15f4a275fcdc712fd9ef64787abb4ba86f2e79986a26108a5f1e7/prek-0.3.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d321cad394ef4436ed9fce74150076a5eefabe310ed01b3f5735746284b9d046", size = 5615550, upload-time = "2026-04-21T11:29:30.985Z" },
{ url = "https://files.pythonhosted.org/packages/7c/1b/a925050ba30791e50e7690a6dabe33dc08a0133e7efab4b9d91088261d40/prek-0.3.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e80017ecf06a11bf9c8ebcb67d38183e0abf2f18e012ee6a5f91d1b9c065d6", size = 5338159, upload-time = "2026-04-21T11:29:25.314Z" },
{ url = "https://files.pythonhosted.org/packages/56/ec/7d563a333198ead10f9a13fcf72ca910a59135b5d58429ed3b1fff9a0ff9/prek-0.3.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba24b4c8e40cb9ae96d58efa3eb91813cdff4f10b78b79201c93a0dc6b8763e2", size = 5728600, upload-time = "2026-04-21T11:29:23.687Z" },
{ url = "https://files.pythonhosted.org/packages/4c/88/10d493ef308f10321b92bb4e65ab60030b3c5c6304790c678be249e383e3/prek-0.3.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aac1c6f8dabb0a202a27516bf78cbfbe8a05b7cfebe2078ff6d0e12e7c77c7d5", size = 6610055, upload-time = "2026-04-21T11:29:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/c4/14/552b239d99fdd09fd6664b69089359340778f84a9f866a1fecc38b66c811/prek-0.3.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a9a446e178562e2c3cfc71c8f0b21043b571209533a104042b6b9e36662849e", size = 6007394, upload-time = "2026-04-21T11:29:14.466Z" },
{ url = "https://files.pythonhosted.org/packages/21/80/12b9de6b6721a27f57ed633a8864435ec2fa7535cba7f48f3ea4a52ce683/prek-0.3.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d7344b2acb88b52592c0e0f3dd33349f75e6a4813d3bb44385aa8c70084990ca", size = 5616625, upload-time = "2026-04-21T11:29:29.511Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/7235f6b6d65a3e6e036b211a240432bc4f51adfdbbdcc03b5ce60a238a84/prek-0.3.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f2fe5ca3fe797a647243c10c0a9f9c2c27c1d4a0cdcb7f836a0cb8c5c2c603d8", size = 5431581, upload-time = "2026-04-21T11:29:20.399Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/0dd0013c6a9c6b23d070a57fe5c015c40c7b3e84217e4fb1ca9e465768c6/prek-0.3.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:6d39031e4acf7670b905d77ea807e2ce1b6309604d54740f3152d601b71ae5e0", size = 5318252, upload-time = "2026-04-21T11:29:40.34Z" },
{ url = "https://files.pythonhosted.org/packages/74/d7/5407ca904a7f2ffe63b1333e503d2f21ec23eda93e93fb45a491d78e0d76/prek-0.3.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:27865f3134dd566ffd76388a9f0de6ee931336794e32741b8e22c90a61f3fc39", size = 5589312, upload-time = "2026-04-21T11:29:18.3Z" },
{ url = "https://files.pythonhosted.org/packages/e0/05/460b0c498db65e9a2e590b6918aad3395291d8d9f2c080fce7201d12a5de/prek-0.3.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3e6b65338a06add2bc47a86f837607b4705855ade9457f2f50676a7a5a509cd9", size = 6122323, upload-time = "2026-04-21T11:29:12.6Z" },
{ url = "https://files.pythonhosted.org/packages/ca/63/fd715bcd9ed5debd60dc589d6117ab9eaf0ef0b106e0bb976e1bcaa96e29/prek-0.3.10-py3-none-win32.whl", hash = "sha256:e2529515ce81292c938181702dbfc75516efa5bb1d4dc1295e6f8b2655eec881", size = 5115503, upload-time = "2026-04-21T11:29:35.677Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0d/a34f57ca3efa228fa75a976fcf698d5705b5b9a750c8dd1b86857b24f3b0/prek-0.3.10-py3-none-win_amd64.whl", hash = "sha256:0846228fa277a8bdf5a3eeea90eb3ac8eff2bf71900919373f12d142e19c8f8c", size = 5496962, upload-time = "2026-04-21T11:29:16.71Z" },
{ url = "https://files.pythonhosted.org/packages/ea/de/8b9f20f712c21756e52feed95fdacd52f48811a7bc6e3816cd04725b1240/prek-0.3.10-py3-none-win_arm64.whl", hash = "sha256:dedcdb4e5a52ee3e0463c061cabdcb94a7443875053cebfadb8228b3bf917035", size = 5340403, upload-time = "2026-04-21T11:29:22.11Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6a/9baa2bda21dccc2927e952416f6cc23a75eb99c9ed18837164ac2e4a5640/prek-0.3.13-py3-none-linux_armv6l.whl", hash = "sha256:b00d38f01235073c35aa5f48df57fefef45a6cec2ae0884d750345a2c7220370", size = 5506622, upload-time = "2026-05-05T18:06:53.091Z" },
{ url = "https://files.pythonhosted.org/packages/56/77/d44b5d9bdca0879b865f8e47bf84cf5dc9e8b358d029e6d9b83d8809c116/prek-0.3.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0d89ac712c60e34d1550a606ad5fdfb8ad71d44ced8afa2fa5cbc106be4abd9e", size = 5878743, upload-time = "2026-05-05T18:07:23.164Z" },
{ url = "https://files.pythonhosted.org/packages/08/cf/19e8525cde8b3aa12858aca434d1fa653ef3b152da5af11eafc857634dc2/prek-0.3.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f9b5265863d18b5be4ea094fdce4fd6ca61a8c89a70ee3d8ee153b3e0ed6b272", size = 5434909, upload-time = "2026-05-05T18:07:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9a/e5f97194782de4dab622ce09dafb3ebdd2ee4d354a83ac4def7ebeee236c/prek-0.3.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:64b59a1550780af2bba37297c704b17f81d8e9df6288af1fab4017938e33b1db", size = 5697536, upload-time = "2026-05-05T18:07:05.475Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8c/e1f548ffc4b227e4c2b5a9b30f5978a7e0e6dad51305b97a2ba5b2a923e7/prek-0.3.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ce6cd8f114ba9bbdbe97422103fd886101949b1c42e588a7543c4436ead2020", size = 5428160, upload-time = "2026-05-05T18:07:01.489Z" },
{ url = "https://files.pythonhosted.org/packages/8a/44/abd919b00905a32d21dca2cec32c707860cf217da2431b62dd52684b310e/prek-0.3.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc03e924a24d8d961f56195853c8b206cb196be6db4ad8312125dae847d718ac", size = 5827275, upload-time = "2026-05-05T18:07:17.437Z" },
{ url = "https://files.pythonhosted.org/packages/af/ed/cafd2b80d58a83faf8371c6543bd1475a2224242a3294da7f8582f6aa551/prek-0.3.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ca8c526a23873177fb3b92013500b08ef5f8bedc7263f9f3a44dd2f49645a26", size = 6710293, upload-time = "2026-05-05T18:07:10.663Z" },
{ url = "https://files.pythonhosted.org/packages/35/09/52a4a27596b764173a34d74db09356b30faaacb4a1075b75adbc036a0008/prek-0.3.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5bbb175478438a871e3281d2c3c3f067288af73ad81707a9bdebfd769766c7d", size = 6096556, upload-time = "2026-05-05T18:07:19.46Z" },
{ url = "https://files.pythonhosted.org/packages/63/60/80f61729ce6498815d46d5580cf76da2c157c9b6494046183682441a0ea3/prek-0.3.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a65327a014d838341af757dfc05a706d10e8e33f039bc32bb3dbe2fa21c440c0", size = 5693267, upload-time = "2026-05-05T18:07:03.66Z" },
{ url = "https://files.pythonhosted.org/packages/36/9d/c7a663fe70676ffab2e0c6c9a71997a3ccd002ed5bc60b7422a937911af0/prek-0.3.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b6a200843a36a5b0c41764ce7639ccb3471d48b097f1c5e3fc8f034219b42626", size = 5532865, upload-time = "2026-05-05T18:07:15.237Z" },
{ url = "https://files.pythonhosted.org/packages/32/68/506ef5a235536030e16f61e7210474554f6e05f845f27df5877d2dbb1a06/prek-0.3.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:bdacaad8f35f343e063d251211fe34db1de9e5cc591795361ad69a6485202258", size = 5395951, upload-time = "2026-05-05T18:06:55.183Z" },
{ url = "https://files.pythonhosted.org/packages/3e/00/22d7c6db7f43b58f7d015913c12660c9bbc82751cff6cfd8c31993cf30eb/prek-0.3.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f00328f1c520d8fefb910ab0d3c6764ee330d227952baa19b7e3de7242bd8b3b", size = 5681195, upload-time = "2026-05-05T18:07:12.804Z" },
{ url = "https://files.pythonhosted.org/packages/10/e3/fdf9882238796914ddaf11381a9083b374980156200a953324f6c795f34d/prek-0.3.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e5530a867bcf5b172b7513a64e71b06a337d1d184696227ae953845867376b8d", size = 6212085, upload-time = "2026-05-05T18:07:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1d/528759931344b5c7103085798f5fa2e86d27d9410b753a6bcbe7726aa8ba/prek-0.3.13-py3-none-win32.whl", hash = "sha256:326fac2bdce00074ce6c5046b861d310638aee2b9de1ed241ba7eb32bdc83898", size = 5199566, upload-time = "2026-05-05T18:07:21.416Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d0/8715ee837c73314a02767d20652cc312d1b6ff6733fa00f52de2b648bc3a/prek-0.3.13-py3-none-win_amd64.whl", hash = "sha256:841049f89f5ec9f4035299283d11e566ac5a068e3742ead1055ea04f886831fc", size = 5589599, upload-time = "2026-05-05T18:06:57.28Z" },
{ url = "https://files.pythonhosted.org/packages/ff/cf/0af0b15be0ebd82f7e50adee149b05a73533d78cb1b97cb889f0647ebffe/prek-0.3.13-py3-none-win_arm64.whl", hash = "sha256:a9fd74e0aec550c6b8d41076fdcdd6ff121cd7d94d743c1338bd794784e3c775", size = 5419029, upload-time = "2026-05-05T18:06:59.645Z" },
]
[[package]]
@@ -1195,27 +1195,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.11"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]
[[package]]
@@ -1246,7 +1246,7 @@ wheels = [
[[package]]
name = "seleniumbase"
version = "4.48.2"
version = "4.48.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -1309,9 +1309,9 @@ dependencies = [
{ name = "wheel" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/3d/5b14700c7329375144d97bab149647489556da043c454206b64bfff24dd3/seleniumbase-4.48.2.tar.gz", hash = "sha256:593ddaeb7e2d48cf3df4bf366c5eecfad12a34220ac45b5c894eeacb1f485bd1", size = 656769, upload-time = "2026-04-18T02:30:16.732Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/bc/08d186179157ffb214c483da88c061145ac86207829565b320de86d6375e/seleniumbase-4.48.4.tar.gz", hash = "sha256:014b5303914aa18d83467bf1866a3598a35a58cccf039d051e4bd693b217bd18", size = 656881, upload-time = "2026-04-30T19:26:55.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/1b/49ffbaf90a72aa99318845761b0601c1ab3a68e57560fed40bbbbc2164e0/seleniumbase-4.48.2-py3-none-any.whl", hash = "sha256:6e82eccb2aebebb285f3f091a068744b11ee9af644844d71671eab3c3400c00c", size = 663581, upload-time = "2026-04-18T02:30:13.3Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b2/d284f5b740cf4cb605d653c0d3b6792cf1b931d573af274d46bef08bede9/seleniumbase-4.48.4-py3-none-any.whl", hash = "sha256:900763122c82b14372698580b19fb39d3d86e958bffc16c5206f5554f8f7c7ed", size = 663699, upload-time = "2026-04-30T19:26:51.658Z" },
]
[[package]]
@@ -1370,8 +1370,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "apprise", specifier = ">=1.9.0" },
{ name = "authlib", specifier = ">=1.7.0,<1.8" },
{ name = "apprise", specifier = ">=1.10.0" },
{ name = "authlib", specifier = ">=1.7.2,<1.8" },
{ name = "beautifulsoup4" },
{ name = "defusedxml" },
{ name = "dnspython" },
@@ -1390,7 +1390,7 @@ requires-dist = [
{ name = "qbittorrent-api" },
{ name = "rarfile" },
{ name = "requests", extras = ["socks"] },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.48.2" },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.48.4" },
{ name = "tqdm" },
{ name = "transmission-rpc" },
]
@@ -1403,7 +1403,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = "==0.15.11" },
{ name = "ruff", specifier = "==0.15.12" },
{ name = "vulture", specifier = ">=2.14" },
]
@@ -1457,14 +1457,14 @@ wheels = [
[[package]]
name = "tabcompleter"
version = "1.4.0"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/1a/ed3544579628c5709bae6fae2255e94c6982a9ff77d42d8ba59fd2f3b21a/tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814", size = 10431, upload-time = "2024-10-28T00:44:52.665Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/f1/6cc9c5ff8905fa1bd963eedbe11380b48bb980b24ae5ef2aa1c33523db66/tabcompleter-1.4.1.tar.gz", hash = "sha256:8894c3fba38371aef27358225acf9aed89937e6a12999844bd525a424ee380a6", size = 8764, upload-time = "2026-04-28T19:32:49.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b", size = 6725, upload-time = "2024-10-28T00:44:51.267Z" },
{ url = "https://files.pythonhosted.org/packages/cb/fc/8c82be70b8f96d09943360f34cfb2ecdd3035294c51bce4131eeabe56645/tabcompleter-1.4.1-py3-none-any.whl", hash = "sha256:26b5cf330a48f32625b00e1664aa589f67c8e98275b6d9c2b85d19917dac1601", size = 6922, upload-time = "2026-04-28T19:32:48.01Z" },
]
[[package]]
@@ -1614,14 +1614,14 @@ wheels = [
[[package]]
name = "wheel"
version = "0.46.3"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" }
sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" },
]
[[package]]