Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91cbd51b67 | ||
|
|
c80c88676c | ||
|
|
0d271f1f69 | ||
|
|
014fc38b48 | ||
|
|
fdd46852f2 | ||
|
|
a57d081caa |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 854 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.1 MiB |
@@ -12,6 +12,8 @@ services:
|
||||
- SYS_PTRACE
|
||||
environment:
|
||||
DEBUG: true
|
||||
# HIDE_LOCAL_AUTH: true
|
||||
OIDC_AUTO_REDIRECT: true
|
||||
volumes:
|
||||
- ./.local/config:/config
|
||||
- ./.local/books:/books
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# OpenID Connect (OIDC) Authentication
|
||||
|
||||
## Callback URL
|
||||
|
||||
```
|
||||
https://<your-shelfmark-domain>/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
With a subpath (`URL_BASE=/shelfmark/`):
|
||||
|
||||
```
|
||||
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
|
||||
|
||||
## Settings
|
||||
|
||||
Configure in **Settings → Security → Authentication Method → OIDC**.
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Discovery URL | `/.well-known/openid-configuration` endpoint | — |
|
||||
| Client ID | OAuth2 client ID | — |
|
||||
| Client Secret | OAuth2 client secret | — |
|
||||
| Scopes | Scopes to request. The group claim is added automatically when admin group authorization is enabled | `openid email profile` |
|
||||
| Group Claim Name | Claim containing user groups | `groups` |
|
||||
| Admin Group Name | Group granted admin access. Leave empty for database-only roles | — |
|
||||
| Use Admin Group for Authorization | Toggle group-based admin detection | `true` |
|
||||
| Auto-Provision Users | Create accounts on first login | `true` |
|
||||
| Login Button Label | Custom text for the sign-in button | — |
|
||||
|
||||
Use **Test Connection** to verify discovery and client configuration before attempting login.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
These optional environment variables control login page behavior when OIDC is enabled.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Issuer validation failed** — The issuer in the token doesn't match the discovery document. Check your provider's external URL / issuer configuration.
|
||||
- **Callback URL mismatch** — Reverse proxy isn't forwarding `X-Forwarded-Proto` or `X-Forwarded-Host`, so the constructed callback URL doesn't match what's registered in the provider.
|
||||
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Users & Requests
|
||||
|
||||
Configure in **Settings → Users & Requests**.
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
Shelfmark supports four authentication methods, configured in **Settings → Security**.
|
||||
|
||||
### Local
|
||||
|
||||
You create user accounts directly in Shelfmark with a username and password. At least one local admin account must exist before this mode can be enabled.
|
||||
|
||||
### Proxy Authentication
|
||||
|
||||
Your reverse proxy handles authentication and passes the username to Shelfmark via a header (e.g. `Remote-User`). Accounts are created automatically on first sign-in. If a local user with the same username already exists, the proxy identity will be linked to that account rather than creating a duplicate. Admin status can optionally be derived from a groups header.
|
||||
|
||||
### OIDC (OpenID Connect)
|
||||
|
||||
Users sign in through your identity provider. Accounts are created automatically on first login (unless auto-provisioning is disabled, in which case you need to pre-create them). If a local user with a matching verified email already exists, the OIDC identity will be linked to that account on first sign-in. Admin status can optionally be derived from a group claim.
|
||||
|
||||
A local admin account is required as a fallback. See [OIDC](oidc.md) for provider setup.
|
||||
|
||||
### Calibre-Web Database
|
||||
|
||||
User accounts are synced from your Calibre-Web `app.db`. If a local user with a matching email already exists, the CWA identity will be linked to that account. Roles are kept in sync with CWA. Users removed from CWA are cleaned up on the next sync.
|
||||
|
||||
Requires mounting your Calibre-Web `app.db` to `/auth/app.db`.
|
||||
|
||||
## Per-User Settings
|
||||
|
||||
Admins can configure per-user settings by editing a user in the user management panel. Non-admin users can also edit their own settings through **My Account** (accessible from the user menu). Admins control which sections are visible in My Account via the **Visible Self-Settings Sections** option.
|
||||
|
||||
There are three categories of per-user settings:
|
||||
|
||||
### Delivery Preferences
|
||||
|
||||
Override where a user's downloads are sent. Options depend on the global output mode configuration:
|
||||
|
||||
- **Output mode** — Folder, Email (SMTP), or BookLore (API)
|
||||
- **Destination** — A custom folder path for this user's ebook downloads
|
||||
- **Audiobook destination** — A custom folder path for audiobook downloads
|
||||
- **BookLore library/path** — Per-user BookLore target (when using BookLore output mode)
|
||||
- **Email recipient** — Per-user email address (when using Email output mode)
|
||||
|
||||
### Notifications
|
||||
|
||||
Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events.
|
||||
|
||||
### Request Policy (admin-only)
|
||||
|
||||
Admins can override the default ebook/audiobook modes and request rules for individual users. See [Per-User Overrides](#per-user-overrides) below.
|
||||
|
||||
---
|
||||
|
||||
## Requests
|
||||
|
||||
The request system controls whether users can download directly or need admin approval first.
|
||||
|
||||
### Policy Modes
|
||||
|
||||
Each content type (ebook, audiobook) has a default mode that sets the baseline:
|
||||
|
||||
| Mode | Behaviour |
|
||||
|------|-----------|
|
||||
| **Download** | Users download directly, no approval needed |
|
||||
| **Request Release** | Users pick a specific release, then submit it for admin approval |
|
||||
| **Request Book** | Users request the book itself — an admin picks the release and fulfils it |
|
||||
| **Blocked** | No downloads or requests allowed |
|
||||
|
||||
### Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Enable Requests | Master toggle. When off, everyone downloads directly | Off |
|
||||
| Default Ebook Mode | Baseline mode for all ebook sources | Download |
|
||||
| Default Audiobook Mode | Baseline mode for all audiobook sources | Download |
|
||||
| Request Rules | Per-source overrides (see below) | None |
|
||||
| Max Pending Requests Per User | Open request limit per user | 20 |
|
||||
| Allow Notes on Requests | Let users attach a note when submitting | On |
|
||||
|
||||
### Request Rules
|
||||
|
||||
The rules matrix lets you override the mode for specific source + content type combinations. Rules can only be **equal to or more restrictive** than the content-type default — they cannot grant more access than the baseline.
|
||||
|
||||
For example, if the default ebook mode is "Download", a rule can restrict a specific source to "Request Release" or "Blocked", but not the other way around. If no rule matches, the content-type default applies.
|
||||
|
||||
### Per-User Overrides
|
||||
|
||||
Admins can override the default ebook/audiobook modes and request rules for individual users. Per-user rules are overlaid on the global rules, not replacing them.
|
||||
|
||||
### Request Lifecycle
|
||||
|
||||
1. User submits a request (book or release level, depending on the resolved policy mode)
|
||||
2. Request appears in the admin request queue as **pending**
|
||||
3. Admin either **fulfils** (queues a download) or **rejects** the request
|
||||
4. For fulfilled requests, delivery state is tracked through the download pipeline
|
||||
5. If delivery fails, an admin can reopen the request to try a different release
|
||||
6. Users can cancel their own pending requests
|
||||
@@ -1 +0,0 @@
|
||||
../baseline-browser-mapping/dist/cli.js
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "shelfmark",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,463 +0,0 @@
|
||||
# [`baseline-browser-mapping`](https://github.com/web-platform-dx/web-features/packages/baseline-browser-mapping)
|
||||
|
||||
By the [W3C WebDX Community Group](https://www.w3.org/community/webdx/) and contributors.
|
||||
|
||||
`baseline-browser-mapping` provides:
|
||||
|
||||
- An `Array` of browsers compatible with Baseline Widely available and Baseline year feature sets via the [`getCompatibleVersions()` function](#get-baseline-widely-available-browser-versions-or-baseline-year-browser-versions).
|
||||
- An `Array`, `Object` or `CSV` as a string describing the Baseline feature set support of all browser versions included in the module's data set via the [`getAllVersions()` function](#get-data-for-all-browser-versions).
|
||||
|
||||
You can use `baseline-browser-mapping` to help you determine minimum browser version support for your chosen Baseline feature set; or to analyse the level of support for different Baseline feature sets in your site's traffic by joining the data with your analytics data.
|
||||
|
||||
## Install for local development
|
||||
|
||||
To install the package, run:
|
||||
|
||||
`npm install --save-dev baseline-browser-mapping`
|
||||
|
||||
`baseline-browser-mapping` depends on `web-features` and `@mdn/browser-compat-data` for core browser version selection, but the data is pre-packaged and minified. This package checks for updates to those modules and the supported [downstream browsers](#downstream-browsers) on a daily basis and is updated frequently. Consider adding a script to your `package.json` to update `baseline-browser-mapping` and using it as part of your build process to ensure your data is as up to date as possible:
|
||||
|
||||
```javascript
|
||||
"scripts": [
|
||||
"refresh-baseline-browser-mapping": "npm i --save-dev baseline-browser-mapping@latest"
|
||||
]
|
||||
```
|
||||
|
||||
The minimum supported NodeJS version for `baseline-browser-mapping` is v8 in alignment with `browserslist`. For NodeJS versions earlier than v13.2, the [`require('baseline-browser-mapping')`](https://nodejs.org/api/modules.html#requireid) syntax should be used to import the module.
|
||||
|
||||
## Keeping `baseline-browser-mapping` up to date
|
||||
|
||||
If you are only using this module to generate minimum browser versions for Baseline Widely available or Baseline year feature sets, you don't need to update this module frequently, as the backward looking data is reasonably stable.
|
||||
|
||||
However, if you are targeting Newly available, using the [`getAllVersions()`](#get-data-for-all-browser-versions) function or heavily relying on the data for downstream browsers, you should update this module more frequently. If you target a feature cut off date within the last two months and your installed version of `baseline-browser-mapping` has data that is more than 2 months old, you will receive a console warning advising you to update to the latest version when you call `getCompatibleVersions()` or `getAllVersions()`.
|
||||
|
||||
If you want to suppress these warnings you can use the `suppressWarnings: true` option in the configuration object passed to `getCompatibleVersions()` or `getAllVersions()`. Alternatively, you can use the `BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true` environment variable when running your build process. This module also respects the `BROWSERSLIST_IGNORE_OLD_DATA=true` environment variable. Environment variables can also be provided in a `.env` file from Node 20 onwards; however, this module does not load .env files automatically to avoid conflicts with other libraries with different requirements. You will need to use `process.loadEnvFile()` or a library like `dotenv` to load .env files before `baseline-browser-mapping` is called.
|
||||
|
||||
If you want to ensure [reproducible builds](https://www.wikiwand.com/en/articles/Reproducible_builds), we strongly recommend using the `widelyAvailableOnDate` option to fix the Widely available date on a per build basis to ensure dependent tools provide the same output and you do not produce data staleness warnings. If you are using [`browserslist`](https://github.com/browserslist/browserslist) to target Baseline Widely available, consider automatically updating your `browserslist` configuration in `package.json` or `.browserslistrc` to `baseline widely available on {YYYY-MM-DD}` as part of your build process to ensure the same or sufficiently similar list of minimum browsers is reproduced for historical builds.
|
||||
|
||||
## Importing `baseline-browser-mapping`
|
||||
|
||||
This module exposes two functions: `getCompatibleVersions()` and `getAllVersions()`, both which can be imported directly from `baseline-browser-mapping`:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
getCompatibleVersions,
|
||||
getAllVersions,
|
||||
} from "baseline-browser-mapping";
|
||||
```
|
||||
|
||||
If you want to load the script and data directly in a web page without hosting it yourself, consider using a CDN:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import {
|
||||
getCompatibleVersions,
|
||||
getAllVersions,
|
||||
} from "https://cdn.jsdelivr.net/npm/baseline-browser-mapping";
|
||||
</script>
|
||||
```
|
||||
|
||||
## Get Baseline Widely available browser versions or Baseline year browser versions
|
||||
|
||||
To get the current list of minimum browser versions compatible with Baseline Widely available features from the core browser set, call the `getCompatibleVersions()` function:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions();
|
||||
```
|
||||
|
||||
Executed on 7th March 2025, the above code returns the following browser versions:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{ browser: "chrome", version: "105", release_date: "2022-09-02" },
|
||||
{
|
||||
browser: "chrome_android",
|
||||
version: "105",
|
||||
release_date: "2022-09-02",
|
||||
},
|
||||
{ browser: "edge", version: "105", release_date: "2022-09-02" },
|
||||
{ browser: "firefox", version: "104", release_date: "2022-08-23" },
|
||||
{
|
||||
browser: "firefox_android",
|
||||
version: "104",
|
||||
release_date: "2022-08-23",
|
||||
},
|
||||
{ browser: "safari", version: "15.6", release_date: "2022-09-02" },
|
||||
{
|
||||
browser: "safari_ios",
|
||||
version: "15.6",
|
||||
release_date: "2022-09-02",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The minimum versions of each browser are not necessarily the final release before the Widely available cutoff date of `TODAY - 30 MONTHS`. Some earlier versions will have supported the full Widely available feature set.
|
||||
|
||||
### `getCompatibleVersions()` configuration options
|
||||
|
||||
`getCompatibleVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
|
||||
|
||||
```javascript
|
||||
{
|
||||
targetYear: undefined,
|
||||
widelyAvailableOnDate: undefined,
|
||||
includeDownstreamBrowsers: false,
|
||||
listAllCompatibleVersions: false,
|
||||
suppressWarnings: false
|
||||
}
|
||||
```
|
||||
|
||||
#### `targetYear`
|
||||
|
||||
The `targetYear` option returns the minimum browser versions compatible with all **Baseline Newly available** features at the end of the specified calendar year. For example, calling:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
targetYear: 2020,
|
||||
});
|
||||
```
|
||||
|
||||
Returns the following versions:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{ browser: "chrome", version: "87", release_date: "2020-11-19" },
|
||||
{
|
||||
browser: "chrome_android",
|
||||
version: "87",
|
||||
release_date: "2020-11-19",
|
||||
},
|
||||
{ browser: "edge", version: "87", release_date: "2020-11-19" },
|
||||
{ browser: "firefox", version: "83", release_date: "2020-11-17" },
|
||||
{
|
||||
browser: "firefox_android",
|
||||
version: "83",
|
||||
release_date: "2020-11-17",
|
||||
},
|
||||
{ browser: "safari", version: "14", release_date: "2020-09-16" },
|
||||
{ browser: "safari_ios", version: "14", release_date: "2020-09-16" },
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The minimum version of each browser is not necessarily the final version released in that calendar year. In the above example, Firefox 84 was the final version released in 2020; however Firefox 83 supported all of the features that were interoperable at the end of 2020.
|
||||
> [!WARNING]
|
||||
> You cannot use `targetYear` and `widelyAavailableDate` together. Please only use one of these options at a time.
|
||||
|
||||
#### `widelyAvailableOnDate`
|
||||
|
||||
The `widelyAvailableOnDate` option returns the minimum versions compatible with Baseline Widely available on a specified date in the format `YYYY-MM-DD`:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
widelyAvailableOnDate: `2023-04-05`,
|
||||
});
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> This option is useful if you provide a versioned library that targets Baseline Widely available on each version's release date and you need to provide a statement on minimum supported browser versions in your documentation.
|
||||
|
||||
#### `includeDownstreamBrowsers`
|
||||
|
||||
Setting `includeDownstreamBrowsers` to `true` will include browsers outside of the Baseline core browser set where it is possible to map those browsers to an upstream Chromium or Gecko version:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
});
|
||||
```
|
||||
|
||||
For more information on downstream browsers, see [the section on downstream browsers](#downstream-browsers) below.
|
||||
|
||||
#### `includeKaiOS`
|
||||
|
||||
KaiOS is an operating system and app framework based on the Gecko engine from Firefox. KaiOS is based on the Gecko engine and feature support can be derived from the upstream Gecko version that each KaiOS version implements. However KaiOS requires other considerations beyond feature compatibility to ensure a good user experience as it runs on device types that do not have either mouse and keyboard or touch screen input in the way that all the other browsers supported by this module do.
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
includeKaiOS: true,
|
||||
});
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Including KaiOS requires you to include all downstream browsers using the `includeDownstreamBrowsers` option.
|
||||
|
||||
#### `listAllCompatibleVersions`
|
||||
|
||||
Setting `listAllCompatibleVersions` to true will include the minimum versions of each compatible browser, and all the subsequent versions:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
listAllCompatibleVersions: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `suppressWarnings`
|
||||
|
||||
Setting `suppressWarnings` to `true` will suppress the console warning about old data:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
suppressWarnings: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Get data for all browser versions
|
||||
|
||||
You may want to obtain data on all the browser versions available in this module for use in an analytics solution or dashboard. To get details of each browser version's level of Baseline support, call the `getAllVersions()` function:
|
||||
|
||||
```javascript
|
||||
import { getAllVersions } from "baseline-browser-mapping";
|
||||
|
||||
getAllVersions();
|
||||
```
|
||||
|
||||
By default, this function returns an `Array` of `Objects` and excludes downstream browsers:
|
||||
|
||||
```javascript
|
||||
[
|
||||
...
|
||||
{
|
||||
browser: "firefox_android", // Browser name
|
||||
version: "125", // Browser version
|
||||
release_date: "2024-04-16", // Release date
|
||||
year: 2023, // Baseline year feature set the version supports
|
||||
wa_compatible: true // Whether the browser version supports Widely available
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
For browser versions in `@mdn/browser-compat-data` that were released before Baseline can be defined, i.e. Baseline 2015, the `year` property is always the string: `"pre_baseline"`.
|
||||
|
||||
### Understanding which browsers support Newly available features
|
||||
|
||||
You may want to understand which recent browser versions support all Newly available features. You can replace the `wa_compatible` property with a `supports` property using the `useSupport` option:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
useSupports: true,
|
||||
});
|
||||
```
|
||||
|
||||
The `supports` property is optional and has two possible values:
|
||||
|
||||
- `widely` for browser versions that support all Widely available features.
|
||||
- `newly` for browser versions that support all Newly available features.
|
||||
|
||||
Browser versions that do not support Widely or Newly available will not include the `support` property in the `array` or `object` outputs, and in the CSV output, the `support` column will contain an empty string. Browser versions that support all Newly available features also support all Widely available features.
|
||||
|
||||
### `getAllVersions()` Configuration options
|
||||
|
||||
`getAllVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
|
||||
|
||||
```javascript
|
||||
{
|
||||
includeDownstreamBrowsers: false,
|
||||
outputFormat: "array",
|
||||
suppressWarnings: false
|
||||
}
|
||||
```
|
||||
|
||||
#### `includeDownstreamBrowsers` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()`, you can set `includeDownstreamBrowsers` to `true` to include the Chromium and Gecko downstream browsers [listed below](#list-of-downstream-browsers).
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
});
|
||||
```
|
||||
|
||||
Downstream browsers include the same properties as core browsers, as well as the `engine`they use and `engine_version`, for example:
|
||||
|
||||
```javascript
|
||||
[
|
||||
...
|
||||
{
|
||||
browser: "samsunginternet_android",
|
||||
version: "27.0",
|
||||
release_date: "2024-11-06",
|
||||
engine: "Blink",
|
||||
engine_version: "125",
|
||||
year: 2023,
|
||||
supports: "widely"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
#### `includeKaiOS` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()` you can include KaiOS in your output. The same requirement to have `includeDownstreamBrowsers: true` applies.
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
includeKaiOS: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `suppressWarnings` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()`, you can set `suppressWarnings` to `true` to suppress the console warning about old data:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
suppressWarnings: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `outputFormat`
|
||||
|
||||
By default, this function returns an `Array` of `Objects` which can be manipulated in Javascript or output to JSON.
|
||||
|
||||
To return an `Object` that nests keys , set `outputFormat` to `object`:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
outputFormat: "object",
|
||||
});
|
||||
```
|
||||
|
||||
In thise case, `getAllVersions()` returns a nested object with the browser [IDs listed below](#list-of-downstream-browsers) as keys, and versions as keys within them:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"chrome": {
|
||||
"53": {
|
||||
"year": 2016,
|
||||
"release_date": "2016-09-07"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Downstream browsers will include extra fields for `engine` and `engine_versions`
|
||||
|
||||
```javascript
|
||||
{
|
||||
...
|
||||
"webview_android": {
|
||||
"53": {
|
||||
"year": 2016,
|
||||
"release_date": "2016-09-07",
|
||||
"engine": "Blink",
|
||||
"engine_version": "53"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
To return a `String` in CSV format, set `outputFormat` to `csv`:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
outputFormat: "csv",
|
||||
});
|
||||
```
|
||||
|
||||
`getAllVersions` returns a `String` with a header row and comma-separated values for each browser version that you can write to a file or pass to another service. Core browsers will have "NULL" as the value for their `engine` and `engine_version`:
|
||||
|
||||
```csv
|
||||
"browser","version","year","supports","release_date","engine","engine_version"
|
||||
...
|
||||
"chrome","24","pre_baseline","","2013-01-10","NULL","NULL"
|
||||
...
|
||||
"chrome","53","2016","","2016-09-07","NULL","NULL"
|
||||
...
|
||||
"firefox","135","2024","widely","2025-02-04","NULL","NULL"
|
||||
"firefox","136","2024","newly","2025-03-04","NULL","NULL"
|
||||
...
|
||||
"ya_android","20.12","2020","year_only","2020-12-20","Blink","87"
|
||||
...
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The above example uses `"includeDownstreamBrowsers": true`
|
||||
|
||||
### Static resources
|
||||
|
||||
The outputs of `getAllVersions()` are available as JSON or CSV files generated on a daily basis and hosted on GitHub pages:
|
||||
|
||||
- Core browsers only
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions.csv)
|
||||
- Core browsers only, with `supports` property
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array_with_supports.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object_with_supports.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_with_supports.csv)
|
||||
- Including downstream browsers
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions.csv)
|
||||
- Including downstream browsers with `supports` property
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array_with_supports.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object_with_supports.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_with_supports.csv)
|
||||
|
||||
These files are updated on a daily basis.
|
||||
|
||||
## CLI
|
||||
|
||||
`baseline-browser-mapping` includes a command line interface that exposes the same data and options as the `getCompatibleVersions()` function. To learn more about using the CLI, run:
|
||||
|
||||
```sh
|
||||
npx baseline-browser-mapping --help
|
||||
```
|
||||
|
||||
## Downstream browsers
|
||||
|
||||
### Limitations
|
||||
|
||||
The browser versions in this module come from two different sources:
|
||||
|
||||
- MDN's `browser-compat-data` module.
|
||||
- Parsed user agent strings provided by [useragents.io](https://useragents.io/)
|
||||
|
||||
MDN `browser-compat-data` is an authoritative source of information for the browsers it contains. The release dates for the Baseline core browser set and the mapping of downstream browsers to Chromium versions should be considered accurate.
|
||||
|
||||
Browser mappings from useragents.io are provided on a best effort basis. They assume that browser vendors are accurately stating the Chromium version they have implemented. The initial set of version mappings was derived from a bulk export in November 2024. This version was iterated over with a Regex match looking for a major Chrome version and a corresponding version of the browser in question, e.g.:
|
||||
|
||||
`Mozilla/5.0 (Linux; U; Android 10; en-US; STK-L21 Build/HUAWEISTK-L21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/100.0.4896.58 UCBrowser/13.8.2.1324 Mobile Safari/537.36`
|
||||
|
||||
Shows UC Browser Mobile 13.8 implementing Chromium 100, and:
|
||||
|
||||
`Mozilla/5.0 (Linux; arm_64; Android 11; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.123 YaBrowser/24.10.2.123.00 SA/3 Mobile Safari/537.36`
|
||||
|
||||
Shows Yandex Browser Mobile 24.10 implementing Chromium 128. The Chromium version from this string is mapped to the corresponding Chrome version from MDN `browser-compat-data`.
|
||||
|
||||
> [!NOTE]
|
||||
> Where possible, approximate release dates have been included based on useragents.io "first seen" data. useragents.io does not have "first seen" dates prior to June 2020. However, these browsers' Baseline compatibility is determined by their Chromium or Gecko version, so their release dates are more informative than critical.
|
||||
|
||||
This data is updated on a daily basis using a [script](https://github.com/web-platform-dx/web-features/tree/main/scripts/refresh-downstream.ts) triggered by a GitHub [action](https://github.com/web-platform-dx/web-features/tree/main/.github/workflows/refresh_downstream.yml). Useragents.io provides a private API for this module which exposes the last 7 days of newly seen user agents for the currently tracked browsers. If a new major version of one of the tracked browsers is encountered with a Chromium version that meets or exceeds the previous latest version of that browser, it is added to the [src/data/downstream-browsers.json](src/data/downstream-browsers.json) file with the date it was first seen by useragents.io as its release date.
|
||||
|
||||
KaiOS is an exception - its upstream version mappings are handled separately from the other browsers because they happen very infrequently.
|
||||
|
||||
### List of downstream browsers
|
||||
|
||||
| Browser | ID | Core | Source |
|
||||
| --------------------- | ------------------------- | ------- | ------------------------- |
|
||||
| Chrome | `chrome` | `true` | MDN `browser-compat-data` |
|
||||
| Chrome for Android | `chrome_android` | `true` | MDN `browser-compat-data` |
|
||||
| Edge | `edge` | `true` | MDN `browser-compat-data` |
|
||||
| Firefox | `firefox` | `true` | MDN `browser-compat-data` |
|
||||
| Firefox for Android | `firefox_android` | `true` | MDN `browser-compat-data` |
|
||||
| Safari | `safari` | `true` | MDN `browser-compat-data` |
|
||||
| Safari on iOS | `safari_ios` | `true` | MDN `browser-compat-data` |
|
||||
| Opera | `opera` | `false` | MDN `browser-compat-data` |
|
||||
| Opera Android | `opera_android` | `false` | MDN `browser-compat-data` |
|
||||
| Samsung Internet | `samsunginternet_android` | `false` | MDN `browser-compat-data` |
|
||||
| WebView Android | `webview_android` | `false` | MDN `browser-compat-data` |
|
||||
| QQ Browser Mobile | `qq_android` | `false` | useragents.io |
|
||||
| UC Browser Mobile | `uc_android` | `false` | useragents.io |
|
||||
| Yandex Browser Mobile | `ya_android` | `false` | useragents.io |
|
||||
| KaiOS | `kai_os` | `false` | Manual |
|
||||
| Facebook for Android | `facebook_android` | `false` | useragents.io |
|
||||
| Instagram for Android | `instagram_android` | `false` | useragents.io |
|
||||
|
||||
> [!NOTE]
|
||||
> All the non-core browsers currently included implement Chromium or Gecko. Their inclusion in any of the above methods is based on the Baseline feature set supported by the Chromium or Gecko version they implement, not their release date.
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "baseline-browser-mapping",
|
||||
"main": "./dist/index.cjs",
|
||||
"version": "2.9.19",
|
||||
"description": "A library for obtaining browser versions with their maximum supported Baseline feature set and Widely Available status.",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./legacy": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"jsdelivr": "./dist/index.js",
|
||||
"files": [
|
||||
"dist/*",
|
||||
"!dist/scripts/*",
|
||||
"LICENSE.txt",
|
||||
"README.md"
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"fix-cli-permissions": "output=$(npx baseline-browser-mapping 2>&1); path=$(printf '%s\n' \"$output\" | sed -n 's/^.*: \\(.*\\): Permission denied$/\\1/p; t; s/^\\(.*\\): Permission denied$/\\1/p'); if [ -n \"$path\" ]; then echo \"Permission denied for: $path\"; echo \"Removing $path ...\"; rm -rf \"$path\"; else echo \"$output\"; fi",
|
||||
"test:format": "npx prettier --check .",
|
||||
"test:lint": "npx eslint .",
|
||||
"test:jasmine": "npx jasmine",
|
||||
"test:jasmine-browser": "npx jasmine-browser-runner runSpecs --config ./spec/support/jasmine-browser.js",
|
||||
"test": "npm run build && npm run fix-cli-permissions && npm run test:format && npm run test:lint && npm run test:jasmine && npm run test:jasmine-browser",
|
||||
"build": "rm -rf dist; npx prettier . --write; rollup -c; rm -rf ./dist/scripts/expose-data.d.ts ./dist/cli.d.ts",
|
||||
"refresh-downstream": "npx tsx scripts/refresh-downstream.ts",
|
||||
"refresh-static": "npx tsx scripts/refresh-static.ts",
|
||||
"update-data-file": "npx tsx scripts/update-data-file.ts; npx prettier ./src/data/data.js --write",
|
||||
"update-data-dependencies": "npm i @mdn/browser-compat-data@latest web-features@latest -D",
|
||||
"check-data-changes": "git diff --name-only | grep -q '^src/data/data.js$' && echo 'changes-available=TRUE' || echo 'changes-available=FALSE'"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "^7.2.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^12.1.3",
|
||||
"@types/node": "^22.15.17",
|
||||
"eslint-plugin-new-with-error": "^5.0.0",
|
||||
"jasmine": "^5.8.0",
|
||||
"jasmine-browser-runner": "^3.0.0",
|
||||
"jasmine-spec-reporter": "^7.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup": "^4.44.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.35.0",
|
||||
"web-features": "^3.14.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/web-platform-dx/baseline-browser-mapping.git"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
flask>=3.1.0,<3.1.3 # Temporary: Flask 3.1.3 breaks flask-socketio (github.com/miguelgrinberg/Flask-SocketIO/pull/2153)
|
||||
flask
|
||||
flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
|
||||
@@ -11,6 +11,7 @@ from shelfmark.bypass import BypassCancelledException
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.download import network
|
||||
@@ -46,7 +47,8 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
f"{bypasser_url}{bypasser_path}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout},
|
||||
timeout=(CONNECT_TIMEOUT, read_timeout)
|
||||
timeout=(CONNECT_TIMEOUT, read_timeout),
|
||||
verify=get_ssl_verify(bypasser_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
@@ -23,7 +23,7 @@ from shelfmark.config.settings import RECORDING_DIR
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download import network
|
||||
from shelfmark.download.network import get_proxies
|
||||
from shelfmark.download.network import get_proxies, get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -931,7 +931,7 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]:
|
||||
headers['User-Agent'] = stored_ua
|
||||
|
||||
logger.debug(f"Trying request with cached cookies: {url}")
|
||||
response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10))
|
||||
response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10), verify=get_ssl_verify(url))
|
||||
if response.status_code == 200:
|
||||
logger.debug("Cached cookies worked, skipped Chrome bypass")
|
||||
return response.text
|
||||
|
||||
@@ -115,6 +115,8 @@ FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
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"))
|
||||
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -18,6 +18,7 @@ from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
ActionButton,
|
||||
TagListField,
|
||||
CustomComponentField,
|
||||
)
|
||||
from shelfmark.core.user_db import sync_builtin_admin_user
|
||||
|
||||
@@ -59,9 +60,10 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return on_save_security(values)
|
||||
|
||||
|
||||
def _test_oidc_connection() -> Dict[str, Any]:
|
||||
def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
return test_oidc_connection(
|
||||
load_security_config=lambda: load_config_file("security"),
|
||||
current_values=current_values or {},
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
@@ -95,12 +97,18 @@ def security_settings():
|
||||
default="none",
|
||||
env_supported=False,
|
||||
),
|
||||
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"),
|
||||
),
|
||||
ActionButton(
|
||||
key="open_users_tab",
|
||||
label="Go to Users",
|
||||
description="Configure local users and admin access in the Users tab.",
|
||||
style="primary",
|
||||
show_when=_auth_condition("builtin"),
|
||||
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
|
||||
),
|
||||
_auth_ui_field(
|
||||
TextField,
|
||||
@@ -140,6 +148,16 @@ def security_settings():
|
||||
),
|
||||
]
|
||||
|
||||
fields.append(
|
||||
CustomComponentField(
|
||||
key="oidc_callback_url",
|
||||
component="settings_label",
|
||||
label="Callback URL",
|
||||
description="{origin}/api/auth/oidc/callback",
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
|
||||
oidc_specs = [
|
||||
(
|
||||
TextField,
|
||||
@@ -239,6 +257,16 @@ def security_settings():
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
fields.append(
|
||||
CustomComponentField(
|
||||
key="oidc_env_info",
|
||||
component="oidc_env_info",
|
||||
label="Environment-Only Options",
|
||||
description="These options can only be set via environment variables because changing them through the UI could lock you out of the application.",
|
||||
wrap_in_field_wrapper=True,
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ from typing import Any, Callable
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
|
||||
_OIDC_LOCKOUT_MESSAGE = "Create a local admin account first (Users tab) before enabling OIDC. This ensures you can still log in with a password if SSO is unavailable."
|
||||
_OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable."
|
||||
|
||||
|
||||
def _has_local_password_admin() -> bool:
|
||||
@@ -47,17 +48,19 @@ def on_save_security(
|
||||
def test_oidc_connection(
|
||||
*,
|
||||
load_security_config: Callable[[], dict[str, Any]],
|
||||
current_values: dict[str, Any] | None = None,
|
||||
logger: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch and validate the configured OIDC discovery document."""
|
||||
import requests
|
||||
|
||||
try:
|
||||
discovery_url = load_security_config().get("OIDC_DISCOVERY_URL", "")
|
||||
# Prefer the current (unsaved) form value over the saved config
|
||||
discovery_url = (current_values or {}).get("OIDC_DISCOVERY_URL") or load_security_config().get("OIDC_DISCOVERY_URL", "")
|
||||
if not discovery_url:
|
||||
return {"success": False, "message": "Discovery URL is not configured."}
|
||||
|
||||
response = requests.get(discovery_url, timeout=10)
|
||||
response = requests.get(discovery_url, timeout=10, verify=get_ssl_verify(discovery_url))
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
|
||||
|
||||
@@ -473,6 +473,17 @@ def network_settings():
|
||||
tor_overrides_network = tor_enabled # Only override when Tor is actually active
|
||||
|
||||
return [
|
||||
SelectField(
|
||||
key="CERTIFICATE_VALIDATION",
|
||||
label="Certificate Validation",
|
||||
description="Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr).",
|
||||
options=[
|
||||
{"value": "enabled", "label": "Enabled (Recommended)"},
|
||||
{"value": "disabled_local", "label": "Disabled for Local Addresses"},
|
||||
{"value": "disabled", "label": "Disabled"},
|
||||
],
|
||||
default="enabled",
|
||||
),
|
||||
SelectField(
|
||||
key="CUSTOM_DNS",
|
||||
label="DNS Provider",
|
||||
|
||||
@@ -63,6 +63,30 @@ def _emit_activity_event(ws_manager: Any | None, *, room: str, payload: dict[str
|
||||
logger.warning("Failed to emit activity_update event: %s", exc)
|
||||
|
||||
|
||||
def _list_admin_user_ids(user_db: UserDB) -> list[int]:
|
||||
admin_ids: set[int] = set()
|
||||
try:
|
||||
users = user_db.list_users()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to list users while resolving admin dismissal scope: %s", exc)
|
||||
return []
|
||||
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
role = str(user.get("role") or "").strip().lower()
|
||||
if role != "admin":
|
||||
continue
|
||||
try:
|
||||
user_id = int(user.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if user_id > 0:
|
||||
admin_ids.add(user_id)
|
||||
|
||||
return sorted(admin_ids)
|
||||
|
||||
|
||||
def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]:
|
||||
if is_admin:
|
||||
request_rows = user_db.list_requests()
|
||||
@@ -302,9 +326,16 @@ def register_activity_routes(
|
||||
emit_request_updates(updated_requests)
|
||||
request_rows = _list_visible_requests(user_db, is_admin=is_admin, db_user_id=db_user_id)
|
||||
|
||||
if not is_admin and db_user_id is not None:
|
||||
if viewer_db_user_id is not None:
|
||||
owner_user_scope = None if is_admin else db_user_id
|
||||
if not is_admin and owner_user_scope is None:
|
||||
owner_user_scope = viewer_db_user_id
|
||||
try:
|
||||
terminal_rows = activity_service.get_undismissed_terminal_downloads(db_user_id, limit=200)
|
||||
terminal_rows = activity_service.get_undismissed_terminal_downloads(
|
||||
viewer_db_user_id,
|
||||
owner_user_id=owner_user_scope,
|
||||
limit=200,
|
||||
)
|
||||
_merge_terminal_snapshot_backfill(status=status, terminal_rows=terminal_rows)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to merge terminal snapshot backfill rows: %s", exc)
|
||||
@@ -361,26 +392,42 @@ def register_activity_routes(
|
||||
logger.warning("Failed to resolve activity snapshot id for dismiss payload: %s", exc)
|
||||
activity_log_id = None
|
||||
|
||||
item_type = str(data.get("item_type") or "").strip().lower()
|
||||
target_user_ids = [db_user_id]
|
||||
if bool(session.get("is_admin")) and item_type == "request":
|
||||
admin_ids = _list_admin_user_ids(user_db)
|
||||
if db_user_id not in admin_ids:
|
||||
admin_ids.append(db_user_id)
|
||||
target_user_ids = sorted(set(admin_ids))
|
||||
|
||||
dismissal = None
|
||||
try:
|
||||
dismissal = activity_service.dismiss_item(
|
||||
user_id=db_user_id,
|
||||
item_type=data.get("item_type"),
|
||||
item_key=data.get("item_key"),
|
||||
activity_log_id=activity_log_id,
|
||||
)
|
||||
for target_user_id in target_user_ids:
|
||||
target_dismissal = activity_service.dismiss_item(
|
||||
user_id=target_user_id,
|
||||
item_type=data.get("item_type"),
|
||||
item_key=data.get("item_key"),
|
||||
activity_log_id=activity_log_id,
|
||||
)
|
||||
if target_user_id == db_user_id:
|
||||
dismissal = target_dismissal
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{db_user_id}",
|
||||
payload={
|
||||
"kind": "dismiss",
|
||||
"user_id": db_user_id,
|
||||
"item_type": dismissal["item_type"],
|
||||
"item_key": dismissal["item_key"],
|
||||
},
|
||||
)
|
||||
if dismissal is None:
|
||||
return jsonify({"error": "Failed to persist dismissal"}), 500
|
||||
|
||||
for target_user_id in target_user_ids:
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{target_user_id}",
|
||||
payload={
|
||||
"kind": "dismiss",
|
||||
"user_id": target_user_id,
|
||||
"item_type": dismissal["item_type"],
|
||||
"item_key": dismissal["item_key"],
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify({"status": "dismissed", "item": dismissal})
|
||||
|
||||
@@ -427,20 +474,40 @@ def register_activity_routes(
|
||||
normalized_payload["activity_log_id"] = activity_log_id
|
||||
normalized_items.append(normalized_payload)
|
||||
|
||||
request_items = [
|
||||
item
|
||||
for item in normalized_items
|
||||
if str(item.get("item_type") or "").strip().lower() == "request"
|
||||
]
|
||||
actor_is_admin = bool(session.get("is_admin"))
|
||||
target_user_ids = [db_user_id]
|
||||
if actor_is_admin and request_items:
|
||||
admin_ids = _list_admin_user_ids(user_db)
|
||||
if db_user_id not in admin_ids:
|
||||
admin_ids.append(db_user_id)
|
||||
target_user_ids = sorted(set(admin_ids))
|
||||
|
||||
try:
|
||||
dismissed_count = activity_service.dismiss_many(user_id=db_user_id, items=normalized_items)
|
||||
if actor_is_admin and request_items:
|
||||
for target_user_id in target_user_ids:
|
||||
if target_user_id == db_user_id:
|
||||
continue
|
||||
activity_service.dismiss_many(user_id=target_user_id, items=request_items)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{db_user_id}",
|
||||
payload={
|
||||
"kind": "dismiss_many",
|
||||
"user_id": db_user_id,
|
||||
"count": dismissed_count,
|
||||
},
|
||||
)
|
||||
for target_user_id in target_user_ids:
|
||||
target_count = dismissed_count if target_user_id == db_user_id else len(request_items)
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{target_user_id}",
|
||||
payload={
|
||||
"kind": "dismiss_many",
|
||||
"user_id": target_user_id,
|
||||
"count": target_count,
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify({"status": "dismissed", "count": dismissed_count})
|
||||
|
||||
|
||||
@@ -543,9 +543,25 @@ class ActivityService:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_undismissed_terminal_downloads(self, user_id: int, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
"""Return latest undismissed terminal download snapshots for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
def get_undismissed_terminal_downloads(
|
||||
self,
|
||||
viewer_user_id: int,
|
||||
*,
|
||||
owner_user_id: int | None,
|
||||
limit: int = 200,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return latest undismissed terminal download snapshots for a viewer.
|
||||
|
||||
`viewer_user_id` controls which dismissals are applied.
|
||||
`owner_user_id` scopes activity rows to one owner when provided; when
|
||||
omitted, rows across all owners are considered.
|
||||
"""
|
||||
normalized_viewer_user_id = self._coerce_positive_int(viewer_user_id, "viewer_user_id")
|
||||
normalized_owner_user_id = (
|
||||
self._coerce_positive_int(owner_user_id, "owner_user_id")
|
||||
if owner_user_id is not None
|
||||
else None
|
||||
)
|
||||
normalized_limit = max(1, min(int(limit), 500))
|
||||
|
||||
conn = self._connect()
|
||||
@@ -568,14 +584,19 @@ class ActivityService:
|
||||
ON d.user_id = ?
|
||||
AND d.item_type = l.item_type
|
||||
AND d.item_key = l.item_key
|
||||
WHERE l.user_id = ?
|
||||
WHERE (? IS NULL OR l.user_id = ?)
|
||||
AND l.item_type = 'download'
|
||||
AND l.final_status IN ('complete', 'error', 'cancelled')
|
||||
AND d.id IS NULL
|
||||
ORDER BY l.terminal_at DESC, l.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(normalized_user_id, normalized_user_id, normalized_limit * 2),
|
||||
(
|
||||
normalized_viewer_user_id,
|
||||
normalized_owner_user_id,
|
||||
normalized_owner_user_id,
|
||||
normalized_limit * 2,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Dict, Optional, Tuple
|
||||
import requests
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -482,6 +483,7 @@ class ImageCacheService:
|
||||
timeout=(5, 10),
|
||||
headers=FETCH_HEADERS,
|
||||
stream=True,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Business logic remains in oidc_auth.py.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
@@ -18,6 +19,7 @@ from shelfmark.core.oidc_auth import (
|
||||
)
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
oauth = OAuth()
|
||||
@@ -37,16 +39,22 @@ def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_email_verified(claims: dict[str, Any]) -> bool:
|
||||
"""Normalize provider-specific email_verified values into a strict boolean."""
|
||||
value = claims.get("email_verified", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() == "true"
|
||||
def _has_username_or_email(claims: dict[str, Any]) -> bool:
|
||||
"""Return True when claims include a usable username or email."""
|
||||
for key in ("preferred_username", "email"):
|
||||
value = claims.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return 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("/")
|
||||
login_url = f"{script_root}/login" if script_root else "/login"
|
||||
return f"{login_url}?oidc_error={quote(message)}"
|
||||
|
||||
|
||||
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
"""Register and return an OIDC client from the current security config."""
|
||||
config = load_config_file("security")
|
||||
@@ -73,6 +81,11 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
|
||||
scopes.append(group_claim)
|
||||
|
||||
def _ssl_compliance_fix(session, **kwargs):
|
||||
"""Set session.verify based on the Certificate Validation setting."""
|
||||
session.verify = get_ssl_verify(discovery_url)
|
||||
return session
|
||||
|
||||
oauth._clients.pop("shelfmark_idp", None)
|
||||
oauth.register(
|
||||
name="shelfmark_idp",
|
||||
@@ -83,6 +96,7 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
"scope": " ".join(scopes),
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
compliance_fix=_ssl_compliance_fix,
|
||||
overwrite=True,
|
||||
)
|
||||
|
||||
@@ -117,7 +131,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
error = request.args.get("error")
|
||||
if error:
|
||||
logger.warning(f"OIDC callback error from IdP: {error}")
|
||||
return jsonify({"error": "Authentication failed"}), 400
|
||||
return redirect(_login_error_url("Authentication failed"))
|
||||
|
||||
client, config = _get_oidc_client()
|
||||
try:
|
||||
@@ -141,32 +155,31 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
provider_issuer or "<unknown>",
|
||||
)
|
||||
if claim_name == "iss":
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": (
|
||||
"OIDC issuer validation failed. Verify your discovery URL and IdP issuer/"
|
||||
"external URL configuration."
|
||||
)
|
||||
}
|
||||
),
|
||||
400,
|
||||
msg = (
|
||||
"OIDC issuer validation failed. Verify your discovery URL and IdP issuer/"
|
||||
"external URL configuration."
|
||||
)
|
||||
return redirect(_login_error_url(msg))
|
||||
|
||||
return jsonify({"error": f"OIDC token claim validation failed: {claim_name}"}), 400
|
||||
return redirect(_login_error_url(f"OIDC token claim validation failed: {claim_name}"))
|
||||
claims = _normalize_claims(token.get("userinfo"))
|
||||
|
||||
# If userinfo isn't present in token payload, request it explicitly.
|
||||
if not claims:
|
||||
# If userinfo is missing or claims are too sparse, request it explicitly.
|
||||
if not claims or not _has_username_or_email(claims):
|
||||
fetched_claims: dict[str, Any] = {}
|
||||
try:
|
||||
claims = _normalize_claims(client.userinfo(token=token))
|
||||
fetched_claims = _normalize_claims(client.userinfo(token=token))
|
||||
except TypeError:
|
||||
claims = _normalize_claims(client.userinfo())
|
||||
fetched_claims = _normalize_claims(client.userinfo())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch OIDC userinfo: {e}")
|
||||
if fetched_claims:
|
||||
claims = {**claims, **fetched_claims}
|
||||
|
||||
if not claims:
|
||||
raise ValueError("OIDC authentication failed: missing user claims")
|
||||
msg = "OIDC authentication failed: missing user claims"
|
||||
logger.error(msg)
|
||||
return redirect(_login_error_url(msg))
|
||||
|
||||
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
|
||||
admin_group = config.get("OIDC_ADMIN_GROUP", "")
|
||||
@@ -180,7 +193,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")) and _is_email_verified(claims)
|
||||
allow_email_link = bool(user_info.get("email"))
|
||||
user = provision_oidc_user(
|
||||
user_db,
|
||||
user_info,
|
||||
@@ -192,7 +205,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
logger.warning(
|
||||
f"OIDC login rejected: auto-provision disabled for {user_info['username']}"
|
||||
)
|
||||
return jsonify({"error": "Account not found. Contact your administrator."}), 403
|
||||
return redirect(_login_error_url("Account not found. Contact your administrator."))
|
||||
|
||||
session["user_id"] = user["username"]
|
||||
session["is_admin"] = user.get("role") == "admin"
|
||||
@@ -204,7 +217,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
return redirect(_login_error_url(str(e)))
|
||||
except Exception as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
return jsonify({"error": "Authentication failed"}), 500
|
||||
return redirect(_login_error_url("Authentication failed"))
|
||||
|
||||
@@ -495,7 +495,10 @@ def register_request_routes(
|
||||
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK:
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
if requested_level != "book":
|
||||
# Direct search results are already concrete releases, so allow release-level
|
||||
# request payloads even when the policy default is request_book.
|
||||
allow_direct_release_payload = source == "direct_download" and requested_level == "release"
|
||||
if requested_level != "book" and not allow_direct_release_payload:
|
||||
logger.debug(
|
||||
"Request not created for '%s' by %s: policy requires book-level requests",
|
||||
request_title,
|
||||
|
||||
@@ -1092,6 +1092,18 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
):
|
||||
_apply_dns_settings(config_obj)
|
||||
|
||||
# Apply certificate validation changes live (network tab)
|
||||
if (
|
||||
config_obj is not None
|
||||
and tab_name == "network"
|
||||
and "CERTIFICATE_VALIDATION" in values_to_save
|
||||
):
|
||||
try:
|
||||
from shelfmark.download.network import _apply_ssl_warning_suppression
|
||||
_apply_ssl_warning_suppression()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to apply certificate validation setting: {e}")
|
||||
|
||||
# Apply AA mirror settings changes live (mirrors tab)
|
||||
aa_keys = {"AA_BASE_URL", "AA_MIRROR_URLS", "AA_ADDITIONAL_URLS"}
|
||||
if (
|
||||
|
||||
@@ -380,6 +380,7 @@ class UserDB:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("UPDATE download_requests SET reviewed_by = NULL WHERE reviewed_by = ?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
|
||||
@@ -18,6 +18,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
@@ -110,7 +111,7 @@ class DelugeClient(DownloadClient):
|
||||
"params": list(params),
|
||||
}
|
||||
|
||||
response = self._session.post(self._rpc_url, json=payload, timeout=timeout)
|
||||
response = self._session.post(self._rpc_url, json=payload, timeout=timeout, verify=get_ssl_verify(self._rpc_url))
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -79,6 +80,7 @@ class NZBGetClient(DownloadClient):
|
||||
headers={"Content-Type": "application/json"},
|
||||
auth=(self.username, self.password),
|
||||
timeout=30,
|
||||
verify=get_ssl_verify(rpc_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -135,7 +137,7 @@ class NZBGetClient(DownloadClient):
|
||||
try:
|
||||
# Fetch NZB content from the URL (handles Prowlarr proxy redirects)
|
||||
logger.debug(f"Fetching NZB from: {url}")
|
||||
response = requests.get(url, timeout=30)
|
||||
response = requests.get(url, timeout=30, verify=get_ssl_verify(url))
|
||||
response.raise_for_status()
|
||||
nzb_content = base64.b64encode(response.content).decode('ascii')
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""qBittorrent download client for Prowlarr integration."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Tuple
|
||||
|
||||
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
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -137,6 +137,7 @@ class QBittorrentClient(DownloadClient):
|
||||
host=self._base_url,
|
||||
username=config.get("QBITTORRENT_USERNAME", ""),
|
||||
password=config.get("QBITTORRENT_PASSWORD", ""),
|
||||
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
|
||||
)
|
||||
self._category = config.get("QBITTORRENT_CATEGORY", "books")
|
||||
self._download_dir = config.get("QBITTORRENT_DOWNLOAD_DIR", "")
|
||||
@@ -503,37 +504,14 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Centralizes the logic shared by `get_status()` and `get_download_path()`:
|
||||
- accept `content_path` only when it's not equal to `save_path`
|
||||
- when the torrent is complete and both `content_path` and `save_path` are present,
|
||||
prefer a path rooted at `save_path` to avoid races where qBittorrent briefly reports
|
||||
a temp/incomplete `content_path` and then moves the payload
|
||||
- otherwise derive via properties+files
|
||||
- finally fall back to `save_path + name`
|
||||
"""
|
||||
|
||||
torrent_progress = getattr(torrent, "progress", 0.0)
|
||||
try:
|
||||
progress = float(torrent_progress)
|
||||
except (TypeError, ValueError):
|
||||
progress = 0.0
|
||||
|
||||
# Prefer content_path, but treat content_path == save_path as invalid.
|
||||
content_path = getattr(torrent, "content_path", "")
|
||||
save_path = getattr(torrent, "save_path", "")
|
||||
if content_path and (not save_path or str(content_path) != str(save_path)):
|
||||
# When using a temp/incomplete directory, qBittorrent can briefly keep reporting
|
||||
# `content_path` under that temp path right at completion, then move the files
|
||||
# into `save_path`. Returning the temp path can race with that move.
|
||||
if save_path and progress >= 1.0:
|
||||
# Use the basename of content_path under save_path (works for single-file
|
||||
# torrents and multi-file torrents where content_path is a top-level dir).
|
||||
try:
|
||||
content_basename = str(Path(str(content_path)).name)
|
||||
except Exception:
|
||||
content_basename = ""
|
||||
rooted = self._build_path(str(save_path), content_basename)
|
||||
if rooted:
|
||||
return rooted
|
||||
|
||||
return str(content_path)
|
||||
|
||||
download_id = getattr(torrent, "hash", "")
|
||||
|
||||
@@ -4,12 +4,14 @@ rTorrent download client for Prowlarr integration.
|
||||
Uses xmlrpc to communicate with rTorrent's RPC interface.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import ssl
|
||||
from typing import Any, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -22,6 +24,21 @@ from shelfmark.download.clients.torrent_utils import (
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _create_rtorrent_server_proxy(url: str) -> Any:
|
||||
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
|
||||
from xmlrpc.client import SafeTransport, ServerProxy
|
||||
|
||||
verify = get_ssl_verify(url)
|
||||
if url.startswith("https://") and not verify:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
transport = SafeTransport(context=ssl_context)
|
||||
return ServerProxy(url, transport=transport)
|
||||
|
||||
return ServerProxy(url)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class RTorrentClient(DownloadClient):
|
||||
"""rTorrent download client using xmlrpc."""
|
||||
@@ -31,8 +48,6 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize rTorrent client with settings from config."""
|
||||
from xmlrpc.client import ServerProxy
|
||||
|
||||
raw_url = config.get("RTORRENT_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("RTORRENT_URL is required")
|
||||
@@ -50,7 +65,7 @@ class RTorrentClient(DownloadClient):
|
||||
f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
|
||||
)
|
||||
|
||||
self._rpc = ServerProxy(self._base_url)
|
||||
self._rpc = _create_rtorrent_server_proxy(self._base_url)
|
||||
self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "")
|
||||
self._label = config.get("RTORRENT_LABEL", "")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -148,7 +149,7 @@ class SABnzbdClient(DownloadClient):
|
||||
if params:
|
||||
request_params.update(params)
|
||||
|
||||
response = requests.get(api_url, params=request_params, timeout=30)
|
||||
response = requests.get(api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url))
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
@@ -177,7 +178,7 @@ class SABnzbdClient(DownloadClient):
|
||||
}
|
||||
files = {"name": (filename, nzb_content, "application/x-nzb")}
|
||||
|
||||
response = requests.post(api_url, params=request_params, files=files, timeout=30)
|
||||
response = requests.post(api_url, params=request_params, files=files, timeout=30, verify=get_ssl_verify(api_url))
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
@@ -190,7 +191,7 @@ class SABnzbdClient(DownloadClient):
|
||||
def _fetch_nzb_content(self, url: str) -> bytes:
|
||||
"""Fetch NZB content, including Prowlarr auth headers when appropriate."""
|
||||
headers = self._get_prowlarr_headers(url)
|
||||
response = requests.get(url, timeout=30, headers=headers)
|
||||
response = requests.get(url, timeout=30, headers=headers, verify=get_ssl_verify(url))
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Shared download client settings registration."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
@@ -12,10 +13,39 @@ from shelfmark.core.settings_registry import (
|
||||
TagListField,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
|
||||
# ==================== Test Connection Callbacks ====================
|
||||
|
||||
@contextmanager
|
||||
def _transmission_session_verify_override(url: str):
|
||||
"""Ensure transmission-rpc constructor uses the configured TLS verify mode."""
|
||||
verify = get_ssl_verify(url)
|
||||
if verify:
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
import transmission_rpc.client as transmission_rpc_client
|
||||
except Exception:
|
||||
yield
|
||||
return
|
||||
|
||||
original_session_factory = transmission_rpc_client.requests.Session
|
||||
|
||||
def _session_factory(*args: Any, **kwargs: Any) -> Any:
|
||||
session = original_session_factory(*args, **kwargs)
|
||||
session.verify = False
|
||||
return session
|
||||
|
||||
transmission_rpc_client.requests.Session = _session_factory
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
transmission_rpc_client.requests.Session = original_session_factory
|
||||
|
||||
|
||||
def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Test the qBittorrent connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
@@ -36,7 +66,7 @@ def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None
|
||||
if not url:
|
||||
return {"success": False, "message": "qBittorrent URL is invalid"}
|
||||
|
||||
client = Client(host=url, username=username, password=password)
|
||||
client = Client(host=url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url))
|
||||
client.auth_log_in()
|
||||
api_version = client.app.web_api_version
|
||||
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
|
||||
@@ -81,17 +111,25 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non
|
||||
"protocol": protocol,
|
||||
}
|
||||
try:
|
||||
client = Client(**client_kwargs)
|
||||
with _transmission_session_verify_override(url):
|
||||
client = Client(**client_kwargs)
|
||||
except TypeError as e:
|
||||
if "protocol" not in str(e):
|
||||
raise
|
||||
client_kwargs.pop("protocol", None)
|
||||
client = Client(**client_kwargs)
|
||||
with _transmission_session_verify_override(url):
|
||||
client = Client(**client_kwargs)
|
||||
if protocol == "https" and hasattr(client, "protocol"):
|
||||
try:
|
||||
setattr(client, "protocol", protocol)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Keep session verify aligned for subsequent calls beyond constructor bootstrap.
|
||||
http_session = getattr(client, "_http_session", None)
|
||||
if http_session is not None:
|
||||
http_session.verify = get_ssl_verify(url)
|
||||
|
||||
session = client.get_session()
|
||||
version = session.version
|
||||
return {"success": True, "message": f"Connected to Transmission {version}"}
|
||||
@@ -151,7 +189,7 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
|
||||
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
|
||||
payload = {"id": rpc_id, "method": method, "params": list(params)}
|
||||
resp = session.post(rpc_url, json=payload, timeout=15)
|
||||
resp = session.post(rpc_url, json=payload, timeout=15, verify=get_ssl_verify(rpc_url))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("error"):
|
||||
@@ -214,8 +252,9 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Test the rTorrent connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
import ssl
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import ServerProxy
|
||||
from xmlrpc.client import SafeTransport, ServerProxy
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
@@ -236,7 +275,16 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
parsed = urlparse(url)
|
||||
url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
|
||||
|
||||
rpc = ServerProxy(url.rstrip("/"))
|
||||
rpc_url = url.rstrip("/")
|
||||
verify = get_ssl_verify(rpc_url)
|
||||
if rpc_url.startswith("https://") and not verify:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
rpc = ServerProxy(rpc_url, transport=SafeTransport(context=ssl_context))
|
||||
else:
|
||||
rpc = ServerProxy(rpc_url)
|
||||
|
||||
version = rpc.system.client_version()
|
||||
return {"success": True, "message": f"Connected to rTorrent {version}"}
|
||||
except Exception as e:
|
||||
@@ -264,7 +312,7 @@ def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
try:
|
||||
rpc_url = f"{url.rstrip('/')}/jsonrpc"
|
||||
payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1}
|
||||
response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30)
|
||||
response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30, verify=get_ssl_verify(rpc_url))
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if "error" in result and result["error"]:
|
||||
@@ -301,7 +349,7 @@ def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
try:
|
||||
api_url = f"{url.rstrip('/')}/api"
|
||||
params = {"apikey": api_key, "mode": "version", "output": "json"}
|
||||
response = requests.get(api_url, params=params, timeout=30)
|
||||
response = requests.get(api_url, params=params, timeout=30, verify=get_ssl_verify(api_url))
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
version = result.get("version", "unknown")
|
||||
|
||||
@@ -11,6 +11,7 @@ import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -88,7 +89,7 @@ def extract_torrent_info(
|
||||
|
||||
# Use allow_redirects=False to handle magnet link redirects manually
|
||||
# Some indexers redirect download URLs to magnet links
|
||||
resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers)
|
||||
resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers, verify=get_ssl_verify(url))
|
||||
|
||||
# Check if this is a redirect to a magnet link
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
@@ -103,7 +104,7 @@ def extract_torrent_info(
|
||||
)
|
||||
# Not a magnet redirect, follow it manually
|
||||
logger.debug(f"Following redirect to: {redirect_url[:80]}...")
|
||||
resp = requests.get(redirect_url, timeout=30, headers=headers)
|
||||
resp = requests.get(redirect_url, timeout=30, headers=headers, verify=get_ssl_verify(redirect_url))
|
||||
|
||||
resp.raise_for_status()
|
||||
torrent_data = resp.content
|
||||
|
||||
@@ -4,12 +4,14 @@ Transmission download client for Prowlarr integration.
|
||||
Uses the transmission-rpc library to communicate with Transmission's RPC API.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator, Optional, Tuple
|
||||
|
||||
|
||||
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
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -23,6 +25,50 @@ from shelfmark.download.clients.torrent_utils import (
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _transmission_session_verify_override(url: str) -> Iterator[None]:
|
||||
"""Temporarily override transmission-rpc's session factory when verify is disabled.
|
||||
|
||||
transmission-rpc performs an RPC call inside Client.__init__, so verify must be
|
||||
set before the client is constructed.
|
||||
"""
|
||||
verify = get_ssl_verify(url)
|
||||
if verify:
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
import transmission_rpc.client as transmission_rpc_client
|
||||
except Exception:
|
||||
# If internals differ, gracefully fall back to default behavior.
|
||||
yield
|
||||
return
|
||||
|
||||
original_session_factory = transmission_rpc_client.requests.Session
|
||||
|
||||
def _session_factory(*args: Any, **kwargs: Any) -> Any:
|
||||
session = original_session_factory(*args, **kwargs)
|
||||
session.verify = False
|
||||
return session
|
||||
|
||||
transmission_rpc_client.requests.Session = _session_factory
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
transmission_rpc_client.requests.Session = original_session_factory
|
||||
|
||||
|
||||
def _apply_transmission_ssl_verify(client: Any, url: str) -> None:
|
||||
"""Apply global certificate validation policy to transmission-rpc client."""
|
||||
session = getattr(client, "_http_session", None)
|
||||
if session is None:
|
||||
return
|
||||
try:
|
||||
session.verify = get_ssl_verify(url)
|
||||
except Exception as e:
|
||||
logger.debug("Unable to apply Transmission TLS verify setting: %s", e)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class TransmissionClient(DownloadClient):
|
||||
"""Transmission download client using transmission-rpc library."""
|
||||
@@ -57,19 +103,22 @@ class TransmissionClient(DownloadClient):
|
||||
"protocol": protocol,
|
||||
}
|
||||
try:
|
||||
self._client = Client(**client_kwargs)
|
||||
with _transmission_session_verify_override(url):
|
||||
self._client = Client(**client_kwargs)
|
||||
except TypeError as e:
|
||||
# Older transmission-rpc versions may not accept protocol as a kwarg.
|
||||
if "protocol" not in str(e):
|
||||
raise
|
||||
client_kwargs.pop("protocol", None)
|
||||
self._client = Client(**client_kwargs)
|
||||
with _transmission_session_verify_override(url):
|
||||
self._client = Client(**client_kwargs)
|
||||
# Some versions expose protocol as an attribute rather than kwarg.
|
||||
if protocol == "https" and hasattr(self._client, "protocol"):
|
||||
try:
|
||||
setattr(self._client, "protocol", protocol)
|
||||
except Exception:
|
||||
pass
|
||||
_apply_transmission_ssl_verify(self._client, url)
|
||||
self._category = config.get("TRANSMISSION_CATEGORY", "books")
|
||||
self._download_dir = config.get("TRANSMISSION_DOWNLOAD_DIR", "")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from shelfmark.download import network
|
||||
from shelfmark.download.network import get_proxies
|
||||
from shelfmark.download.network import get_proxies, get_ssl_verify
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
@@ -261,6 +261,7 @@ def html_get_page(
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
allow_redirects=allow_redirects,
|
||||
verify=get_ssl_verify(current_url),
|
||||
)
|
||||
|
||||
if is_aa_url and response.is_redirect:
|
||||
@@ -403,7 +404,7 @@ def download_url(
|
||||
logger.info(f"Downloading: {current_url} (attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
|
||||
# Try with CF cookies/UA if available
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
|
||||
response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers, verify=get_ssl_verify(current_url))
|
||||
response.raise_for_status()
|
||||
|
||||
if status_callback:
|
||||
@@ -514,7 +515,7 @@ def _try_resume(
|
||||
cookies = _apply_cf_bypass(url, resume_headers)
|
||||
response = requests.get(
|
||||
url, stream=True, proxies=get_proxies(url), timeout=REQUEST_TIMEOUT,
|
||||
headers=resume_headers, cookies=cookies
|
||||
headers=resume_headers, cookies=cookies, verify=get_ssl_verify(url)
|
||||
)
|
||||
|
||||
# Check resume support
|
||||
|
||||
@@ -90,6 +90,59 @@ def get_proxies(url: str = "") -> dict:
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_ssl_verify(url: str = "") -> bool:
|
||||
"""Return the ``verify`` value for outbound requests based on the
|
||||
CERTIFICATE_VALIDATION setting.
|
||||
|
||||
- ``enabled`` → always ``True``
|
||||
- ``disabled_local`` → ``False`` for local/private addresses, ``True`` otherwise
|
||||
- ``disabled`` → always ``False``
|
||||
"""
|
||||
mode = app_config.get("CERTIFICATE_VALIDATION", "enabled")
|
||||
|
||||
if mode == "disabled":
|
||||
return False
|
||||
|
||||
if mode == "disabled_local" and url:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
hostname = parsed.hostname or ""
|
||||
if hostname and _is_local_address(hostname):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_ssl_warnings_suppressed = False
|
||||
|
||||
|
||||
def _apply_ssl_warning_suppression() -> None:
|
||||
"""Suppress or restore urllib3 InsecureRequestWarning based on the
|
||||
CERTIFICATE_VALIDATION setting.
|
||||
|
||||
Called once at init and again whenever the setting changes via the UI.
|
||||
Only modifies warning filters when the mode is not 'enabled', so the
|
||||
default case is a complete no-op (zero behavioural change for users who
|
||||
never touch the setting).
|
||||
"""
|
||||
global _ssl_warnings_suppressed # noqa: PLW0603
|
||||
import urllib3
|
||||
|
||||
mode = app_config.get("CERTIFICATE_VALIDATION", "enabled")
|
||||
if mode in ("disabled", "disabled_local"):
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
_ssl_warnings_suppressed = True
|
||||
logger.debug("SSL warnings suppressed (certificate validation: %s)", mode)
|
||||
elif _ssl_warnings_suppressed:
|
||||
import warnings
|
||||
warnings.simplefilter("default", urllib3.exceptions.InsecureRequestWarning)
|
||||
_ssl_warnings_suppressed = False
|
||||
logger.debug("SSL warnings restored (certificate validation: enabled)")
|
||||
|
||||
|
||||
# DNS state - authoritative values managed by this module
|
||||
# Other modules should use get_dns_config() to read these
|
||||
CUSTOM_DNS: List[str] = []
|
||||
@@ -418,7 +471,8 @@ class DoHResolver:
|
||||
self.base_url,
|
||||
params=params,
|
||||
proxies=get_proxies(self.base_url),
|
||||
timeout=10 # Increased from 5s to handle slow network conditions
|
||||
timeout=10, # Increased from 5s to handle slow network conditions
|
||||
verify=get_ssl_verify(self.base_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -940,7 +994,7 @@ def _initialize_aa_state() -> None:
|
||||
logger.debug(f"AA_BASE_URL: auto, checking available urls {_aa_urls}")
|
||||
for i, url in enumerate(_aa_urls):
|
||||
try:
|
||||
response = requests.get(url, proxies=get_proxies(url), timeout=3)
|
||||
response = requests.get(url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url))
|
||||
if response.status_code == 200:
|
||||
_current_aa_url_index = i
|
||||
_aa_base_url = url
|
||||
@@ -1036,6 +1090,7 @@ def init(force: bool = False) -> None:
|
||||
try:
|
||||
init_dns(force=force)
|
||||
init_aa(force=force)
|
||||
_apply_ssl_warning_suppression()
|
||||
# Only set flag AFTER work completes successfully
|
||||
_initialized = True
|
||||
except Exception:
|
||||
|
||||
@@ -21,8 +21,9 @@ from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.release_sources.direct_download import SearchUnavailable
|
||||
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
|
||||
from shelfmark.config.env import (
|
||||
BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DEBUG, FLASK_HOST, FLASK_PORT,
|
||||
RELEASE_VERSION, _is_config_dir_writable,
|
||||
BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DEBUG, HIDE_LOCAL_AUTH,
|
||||
FLASK_HOST, FLASK_PORT, OIDC_AUTO_REDIRECT, RELEASE_VERSION,
|
||||
_is_config_dir_writable,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -1596,6 +1597,9 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
|
||||
if auth_mode == "proxy":
|
||||
return jsonify({"error": "Proxy authentication is enabled"}), 401
|
||||
|
||||
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
remember_me = data.get('remember_me', False)
|
||||
@@ -1794,11 +1798,15 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
|
||||
if logout_url:
|
||||
response_data["logout_url"] = logout_url
|
||||
|
||||
# Add custom OIDC button label if configured
|
||||
# Add custom OIDC button label and SSO enforcement flags if configured
|
||||
if auth_mode == "oidc":
|
||||
oidc_button_label = security_config.get("OIDC_BUTTON_LABEL", "")
|
||||
if oidc_button_label:
|
||||
response_data["oidc_button_label"] = oidc_button_label
|
||||
if HIDE_LOCAL_AUTH:
|
||||
response_data["hide_local_auth"] = True
|
||||
if OIDC_AUTO_REDIRECT:
|
||||
response_data["oidc_auto_redirect"] = True
|
||||
|
||||
return jsonify(response_data)
|
||||
except Exception as e:
|
||||
@@ -2035,6 +2043,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
get_provider,
|
||||
is_provider_registered,
|
||||
get_provider_kwargs,
|
||||
@@ -2064,27 +2073,70 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
|
||||
if not provider or not book_id:
|
||||
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
|
||||
|
||||
if not is_provider_registered(provider):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
|
||||
# Direct mode request approvals can open ReleaseModal with provider=direct_download.
|
||||
# In that flow, treat the direct result as release-search context instead of requiring
|
||||
# a metadata provider registration.
|
||||
if provider == "direct_download":
|
||||
direct_book = backend.get_book_info(book_id)
|
||||
if not isinstance(direct_book, dict):
|
||||
return jsonify({"error": "Book not found in direct source"}), 404
|
||||
|
||||
# Get book metadata from provider
|
||||
kwargs = get_provider_kwargs(provider)
|
||||
prov = get_provider(provider, **kwargs)
|
||||
book = prov.get_book(book_id)
|
||||
resolved_title = title_param or str(direct_book.get("title") or "").strip() or "Unknown title"
|
||||
resolved_author = author_param or str(direct_book.get("author") or "").strip()
|
||||
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
|
||||
if not authors and resolved_author:
|
||||
authors = [resolved_author]
|
||||
|
||||
if not book:
|
||||
return jsonify({"error": "Book not found in metadata provider"}), 404
|
||||
raw_publish_year = direct_book.get("year")
|
||||
publish_year = None
|
||||
if isinstance(raw_publish_year, int):
|
||||
publish_year = raw_publish_year
|
||||
elif isinstance(raw_publish_year, str):
|
||||
normalized_year = raw_publish_year.strip()
|
||||
if normalized_year.isdigit():
|
||||
publish_year = int(normalized_year)
|
||||
|
||||
# Override title from frontend if available (search results may have better data)
|
||||
# Note: We intentionally DON'T override authors here - get_book() now returns
|
||||
# filtered authors (primary authors only, excluding translators/narrators),
|
||||
# which gives better release search results than the unfiltered search data
|
||||
if title_param:
|
||||
book.title = title_param
|
||||
book = BookMetadata(
|
||||
provider="direct_download",
|
||||
provider_id=book_id,
|
||||
provider_display_name="Direct Download",
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
authors=authors,
|
||||
cover_url=direct_book.get("preview"),
|
||||
description=direct_book.get("description"),
|
||||
publisher=direct_book.get("publisher"),
|
||||
publish_year=publish_year,
|
||||
language=direct_book.get("language"),
|
||||
source_url=direct_book.get("source_url"),
|
||||
)
|
||||
else:
|
||||
if not is_provider_registered(provider):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
|
||||
|
||||
# Get book metadata from provider
|
||||
kwargs = get_provider_kwargs(provider)
|
||||
prov = get_provider(provider, **kwargs)
|
||||
book = prov.get_book(book_id)
|
||||
|
||||
if not book:
|
||||
return jsonify({"error": "Book not found in metadata provider"}), 404
|
||||
|
||||
# Override title from frontend if available (search results may have better data)
|
||||
# Note: We intentionally DON'T override authors here - get_book() now returns
|
||||
# filtered authors (primary authors only, excluding translators/narrators),
|
||||
# which gives better release search results than the unfiltered search data
|
||||
if title_param:
|
||||
book.title = title_param
|
||||
|
||||
# Determine which release sources to search
|
||||
if source_filter:
|
||||
sources_to_search = [source_filter]
|
||||
elif provider == "direct_download":
|
||||
# Direct mode has no metadata-provider fanout; keep release browsing focused
|
||||
# on Direct Download results (same dataset as legacy direct search).
|
||||
sources_to_search = ["direct_download"]
|
||||
else:
|
||||
# Search only enabled sources
|
||||
sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]]
|
||||
|
||||
@@ -20,6 +20,7 @@ from shelfmark.core.settings_registry import (
|
||||
HeadingField,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
@@ -233,7 +234,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
url = f"{GOOGLE_BOOKS_BASE_URL}{endpoint}"
|
||||
|
||||
try:
|
||||
response = self.session.get(url, params=params, timeout=15)
|
||||
response = self.session.get(url, params=params, timeout=15, verify=get_ssl_verify(url))
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from shelfmark.core.settings_registry import (
|
||||
HeadingField,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
@@ -635,7 +636,8 @@ class HardcoverProvider(MetadataProvider):
|
||||
response = self.session.post(
|
||||
HARDCOVER_API_URL,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=15
|
||||
timeout=15,
|
||||
verify=get_ssl_verify(HARDCOVER_API_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import requests
|
||||
|
||||
from shelfmark.core.cache import cacheable
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
CheckboxField,
|
||||
@@ -188,7 +189,8 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
params=params,
|
||||
timeout=15
|
||||
timeout=15,
|
||||
verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -229,7 +231,8 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/works/{book_id}.json",
|
||||
timeout=15
|
||||
timeout=15,
|
||||
verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
work = response.json()
|
||||
@@ -261,7 +264,8 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
# First try the ISBN API which returns edition data
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/isbn/{clean_isbn}.json",
|
||||
timeout=15
|
||||
timeout=15,
|
||||
verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
edition = response.json()
|
||||
@@ -485,7 +489,8 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}{author_key}.json",
|
||||
timeout=10
|
||||
timeout=10,
|
||||
verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
author = response.json()
|
||||
@@ -504,7 +509,8 @@ def _test_openlibrary_connection() -> Dict[str, Any]:
|
||||
response = provider.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
params={"q": "test", "limit": 1},
|
||||
timeout=10
|
||||
timeout=10,
|
||||
verify=get_ssl_verify(OPENLIBRARY_BASE_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -113,6 +113,13 @@ class LeadingCellConfig:
|
||||
uppercase: bool = False # Force uppercase for badge text
|
||||
|
||||
|
||||
@dataclass
|
||||
class SortOption:
|
||||
"""A sort option that appears in the sort dropdown without being tied to a column."""
|
||||
label: str # Display label in the sort dropdown
|
||||
sort_key: str # Field to sort by on the Release object
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceActionButton:
|
||||
"""Action button configuration for a release source."""
|
||||
@@ -131,6 +138,7 @@ class ReleaseColumnConfig:
|
||||
default_indexers: Optional[List[str]] = None # For Prowlarr: indexers selected in settings (pre-selected in filter)
|
||||
cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min)
|
||||
supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language", "indexer"]
|
||||
extra_sort_options: Optional[List[SortOption]] = None # Additional sort options not tied to a column
|
||||
action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search)
|
||||
|
||||
|
||||
@@ -191,6 +199,13 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
|
||||
if config.supported_filters is not None:
|
||||
result["supported_filters"] = config.supported_filters
|
||||
|
||||
# Include extra sort options (sort entries not tied to a column)
|
||||
if config.extra_sort_options:
|
||||
result["extra_sort_options"] = [
|
||||
{"label": opt.label, "sort_key": opt.sort_key}
|
||||
for opt in config.extra_sort_options
|
||||
]
|
||||
|
||||
# Include action button if specified (replaces default expand search)
|
||||
if config.action_button is not None:
|
||||
result["action_button"] = {
|
||||
|
||||
@@ -164,7 +164,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
|
||||
filters_query = ""
|
||||
|
||||
for value in filters.lang or config.BOOK_LANGUAGE:
|
||||
for value in filters.lang if filters.lang is not None else config.BOOK_LANGUAGE:
|
||||
if value != "all":
|
||||
filters_query += f"&lang={quote(value)}"
|
||||
|
||||
@@ -704,6 +704,7 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None)
|
||||
timeout=(5, 10),
|
||||
allow_redirects=True,
|
||||
proxies=network.get_proxies(link),
|
||||
verify=network.get_ssl_verify(link),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -1172,8 +1173,7 @@ class DirectDownloadSource(ReleaseSource):
|
||||
if isbn:
|
||||
logger.debug(f"Searching direct_download: isbn='{isbn}', langs={lang_filter}")
|
||||
filters = SearchFilters(isbn=[isbn])
|
||||
if lang_filter:
|
||||
filters.lang = lang_filter
|
||||
filters.lang = lang_filter if lang_filter is not None else []
|
||||
try:
|
||||
results = search_books(isbn, filters)
|
||||
if results:
|
||||
@@ -1200,7 +1200,7 @@ class DirectDownloadSource(ReleaseSource):
|
||||
continue
|
||||
|
||||
logger.debug(f"Searching direct_download: title_author='{query}', langs={langs}")
|
||||
filters = SearchFilters(lang=langs) if langs else SearchFilters()
|
||||
filters = SearchFilters(lang=langs if langs is not None else [])
|
||||
try:
|
||||
for bi in search_books(query, filters):
|
||||
if bi.id not in seen_ids:
|
||||
|
||||
@@ -6,6 +6,7 @@ import requests
|
||||
|
||||
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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -42,6 +43,7 @@ class ProwlarrClient:
|
||||
params=params,
|
||||
json=json_data,
|
||||
timeout=self.timeout,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
@@ -193,6 +195,7 @@ class ProwlarrClient:
|
||||
# Override the session default JSON accept header.
|
||||
"Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8"
|
||||
},
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
if not response.ok:
|
||||
try:
|
||||
|
||||
@@ -22,6 +22,7 @@ from shelfmark.release_sources import (
|
||||
ColumnColorHint,
|
||||
LeadingCellConfig,
|
||||
LeadingCellType,
|
||||
SortOption,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
@@ -467,6 +468,9 @@ class ProwlarrSource(ReleaseSource):
|
||||
sort_key="size_bytes",
|
||||
),
|
||||
],
|
||||
extra_sort_options=[
|
||||
SortOption(label="Peers", sort_key="seeders"),
|
||||
],
|
||||
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px",
|
||||
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr
|
||||
available_indexers=available_indexers,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ButtonStateInfo,
|
||||
RequestPolicyMode,
|
||||
CreateRequestPayload,
|
||||
isMetadataBook,
|
||||
} from './types';
|
||||
import {
|
||||
getBookInfo,
|
||||
@@ -150,9 +151,12 @@ function App() {
|
||||
username,
|
||||
displayName,
|
||||
oidcButtonLabel,
|
||||
hideLocalAuth,
|
||||
oidcAutoRedirect,
|
||||
loginError,
|
||||
isLoggingIn,
|
||||
setIsAuthenticated,
|
||||
refreshAuth,
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
} = useAuth({
|
||||
@@ -670,12 +674,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Find Downloads" from DetailsModal
|
||||
const handleFindDownloads = (book: Book) => {
|
||||
setSelectedBook(null);
|
||||
setReleaseBook(book);
|
||||
};
|
||||
|
||||
const submitRequest = useCallback(
|
||||
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -1324,9 +1322,16 @@ function App() {
|
||||
book={selectedBook}
|
||||
onClose={() => setSelectedBook(null)}
|
||||
onDownload={handleDownload}
|
||||
onFindDownloads={handleFindDownloads}
|
||||
onFindDownloads={(book) => {
|
||||
setSelectedBook(null);
|
||||
void handleGetReleases(book);
|
||||
}}
|
||||
onSearchSeries={handleSearchSeries}
|
||||
buttonState={getDirectActionButtonState(selectedBook.id)}
|
||||
buttonState={
|
||||
isMetadataBook(selectedBook)
|
||||
? getUniversalActionButtonState(selectedBook.id)
|
||||
: getDirectActionButtonState(selectedBook.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1351,6 +1356,8 @@ function App() {
|
||||
currentStatus={statusForButtonState}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode}
|
||||
isRequestMode={isBrowseFulfilMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1409,6 +1416,7 @@ function App() {
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onShowToast={showToast}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
onRefreshAuth={refreshAuth}
|
||||
/>
|
||||
|
||||
<SelfSettingsModal
|
||||
@@ -1497,6 +1505,8 @@ function App() {
|
||||
isLoading={isLoggingIn}
|
||||
authMode={authMode}
|
||||
oidcButtonLabel={oidcButtonLabel}
|
||||
hideLocalAuth={hideLocalAuth}
|
||||
oidcAutoRedirect={oidcAutoRedirect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ export const BookDownloadButton = ({
|
||||
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
|
||||
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
|
||||
const isRequestAction = buttonState.state === 'download' && buttonState.text === 'Request';
|
||||
const iconVariantActionIconPath = isRequestAction
|
||||
? 'M12 4.5v15m7.5-7.5h-15'
|
||||
: 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3';
|
||||
const primaryActionIconPath = isRequestAction ? 'M12 4.5v15m7.5-7.5h-15' : 'M12 4v12m0 0l-4-4m4 4 4-4M6 20h12';
|
||||
|
||||
const primaryStateClasses =
|
||||
isCompleted
|
||||
@@ -198,10 +203,10 @@ export const BookDownloadButton = ({
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
@@ -221,7 +226,7 @@ export const BookDownloadButton = ({
|
||||
>
|
||||
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={primaryActionIconPath} />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
|
||||
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
|
||||
const isMetadata = isMetadataBook(book);
|
||||
|
||||
const metadataActionText =
|
||||
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
|
||||
? 'Find Downloads'
|
||||
: buttonState.text;
|
||||
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
|
||||
|
||||
// Build metadata grid based on mode
|
||||
@@ -336,19 +339,21 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Action button - Find Downloads (Universal) or Download (Direct) */}
|
||||
{/* Action button - mirrors search result action state/flow */}
|
||||
<button
|
||||
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
|
||||
disabled={!isMetadata && buttonState.state !== 'download'}
|
||||
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
|
||||
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
? buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
|
||||
}`}
|
||||
>
|
||||
{isMetadata ? 'Find Downloads' : buttonState.text}
|
||||
{isMetadata ? metadataActionText : buttonState.text}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { LoginCredentials } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
@@ -9,6 +10,8 @@ interface LoginFormProps {
|
||||
autoFocus?: boolean;
|
||||
authMode?: string;
|
||||
oidcButtonLabel?: string | null;
|
||||
hideLocalAuth?: boolean;
|
||||
oidcAutoRedirect?: boolean;
|
||||
}
|
||||
|
||||
const EyeIcon = () => (
|
||||
@@ -219,9 +222,13 @@ export const LoginForm = ({
|
||||
autoFocus = true,
|
||||
authMode,
|
||||
oidcButtonLabel,
|
||||
hideLocalAuth = false,
|
||||
oidcAutoRedirect = false,
|
||||
}: LoginFormProps) => {
|
||||
const isOidc = authMode === 'oidc';
|
||||
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const oidcError = searchParams.get('oidc_error');
|
||||
|
||||
// Auto-expand password form if there's an error (likely from a password attempt)
|
||||
useEffect(() => {
|
||||
@@ -230,6 +237,13 @@ export const LoginForm = ({
|
||||
}
|
||||
}, [error, isOidc]);
|
||||
|
||||
// Auto-redirect to OIDC provider when enabled and no errors present
|
||||
useEffect(() => {
|
||||
if (oidcAutoRedirect && isOidc && !error && !oidcError) {
|
||||
window.location.href = withBasePath('/api/auth/oidc/login');
|
||||
}
|
||||
}, [oidcAutoRedirect, isOidc, error, oidcError]);
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@@ -245,11 +259,13 @@ export const LoginForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = oidcError || error;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
{displayError && (
|
||||
<div className="mb-4 p-3 rounded-lg text-sm bg-red-600 text-white">
|
||||
{error}
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -262,22 +278,26 @@ export const LoginForm = ({
|
||||
{oidcButtonLabel || 'Sign in with OIDC'}
|
||||
</a>
|
||||
|
||||
<div className="flex items-center mt-5 mb-2">
|
||||
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswordLogin((prev) => !prev)}
|
||||
className="px-3 text-sm opacity-60 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{showPasswordLogin ? 'Hide' : 'Use password'}
|
||||
</button>
|
||||
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
|
||||
</div>
|
||||
{!hideLocalAuth && (
|
||||
<>
|
||||
<div className="flex items-center mt-5 mb-2">
|
||||
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswordLogin((prev) => !prev)}
|
||||
className="px-3 text-sm opacity-60 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{showPasswordLogin ? 'Hide' : 'Use password'}
|
||||
</button>
|
||||
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
|
||||
</div>
|
||||
|
||||
{showPasswordLogin && (
|
||||
<div className="pt-2">
|
||||
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
|
||||
</div>
|
||||
{showPasswordLogin && (
|
||||
<div className="pt-2">
|
||||
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -24,7 +24,13 @@ import { ReleaseCell } from './ReleaseCell';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { getNestedValue } from '../utils/objectHelpers';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues, releaseLanguageMatchesFilter, buildLanguageNormalizer } from '../utils/languageFilters';
|
||||
import {
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
getLanguageFilterValues,
|
||||
getReleaseSearchLanguageParams,
|
||||
releaseLanguageMatchesFilter,
|
||||
buildLanguageNormalizer,
|
||||
} from '../utils/languageFilters';
|
||||
|
||||
// Module-level cache for release search results
|
||||
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
|
||||
@@ -364,6 +370,8 @@ interface ReleaseModalProps {
|
||||
currentStatus: StatusData;
|
||||
defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download')
|
||||
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
|
||||
defaultShowManualQuery?: boolean;
|
||||
isRequestMode?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -793,11 +801,14 @@ export const ReleaseModal = ({
|
||||
currentStatus,
|
||||
defaultReleaseSource,
|
||||
onSearchSeries,
|
||||
defaultShowManualQuery = false,
|
||||
isRequestMode = false,
|
||||
}: ReleaseModalProps) => {
|
||||
// Use audiobook formats when in audiobook mode
|
||||
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
|
||||
? supportedAudiobookFormats
|
||||
: supportedFormats;
|
||||
const isDirectProviderContext = (book?.provider || '').toLowerCase() === 'direct_download';
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isRequestingBook, setIsRequestingBook] = useState(false);
|
||||
|
||||
@@ -898,6 +909,7 @@ export const ReleaseModal = ({
|
||||
useEffect(() => {
|
||||
setDescriptionExpanded(false);
|
||||
setDescriptionOverflows(false);
|
||||
setShowHeaderThumb(false);
|
||||
setReleasesBySource({});
|
||||
setLoadingBySource({});
|
||||
setErrorBySource({});
|
||||
@@ -906,8 +918,11 @@ export const ReleaseModal = ({
|
||||
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
|
||||
setIndexerFilter([]);
|
||||
indexerFilterInitializedRef.current = new Set();
|
||||
setManualQuery('');
|
||||
setShowManualQuery(false);
|
||||
const baseTitle = book?.search_title || book?.title || '';
|
||||
const baseAuthor = book?.search_author || book?.author || '';
|
||||
const defaultQuery = `${baseTitle} ${baseAuthor}`.trim();
|
||||
setManualQuery(defaultShowManualQuery ? defaultQuery : '');
|
||||
setShowManualQuery(defaultShowManualQuery);
|
||||
setSearchStatus(null);
|
||||
lastStatusTimeRef.current = 0;
|
||||
pendingStatusRef.current = null;
|
||||
@@ -915,7 +930,7 @@ export const ReleaseModal = ({
|
||||
clearTimeout(statusTimeoutRef.current);
|
||||
statusTimeoutRef.current = null;
|
||||
}
|
||||
}, [book?.id]);
|
||||
}, [book?.id, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]);
|
||||
|
||||
// Set up WebSocket listener for search status updates
|
||||
useEffect(() => {
|
||||
@@ -1026,14 +1041,26 @@ export const ReleaseModal = ({
|
||||
try {
|
||||
setSourcesLoading(true);
|
||||
const sources = await getReleaseSources();
|
||||
setAvailableSources(sources);
|
||||
const modalSources = isDirectProviderContext
|
||||
? sources.filter((source) => source.name === 'direct_download')
|
||||
: sources;
|
||||
setAvailableSources(modalSources);
|
||||
|
||||
// Filter sources by content type support
|
||||
const supportedSources = sources.filter(s => {
|
||||
const supportedSources = modalSources.filter(s => {
|
||||
const types = s.supported_content_types || ['ebook', 'audiobook'];
|
||||
return types.includes(contentType);
|
||||
});
|
||||
|
||||
if (isDirectProviderContext) {
|
||||
if (supportedSources.some((source) => source.name === 'direct_download')) {
|
||||
setActiveTab('direct_download');
|
||||
} else {
|
||||
setActiveTab('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set active tab: prefer defaultReleaseSource if enabled and supports content type
|
||||
if (supportedSources.length > 0) {
|
||||
const enabledSources = supportedSources.filter(s => s.enabled);
|
||||
@@ -1071,7 +1098,7 @@ export const ReleaseModal = ({
|
||||
};
|
||||
|
||||
fetchSources();
|
||||
}, [book, defaultReleaseSource, contentType]);
|
||||
}, [book, defaultReleaseSource, contentType, isDirectProviderContext]);
|
||||
|
||||
// Fetch releases when active tab changes (with caching)
|
||||
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
|
||||
@@ -1125,10 +1152,7 @@ export const ReleaseModal = ({
|
||||
|
||||
try {
|
||||
// Resolve language codes for the API call (same logic as Apply button)
|
||||
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
|
||||
? undefined
|
||||
: langCodes;
|
||||
const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
|
||||
|
||||
// Pass indexer filter only if the source supports it (empty array = search all)
|
||||
const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer');
|
||||
@@ -1175,8 +1199,9 @@ export const ReleaseModal = ({
|
||||
|
||||
// Filter to only enabled sources that support this content type
|
||||
availableSources.forEach((src) => {
|
||||
// Skip disabled sources entirely - they won't appear as tabs
|
||||
if (!src.enabled) {
|
||||
const allowDisabledDirectTab = isDirectProviderContext && src.name === 'direct_download';
|
||||
// Skip disabled sources entirely, except direct tab in direct-provider context.
|
||||
if (!src.enabled && !allowDisabledDirectTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1199,7 +1224,7 @@ export const ReleaseModal = ({
|
||||
}
|
||||
|
||||
return enabledTabs;
|
||||
}, [availableSources, defaultReleaseSource, contentType]);
|
||||
}, [availableSources, defaultReleaseSource, contentType, isDirectProviderContext]);
|
||||
|
||||
// Update tab indicator position when active tab changes
|
||||
useEffect(() => {
|
||||
@@ -1289,6 +1314,21 @@ export const ReleaseModal = ({
|
||||
return columnConfig.columns.filter(col => col.sortable) || [];
|
||||
}, [columnConfig]);
|
||||
|
||||
// Build unified list of all sort options (from sortable columns + extra_sort_options)
|
||||
const allSortOptions = useMemo(() => {
|
||||
const fromColumns = sortableColumns.map(col => ({
|
||||
label: col.label,
|
||||
sortKey: col.sort_key || col.key,
|
||||
defaultDirection: inferDefaultDirection(col.render_type) as 'asc' | 'desc',
|
||||
}));
|
||||
const fromExtra = (columnConfig.extra_sort_options || []).map(opt => ({
|
||||
label: opt.label,
|
||||
sortKey: opt.sort_key,
|
||||
defaultDirection: 'desc' as const, // Extra sort options are typically numeric (e.g., peers)
|
||||
}));
|
||||
return [...fromColumns, ...fromExtra];
|
||||
}, [sortableColumns, columnConfig.extra_sort_options]);
|
||||
|
||||
// Get current sort state for active tab (from state, localStorage, or default to null = best match)
|
||||
const currentSort = useMemo((): SortState | null => {
|
||||
// Check state first - explicit null means "Default" was selected
|
||||
@@ -1299,17 +1339,17 @@ export const ReleaseModal = ({
|
||||
const saved = getSavedSort(activeTab);
|
||||
if (saved) {
|
||||
// Verify the saved sort is still valid for this source
|
||||
const isValid = sortableColumns.some(col => (col.sort_key || col.key) === saved.key);
|
||||
const isValid = allSortOptions.some(opt => opt.sortKey === saved.key);
|
||||
if (isValid) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
// Default to null (best-match sorting)
|
||||
return null;
|
||||
}, [activeTab, sortBySource, sortableColumns]);
|
||||
}, [activeTab, sortBySource, allSortOptions]);
|
||||
|
||||
// Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column
|
||||
const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
|
||||
const handleSortChange = useCallback((sortKey: string | null, defaultDirection: 'asc' | 'desc') => {
|
||||
if (sortKey === null) {
|
||||
// "Default" selected - use best-match sorting
|
||||
setSortBySource(prev => {
|
||||
@@ -1330,16 +1370,16 @@ export const ReleaseModal = ({
|
||||
let newState: SortState;
|
||||
|
||||
if (currentState && currentState.key === sortKey) {
|
||||
// Same column - toggle direction
|
||||
// Same key - toggle direction
|
||||
newState = {
|
||||
key: sortKey,
|
||||
direction: currentState.direction === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
} else {
|
||||
// New column - use default direction for this column type
|
||||
// New key - use provided default direction
|
||||
newState = {
|
||||
key: sortKey,
|
||||
direction: inferDefaultDirection(column!.render_type),
|
||||
direction: defaultDirection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1385,7 +1425,7 @@ export const ReleaseModal = ({
|
||||
});
|
||||
|
||||
// Then, sort by explicit column, or default to book-title relevance with exact author boost
|
||||
if (currentSort && sortableColumns.length > 0) {
|
||||
if (currentSort && allSortOptions.length > 0) {
|
||||
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
|
||||
} else {
|
||||
const responseBook = releasesBySource[activeTab]?.book;
|
||||
@@ -1395,7 +1435,7 @@ export const ReleaseModal = ({
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig, book]);
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, allSortOptions, columnConfig, book]);
|
||||
|
||||
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
|
||||
const displayFields = useMemo(() => {
|
||||
@@ -1575,140 +1615,142 @@ export const ReleaseModal = ({
|
||||
{/* Scrollable content */}
|
||||
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Book summary - scrolls with content */}
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt="Book cover"
|
||||
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{book.year && <span>{book.year}</span>}
|
||||
{displayFields?.starField && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
|
||||
<span>{displayFields.starField.value}</span>
|
||||
{displayFields.ratingsField && (
|
||||
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.usersField && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
{displayFields.usersField.value} readers
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.pagesField && (
|
||||
<span>{displayFields.pagesField.value} pages</span>
|
||||
)}
|
||||
</div>
|
||||
{!isRequestMode && (
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt="Book cover"
|
||||
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{book.year && <span>{book.year}</span>}
|
||||
{displayFields?.starField && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
|
||||
<span>{displayFields.starField.value}</span>
|
||||
{displayFields.ratingsField && (
|
||||
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.usersField && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
{displayFields.usersField.value} readers
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.pagesField && (
|
||||
<span>{displayFields.pagesField.value} pages</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Series info */}
|
||||
{book.series_name && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{book.series_position != null ? (
|
||||
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
|
||||
) : (
|
||||
<>Part of {book.series_name}</>
|
||||
{/* Series info */}
|
||||
{book.series_name && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{book.series_position != null ? (
|
||||
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
|
||||
) : (
|
||||
<>Part of {book.series_name}</>
|
||||
)}
|
||||
</span>
|
||||
{onSearchSeries && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
handleClose();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
View series
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{onSearchSeries && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{book.description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
|
||||
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
|
||||
{book.description}
|
||||
{descriptionExpanded && descriptionOverflows && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(false)}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:underline font-medium inline"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!descriptionExpanded && descriptionOverflows && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(true)}
|
||||
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
|
||||
>
|
||||
more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{(book.isbn_13 || book.isbn_10) && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
ISBN: {book.isbn_13 || book.isbn_10}
|
||||
</span>
|
||||
)}
|
||||
{book.source_url && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
View on {providerDisplay}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{onRequestBook && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
handleClose();
|
||||
void handleRequestBook();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||
disabled={isRequestingBook}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
View series
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{book.description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
|
||||
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
|
||||
{book.description}
|
||||
{descriptionExpanded && descriptionOverflows && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(false)}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:underline font-medium inline"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!descriptionExpanded && descriptionOverflows && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(true)}
|
||||
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
|
||||
>
|
||||
more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{(book.isbn_13 || book.isbn_10) && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
ISBN: {book.isbn_13 || book.isbn_10}
|
||||
</span>
|
||||
)}
|
||||
{book.source_url && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
View on {providerDisplay}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{onRequestBook && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleRequestBook();
|
||||
}}
|
||||
disabled={isRequestingBook}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source tabs + filters - sticky within scroll container */}
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
|
||||
@@ -1771,8 +1813,8 @@ export const ReleaseModal = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Sort dropdown - only show if source has sortable columns */}
|
||||
{sortableColumns.length > 0 && (
|
||||
{/* Sort dropdown - only show if source has sort options */}
|
||||
{allSortOptions.length > 0 && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
@@ -1800,7 +1842,7 @@ export const ReleaseModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSortChange(null, null);
|
||||
handleSortChange(null, 'asc');
|
||||
close();
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
|
||||
@@ -1815,16 +1857,15 @@ export const ReleaseModal = ({
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{sortableColumns.map((col) => {
|
||||
const sortKey = col.sort_key || col.key;
|
||||
const isSelected = currentSort?.key === sortKey;
|
||||
{allSortOptions.map((opt) => {
|
||||
const isSelected = currentSort?.key === opt.sortKey;
|
||||
const direction = isSelected ? currentSort?.direction : null;
|
||||
return (
|
||||
<button
|
||||
key={sortKey}
|
||||
key={opt.sortKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSortChange(sortKey, col);
|
||||
handleSortChange(opt.sortKey, opt.defaultDirection);
|
||||
// Don't close - allow toggling direction
|
||||
if (!isSelected) close();
|
||||
}}
|
||||
@@ -1833,7 +1874,7 @@ export const ReleaseModal = ({
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>{col.label}</span>
|
||||
<span>{opt.label}</span>
|
||||
{isSelected && direction && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
{direction === 'asc' ? (
|
||||
@@ -1955,11 +1996,7 @@ export const ReleaseModal = ({
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
|
||||
try {
|
||||
// Resolve language codes for the API call
|
||||
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
// Don't pass languages if "All" is selected or null
|
||||
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
|
||||
? undefined
|
||||
: langCodes;
|
||||
const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
|
||||
|
||||
// Pass indexer filter only if the source supports it (empty array = search all)
|
||||
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
|
||||
|
||||
@@ -434,7 +434,7 @@ export const ActivityCard = ({
|
||||
|
||||
const provider = toOptionalText(bookData.provider)?.toLowerCase();
|
||||
const providerId = toOptionalText(bookData.provider_id);
|
||||
const canBrowseAlternatives = Boolean(provider && providerId && provider !== 'direct_download');
|
||||
const canBrowseAlternatives = Boolean(provider && providerId);
|
||||
|
||||
const handleReviewApprove = async () => {
|
||||
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
|
||||
|
||||
@@ -53,6 +53,7 @@ interface SettingsContentProps {
|
||||
authMode?: string;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
onRefreshOverrideSummary?: () => void;
|
||||
onRefreshAuth?: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -407,6 +408,7 @@ export const SettingsContent = ({
|
||||
authMode: customFieldContext?.authMode,
|
||||
onShowToast: customFieldContext?.onShowToast,
|
||||
onRefreshOverrideSummary: customFieldContext?.onRefreshOverrideSummary,
|
||||
onRefreshAuth: customFieldContext?.onRefreshAuth,
|
||||
})
|
||||
: renderField(
|
||||
field,
|
||||
|
||||
@@ -13,9 +13,10 @@ interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
onSettingsSaved?: () => void;
|
||||
onRefreshAuth?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
|
||||
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved, onRefreshAuth }: SettingsModalProps) => {
|
||||
const {
|
||||
tabs,
|
||||
groups,
|
||||
@@ -301,6 +302,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
authMode: usersAuthMode,
|
||||
onShowToast,
|
||||
onRefreshOverrideSummary: handleRefreshCurrentTabOverrideSummary,
|
||||
onRefreshAuth,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getAdminUsers } from '../../../services/api';
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
|
||||
const [needsAdmin, setNeedsAdmin] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAdminUsers()
|
||||
.then((users) => {
|
||||
if (!cancelled) {
|
||||
setNeedsAdmin(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setNeedsAdmin(true);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (!needsAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
|
||||
{field.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
|
||||
<div
|
||||
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
|
||||
style={{ background: 'var(--bg-soft)' }}
|
||||
>
|
||||
docker-compose.yml
|
||||
</div>
|
||||
<pre
|
||||
className="px-3 py-3 text-xs overflow-x-auto"
|
||||
style={{ background: 'var(--bg-soft)' }}
|
||||
>
|
||||
<code>
|
||||
<span className="opacity-60">environment:</span>{'\n'}
|
||||
{' '}- <span className="text-blue-400">HIDE_LOCAL_AUTH</span>=<span className="text-green-400">true</span>
|
||||
{' '}<span className="opacity-40"># Hide the local login form</span>{'\n'}
|
||||
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=<span className="text-green-400">true</span>
|
||||
{' '}<span className="opacity-40"># Skip login page, redirect straight to OIDC</span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
const interpolate = (text: string): string =>
|
||||
text.replace(/\{origin\}/g, window.location.origin);
|
||||
|
||||
export const SettingsLabel = ({ field }: CustomSettingsFieldRendererProps) => {
|
||||
return (
|
||||
<div className="text-sm px-3 py-2 rounded-lg bg-sky-500/20">
|
||||
{field.label && <span className="opacity-60">{field.label} </span>}
|
||||
{field.description && (
|
||||
<code className="font-mono text-xs">{interpolate(field.description)}</code>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export const UsersManagementField = ({
|
||||
authMode,
|
||||
onShowToast,
|
||||
onRefreshOverrideSummary,
|
||||
onRefreshAuth,
|
||||
}: CustomSettingsFieldRendererProps) => {
|
||||
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
|
||||
const activeEditRequestIdRef = useRef(0);
|
||||
@@ -111,6 +112,7 @@ export const UsersManagementField = ({
|
||||
};
|
||||
|
||||
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode || 'none');
|
||||
const needsLocalAdmin = !users.some(u => u.role === 'admin' && u.auth_source === 'builtin');
|
||||
|
||||
const handleBackToList = () => {
|
||||
onUiStateChange('routeKind', 'list');
|
||||
@@ -129,6 +131,7 @@ export const UsersManagementField = ({
|
||||
const ok = await createUser();
|
||||
if (ok) {
|
||||
onRefreshOverrideSummary?.();
|
||||
onRefreshAuth?.();
|
||||
backToList();
|
||||
}
|
||||
};
|
||||
@@ -213,9 +216,10 @@ export const UsersManagementField = ({
|
||||
const ok = await deleteUser(userId);
|
||||
if (ok) {
|
||||
onRefreshOverrideSummary?.();
|
||||
onRefreshAuth?.();
|
||||
}
|
||||
return ok;
|
||||
}, [deleteUser, onRefreshOverrideSummary]);
|
||||
}, [deleteUser, onRefreshAuth, onRefreshOverrideSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.kind !== 'edit-overrides') {
|
||||
@@ -263,7 +267,13 @@ export const UsersManagementField = ({
|
||||
loadingUsers={loading}
|
||||
loadError={loadError}
|
||||
onRetryLoadUsers={() => void fetchUsers({ force: true })}
|
||||
onCreate={openCreate}
|
||||
onCreate={() => {
|
||||
if (needsLocalAdmin) {
|
||||
setCreateForm({ ...createForm, role: 'admin' });
|
||||
}
|
||||
openCreate();
|
||||
}}
|
||||
needsLocalAdmin={needsLocalAdmin}
|
||||
showCreateForm={route.kind === 'create'}
|
||||
createForm={createForm}
|
||||
onCreateFormChange={setCreateForm}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ComponentType, ReactNode } from 'react';
|
||||
import { OidcAdminHint } from './OidcAdminHint';
|
||||
import { OidcEnvInfo } from './OidcEnvInfo';
|
||||
import { RequestPolicyGridField } from './RequestPolicyGridField';
|
||||
import { SettingsLabel } from './SettingsLabel';
|
||||
import { UsersManagementField } from './UsersManagementField';
|
||||
import {
|
||||
CustomSettingsFieldLayout,
|
||||
@@ -39,6 +42,15 @@ const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
|
||||
request_policy_grid: {
|
||||
renderer: RequestPolicyGridField,
|
||||
},
|
||||
settings_label: {
|
||||
renderer: SettingsLabel,
|
||||
},
|
||||
oidc_admin_hint: {
|
||||
renderer: OidcAdminHint,
|
||||
},
|
||||
oidc_env_info: {
|
||||
renderer: OidcEnvInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export const renderCustomSettingsField = (
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CustomSettingsFieldRendererProps {
|
||||
authMode?: string;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
onRefreshOverrideSummary?: () => void;
|
||||
onRefreshAuth?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CustomSettingsFieldLayout {
|
||||
|
||||
@@ -336,6 +336,7 @@ interface UserCreateCardProps {
|
||||
onChange: (form: CreateUserFormState) => void;
|
||||
creating: boolean;
|
||||
isFirstUser: boolean;
|
||||
needsLocalAdmin?: boolean;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -345,6 +346,7 @@ export const UserCreateCard = ({
|
||||
onChange,
|
||||
creating,
|
||||
isFirstUser,
|
||||
needsLocalAdmin = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: UserCreateCardProps) => {
|
||||
@@ -368,6 +370,11 @@ export const UserCreateCard = ({
|
||||
This will be the first account and will be created as admin.
|
||||
</p>
|
||||
)}
|
||||
{needsLocalAdmin && !isFirstUser && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
An admin account is required before OIDC can be enabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{renderTextField(usernameField, form.username, (value) => onChange({ ...form, username: value }))}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UserListViewProps {
|
||||
loadingUsers: boolean;
|
||||
loadError: string | null;
|
||||
onRetryLoadUsers: () => void;
|
||||
needsLocalAdmin: boolean;
|
||||
onCreate: () => void;
|
||||
showCreateForm: boolean;
|
||||
createForm: CreateUserFormState;
|
||||
@@ -46,6 +47,7 @@ export const UserListView = ({
|
||||
loadingUsers,
|
||||
loadError,
|
||||
onRetryLoadUsers,
|
||||
needsLocalAdmin,
|
||||
onCreate,
|
||||
showCreateForm,
|
||||
createForm,
|
||||
@@ -225,6 +227,7 @@ export const UserListView = ({
|
||||
onChange={onCreateFormChange}
|
||||
creating={creating}
|
||||
isFirstUser={isFirstUser}
|
||||
needsLocalAdmin={needsLocalAdmin}
|
||||
onSubmit={onCreateSubmit}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
|
||||
@@ -18,9 +18,12 @@ interface UseAuthReturn {
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
oidcButtonLabel: string | null;
|
||||
hideLocalAuth: boolean;
|
||||
oidcAutoRedirect: boolean;
|
||||
loginError: string | null;
|
||||
isLoggingIn: boolean;
|
||||
setIsAuthenticated: (value: boolean) => void;
|
||||
refreshAuth: () => Promise<void>;
|
||||
handleLogin: (credentials: LoginCredentials) => Promise<void>;
|
||||
handleLogout: () => Promise<void>;
|
||||
}
|
||||
@@ -38,6 +41,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [displayName, setDisplayName] = useState<string | null>(null);
|
||||
const [oidcButtonLabel, setOidcButtonLabel] = useState<string | null>(null);
|
||||
const [hideLocalAuth, setHideLocalAuth] = useState<boolean>(false);
|
||||
const [oidcAutoRedirect, setOidcAutoRedirect] = useState<boolean>(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
|
||||
|
||||
@@ -49,6 +54,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
setUsername(response.username || null);
|
||||
setDisplayName(response.display_name || null);
|
||||
setOidcButtonLabel(response.oidc_button_label || null);
|
||||
setHideLocalAuth(response.hide_local_auth || false);
|
||||
setOidcAutoRedirect(response.oidc_auto_redirect || false);
|
||||
}, []);
|
||||
|
||||
const refreshSocketSession = useCallback(() => {
|
||||
@@ -107,6 +114,14 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
};
|
||||
}, [applyAuthResponse]);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
applyAuthResponse(await checkAuth());
|
||||
} catch (error) {
|
||||
console.error('Auth refresh failed:', error);
|
||||
}
|
||||
}, [applyAuthResponse]);
|
||||
|
||||
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
@@ -145,6 +160,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
setUsername(null);
|
||||
setDisplayName(null);
|
||||
setOidcButtonLabel(null);
|
||||
setHideLocalAuth(false);
|
||||
setOidcAutoRedirect(false);
|
||||
onLogoutSuccess?.();
|
||||
navigate('/login', { replace: true });
|
||||
} catch (error) {
|
||||
@@ -162,9 +179,12 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
username,
|
||||
displayName,
|
||||
oidcButtonLabel,
|
||||
hideLocalAuth,
|
||||
oidcAutoRedirect,
|
||||
loginError,
|
||||
isLoggingIn,
|
||||
setIsAuthenticated,
|
||||
refreshAuth,
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
};
|
||||
|
||||
@@ -8,9 +8,11 @@ interface LoginPageProps {
|
||||
isLoading: boolean;
|
||||
authMode?: string;
|
||||
oidcButtonLabel?: string | null;
|
||||
hideLocalAuth?: boolean;
|
||||
oidcAutoRedirect?: boolean;
|
||||
}
|
||||
|
||||
export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel }: LoginPageProps) => {
|
||||
export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel, hideLocalAuth, oidcAutoRedirect }: LoginPageProps) => {
|
||||
const logoUrl = withBasePath('/logo.png');
|
||||
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel
|
||||
<div className="text-center mb-5">
|
||||
<img src={logoUrl} alt="Logo" className="mx-auto w-12 h-12" />
|
||||
</div>
|
||||
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} oidcButtonLabel={oidcButtonLabel} />
|
||||
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} oidcButtonLabel={oidcButtonLabel} hideLocalAuth={hideLocalAuth} oidcAutoRedirect={oidcAutoRedirect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Language } from '../types/index.js';
|
||||
import {
|
||||
LANGUAGE_OPTION_ALL,
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
getReleaseSearchLanguageParams,
|
||||
} from '../utils/languageFilters.js';
|
||||
|
||||
const supportedLanguages: Language[] = [
|
||||
{ code: 'en', language: 'English' },
|
||||
{ code: 'de', language: 'German' },
|
||||
{ code: 'hu', language: 'Hungarian' },
|
||||
];
|
||||
|
||||
describe('languageFilters release search params', () => {
|
||||
it('omits languages when only default selection is active', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
[LANGUAGE_OPTION_DEFAULT],
|
||||
supportedLanguages,
|
||||
['en'],
|
||||
);
|
||||
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it('preserves explicit all-languages selection', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
[LANGUAGE_OPTION_ALL],
|
||||
supportedLanguages,
|
||||
['en'],
|
||||
);
|
||||
|
||||
assert.deepEqual(result, [LANGUAGE_OPTION_ALL]);
|
||||
});
|
||||
|
||||
it('resolves explicit language selections to codes', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
['de', 'hu'],
|
||||
supportedLanguages,
|
||||
['en'],
|
||||
);
|
||||
|
||||
assert.deepEqual(result, ['de', 'hu']);
|
||||
});
|
||||
});
|
||||
@@ -45,13 +45,14 @@ describe('requestPayload utilities', () => {
|
||||
assert.equal(payload.release_data?.source, 'direct_download');
|
||||
});
|
||||
|
||||
it('creates direct request payload at book level for request_book mode', () => {
|
||||
it('creates direct request payload with attached release for request_book mode', () => {
|
||||
const payload = buildDirectRequestPayload(baseBook, 'request_book');
|
||||
|
||||
assert.equal(payload.context.request_level, 'book');
|
||||
assert.equal(payload.context.request_level, 'release');
|
||||
assert.equal(payload.context.source, 'direct_download');
|
||||
assert.equal(payload.context.content_type, 'ebook');
|
||||
assert.equal(payload.release_data, null);
|
||||
assert.ok(payload.release_data);
|
||||
assert.equal(payload.release_data?.source, 'direct_download');
|
||||
});
|
||||
|
||||
it('builds metadata book + release payload fragments', () => {
|
||||
|
||||
@@ -261,6 +261,8 @@ export interface AuthResponse {
|
||||
error?: string;
|
||||
logout_url?: string;
|
||||
oidc_button_label?: string;
|
||||
hide_local_auth?: boolean;
|
||||
oidc_auto_redirect?: boolean;
|
||||
}
|
||||
|
||||
// Type guard to check if a book is from a metadata provider
|
||||
@@ -313,6 +315,11 @@ export interface LeadingCellConfig {
|
||||
uppercase?: boolean; // Force uppercase for badge text
|
||||
}
|
||||
|
||||
export interface ExtraSortOption {
|
||||
label: string; // Display label in the sort dropdown
|
||||
sort_key: string; // Field to sort by on the Release object
|
||||
}
|
||||
|
||||
export interface SourceActionButton {
|
||||
label: string; // Button text (e.g., "Refresh search")
|
||||
action: string; // Action type: "expand" triggers expand_search
|
||||
@@ -327,6 +334,7 @@ export interface ReleaseColumnConfig {
|
||||
default_indexers?: string[]; // For Prowlarr: indexers selected in settings (pre-selected in filter)
|
||||
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
|
||||
supported_filters?: string[]; // Which filters this source supports: ["format", "language", "indexer"]
|
||||
extra_sort_options?: ExtraSortOption[]; // Additional sort options not tied to a column
|
||||
action_button?: SourceActionButton; // Custom action button (replaces default expand search)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,21 @@ export const getLanguageFilterValues = (
|
||||
return resolved.size ? Array.from(resolved) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve language selection for /api/releases requests.
|
||||
* - undefined: use backend defaults
|
||||
* - ["all"]: disable language filtering
|
||||
* - ["en", ...]: explicit filter list
|
||||
*/
|
||||
export const getReleaseSearchLanguageParams = (
|
||||
selection: string[],
|
||||
supportedLanguages: Language[],
|
||||
defaultLanguageCodes: string[] = [],
|
||||
): string[] | undefined => {
|
||||
const resolved = getLanguageFilterValues(selection, supportedLanguages, defaultLanguageCodes);
|
||||
return resolved === null ? undefined : resolved;
|
||||
};
|
||||
|
||||
export const formatDefaultLanguageLabel = (
|
||||
languageCodes: string[],
|
||||
supportedLanguages: Language[],
|
||||
@@ -136,4 +151,3 @@ export const releaseLanguageMatchesFilter = (
|
||||
const selectedSet = new Set(selectedCodes.map(c => c.toLowerCase()));
|
||||
return releaseCodes.every(code => selectedSet.has(code));
|
||||
};
|
||||
|
||||
|
||||
@@ -90,14 +90,18 @@ export const buildDirectRequestPayload = (
|
||||
mode: Extract<RequestPolicyMode, 'request_release' | 'request_book'>
|
||||
): CreateRequestPayload => {
|
||||
const bookData = buildDirectBookRequestData(book);
|
||||
|
||||
// In direct mode, every result already represents a concrete downloadable release.
|
||||
// Even when policy defaults resolve to request_book, attach the selected release so
|
||||
// admins can approve immediately or browse alternatives from the same record.
|
||||
if (mode === 'request_book') {
|
||||
return {
|
||||
book_data: bookData,
|
||||
release_data: null,
|
||||
release_data: buildReleaseDataFromDirectBook(book),
|
||||
context: {
|
||||
source: 'direct_download',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ class TestSecuritySettings:
|
||||
action = next((f for f in fields if f.key == "open_users_tab"), None)
|
||||
assert action is not None
|
||||
assert action.label == "Go to Users"
|
||||
assert action.show_when == {"field": "AUTH_METHOD", "value": "builtin"}
|
||||
assert action.show_when == {"field": "AUTH_METHOD", "value": ["builtin", "oidc"]}
|
||||
|
||||
|
||||
class TestSecurityOnSave:
|
||||
|
||||
@@ -279,6 +279,40 @@ class TestActivityRoutes:
|
||||
assert "expired-task-1" in response.json["status"]["complete"]
|
||||
assert response.json["status"]["complete"]["expired-task-1"]["id"] == "expired-task-1"
|
||||
|
||||
def test_admin_snapshot_backfills_terminal_downloads_across_users(self, main_module, client):
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
request_owner = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
|
||||
|
||||
main_module.activity_service.record_terminal_snapshot(
|
||||
user_id=request_owner["id"],
|
||||
item_type="download",
|
||||
item_key="download:cross-user-expired-task",
|
||||
origin="requested",
|
||||
final_status="complete",
|
||||
source_id="cross-user-expired-task",
|
||||
snapshot={
|
||||
"kind": "download",
|
||||
"download": {
|
||||
"id": "cross-user-expired-task",
|
||||
"title": "Cross User Task",
|
||||
"author": "Another User",
|
||||
"added_time": 123,
|
||||
"status_message": "Finished",
|
||||
"source": "direct_download",
|
||||
"user_id": request_owner["id"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
|
||||
response = client.get("/api/activity/snapshot")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "cross-user-expired-task" in response.json["status"]["complete"]
|
||||
assert response.json["status"]["complete"]["cross-user-expired-task"]["id"] == "cross-user-expired-task"
|
||||
|
||||
def test_snapshot_clears_stale_download_dismissal_when_same_task_is_active(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)
|
||||
@@ -332,6 +366,29 @@ class TestActivityRoutes:
|
||||
assert snapshot_two.status_code == 200
|
||||
assert {"item_type": "download", "item_key": "download:shared-task"} not in snapshot_two.json["dismissed"]
|
||||
|
||||
def test_admin_request_dismissal_is_shared_across_admin_users(self, main_module, client):
|
||||
admin_one = _create_user(main_module, prefix="admin-one", role="admin")
|
||||
admin_two = _create_user(main_module, prefix="admin-two", role="admin")
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
_set_session(client, user_id=admin_one["username"], db_user_id=admin_one["id"], is_admin=True)
|
||||
dismiss_response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
json={"item_type": "request", "item_key": "request:999999"},
|
||||
)
|
||||
assert dismiss_response.status_code == 200
|
||||
|
||||
_set_session(client, user_id=admin_two["username"], db_user_id=admin_two["id"], is_admin=True)
|
||||
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
|
||||
snapshot_response = client.get("/api/activity/snapshot")
|
||||
history_response = client.get("/api/activity/history?limit=50&offset=0")
|
||||
|
||||
assert snapshot_response.status_code == 200
|
||||
assert {"item_type": "request", "item_key": "request:999999"} in snapshot_response.json["dismissed"]
|
||||
|
||||
assert history_response.status_code == 200
|
||||
assert any(row["item_key"] == "request:999999" for row in history_response.json)
|
||||
|
||||
def test_history_paging_is_stable_and_non_overlapping(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="history-user")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
@@ -235,7 +235,10 @@ class TestActivityService:
|
||||
item_key="download:task-2",
|
||||
)
|
||||
|
||||
rows = activity_service.get_undismissed_terminal_downloads(user["id"])
|
||||
rows = activity_service.get_undismissed_terminal_downloads(
|
||||
user["id"],
|
||||
owner_user_id=user["id"],
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["item_key"] == "download:task-1"
|
||||
assert rows[0]["final_status"] == "complete"
|
||||
@@ -243,3 +246,50 @@ class TestActivityService:
|
||||
"kind": "download",
|
||||
"download": {"id": "task-1", "status_message": "done"},
|
||||
}
|
||||
|
||||
def test_get_undismissed_terminal_downloads_can_span_owners_for_admin_viewer(
|
||||
self,
|
||||
user_db,
|
||||
activity_service,
|
||||
):
|
||||
viewer = user_db.create_user(username="admin-viewer", role="admin")
|
||||
owner_one = user_db.create_user(username="owner-one")
|
||||
owner_two = user_db.create_user(username="owner-two")
|
||||
|
||||
activity_service.record_terminal_snapshot(
|
||||
user_id=owner_one["id"],
|
||||
item_type="download",
|
||||
item_key="download:owner-one-task",
|
||||
origin="direct",
|
||||
final_status="complete",
|
||||
source_id="owner-one-task",
|
||||
terminal_at="2026-01-01T10:00:00+00:00",
|
||||
snapshot={"kind": "download", "download": {"id": "owner-one-task"}},
|
||||
)
|
||||
activity_service.record_terminal_snapshot(
|
||||
user_id=owner_two["id"],
|
||||
item_type="download",
|
||||
item_key="download:owner-two-task",
|
||||
origin="direct",
|
||||
final_status="complete",
|
||||
source_id="owner-two-task",
|
||||
terminal_at="2026-01-01T11:00:00+00:00",
|
||||
snapshot={"kind": "download", "download": {"id": "owner-two-task"}},
|
||||
)
|
||||
activity_service.dismiss_item(
|
||||
user_id=viewer["id"],
|
||||
item_type="download",
|
||||
item_key="download:owner-two-task",
|
||||
)
|
||||
|
||||
all_owner_rows = activity_service.get_undismissed_terminal_downloads(
|
||||
viewer["id"],
|
||||
owner_user_id=None,
|
||||
)
|
||||
assert [row["item_key"] for row in all_owner_rows] == ["download:owner-one-task"]
|
||||
|
||||
owner_one_rows = activity_service.get_undismissed_terminal_downloads(
|
||||
viewer["id"],
|
||||
owner_user_id=owner_one["id"],
|
||||
)
|
||||
assert [row["item_key"] for row in owner_one_rows] == ["download:owner-one-task"]
|
||||
|
||||
@@ -259,3 +259,51 @@ class TestProvisionOIDCUser:
|
||||
assert user["username"] != "john" # Should have a suffix
|
||||
assert user["oidc_subject"] == "sub-456"
|
||||
assert user["auth_source"] == "oidc"
|
||||
|
||||
def test_provision_links_to_existing_user_by_email(self, user_db):
|
||||
"""When allow_email_link=True and emails match, link to existing local user."""
|
||||
from shelfmark.core.oidc_auth import provision_oidc_user
|
||||
user_db.create_user(
|
||||
username="localuser",
|
||||
email="shared@example.com",
|
||||
password_hash="hash",
|
||||
)
|
||||
|
||||
user_info = {
|
||||
"oidc_subject": "oidc-sub-789",
|
||||
"username": "oidcuser",
|
||||
"email": "shared@example.com",
|
||||
"display_name": "OIDC User",
|
||||
}
|
||||
user = provision_oidc_user(
|
||||
user_db, user_info, is_admin=False, allow_email_link=True,
|
||||
)
|
||||
assert user["username"] == "localuser"
|
||||
assert user["oidc_subject"] == "oidc-sub-789"
|
||||
assert user["auth_source"] == "oidc"
|
||||
assert user["email"] == "shared@example.com"
|
||||
|
||||
def test_provision_does_not_link_by_email_when_disabled(self, user_db):
|
||||
"""When allow_email_link=False (default), don't link by email."""
|
||||
from shelfmark.core.oidc_auth import provision_oidc_user
|
||||
user_db.create_user(
|
||||
username="localuser",
|
||||
email="shared@example.com",
|
||||
password_hash="hash",
|
||||
)
|
||||
|
||||
user_info = {
|
||||
"oidc_subject": "oidc-sub-no-link",
|
||||
"username": "oidcuser",
|
||||
"email": "shared@example.com",
|
||||
"display_name": "OIDC User",
|
||||
}
|
||||
user = provision_oidc_user(
|
||||
user_db, user_info, is_admin=False, allow_email_link=False,
|
||||
)
|
||||
# Should create a new user, not link to existing
|
||||
assert user["username"] == "oidcuser"
|
||||
assert user["oidc_subject"] == "oidc-sub-no-link"
|
||||
|
||||
original = user_db.get_user(username="localuser")
|
||||
assert original["oidc_subject"] is None
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
@@ -11,6 +12,15 @@ from flask import Flask, redirect
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def _get_oidc_error(resp) -> str | None:
|
||||
"""Extract the oidc_error query param from a redirect response."""
|
||||
assert resp.status_code == 302
|
||||
parsed = urlparse(resp.headers["Location"])
|
||||
params = parse_qs(parsed.query)
|
||||
errors = params.get("oidc_error", [])
|
||||
return errors[0] if errors else None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -142,6 +152,7 @@ class TestOIDCCallbackEndpoint:
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
assert resp.status_code == 302
|
||||
fake_client.userinfo.assert_not_called()
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "john"
|
||||
@@ -181,18 +192,54 @@ class TestOIDCCallbackEndpoint:
|
||||
assert resp.status_code == 302
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_returns_400_when_claims_missing(self, mock_get_client, client):
|
||||
def test_callback_fetches_userinfo_when_token_claims_are_sparse(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
token = {"userinfo": {"sub": "sparse-sub"}}
|
||||
fake_client.authorize_access_token.return_value = token
|
||||
fake_client.userinfo.return_value = {
|
||||
"sub": "sparse-sub",
|
||||
"email": "sparse@example.com",
|
||||
"preferred_username": "sparse-user",
|
||||
"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
|
||||
fake_client.userinfo.assert_called_once_with(token=token)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "sparse-user"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_uses_sparse_claims_when_userinfo_fetch_fails(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
token = {"userinfo": {"sub": "fallback-sub"}}
|
||||
fake_client.authorize_access_token.return_value = token
|
||||
fake_client.userinfo.side_effect = RuntimeError("userinfo failed")
|
||||
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
|
||||
fake_client.userinfo.assert_called_once_with(token=token)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "fallback-sub"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_with_error_when_claims_missing(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {}
|
||||
fake_client.userinfo.side_effect = RuntimeError("userinfo failed")
|
||||
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 == 400
|
||||
assert "missing user claims" in resp.get_json()["error"]
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "missing user claims" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_returns_400_with_issuer_guidance_on_invalid_issuer_claim(
|
||||
def test_callback_redirects_with_issuer_guidance_on_invalid_issuer_claim(
|
||||
self, mock_get_client, client
|
||||
):
|
||||
fake_client = Mock()
|
||||
@@ -201,17 +248,19 @@ class TestOIDCCallbackEndpoint:
|
||||
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 == 400
|
||||
assert "issuer validation failed" in resp.get_json()["error"]
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "issuer validation failed" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_rejects_when_auto_provision_disabled(self, mock_get_client, client):
|
||||
def test_callback_redirects_when_auto_provision_disabled_and_no_email_match(
|
||||
self, mock_get_client, client
|
||||
):
|
||||
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "unknown-user",
|
||||
"email": "unknown@example.com",
|
||||
"preferred_username": "unknown",
|
||||
"groups": [],
|
||||
}
|
||||
@@ -219,10 +268,12 @@ class TestOIDCCallbackEndpoint:
|
||||
mock_get_client.return_value = (fake_client, config)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
assert resp.status_code == 403
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "Account not found" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_allows_pre_created_user_by_verified_email_when_no_provision(
|
||||
def test_callback_links_pre_created_user_by_email_when_no_provision(
|
||||
self, mock_get_client, client, user_db
|
||||
):
|
||||
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
||||
@@ -233,7 +284,6 @@ class TestOIDCCallbackEndpoint:
|
||||
"userinfo": {
|
||||
"sub": "oidc-alice-sub",
|
||||
"email": "alice@example.com",
|
||||
"email_verified": True,
|
||||
"preferred_username": "alice_oidc",
|
||||
"groups": [],
|
||||
}
|
||||
@@ -248,18 +298,16 @@ class TestOIDCCallbackEndpoint:
|
||||
assert sess.get("db_user_id") is not None
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_does_not_link_unverified_email_when_no_provision(
|
||||
def test_callback_does_not_link_when_no_email_and_no_provision(
|
||||
self, mock_get_client, client, user_db
|
||||
):
|
||||
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
||||
user = user_db.create_user(username="bob", email="bob@example.com", password_hash="hash")
|
||||
user_db.create_user(username="bob", email="bob@example.com", password_hash="hash")
|
||||
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "oidc-bob-sub",
|
||||
"email": "bob@example.com",
|
||||
"email_verified": False,
|
||||
"preferred_username": "bob_oidc",
|
||||
"groups": [],
|
||||
}
|
||||
@@ -267,7 +315,126 @@ class TestOIDCCallbackEndpoint:
|
||||
mock_get_client.return_value = (fake_client, config)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
assert resp.status_code == 403
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "Account not found" in error
|
||||
|
||||
updated_user = user_db.get_user(user_id=user["id"])
|
||||
updated_user = user_db.get_user(username="bob")
|
||||
assert updated_user["oidc_subject"] is None
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_on_idp_error(self, mock_get_client, client):
|
||||
mock_get_client.return_value = (Mock(), MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?error=access_denied")
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "Authentication failed" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_error_redirect_honors_script_root(self, mock_get_client, client):
|
||||
mock_get_client.return_value = (Mock(), MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get(
|
||||
"/api/auth/oidc/callback?error=access_denied",
|
||||
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 302
|
||||
parsed = urlparse(resp.headers["Location"])
|
||||
assert parsed.path == "/shelfmark/login"
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "Authentication failed" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_on_generic_exception(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.side_effect = RuntimeError("unexpected")
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "Authentication failed" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_links_to_existing_user_by_email(
|
||||
self, mock_get_client, client, user_db
|
||||
):
|
||||
"""OIDC login with matching email should link to existing local user."""
|
||||
user_db.create_user(username="localuser", email="shared@example.com", password_hash="hash")
|
||||
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "oidc-new-sub",
|
||||
"email": "shared@example.com",
|
||||
"preferred_username": "oidcuser",
|
||||
"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"] == "localuser"
|
||||
|
||||
linked = user_db.get_user(username="localuser")
|
||||
assert linked["oidc_subject"] == "oidc-new-sub"
|
||||
assert linked["auth_source"] == "oidc"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_creates_new_user_when_no_email_match(
|
||||
self, mock_get_client, client, user_db
|
||||
):
|
||||
"""OIDC login without matching email creates a new user."""
|
||||
user_db.create_user(username="existing", email="other@example.com", password_hash="hash")
|
||||
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "oidc-nomatch",
|
||||
"email": "different@example.com",
|
||||
"preferred_username": "newuser",
|
||||
"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"] == "newuser"
|
||||
|
||||
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."""
|
||||
user_db.create_user(username="existing", email="existing@example.com", password_hash="hash")
|
||||
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "oidc-noemail",
|
||||
"preferred_username": "noemailuser",
|
||||
"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"] == "noemailuser"
|
||||
|
||||
original = user_db.get_user(username="existing")
|
||||
assert original["oidc_subject"] is None
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for /api/releases with direct_download provider context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources import (
|
||||
ColumnAlign,
|
||||
ColumnRenderType,
|
||||
ColumnSchema,
|
||||
Release,
|
||||
ReleaseColumnConfig,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(main_module):
|
||||
return main_module.app.test_client()
|
||||
|
||||
|
||||
class _FakeDirectSource:
|
||||
last_search_type = "title_author"
|
||||
|
||||
def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001
|
||||
assert book.provider == "direct_download"
|
||||
assert book.provider_id == "md5-abc"
|
||||
assert book.title == "The Gun Seller"
|
||||
assert plan.primary_query
|
||||
return [
|
||||
Release(
|
||||
source="direct_download",
|
||||
source_id="md5-rel-1",
|
||||
title="The Gun Seller",
|
||||
format="epub",
|
||||
size="2 MB",
|
||||
)
|
||||
]
|
||||
|
||||
def get_column_config(self):
|
||||
return ReleaseColumnConfig(
|
||||
columns=[
|
||||
ColumnSchema(
|
||||
key="format",
|
||||
label="Format",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) 80px",
|
||||
)
|
||||
|
||||
|
||||
def test_releases_accepts_direct_download_provider(main_module, client):
|
||||
fake_direct_source = _FakeDirectSource()
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_book_info",
|
||||
return_value={
|
||||
"id": "md5-abc",
|
||||
"title": "The Gun Seller",
|
||||
"author": "Iain Banks",
|
||||
"preview": "https://example.com/cover.jpg",
|
||||
},
|
||||
) as mock_get_book_info:
|
||||
with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source:
|
||||
with patch(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
side_effect=AssertionError("list_available_sources should not be called"),
|
||||
):
|
||||
resp = client.get(
|
||||
"/api/releases",
|
||||
query_string={
|
||||
"provider": "direct_download",
|
||||
"book_id": "md5-abc",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["sources_searched"] == ["direct_download"]
|
||||
assert body["book"]["provider"] == "direct_download"
|
||||
assert body["book"]["provider_id"] == "md5-abc"
|
||||
assert body["book"]["title"] == "The Gun Seller"
|
||||
assert body["releases"][0]["source"] == "direct_download"
|
||||
assert body["releases"][0]["source_id"] == "md5-rel-1"
|
||||
assert body["search_info"]["direct_download"]["search_type"] == "title_author"
|
||||
mock_get_book_info.assert_called_once_with("md5-abc")
|
||||
mock_get_source.assert_called_once_with("direct_download")
|
||||
|
||||
|
||||
def test_releases_direct_provider_returns_404_when_book_missing(main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "get_book_info", return_value=None):
|
||||
with patch("shelfmark.release_sources.get_source") as mock_get_source:
|
||||
resp = client.get(
|
||||
"/api/releases",
|
||||
query_string={
|
||||
"provider": "direct_download",
|
||||
"book_id": "missing-md5",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"error": "Book not found in direct source"}
|
||||
mock_get_source.assert_not_called()
|
||||
@@ -541,6 +541,44 @@ class TestRequestRoutes:
|
||||
assert resp.json["code"] == "policy_requires_request"
|
||||
assert resp.json["required_mode"] == "request_book"
|
||||
|
||||
def test_request_book_policy_allows_direct_release_level_request(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_book")
|
||||
|
||||
payload = {
|
||||
"book_data": {
|
||||
"title": "Direct Result",
|
||||
"author": "Direct Author",
|
||||
"content_type": "ebook",
|
||||
"provider": "direct_download",
|
||||
"provider_id": "dd-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "direct_download",
|
||||
"source_id": "dd-1",
|
||||
"title": "Direct Result.epub",
|
||||
"format": "epub",
|
||||
"size": "2 MB",
|
||||
},
|
||||
}
|
||||
|
||||
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):
|
||||
resp = client.post("/api/requests", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json["request_level"] == "release"
|
||||
assert resp.json["policy_mode"] == "request_book"
|
||||
assert resp.json["release_data"]["source"] == "direct_download"
|
||||
assert resp.json["release_data"]["source_id"] == "dd-1"
|
||||
|
||||
def test_non_admin_cannot_access_admin_request_routes(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)
|
||||
|
||||
@@ -2,10 +2,11 @@ import requests
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, *, headers: dict | None = None, text: str = "") -> None:
|
||||
def __init__(self, status_code: int, *, headers: dict | None = None, text: str = "", url: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
self.url = url
|
||||
|
||||
@property
|
||||
def is_redirect(self) -> bool: # requests.Response compatibility
|
||||
@@ -53,9 +54,9 @@ def test_html_get_page_aa_cross_host_redirect_rotates_mirror(monkeypatch):
|
||||
def fake_get(url: str, **kwargs):
|
||||
calls.append({"url": url, "allow_redirects": kwargs.get("allow_redirects")})
|
||||
if url.startswith("https://annas-archive.li/"):
|
||||
return _FakeResponse(302, headers={"Location": "https://annas-archive.pm/search?q=test"})
|
||||
return _FakeResponse(302, headers={"Location": "https://annas-archive.pm/search?q=test"}, url=url)
|
||||
if url.startswith("https://annas-archive.gl/"):
|
||||
return _FakeResponse(200, text="OK")
|
||||
return _FakeResponse(200, text="OK", url=url)
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
@@ -88,9 +89,9 @@ def test_html_get_page_aa_same_host_redirect_is_followed(monkeypatch):
|
||||
def fake_get(url: str, **kwargs):
|
||||
calls.append({"url": url, "allow_redirects": kwargs.get("allow_redirects")})
|
||||
if url == "https://annas-archive.li/search?q=test":
|
||||
return _FakeResponse(302, headers={"Location": "/search?q=test&page=1"})
|
||||
return _FakeResponse(302, headers={"Location": "/search?q=test&page=1"}, url=url)
|
||||
if url == "https://annas-archive.li/search?q=test&page=1":
|
||||
return _FakeResponse(200, text="OK2")
|
||||
return _FakeResponse(200, text="OK2", url=url)
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
@@ -125,7 +126,7 @@ def test_html_get_page_locked_aa_does_not_fail_over_on_cross_host_redirect(monke
|
||||
def fake_get(url: str, **kwargs):
|
||||
calls.append(url)
|
||||
if url.startswith("https://annas-archive.li/"):
|
||||
return _FakeResponse(302, headers={"Location": "https://annas-archive.pm/search?q=test"})
|
||||
return _FakeResponse(302, headers={"Location": "https://annas-archive.pm/search?q=test"}, url=url)
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for certificate validation / SSL verify utilities."""
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_ssl_verify()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetSslVerify:
|
||||
"""Tests for get_ssl_verify() return values across all modes."""
|
||||
|
||||
def test_enabled_returns_true(self, monkeypatch):
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
assert network.get_ssl_verify("https://example.com") is True
|
||||
|
||||
def test_enabled_returns_true_for_local_url(self, monkeypatch):
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
assert network.get_ssl_verify("https://localhost:8080") is True
|
||||
|
||||
def test_disabled_returns_false_for_public_url(self, monkeypatch):
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
assert network.get_ssl_verify("https://example.com") is False
|
||||
|
||||
def test_disabled_returns_false_for_local_url(self, monkeypatch):
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
assert network.get_ssl_verify("https://192.168.1.1:9091") is False
|
||||
|
||||
def test_disabled_returns_false_with_no_url(self, monkeypatch):
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
assert network.get_ssl_verify() is False
|
||||
|
||||
def test_default_when_unset_returns_true(self, monkeypatch):
|
||||
"""When CERTIFICATE_VALIDATION is not in config, default is 'enabled'."""
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": d)
|
||||
assert network.get_ssl_verify("https://example.com") is True
|
||||
|
||||
|
||||
class TestGetSslVerifyDisabledLocal:
|
||||
"""Tests for 'disabled_local' mode with various address types."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_mode(self, monkeypatch):
|
||||
import shelfmark.download.network as network
|
||||
|
||||
self.network = network
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled_local" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
|
||||
# --- Should return False (local addresses) ---
|
||||
|
||||
def test_localhost(self):
|
||||
assert self.network.get_ssl_verify("https://localhost:8080/path") is False
|
||||
|
||||
def test_127_0_0_1(self):
|
||||
assert self.network.get_ssl_verify("http://127.0.0.1:9091") is False
|
||||
|
||||
def test_ipv6_loopback(self):
|
||||
assert self.network.get_ssl_verify("http://[::1]:8080") is False
|
||||
|
||||
def test_private_10_x(self):
|
||||
assert self.network.get_ssl_verify("https://10.0.0.5:443") is False
|
||||
|
||||
def test_private_172_16_x(self):
|
||||
assert self.network.get_ssl_verify("https://172.16.0.1:8080") is False
|
||||
|
||||
def test_private_172_31_x(self):
|
||||
assert self.network.get_ssl_verify("https://172.31.255.255:443") is False
|
||||
|
||||
def test_private_192_168_x(self):
|
||||
assert self.network.get_ssl_verify("https://192.168.1.100:9696") is False
|
||||
|
||||
def test_dot_local_domain(self):
|
||||
assert self.network.get_ssl_verify("https://authelia.local:9091") is False
|
||||
|
||||
def test_dot_internal_domain(self):
|
||||
assert self.network.get_ssl_verify("https://prowlarr.internal:9696") is False
|
||||
|
||||
def test_dot_lan_domain(self):
|
||||
assert self.network.get_ssl_verify("https://server.lan:443") is False
|
||||
|
||||
def test_dot_home_domain(self):
|
||||
assert self.network.get_ssl_verify("https://nas.home:5000") is False
|
||||
|
||||
def test_dot_docker_domain(self):
|
||||
assert self.network.get_ssl_verify("https://app.docker:8080") is False
|
||||
|
||||
def test_simple_hostname_no_dot(self):
|
||||
"""Docker-style service names like 'prowlarr', 'deluge'."""
|
||||
assert self.network.get_ssl_verify("http://prowlarr:9696") is False
|
||||
|
||||
def test_link_local_169_254(self):
|
||||
assert self.network.get_ssl_verify("http://169.254.1.1:8080") is False
|
||||
|
||||
# --- Should return True (public addresses) ---
|
||||
|
||||
def test_public_domain(self):
|
||||
assert self.network.get_ssl_verify("https://example.com") is True
|
||||
|
||||
def test_public_ip(self):
|
||||
assert self.network.get_ssl_verify("https://8.8.8.8:443") is True
|
||||
|
||||
def test_public_subdomain(self):
|
||||
assert self.network.get_ssl_verify("https://api.hardcover.app/v1/graphql") is True
|
||||
|
||||
def test_172_32_is_public(self):
|
||||
"""172.32.x.x is NOT in the private range (only 172.16-31.x.x)."""
|
||||
assert self.network.get_ssl_verify("https://172.32.0.1:443") is True
|
||||
|
||||
def test_empty_url_returns_true(self):
|
||||
"""No URL means we can't determine locality — default to verify."""
|
||||
assert self.network.get_ssl_verify("") is True
|
||||
|
||||
def test_no_url_returns_true(self):
|
||||
assert self.network.get_ssl_verify() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _apply_ssl_warning_suppression()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplySslWarningSuppression:
|
||||
"""Tests for urllib3 InsecureRequestWarning suppression toggling."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_suppression_flag(self):
|
||||
"""Ensure the module-level flag is clean before each test."""
|
||||
import shelfmark.download.network as network
|
||||
original = network._ssl_warnings_suppressed
|
||||
yield
|
||||
network._ssl_warnings_suppressed = original
|
||||
|
||||
def test_enabled_at_init_is_noop(self, monkeypatch):
|
||||
"""When mode is 'enabled' and warnings were never suppressed, nothing changes."""
|
||||
import shelfmark.download.network as network
|
||||
|
||||
network._ssl_warnings_suppressed = False
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
|
||||
filters_before = list(warnings.filters)
|
||||
network._apply_ssl_warning_suppression()
|
||||
filters_after = list(warnings.filters)
|
||||
|
||||
assert filters_before == filters_after
|
||||
|
||||
def test_disabled_mode_suppresses_warnings(self, monkeypatch):
|
||||
import urllib3
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
network._apply_ssl_warning_suppression()
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn("test", urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# urllib3.disable_warnings adds a filter that suppresses — so recorded warnings
|
||||
# should be empty after suppression is applied. However, our catch_warnings
|
||||
# with "always" takes precedence within the context manager. Instead, check
|
||||
# that the filter was installed.
|
||||
filters = [f for f in warnings.filters if len(f) >= 3 and f[2] is urllib3.exceptions.InsecureRequestWarning]
|
||||
assert len(filters) > 0
|
||||
|
||||
def test_disabled_local_mode_suppresses_warnings(self, monkeypatch):
|
||||
import urllib3
|
||||
import shelfmark.download.network as network
|
||||
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled_local" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
network._apply_ssl_warning_suppression()
|
||||
|
||||
filters = [f for f in warnings.filters if len(f) >= 3 and f[2] is urllib3.exceptions.InsecureRequestWarning]
|
||||
assert len(filters) > 0
|
||||
|
||||
def test_enabled_mode_restores_warnings(self, monkeypatch):
|
||||
import urllib3
|
||||
import shelfmark.download.network as network
|
||||
|
||||
# First suppress
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
network._apply_ssl_warning_suppression()
|
||||
|
||||
# Then restore
|
||||
monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d)
|
||||
network._apply_ssl_warning_suppression()
|
||||
|
||||
# "default" filter should be present for InsecureRequestWarning
|
||||
default_filters = [
|
||||
f for f in warnings.filters
|
||||
if len(f) >= 3 and f[0] == "default" and f[2] is urllib3.exceptions.InsecureRequestWarning
|
||||
]
|
||||
assert len(default_filters) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCertificateValidationSetting:
|
||||
"""Tests for the CERTIFICATE_VALIDATION settings field registration."""
|
||||
|
||||
def _get_network_fields(self):
|
||||
import shelfmark.config.settings # noqa: F401 — ensure settings tabs are registered
|
||||
|
||||
from shelfmark.core.settings_registry import get_settings_tab
|
||||
|
||||
tab = get_settings_tab("network")
|
||||
assert tab is not None
|
||||
return {field.key: field for field in tab.fields if hasattr(field, "key")}
|
||||
|
||||
def test_field_registered(self):
|
||||
fields = self._get_network_fields()
|
||||
assert "CERTIFICATE_VALIDATION" in fields
|
||||
|
||||
def test_field_is_select(self):
|
||||
from shelfmark.core.settings_registry import SelectField
|
||||
|
||||
fields = self._get_network_fields()
|
||||
assert isinstance(fields["CERTIFICATE_VALIDATION"], SelectField)
|
||||
|
||||
def test_field_default_is_enabled(self):
|
||||
fields = self._get_network_fields()
|
||||
assert fields["CERTIFICATE_VALIDATION"].default == "enabled"
|
||||
|
||||
def test_field_has_three_options(self):
|
||||
fields = self._get_network_fields()
|
||||
options = fields["CERTIFICATE_VALIDATION"].options
|
||||
assert len(options) == 3
|
||||
|
||||
def test_field_option_values(self):
|
||||
fields = self._get_network_fields()
|
||||
values = [opt["value"] for opt in fields["CERTIFICATE_VALIDATION"].options]
|
||||
assert values == ["enabled", "disabled_local", "disabled"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-apply on settings save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_update_settings_certificate_validation_triggers_suppression(monkeypatch):
|
||||
"""Changing CERTIFICATE_VALIDATION via update_settings calls _apply_ssl_warning_suppression."""
|
||||
import shelfmark.config.settings # noqa: F401 — ensure settings tabs are registered
|
||||
|
||||
from shelfmark.core.config import config as config_obj
|
||||
from shelfmark.core.settings_registry import update_settings
|
||||
|
||||
monkeypatch.setattr("shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True)
|
||||
monkeypatch.setattr(config_obj, "refresh", lambda: None)
|
||||
|
||||
called = {"count": 0}
|
||||
|
||||
import shelfmark.download.network as network
|
||||
|
||||
def fake_apply():
|
||||
called["count"] += 1
|
||||
|
||||
monkeypatch.setattr(network, "_apply_ssl_warning_suppression", fake_apply)
|
||||
|
||||
result = update_settings("network", {"CERTIFICATE_VALIDATION": "disabled"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert called["count"] == 1
|
||||
@@ -0,0 +1,115 @@
|
||||
"""SSL verification behavior for download client settings test callbacks."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def make_config_getter(values):
|
||||
"""Create a config.get function that returns values from a dict."""
|
||||
|
||||
def getter(key, default=""):
|
||||
return values.get(key, default)
|
||||
|
||||
return getter
|
||||
|
||||
|
||||
def test_transmission_settings_test_connection_applies_ssl_verify(monkeypatch):
|
||||
"""Transmission settings callback should apply verify mode to transmission-rpc session."""
|
||||
from shelfmark.core.config import config as config_obj
|
||||
from shelfmark.download.clients import settings as settings_module
|
||||
|
||||
current_values = {
|
||||
"TRANSMISSION_URL": "https://localhost:9091",
|
||||
"TRANSMISSION_USERNAME": "admin",
|
||||
"TRANSMISSION_PASSWORD": "password",
|
||||
}
|
||||
monkeypatch.setattr(config_obj, "get", make_config_getter(current_values))
|
||||
monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: False)
|
||||
|
||||
mock_http_session = SimpleNamespace(verify=True)
|
||||
mock_client = MagicMock()
|
||||
mock_client._http_session = mock_http_session
|
||||
mock_client.get_session.return_value = SimpleNamespace(version="4.0.0")
|
||||
|
||||
mock_transmission_rpc = MagicMock()
|
||||
mock_transmission_rpc.Client = MagicMock(return_value=mock_client)
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
result = settings_module._test_transmission_connection(current_values=current_values)
|
||||
|
||||
assert result["success"] is True
|
||||
assert mock_http_session.verify is False
|
||||
|
||||
|
||||
def test_transmission_settings_test_connection_disables_verify_during_constructor(monkeypatch):
|
||||
"""Settings callback should disable verify before transmission-rpc constructor bootstraps."""
|
||||
from shelfmark.core.config import config as config_obj
|
||||
from shelfmark.download.clients import settings as settings_module
|
||||
|
||||
current_values = {
|
||||
"TRANSMISSION_URL": "https://localhost:9091",
|
||||
"TRANSMISSION_USERNAME": "admin",
|
||||
"TRANSMISSION_PASSWORD": "password",
|
||||
}
|
||||
monkeypatch.setattr(config_obj, "get", make_config_getter(current_values))
|
||||
monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: False)
|
||||
|
||||
transmission_pkg = types.ModuleType("transmission_rpc")
|
||||
transmission_pkg.__path__ = []
|
||||
transmission_client_mod = types.ModuleType("transmission_rpc.client")
|
||||
|
||||
def _base_session_factory():
|
||||
return types.SimpleNamespace(verify=True)
|
||||
|
||||
transmission_client_mod.requests = types.SimpleNamespace(Session=_base_session_factory)
|
||||
|
||||
def _fake_client_ctor(**_kwargs):
|
||||
bootstrap_session = transmission_client_mod.requests.Session()
|
||||
if bootstrap_session.verify is not False:
|
||||
raise RuntimeError("verify not disabled during constructor bootstrap")
|
||||
client = MagicMock()
|
||||
client._http_session = bootstrap_session
|
||||
client.get_session.return_value = types.SimpleNamespace(version="4.0.0")
|
||||
return client
|
||||
|
||||
transmission_pkg.Client = _fake_client_ctor
|
||||
transmission_pkg.client = transmission_client_mod
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"transmission_rpc": transmission_pkg,
|
||||
"transmission_rpc.client": transmission_client_mod,
|
||||
},
|
||||
):
|
||||
result = settings_module._test_transmission_connection(current_values=current_values)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
def test_rtorrent_settings_test_connection_uses_unverified_transport_when_disabled(monkeypatch):
|
||||
"""rTorrent settings callback should pass SafeTransport for HTTPS when verify is disabled."""
|
||||
from shelfmark.core.config import config as config_obj
|
||||
from shelfmark.download.clients import settings as settings_module
|
||||
|
||||
current_values = {
|
||||
"RTORRENT_URL": "https://localhost:8080/RPC2",
|
||||
"RTORRENT_USERNAME": "",
|
||||
"RTORRENT_PASSWORD": "",
|
||||
}
|
||||
monkeypatch.setattr(config_obj, "get", make_config_getter(current_values))
|
||||
monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: False)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.system.client_version.return_value = "0.9.8"
|
||||
|
||||
mock_xmlrpc = MagicMock()
|
||||
mock_xmlrpc.ServerProxy = MagicMock(return_value=mock_rpc)
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
result = settings_module._test_rtorrent_connection(current_values=current_values)
|
||||
|
||||
assert result["success"] is True
|
||||
assert mock_xmlrpc.SafeTransport.called is True
|
||||
assert "transport" in mock_xmlrpc.ServerProxy.call_args.kwargs
|
||||
@@ -244,8 +244,8 @@ class TestQBittorrentClientGetStatus:
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/downloads/completed.epub"
|
||||
|
||||
def test_get_status_complete_roots_content_path_at_save_path(self, monkeypatch):
|
||||
"""Prefer a save_path-rooted path when qBittorrent reports a temp/incomplete content_path."""
|
||||
def test_get_status_complete_returns_content_path(self, monkeypatch):
|
||||
"""Completed torrents return content_path as-is."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
@@ -261,12 +261,11 @@ class TestQBittorrentClientGetStatus:
|
||||
hash_val="abc123",
|
||||
progress=1.0,
|
||||
state="uploading",
|
||||
content_path="/media/incomplete/book.m4b",
|
||||
content_path="/downloads/shelfmark/Ground State - Craig Alanson/Ground State - Craig Alanson.epub",
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
# Include save_path in the info payload to simulate a temp/incomplete directory config.
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/media"}
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads/shelfmark"}
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -279,7 +278,7 @@ class TestQBittorrentClientGetStatus:
|
||||
status = client.get_status("abc123")
|
||||
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/media/book.m4b"
|
||||
assert status.file_path == "/downloads/shelfmark/Ground State - Craig Alanson/Ground State - Craig Alanson.epub"
|
||||
|
||||
def test_get_status_complete_derives_when_content_path_equals_save_path(self, monkeypatch):
|
||||
"""Keep get_status() and get_download_path() consistent."""
|
||||
@@ -723,8 +722,8 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
|
||||
assert path == "/downloads/some/book.epub"
|
||||
|
||||
def test_get_download_path_roots_content_path_at_save_path_when_complete(self, monkeypatch):
|
||||
"""Mirror get_status(): completed torrents should return the save_path-rooted path."""
|
||||
def test_get_download_path_returns_content_path_when_complete(self, monkeypatch):
|
||||
"""Completed torrents return content_path as-is, preserving subdirectories."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
@@ -740,11 +739,11 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
hash_val="abc123",
|
||||
progress=1.0,
|
||||
state="uploading",
|
||||
content_path="/media/incomplete/book.m4b",
|
||||
content_path="/downloads/shelfmark/BookFolder/book.epub",
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/media"}
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads/shelfmark"}
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -756,7 +755,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
client = qb_module.QBittorrentClient()
|
||||
path = client.get_download_path("abc123")
|
||||
|
||||
assert path == "/media/book.m4b"
|
||||
assert path == "/downloads/shelfmark/BookFolder/book.epub"
|
||||
|
||||
def test_get_download_path_does_not_accept_content_path_equal_save_path(self, monkeypatch):
|
||||
"""content_path == save_path indicates a path error."""
|
||||
|
||||
@@ -80,6 +80,36 @@ class TestRTorrentClientIsConfigured:
|
||||
class TestRTorrentClientTestConnection:
|
||||
"""Tests for RTorrentClient.test_connection()."""
|
||||
|
||||
def test_init_https_disabled_verification_uses_unverified_transport(self, monkeypatch):
|
||||
"""HTTPS rTorrent with verify disabled should use a SafeTransport with custom SSL context."""
|
||||
config_values = {
|
||||
"RTORRENT_URL": "https://localhost:8080/RPC2",
|
||||
"RTORRENT_USERNAME": "",
|
||||
"RTORRENT_PASSWORD": "",
|
||||
"RTORRENT_DOWNLOAD_DIR": "/downloads",
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_xmlrpc = create_mock_xmlrpc_module()
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.download.clients import rtorrent as rtorrent_module
|
||||
|
||||
monkeypatch.setattr(rtorrent_module, "get_ssl_verify", lambda _url: False)
|
||||
rtorrent_module.RTorrentClient()
|
||||
|
||||
assert mock_xmlrpc.SafeTransport.called is True
|
||||
assert "transport" in mock_xmlrpc.ServerProxy.call_args.kwargs
|
||||
|
||||
def test_test_connection_success(self, monkeypatch):
|
||||
"""Test successful connection."""
|
||||
config_values = {
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
from datetime import timedelta
|
||||
import pytest
|
||||
import sys
|
||||
import types
|
||||
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
|
||||
@@ -154,6 +155,87 @@ class TestTransmissionClientTestConnection:
|
||||
TransmissionClient()
|
||||
assert mock_transmission_rpc.Client.call_args.kwargs.get("protocol") == "https"
|
||||
|
||||
def test_init_applies_certificate_validation_to_session(self, monkeypatch):
|
||||
"""Test Transmission client applies verify mode onto transmission-rpc session."""
|
||||
config_values = {
|
||||
"TRANSMISSION_URL": "https://localhost:9091",
|
||||
"TRANSMISSION_USERNAME": "admin",
|
||||
"TRANSMISSION_PASSWORD": "password",
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
mock_http_session = MagicMock()
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._http_session = mock_http_session
|
||||
|
||||
mock_transmission_rpc = create_mock_transmission_rpc_module()
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.download.clients import transmission as transmission_module
|
||||
|
||||
monkeypatch.setattr(transmission_module, "get_ssl_verify", lambda _url: False)
|
||||
transmission_module.TransmissionClient()
|
||||
|
||||
assert mock_http_session.verify is False
|
||||
|
||||
def test_init_disables_verify_before_constructor_bootstrap(self, monkeypatch):
|
||||
"""verify=False must be in place before transmission-rpc constructor bootstraps RPC session."""
|
||||
config_values = {
|
||||
"TRANSMISSION_URL": "https://localhost:9091",
|
||||
"TRANSMISSION_USERNAME": "admin",
|
||||
"TRANSMISSION_PASSWORD": "password",
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
transmission_pkg = types.ModuleType("transmission_rpc")
|
||||
transmission_pkg.__path__ = [] # Mark as package for submodule imports.
|
||||
transmission_client_mod = types.ModuleType("transmission_rpc.client")
|
||||
|
||||
def _base_session_factory():
|
||||
return types.SimpleNamespace(verify=True)
|
||||
|
||||
transmission_client_mod.requests = types.SimpleNamespace(Session=_base_session_factory)
|
||||
|
||||
def _fake_client_ctor(**_kwargs):
|
||||
bootstrap_session = transmission_client_mod.requests.Session()
|
||||
if bootstrap_session.verify is not False:
|
||||
raise RuntimeError("verify not disabled during constructor bootstrap")
|
||||
client = MagicMock()
|
||||
client._http_session = bootstrap_session
|
||||
client.get_session.return_value = MockSession(version="4.0.5")
|
||||
return client
|
||||
|
||||
transmission_pkg.Client = _fake_client_ctor
|
||||
transmission_pkg.client = transmission_client_mod
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"transmission_rpc": transmission_pkg,
|
||||
"transmission_rpc.client": transmission_client_mod,
|
||||
},
|
||||
):
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.download.clients import transmission as transmission_module
|
||||
|
||||
monkeypatch.setattr(transmission_module, "get_ssl_verify", lambda _url: False)
|
||||
client = transmission_module.TransmissionClient()
|
||||
assert client._client._http_session.verify is False
|
||||
|
||||
def test_test_connection_success(self, monkeypatch):
|
||||
"""Test successful connection."""
|
||||
config_values = {
|
||||
|
||||