diff --git a/docs/dev/plugin-settings.md b/docs/dev/plugin-settings.md index a8aab0d6..55247a1c 100644 --- a/docs/dev/plugin-settings.md +++ b/docs/dev/plugin-settings.md @@ -190,6 +190,31 @@ HeadingField( ) ``` +### CustomComponentField + +Render a frontend-registered custom settings component while still using the +decorator-based schema. + +```python +from shelfmark.core.settings_registry import CustomComponentField + +CustomComponentField( + key="request_policy_editor", + component="request_policy_grid", # frontend registry key + label="Request Policy Rules", + description="Custom editor for policy defaults and matrix rules.", + value_fields=[ + SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"), + SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"), + TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]), + ], + wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout +) +``` + +When `value_fields` is provided, those backing fields are included in +serialization/save/validation automatically and are hidden from the default renderer. + ## Common Field Properties All field types support these common properties: @@ -206,6 +231,7 @@ All field types support these common properties: | `requires_restart` | `bool` | `False` | Whether changes require container restart | | `show_when` | `dict` | `None` | Conditional visibility (see below) | | `disabled_when` | `dict` | `None` | Conditional disable (see below) | +| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path | ## Conditional Visibility diff --git a/node_modules/.bin/baseline-browser-mapping b/node_modules/.bin/baseline-browser-mapping new file mode 120000 index 00000000..d2961883 --- /dev/null +++ b/node_modules/.bin/baseline-browser-mapping @@ -0,0 +1 @@ +../baseline-browser-mapping/dist/cli.js \ No newline at end of file diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 00000000..5732cfdc --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} diff --git a/node_modules/baseline-browser-mapping/LICENSE.txt b/node_modules/baseline-browser-mapping/LICENSE.txt new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/node_modules/baseline-browser-mapping/LICENSE.txt @@ -0,0 +1,201 @@ + 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. diff --git a/node_modules/baseline-browser-mapping/README.md b/node_modules/baseline-browser-mapping/README.md new file mode 100644 index 00000000..30113635 --- /dev/null +++ b/node_modules/baseline-browser-mapping/README.md @@ -0,0 +1,463 @@ +# [`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 + +``` + +## 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. diff --git a/node_modules/baseline-browser-mapping/package.json b/node_modules/baseline-browser-mapping/package.json new file mode 100644 index 00000000..7dcb17b8 --- /dev/null +++ b/node_modules/baseline-browser-mapping/package.json @@ -0,0 +1,64 @@ +{ + "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" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..d394c756 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "shelfmark", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "baseline-browser-mapping": "^2.9.19" + } + }, + "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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..57d62f41 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "baseline-browser-mapping": "^2.9.19" + } +} diff --git a/readme.md b/readme.md index fd93a2b9..317228e3 100644 --- a/readme.md +++ b/readme.md @@ -151,21 +151,15 @@ If you need Cloudflare bypass with the Lite image, configure an external resolve ## πŸ” Authentication -Authentication is optional but recommended for shared or exposed instances. Four authentication methods are available in Settings: +Authentication is optional but recommended for shared or exposed instances. Three authentication methods are available in Settings: -**1. Built-in Username/Password** +**1. Single Username/Password** -Multi-user support with admin user management. The first user is always admin. Admins can create additional users, set per-user download destinations, and manage roles. - -**2. OpenID Connect (OIDC)** - -Integrate with any OIDC provider (Authentik, Keycloak, Pocket ID, etc.) for SSO. Supports auto-provisioning, group-based admin mapping, and per-user download settings. Configure your provider's discovery URL, client ID, and client secret in Settings. - -**3. Proxy (Forward) Authentication** +**2. Proxy (Forward) Authentication** Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests. -**4. Calibre-Web Database** +**3. Calibre-Web Database** If you're running Calibre-Web, you can reuse its user database by mounting it: @@ -174,15 +168,6 @@ volumes: - /path/to/calibre-web/app.db:/auth/app.db:ro ``` -### Multi-User Features (Built-in & OIDC) - -- Admin user management panel in Settings -- Per-user download destination overrides -- Per-user BookLore library/path overrides -- Per-user email recipient overrides -- Download queue scoped per user (admins see all) -- `{User}` template variable for organizing downloads by user - ## Health Monitoring The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose: @@ -230,16 +215,13 @@ The frontend dev server proxies to the backend on port 8084. β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Flask Backend β”‚ β”‚ (REST API + WebSocket) β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Authentication β”‚ -β”‚ (Built-in / OIDC / Proxy / CWA / None) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Metadata Providersβ”‚ Download Queue β”‚ Cloudflare β”‚ β”‚ β”‚ & Orchestrator β”‚ Bypass β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β€’ Hardcover β”‚ β€’ Task scheduling β”‚ β€’ Internal β”‚ β”‚ β€’ Open Library β”‚ β€’ Progress tracking β”‚ β€’ External β”‚ -β”‚ β”‚ β€’ Per-user scoping β”‚ (FlareSolverr) β”‚ +β”‚ β”‚ β€’ Retry logic β”‚ (FlareSolverr) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Release Sources β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ diff --git a/shelfmark/api/websocket.py b/shelfmark/api/websocket.py index 1823d83b..0ce655e5 100644 --- a/shelfmark/api/websocket.py +++ b/shelfmark/api/websocket.py @@ -21,6 +21,7 @@ class WebSocketManager: self._on_all_disconnect_callbacks: List[Callable[[], None]] = [] self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect self._user_rooms: Dict[str, int] = {} # room_name -> ref count + self._sid_rooms: Dict[str, str] = {} # sid -> room_name self._rooms_lock = threading.Lock() self._queue_status_fn: Optional[Callable] = None # Reference to queue_status() @@ -107,29 +108,53 @@ class WebSocketManager: """Set the queue_status function reference for per-room filtering.""" self._queue_status_fn = fn + def _increment_user_room_locked(self, room: str): + self._user_rooms[room] = self._user_rooms.get(room, 0) + 1 + + def _decrement_user_room_locked(self, room: str): + count = self._user_rooms.get(room, 1) - 1 + if count <= 0: + self._user_rooms.pop(room, None) + else: + self._user_rooms[room] = count + + def _set_sid_room_locked(self, sid: str, room: Optional[str]): + current_room = self._sid_rooms.get(sid) + if current_room == room: + return + + if current_room is not None: + leave_room(current_room, sid=sid) + if current_room.startswith("user_"): + self._decrement_user_room_locked(current_room) + self._sid_rooms.pop(sid, None) + + if room is not None: + join_room(room, sid=sid) + self._sid_rooms[sid] = room + if room.startswith("user_"): + self._increment_user_room_locked(room) + + def sync_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None): + """Ensure a SID is in exactly one room matching the current session scope.""" + room: Optional[str] = None + if is_admin: + room = "admins" + elif db_user_id is not None: + room = f"user_{db_user_id}" + + with self._rooms_lock: + self._set_sid_room_locked(sid, room) + def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None): """Join the appropriate room based on user role.""" - if is_admin or db_user_id is None: - join_room("admins", sid=sid) - else: - room = f"user_{db_user_id}" - join_room(room, sid=sid) - with self._rooms_lock: - self._user_rooms[room] = self._user_rooms.get(room, 0) + 1 + self.sync_user_room(sid, is_admin, db_user_id) - def leave_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None): - """Leave the user's room on disconnect.""" - if is_admin or db_user_id is None: - leave_room("admins", sid=sid) - else: - room = f"user_{db_user_id}" - leave_room(room, sid=sid) - with self._rooms_lock: - count = self._user_rooms.get(room, 1) - 1 - if count <= 0: - self._user_rooms.pop(room, None) - else: - self._user_rooms[room] = count + def leave_user_room(self, sid: str, is_admin: bool = False, db_user_id: Optional[int] = None): + """Leave whichever room the SID currently belongs to.""" + del is_admin, db_user_id # Backward-compatible signature; routing is SID-based. + with self._rooms_lock: + self._set_sid_room_locked(sid, None) def broadcast_status_update(self, status_data: Dict[str, Any]): """Broadcast status update to all connected clients, filtered by user room.""" diff --git a/shelfmark/config/security.py b/shelfmark/config/security.py index a8ab233c..b211d491 100644 --- a/shelfmark/config/security.py +++ b/shelfmark/config/security.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Callable -from werkzeug.security import generate_password_hash - from shelfmark.config.migrations import migrate_security_settings from shelfmark.config.security_handlers import ( on_save_security, @@ -58,13 +56,7 @@ def _migrate_security_settings() -> None: def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]: - return on_save_security( - values, - load_security_config=lambda: load_config_file("security"), - hash_password=generate_password_hash, - sync_builtin_admin_user=sync_builtin_admin_user, - logger=logger, - ) + return on_save_security(values) def _test_oidc_connection() -> Dict[str, Any]: diff --git a/shelfmark/config/security_handlers.py b/shelfmark/config/security_handlers.py index 80e4a9d4..5f563234 100644 --- a/shelfmark/config/security_handlers.py +++ b/shelfmark/config/security_handlers.py @@ -18,44 +18,11 @@ def _has_local_password_admin() -> bool: def on_save_security( values: dict[str, Any], - *, - load_security_config: Callable[[], dict[str, Any]], - hash_password: Callable[[str], str], - sync_builtin_admin_user: Callable[[str, str], None], - logger: Any, ) -> dict[str, Any]: - """Validate/process security values before persistence.""" + """Validate security values before persistence.""" if values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin(): return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": values} - password = values.pop("BUILTIN_PASSWORD", "") - password_confirm = values.pop("BUILTIN_PASSWORD_CONFIRM", "") - - if password: - if not values.get("BUILTIN_USERNAME"): - return {"error": True, "message": "Username cannot be empty", "values": values} - if password != password_confirm: - return {"error": True, "message": "Passwords do not match", "values": values} - if len(password) < 4: - return {"error": True, "message": "Password must be at least 4 characters", "values": values} - - values["BUILTIN_PASSWORD_HASH"] = hash_password(password) - logger.info("Password hash updated") - elif "BUILTIN_USERNAME" in values: - existing = load_security_config() - if "BUILTIN_PASSWORD_HASH" in existing: - values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"] - - if values.get("AUTH_METHOD") == "builtin": - try: - sync_builtin_admin_user( - values.get("BUILTIN_USERNAME", ""), - values.get("BUILTIN_PASSWORD_HASH", ""), - ) - except Exception as exc: - logger.error(f"Failed to sync builtin admin user: {exc}") - return {"error": True, "message": "Failed to create/update local admin user from builtin credentials", "values": values} - return {"error": False, "values": values} diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index 5b2b5c22..ec7343f8 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -7,15 +7,178 @@ that talks to /api/admin/users endpoints. from shelfmark.core.settings_registry import ( CheckboxField, + CustomComponentField, HeadingField, + NumberField, + SelectField, + TableField, + register_on_save, register_settings, ) +from shelfmark.core.request_policy import ( + get_source_content_type_capabilities, + parse_policy_mode, + validate_policy_rules, +) -@register_settings("users", "Users", icon="users", order=6) +_REQUEST_DEFAULT_MODE_OPTIONS = [ + { + "value": "download", + "label": "Download", + "description": "Allow direct downloads.", + }, + { + "value": "request_release", + "label": "Request Release", + "description": "Block direct download; allow requesting a specific release.", + }, + { + "value": "request_book", + "label": "Request Book", + "description": "Block direct download; allow book-level requests only.", + }, + { + "value": "blocked", + "label": "Blocked", + "description": "Block both downloading and requesting.", + }, +] + +_REQUEST_MATRIX_MODE_OPTIONS = [ + option for option in _REQUEST_DEFAULT_MODE_OPTIONS if option["value"] != "request_book" +] + +_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = { + "builtin": ( + "Create and manage user accounts directly. Passwords are stored locally and users sign in " + "with their username and password." + ), + "oidc": ( + "Users sign in through your identity provider. New accounts can be created automatically on " + "first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll " + "be linked by email on first sign-in." + ), + "proxy": ( + "Users are authenticated by your reverse proxy. Accounts are automatically created on first " + "sign-in. If a local user with a matching username already exists, it will be linked instead." + ), + "cwa": ( + "User accounts are synced from your Calibre-Web database. Users are matched by email, and new " + "accounts are created here when new CWA users are found." + ), + "none": "Authentication is disabled. Anyone can access Shelfmark without signing in.", + "default": "Authentication is disabled. Anyone can access Shelfmark without signing in.", +} + + +def _get_request_source_options(): + """Build request-policy source options from registered release sources.""" + from shelfmark.release_sources import list_available_sources + + options = [] + for source in list_available_sources(): + options.append( + { + "value": source["name"], + "label": source["display_name"], + } + ) + return options + + +def _get_request_policy_rule_columns(): + source_capabilities = get_source_content_type_capabilities() + content_type_options = [] + + for source_name, supported_types in source_capabilities.items(): + normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types] + for content_type in normalized_types: + content_type_options.append( + { + "value": content_type, + "label": "Ebook" if content_type == "ebook" else "Audiobook", + "childOf": source_name, + } + ) + + return [ + { + "key": "source", + "label": "Source", + "type": "select", + "options": _get_request_source_options(), + "defaultValue": "", + "placeholder": "Select source...", + }, + { + "key": "content_type", + "label": "Content Type", + "type": "select", + "options": content_type_options, + "defaultValue": "", + "placeholder": "Select content type...", + "filterByField": "source", + }, + { + "key": "mode", + "label": "Mode", + "type": "select", + "options": _REQUEST_MATRIX_MODE_OPTIONS, + "defaultValue": "", + "placeholder": "Select mode...", + }, + ] + + +def _on_save_users(values): + """Validate users/request-policy settings before persistence.""" + if "REQUEST_POLICY_DEFAULT_EBOOK" in values: + if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None: + return { + "error": True, + "message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode", + "values": values, + } + + if "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values: + if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None: + return { + "error": True, + "message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode", + "values": values, + } + + if "REQUEST_POLICY_RULES" in values: + normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"]) + if errors: + return { + "error": True, + "message": "; ".join(errors), + "values": values, + } + values["REQUEST_POLICY_RULES"] = normalized_rules + + return {"error": False, "values": values} + + +register_on_save("users", _on_save_users) + + +@register_settings("users", "Users & Requests", icon="users", order=6) def users_settings(): """User management tab - rendered as a custom component on the frontend.""" return [ + HeadingField( + key="users_heading", + title="Users", + description=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE["default"], + description_by_auth_mode=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE, + ), + CustomComponentField( + key="users_management", + component="users_management", + ), HeadingField( key="users_access_heading", title="Options", @@ -31,4 +194,83 @@ def users_settings(): default=True, env_supported=False, ), + HeadingField( + key="requests_heading", + title="Request Policy", + description=( + "Configure when users can download directly and when they must create requests." + ), + ), + CheckboxField( + key="REQUESTS_ENABLED", + label="Enable Request Workflow", + description=( + "When disabled, request actions are hidden and only direct downloads are used." + ), + default=False, + user_overridable=True, + ), + CustomComponentField( + key="request_policy_editor", + component="request_policy_grid", + label="Request Policy Rules", + description=( + "Source/content-type rules can only restrict the content-type default ceiling." + ), + show_when={"field": "REQUESTS_ENABLED", "value": True}, + wrap_in_field_wrapper=True, + value_fields=[ + SelectField( + key="REQUEST_POLICY_DEFAULT_EBOOK", + label="Default Ebook Mode", + description=( + "Global ceiling for ebook actions. Source rules can only match or restrict this mode." + ), + options=_REQUEST_DEFAULT_MODE_OPTIONS, + default="download", + user_overridable=True, + ), + SelectField( + key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", + label="Default Audiobook Mode", + description=( + "Global ceiling for audiobook actions. Source rules can only match or restrict this mode." + ), + options=_REQUEST_DEFAULT_MODE_OPTIONS, + default="download", + user_overridable=True, + ), + TableField( + key="REQUEST_POLICY_RULES", + label="Request Policy Rules", + description=( + "Source/content-type rules can only restrict the content-type default ceiling." + ), + columns=_get_request_policy_rule_columns, + default=[], + add_label="Add Rule", + empty_message="No request policy rules configured.", + env_supported=False, + user_overridable=True, + ), + ], + ), + NumberField( + key="MAX_PENDING_REQUESTS_PER_USER", + label="Max Pending Requests Per User", + description="Maximum number of pending requests a user can have at once.", + default=20, + min_value=1, + max_value=1000, + user_overridable=True, + show_when={"field": "REQUESTS_ENABLED", "value": True}, + ), + CheckboxField( + key="REQUESTS_ALLOW_NOTES", + label="Allow Request Notes", + description="Allow users to include notes when creating requests.", + default=True, + user_overridable=True, + show_when={"field": "REQUESTS_ENABLED", "value": True}, + ), ] diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py index 3e7ec22d..db3782cc 100644 --- a/shelfmark/core/admin_settings_routes.py +++ b/shelfmark/core/admin_settings_routes.py @@ -6,12 +6,14 @@ from flask import Flask, jsonify, request from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_db import UserDB +from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules def _get_settings_registry(): # Ensure settings modules are loaded before reading registry metadata. import shelfmark.config.settings # noqa: F401 import shelfmark.config.security # noqa: F401 + import shelfmark.config.users_settings # noqa: F401 from shelfmark.core import settings_registry return settings_registry @@ -39,6 +41,24 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li elif key not in overridable_map: errors.append(f"Setting not user-overridable: {key}") else: + # null means "clear the per-user override; use global default" + if value is None: + valid[key] = None + continue + + if key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}: + if parse_policy_mode(value) is None: + errors.append(f"Invalid policy mode for {key}: {value}") + continue + + if key == "REQUEST_POLICY_RULES": + normalized_rules, rule_errors = validate_policy_rules(value) + if rule_errors: + errors.extend(rule_errors) + continue + valid[key] = normalized_rules + continue + valid[key] = value return valid, errors diff --git a/shelfmark/core/request_policy.py b/shelfmark/core/request_policy.py new file mode 100644 index 00000000..dde9d364 --- /dev/null +++ b/shelfmark/core/request_policy.py @@ -0,0 +1,351 @@ +"""Request-policy resolution helpers. + +This module is intentionally pure and side-effect free so it can be reused by +routes/services and tested independently. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Iterable, Mapping, Sequence + + +class PolicyMode(str, Enum): + """Allowed request-policy modes. + + Ordered from most to least permissive. The content-type default acts as a + ceiling β€” matrix rules can only match or restrict further, never upgrade + beyond the default. + """ + + DOWNLOAD = "download" + REQUEST_RELEASE = "request_release" + REQUEST_BOOK = "request_book" + BLOCKED = "blocked" + + +# Permissiveness ordering: lower index = more permissive. +_MODE_PERMISSIVENESS: dict[PolicyMode, int] = { + PolicyMode.DOWNLOAD: 0, + PolicyMode.REQUEST_RELEASE: 1, + PolicyMode.REQUEST_BOOK: 2, + PolicyMode.BLOCKED: 3, +} + +# Modes allowed in REQUEST_POLICY_RULES matrix rows. +MATRIX_ALLOWED_MODES = frozenset({PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED}) + + +def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode: + """Cap a resolved mode so it cannot be more permissive than the ceiling.""" + if _MODE_PERMISSIVENESS[mode] < _MODE_PERMISSIVENESS[ceiling]: + return ceiling + return mode + + +REQUEST_POLICY_KEYS = frozenset( + { + "REQUESTS_ENABLED", + "REQUEST_POLICY_DEFAULT_EBOOK", + "REQUEST_POLICY_DEFAULT_AUDIOBOOK", + "REQUEST_POLICY_RULES", + "MAX_PENDING_REQUESTS_PER_USER", + "REQUESTS_ALLOW_NOTES", + } +) + +REQUEST_POLICY_DEFAULT_FALLBACK_MODE = PolicyMode.REQUEST_BOOK + +DEFAULT_SUPPORTED_CONTENT_TYPES = ("ebook", "audiobook") + + +def filter_request_policy_settings(settings: Mapping[str, Any] | None) -> dict[str, Any]: + """Return only uppercase request-policy keys from a settings JSON object.""" + if not isinstance(settings, Mapping): + return {} + return {key: settings[key] for key in REQUEST_POLICY_KEYS if key in settings} + + +def merge_request_policy_settings( + global_settings: Mapping[str, Any] | None, + user_settings: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Merge global settings with per-user request-policy overrides.""" + merged = filter_request_policy_settings(global_settings) + user_filtered = filter_request_policy_settings(user_settings) + + # Preserve global rules by default and treat user rules as per-cell overlays. + # This allows per-user REQUEST_POLICY_RULES payloads to store only explicit + # differences instead of replacing the full global matrix. + global_rules = list(_iter_rules(merged.get("REQUEST_POLICY_RULES", []))) + user_has_rules = "REQUEST_POLICY_RULES" in user_filtered + + for key, value in user_filtered.items(): + if key == "REQUEST_POLICY_RULES": + continue + merged[key] = value + + if user_has_rules: + merged_rules: dict[tuple[str, str], tuple[str, str, PolicyMode]] = { + (source, content_type): (source, content_type, mode) + for source, content_type, mode in global_rules + } + for source, content_type, mode in _iter_rules(user_filtered.get("REQUEST_POLICY_RULES", [])): + merged_rules[(source, content_type)] = (source, content_type, mode) + merged["REQUEST_POLICY_RULES"] = [ + {"source": source, "content_type": content_type, "mode": mode.value} + for source, content_type, mode in merged_rules.values() + ] + + return merged + + +def normalize_content_type(content_type: Any) -> str: + """Normalize arbitrary content type values to `ebook` or `audiobook`.""" + if not isinstance(content_type, str): + return "ebook" + + value = content_type.strip().lower() + if not value: + return "ebook" + + if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}: + return "audiobook" + + return "ebook" + + +def normalize_source(source: Any) -> str: + """Normalize source values for policy matching.""" + if not isinstance(source, str): + return "*" + + value = source.strip().lower() + return value or "*" + + +def parse_policy_mode(mode: Any) -> PolicyMode | None: + """Parse an arbitrary mode value into a PolicyMode enum member.""" + if isinstance(mode, PolicyMode): + return mode + if not isinstance(mode, str): + return None + try: + return PolicyMode(mode.strip().lower()) + except ValueError: + return None + + +def _normalize_rule_content_type(content_type: Any) -> str | None: + if not isinstance(content_type, str): + return None + value = content_type.strip().lower() + if not value: + return None + if value in {"*", "any"}: + return "*" + if value in {"ebook", "book", "books", "book (fiction)"}: + return "ebook" + if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}: + return "audiobook" + return None + + +def _normalize_rule_source(source: Any) -> str | None: + if not isinstance(source, str): + return None + value = source.strip().lower() + if not value: + return None + if value in {"*", "any"}: + return "*" + return value + + +def get_source_content_type_capabilities() -> dict[str, set[str]]: + """Return source -> supported content type map from registered sources.""" + try: + from shelfmark.release_sources import list_available_sources + except Exception: + return {} + + capabilities: dict[str, set[str]] = {} + for source in list_available_sources(): + raw_name = source.get("name") + name = normalize_source(raw_name) + if not name or name == "*": + continue + + raw_types = source.get("supported_content_types", DEFAULT_SUPPORTED_CONTENT_TYPES) + if isinstance(raw_types, str) or not isinstance(raw_types, Sequence): + raw_types = DEFAULT_SUPPORTED_CONTENT_TYPES + + normalized_types: set[str] = set() + for content_type in raw_types: + normalized_type = _normalize_rule_content_type(content_type) + if normalized_type and normalized_type != "*": + normalized_types.add(normalized_type) + + if not normalized_types: + normalized_types = set(DEFAULT_SUPPORTED_CONTENT_TYPES) + capabilities[name] = normalized_types + return capabilities + + +def validate_policy_rules( + rules: Any, + source_capabilities: Mapping[str, set[str]] | None = None, +) -> tuple[list[dict[str, str]], list[str]]: + """Validate and normalize policy rule rows. + + Validation covers: + - row shape and required keys + - valid mode/content_type values + - known source names + - source/content-type compatibility from source declarations + """ + capabilities = source_capabilities if source_capabilities is not None else get_source_content_type_capabilities() + normalized_capabilities = { + normalize_source(source): {normalize_content_type(content_type) for content_type in content_types} + for source, content_types in capabilities.items() + } + + normalized_rules: list[dict[str, str]] = [] + errors: list[str] = [] + + if rules is None: + return normalized_rules, errors + if not isinstance(rules, list): + return normalized_rules, ["REQUEST_POLICY_RULES must be a list"] + + for index, rule in enumerate(rules): + row_label = f"Rule {index + 1}" + if not isinstance(rule, Mapping): + errors.append(f"{row_label}: must be an object") + continue + + source = _normalize_rule_source(rule.get("source")) + raw_content_type = rule.get("content_type") + content_type = _normalize_rule_content_type(rule.get("content_type")) + raw_mode = rule.get("mode") + mode = parse_policy_mode(rule.get("mode")) + + if source is None: + errors.append(f"{row_label}: source is required") + continue + if ( + raw_content_type is None + or (isinstance(raw_content_type, str) and not raw_content_type.strip()) + ): + errors.append(f"{row_label}: content_type is required") + continue + if content_type is None: + errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'") + continue + if ( + raw_mode is None + or (isinstance(raw_mode, str) and not raw_mode.strip()) + ): + errors.append(f"{row_label}: mode is required") + continue + if mode is None: + errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'") + continue + if mode not in MATRIX_ALLOWED_MODES: + errors.append(f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)") + continue + + if source != "*" and source not in normalized_capabilities: + errors.append(f"{row_label}: unknown source '{source}'") + continue + + if ( + source != "*" + and content_type != "*" + and source in normalized_capabilities + and content_type not in normalized_capabilities[source] + ): + errors.append( + f"{row_label}: source '{source}' does not support content_type '{content_type}'" + ) + continue + + normalized_rules.append( + { + "source": source, + "content_type": content_type, + "mode": mode.value, + } + ) + + return normalized_rules, errors + + +def _iter_rules(rules: Any) -> Iterable[tuple[str, str, PolicyMode]]: + if not isinstance(rules, list): + return [] + + normalized: list[tuple[str, str, PolicyMode]] = [] + for rule in rules: + if not isinstance(rule, Mapping): + continue + source = _normalize_rule_source(rule.get("source")) + content_type = _normalize_rule_content_type(rule.get("content_type")) + mode = parse_policy_mode(rule.get("mode")) + if ( + source is None + or content_type is None + or mode is None + or mode not in MATRIX_ALLOWED_MODES + ): + continue + normalized.append((source, content_type, mode)) + return normalized + + +def resolve_policy_mode( + *, + source: Any, + content_type: Any, + global_settings: Mapping[str, Any] | None, + user_settings: Mapping[str, Any] | None = None, +) -> PolicyMode: + """Resolve an effective policy mode for a request context. + + Resolution: + 1. Resolve the content-type default (ceiling). + 2. Match rules in specificity order. + 3. Cap the matched rule at the ceiling. + 4. If no rule matches, return the ceiling. + + The content-type default acts as a ceiling β€” matrix rules can only + match or restrict further, never upgrade beyond the default. + """ + + effective = merge_request_policy_settings(global_settings, user_settings) + normalized_source = normalize_source(source) + normalized_content_type = normalize_content_type(content_type) + + # Resolve the content-type default (ceiling) + default_key = ( + "REQUEST_POLICY_DEFAULT_AUDIOBOOK" + if normalized_content_type == "audiobook" + else "REQUEST_POLICY_DEFAULT_EBOOK" + ) + default_mode = parse_policy_mode(effective.get(default_key)) + ceiling = default_mode if default_mode is not None else REQUEST_POLICY_DEFAULT_FALLBACK_MODE + + # Match rules in specificity order + rules = tuple(_iter_rules(effective.get("REQUEST_POLICY_RULES", []))) + candidates = ( + (normalized_source, normalized_content_type), + (normalized_source, "*"), + ("*", normalized_content_type), + ("*", "*"), + ) + for candidate_source, candidate_content_type in candidates: + for rule_source, rule_content_type, rule_mode in rules: + if rule_source == candidate_source and rule_content_type == candidate_content_type: + return cap_mode(rule_mode, ceiling) + + return ceiling diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py new file mode 100644 index 00000000..59807c0a --- /dev/null +++ b/shelfmark/core/request_routes.py @@ -0,0 +1,578 @@ +"""Request API routes and policy snapshot endpoint.""" + +from __future__ import annotations + +from typing import Any, Callable + +from flask import Flask, jsonify, request, session + +from shelfmark.core.logger import setup_logger +from shelfmark.core.request_policy import ( + PolicyMode, + REQUEST_POLICY_DEFAULT_FALLBACK_MODE, + get_source_content_type_capabilities, + merge_request_policy_settings, + normalize_content_type, + normalize_source, + parse_policy_mode, + resolve_policy_mode, +) +from shelfmark.core.requests_service import ( + RequestServiceError, + cancel_request, + create_request, + fulfil_request, + reject_request, +) +from shelfmark.core.settings_registry import load_config_file +from shelfmark.core.user_db import UserDB + +logger = setup_logger(__name__) + + +def _load_users_request_policy_settings() -> dict[str, Any]: + """Load global request-policy settings from users config.""" + return load_config_file("users") + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + return bool(value) + + +def _as_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed + + +def _error_response( + message: str, + status_code: int, + *, + code: str | None = None, + required_mode: str | None = None, +): + payload: dict[str, Any] = {"error": message} + if code is not None: + payload["code"] = code + if required_mode is not None: + payload["required_mode"] = required_mode + return jsonify(payload), status_code + + +def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]): + auth_mode = resolve_auth_mode() + if auth_mode == "none": + return _error_response( + "Request workflow is unavailable in no-auth mode", + 403, + code="requests_unavailable", + ) + if "user_id" not in session: + return jsonify({"error": "Unauthorized"}), 401 + return None + + +def _require_db_user_id() -> tuple[int | None, Any | None]: + raw_user_id = session.get("db_user_id") + if raw_user_id is None: + return None, _error_response( + "User identity is unavailable for request workflow", + 403, + code="user_identity_unavailable", + ) + try: + return int(raw_user_id), None + except (TypeError, ValueError): + return None, _error_response( + "User identity is unavailable for request workflow", + 403, + code="user_identity_unavailable", + ) + + +def _resolve_effective_policy( + user_db: UserDB, + *, + db_user_id: int | None, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], bool]: + global_settings = _load_users_request_policy_settings() + user_settings = user_db.get_user_settings(db_user_id) if db_user_id is not None else {} + effective = merge_request_policy_settings(global_settings, user_settings) + requests_enabled = _as_bool(effective.get("REQUESTS_ENABLED"), False) + return global_settings, user_settings, effective, requests_enabled + + +def _emit_request_event( + ws_manager: Any, + *, + event_name: str, + payload: dict[str, Any], + room: str, +) -> None: + if ws_manager is None: + return + try: + socketio = getattr(ws_manager, "socketio", None) + is_enabled = getattr(ws_manager, "is_enabled", None) + if socketio is None or not callable(is_enabled) or not is_enabled(): + return + socketio.emit(event_name, payload, to=room) + except Exception as exc: + logger.warning(f"Failed to emit WebSocket event '{event_name}' to room '{room}': {exc}") + + +def register_request_routes( + app: Flask, + user_db: UserDB, + *, + resolve_auth_mode: Callable[[], str], + queue_release: Callable[..., tuple[bool, str | None]], + ws_manager: Any | None = None, +) -> None: + """Register request policy and request lifecycle routes.""" + + @app.route("/api/request-policy", methods=["GET"]) + def api_request_policy(): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + + is_admin = bool(session.get("is_admin", False)) + db_user_id: int | None = None + if not is_admin: + db_user_id, db_gate = _require_db_user_id() + if db_gate is not None: + return db_gate + else: + raw_id = session.get("db_user_id") + if raw_id is not None: + try: + db_user_id = int(raw_id) + except (TypeError, ValueError): + db_user_id = None + + global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy( + user_db, + db_user_id=db_user_id, + ) + + default_ebook_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_EBOOK")) + default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK")) + + source_capabilities = get_source_content_type_capabilities() + source_modes = [] + for source_name in sorted(source_capabilities): + supported_types = sorted( + source_capabilities[source_name], + key=lambda ct: (ct != "ebook", ct), + ) + modes = { + content_type: resolve_policy_mode( + source=source_name, + content_type=content_type, + global_settings=global_settings, + user_settings=user_settings, + ).value + for content_type in supported_types + } + source_modes.append( + { + "source": source_name, + "supported_content_types": supported_types, + "modes": modes, + } + ) + + logger.debug( + "request-policy snapshot user=%s db_user_id=%s is_admin=%s requests_enabled=%s defaults=%s", + session.get("user_id"), + db_user_id, + is_admin, + requests_enabled, + { + "ebook": ( + default_ebook_mode.value + if default_ebook_mode is not None + else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value + ), + "audiobook": ( + default_audio_mode.value + if default_audio_mode is not None + else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value + ), + }, + ) + + return jsonify( + { + "requests_enabled": requests_enabled, + "is_admin": is_admin, + "allow_notes": _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True), + "defaults": { + "ebook": ( + default_ebook_mode.value + if default_ebook_mode is not None + else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value + ), + "audiobook": ( + default_audio_mode.value + if default_audio_mode is not None + else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value + ), + }, + "rules": effective.get("REQUEST_POLICY_RULES", []), + "source_modes": source_modes, + } + ) + + @app.route("/api/requests", methods=["POST"]) + def api_create_request(): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + + db_user_id, db_gate = _require_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "No data provided"}), 400 + + context = data.get("context") or {} + if not isinstance(context, dict): + return jsonify({"error": "context must be an object"}), 400 + + source = normalize_source(context.get("source")) + release_data = data.get("release_data") + request_level = context.get("request_level") + if request_level is None: + request_level = "book" if release_data is None else "release" + + book_data = data.get("book_data") + if not isinstance(book_data, dict): + return jsonify({"error": "book_data must be an object"}), 400 + + content_type = normalize_content_type( + context.get("content_type") + or data.get("content_type") + or book_data.get("content_type") + ) + + global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy( + user_db, + db_user_id=db_user_id, + ) + if not requests_enabled: + return _error_response( + "Request workflow is disabled by policy", + 403, + code="requests_unavailable", + ) + + max_pending = _as_int( + effective.get("MAX_PENDING_REQUESTS_PER_USER"), + default=20, + ) + if max_pending < 1: + max_pending = 1 + if max_pending > 1000: + max_pending = 1000 + allow_notes = _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True) + note_value = data.get("note") if allow_notes else None + + resolved_mode = resolve_policy_mode( + source=source, + content_type=content_type, + global_settings=global_settings, + user_settings=user_settings, + ) + logger.debug( + "request create policy user=%s db_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s", + session.get("user_id"), + db_user_id, + source, + content_type, + request_level, + resolved_mode.value, + ) + + if resolved_mode == PolicyMode.BLOCKED: + return _error_response( + "Requesting is blocked by policy", + 403, + code="policy_blocked", + required_mode=PolicyMode.BLOCKED.value, + ) + + if resolved_mode == PolicyMode.REQUEST_BOOK: + requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else "" + if requested_level != "book": + return _error_response( + "Policy requires book-level requests", + 403, + code="policy_requires_request", + required_mode=PolicyMode.REQUEST_BOOK.value, + ) + + try: + created = create_request( + user_db, + user_id=db_user_id, + source_hint=source, + content_type=content_type, + request_level=request_level, + policy_mode=resolved_mode.value, + book_data=book_data, + release_data=release_data, + note=note_value, + max_pending_per_user=max_pending, + ) + except RequestServiceError as exc: + return _error_response(str(exc), exc.status_code, code=exc.code) + + event_payload = { + "request_id": created["id"], + "status": created["status"], + "title": (created.get("book_data") or {}).get("title") or "Unknown title", + } + _emit_request_event( + ws_manager, + event_name="new_request", + payload=event_payload, + room="admins", + ) + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room=f"user_{db_user_id}", + ) + + return jsonify(created), 201 + + @app.route("/api/requests", methods=["GET"]) + def api_list_requests(): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + + db_user_id, db_gate = _require_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + status = request.args.get("status") + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int, default=0) or 0 + + try: + rows = user_db.list_requests( + user_id=db_user_id, + status=status, + limit=limit, + offset=offset, + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(rows) + + @app.route("/api/requests/", methods=["DELETE"]) + def api_cancel_request(request_id: int): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + + db_user_id, db_gate = _require_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + try: + updated = cancel_request( + user_db, + request_id=request_id, + actor_user_id=db_user_id, + ) + except RequestServiceError as exc: + return _error_response(str(exc), exc.status_code, code=exc.code) + + event_payload = { + "request_id": updated["id"], + "status": updated["status"], + "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + } + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room=f"user_{db_user_id}", + ) + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room="admins", + ) + + return jsonify(updated) + + @app.route("/api/admin/requests", methods=["GET"]) + def api_admin_list_requests(): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + if not session.get("is_admin", False): + return jsonify({"error": "Admin access required"}), 403 + + status = request.args.get("status") + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int, default=0) or 0 + + try: + rows = user_db.list_requests(status=status, limit=limit, offset=offset) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + user_cache: dict[int, str] = {} + for row in rows: + requester_id = row["user_id"] + if requester_id not in user_cache: + requester = user_db.get_user(user_id=requester_id) + user_cache[requester_id] = requester.get("username", "") if requester else "" + row["username"] = user_cache[requester_id] + + return jsonify(rows) + + @app.route("/api/admin/requests/count", methods=["GET"]) + def api_admin_request_counts(): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + if not session.get("is_admin", False): + return jsonify({"error": "Admin access required"}), 403 + + by_status = { + status: len(user_db.list_requests(status=status)) + for status in ("pending", "fulfilled", "rejected", "cancelled") + } + return jsonify( + { + "pending": by_status["pending"], + "total": sum(by_status.values()), + "by_status": by_status, + } + ) + + @app.route("/api/admin/requests//fulfil", methods=["POST"]) + def api_admin_fulfil_request(request_id: int): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + if not session.get("is_admin", False): + return jsonify({"error": "Admin access required"}), 403 + + raw_admin_id = session.get("db_user_id") + if raw_admin_id is None: + return jsonify({"error": "Admin user identity unavailable"}), 403 + try: + admin_user_id = int(raw_admin_id) + except (TypeError, ValueError): + return jsonify({"error": "Admin user identity unavailable"}), 403 + + data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + return jsonify({"error": "Invalid payload"}), 400 + + try: + updated = fulfil_request( + user_db, + request_id=request_id, + admin_user_id=admin_user_id, + queue_release=queue_release, + release_data=data.get("release_data"), + admin_note=data.get("admin_note"), + ) + except RequestServiceError as exc: + return _error_response(str(exc), exc.status_code, code=exc.code) + + event_payload = { + "request_id": updated["id"], + "status": updated["status"], + "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + } + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room=f"user_{updated['user_id']}", + ) + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room="admins", + ) + + return jsonify(updated) + + @app.route("/api/admin/requests//reject", methods=["POST"]) + def api_admin_reject_request(request_id: int): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + if not session.get("is_admin", False): + return jsonify({"error": "Admin access required"}), 403 + + raw_admin_id = session.get("db_user_id") + if raw_admin_id is None: + return jsonify({"error": "Admin user identity unavailable"}), 403 + try: + admin_user_id = int(raw_admin_id) + except (TypeError, ValueError): + return jsonify({"error": "Admin user identity unavailable"}), 403 + + data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + return jsonify({"error": "Invalid payload"}), 400 + + try: + updated = reject_request( + user_db, + request_id=request_id, + admin_user_id=admin_user_id, + admin_note=data.get("admin_note"), + ) + except RequestServiceError as exc: + return _error_response(str(exc), exc.status_code, code=exc.code) + + event_payload = { + "request_id": updated["id"], + "status": updated["status"], + "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + } + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room=f"user_{updated['user_id']}", + ) + _emit_request_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room="admins", + ) + + return jsonify(updated) diff --git a/shelfmark/core/requests_service.py b/shelfmark/core/requests_service.py new file mode 100644 index 00000000..54b1da2e --- /dev/null +++ b/shelfmark/core/requests_service.py @@ -0,0 +1,391 @@ +"""Request lifecycle helpers and service-level validation.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import Any, Callable, TYPE_CHECKING + +from shelfmark.core.request_policy import normalize_content_type, parse_policy_mode + + +VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"}) +TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"}) +VALID_REQUEST_LEVELS = frozenset({"book", "release"}) +MAX_REQUEST_NOTE_LENGTH = 1000 +MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024 + + +if TYPE_CHECKING: + from shelfmark.core.user_db import UserDB + + +class RequestServiceError(ValueError): + """Structured error raised by request lifecycle service methods.""" + + def __init__( + self, + message: str, + *, + status_code: int = 400, + code: str | None = None, + ): + super().__init__(message) + self.status_code = status_code + self.code = code + + +def normalize_request_status(status: Any) -> str: + """Validate and normalize request status values.""" + if not isinstance(status, str): + raise ValueError(f"Invalid request status: {status}") + normalized = status.strip().lower() + if normalized not in VALID_REQUEST_STATUSES: + raise ValueError(f"Invalid request status: {status}") + return normalized + + +def normalize_policy_mode(mode: Any) -> str: + """Validate and normalize policy mode values.""" + parsed = parse_policy_mode(mode) + if parsed is None: + raise ValueError(f"Invalid policy_mode: {mode}") + return parsed.value + + +def normalize_request_level(request_level: Any) -> str: + """Validate and normalize request level values.""" + if not isinstance(request_level, str): + raise ValueError(f"Invalid request_level: {request_level}") + normalized = request_level.strip().lower() + if normalized not in VALID_REQUEST_LEVELS: + raise ValueError(f"Invalid request_level: {request_level}") + return normalized + + +def validate_request_level_payload(request_level: Any, release_data: Any) -> str: + """Validate request_level and release_data shape coupling.""" + normalized_level = normalize_request_level(request_level) + if normalized_level == "release" and release_data is None: + raise ValueError("request_level=release requires non-null release_data") + if normalized_level == "book" and release_data is not None: + raise ValueError("request_level=book requires null release_data") + return normalized_level + + +def validate_status_transition(current_status: Any, new_status: Any) -> tuple[str, str]: + """Validate request status transitions and terminal immutability.""" + current = normalize_request_status(current_status) + new = normalize_request_status(new_status) + if current in TERMINAL_REQUEST_STATUSES and new != current: + raise ValueError("Terminal request statuses are immutable") + return current, new + + +def _normalize_match_text(value: Any) -> str: + if not isinstance(value, str): + return "" + return value.strip().lower() + + +def normalize_note(note: Any) -> str | None: + """Validate request notes and normalize empty strings to None.""" + if note is None: + return None + if not isinstance(note, str): + raise RequestServiceError("note must be a string", status_code=400) + normalized = note.strip() + if len(normalized) > MAX_REQUEST_NOTE_LENGTH: + raise RequestServiceError( + f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters", + status_code=400, + ) + return normalized or None + + +def _validate_book_data(book_data: Any) -> dict[str, Any]: + if not isinstance(book_data, dict): + raise RequestServiceError("book_data must be an object", status_code=400) + + required_fields = ("title", "author", "provider", "provider_id") + missing = [field for field in required_fields if not _normalize_match_text(book_data.get(field))] + if missing: + raise RequestServiceError( + f"book_data missing required field(s): {', '.join(missing)}", + status_code=400, + ) + return dict(book_data) + + +def _validate_json_blob_size(field: str, payload: Any) -> None: + if payload is None: + return + + try: + serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise RequestServiceError(f"{field} must be JSON-serializable", status_code=400) from exc + + payload_size = len(serialized.encode("utf-8")) + if payload_size > MAX_REQUEST_JSON_BLOB_BYTES: + raise RequestServiceError( + f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes", + status_code=400, + code="request_payload_too_large", + ) + + +def _find_duplicate_pending_request( + user_db: "UserDB", + *, + user_id: int, + title: str, + author: str, + content_type: str, +) -> dict[str, Any] | None: + pending_rows = user_db.list_requests(user_id=user_id, status="pending") + for row in pending_rows: + row_book_data = row.get("book_data") or {} + if not isinstance(row_book_data, dict): + continue + + row_title = _normalize_match_text(row_book_data.get("title")) + row_author = _normalize_match_text(row_book_data.get("author")) + row_content_type = normalize_content_type( + row.get("content_type") or row_book_data.get("content_type") + ) + if row_title == title and row_author == author and row_content_type == content_type: + return row + return None + + +def _now_timestamp() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def create_request( + user_db: "UserDB", + *, + user_id: int, + source_hint: str | None, + content_type: Any, + request_level: Any, + policy_mode: Any, + book_data: Any, + release_data: Any = None, + note: Any = None, + max_pending_per_user: int | None = None, +) -> dict[str, Any]: + """Create a pending request after service-level validation.""" + validated_book_data = _validate_book_data(book_data) + normalized_note = normalize_note(note) + normalized_content_type = normalize_content_type( + content_type or validated_book_data.get("content_type") + ) + validated_book_data["content_type"] = normalized_content_type + + try: + normalized_request_level = validate_request_level_payload(request_level, release_data) + normalized_policy_mode = normalize_policy_mode(policy_mode) + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc + + _validate_json_blob_size("book_data", validated_book_data) + _validate_json_blob_size("release_data", release_data) + + if max_pending_per_user is not None: + pending_count = user_db.count_user_pending_requests(user_id) + if pending_count >= max_pending_per_user: + raise RequestServiceError( + "Maximum pending requests reached for this user", + status_code=409, + code="max_pending_reached", + ) + + duplicate = _find_duplicate_pending_request( + user_db, + user_id=user_id, + title=_normalize_match_text(validated_book_data.get("title")), + author=_normalize_match_text(validated_book_data.get("author")), + content_type=normalized_content_type, + ) + if duplicate is not None: + raise RequestServiceError( + "Duplicate pending request exists for this title/author/content_type", + status_code=409, + code="duplicate_pending_request", + ) + + try: + return user_db.create_request( + user_id=user_id, + source_hint=source_hint, + content_type=normalized_content_type, + request_level=normalized_request_level, + policy_mode=normalized_policy_mode, + book_data=validated_book_data, + release_data=release_data, + note=normalized_note, + ) + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc + + +def ensure_request_access( + user_db: "UserDB", + *, + request_id: int, + actor_user_id: int | None, + is_admin: bool, +) -> dict[str, Any]: + """Get request by ID and enforce ownership for non-admin actors.""" + request_row = user_db.get_request(request_id) + if request_row is None: + raise RequestServiceError("Request not found", status_code=404) + + if not is_admin: + if actor_user_id is None or request_row["user_id"] != actor_user_id: + raise RequestServiceError("Forbidden", status_code=403) + + return request_row + + +def cancel_request( + user_db: "UserDB", + *, + request_id: int, + actor_user_id: int, +) -> dict[str, Any]: + """Cancel a pending request owned by the actor.""" + request_row = ensure_request_access( + user_db, + request_id=request_id, + actor_user_id=actor_user_id, + is_admin=False, + ) + if request_row["status"] != "pending": + raise RequestServiceError( + "Request is already in a terminal state", + status_code=409, + code="stale_transition", + ) + + try: + return user_db.update_request(request_id, status="cancelled") + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc + + +def reject_request( + user_db: "UserDB", + *, + request_id: int, + admin_user_id: int, + admin_note: Any = None, +) -> dict[str, Any]: + """Reject a pending request as admin.""" + request_row = ensure_request_access( + user_db, + request_id=request_id, + actor_user_id=admin_user_id, + is_admin=True, + ) + if request_row["status"] != "pending": + raise RequestServiceError( + "Request is already in a terminal state", + status_code=409, + code="stale_transition", + ) + + normalized_admin_note = None + if admin_note is not None: + if not isinstance(admin_note, str): + raise RequestServiceError("admin_note must be a string", status_code=400) + normalized_admin_note = admin_note.strip() or None + + try: + return user_db.update_request( + request_id, + status="rejected", + admin_note=normalized_admin_note, + reviewed_by=admin_user_id, + reviewed_at=_now_timestamp(), + ) + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc + + +def fulfil_request( + user_db: "UserDB", + *, + request_id: int, + admin_user_id: int, + queue_release: Callable[..., tuple[bool, str | None]], + release_data: Any = None, + admin_note: Any = None, +) -> dict[str, Any]: + """Fulfil a pending request and queue the release under requesting-user identity.""" + request_row = ensure_request_access( + user_db, + request_id=request_id, + actor_user_id=admin_user_id, + is_admin=True, + ) + if request_row["status"] != "pending": + raise RequestServiceError( + "Request is already in a terminal state", + status_code=409, + code="stale_transition", + ) + + normalized_admin_note = None + if admin_note is not None: + if not isinstance(admin_note, str): + raise RequestServiceError("admin_note must be a string", status_code=400) + normalized_admin_note = admin_note.strip() or None + + selected_release_data = release_data if release_data is not None else request_row.get("release_data") + if selected_release_data is not None and not isinstance(selected_release_data, dict): + raise RequestServiceError("release_data must be an object", status_code=400) + + if request_row["request_level"] == "book" and selected_release_data is None: + raise RequestServiceError( + "release_data is required to fulfil book-level requests", + status_code=400, + ) + if request_row["request_level"] == "release" and selected_release_data is None: + raise RequestServiceError( + "release_data is required to fulfil release-level requests", + status_code=400, + ) + + _validate_json_blob_size("release_data", selected_release_data) + + requester = user_db.get_user(user_id=request_row["user_id"]) + if requester is None: + raise RequestServiceError("Requesting user not found", status_code=404) + + success, error = queue_release( + selected_release_data, + 0, + user_id=request_row["user_id"], + username=requester.get("username"), + ) + if not success: + raise RequestServiceError( + error or "Failed to queue release", + status_code=409, + code="queue_failed", + ) + + try: + return user_db.update_request( + request_id, + status="fulfilled", + release_data=selected_release_data, + admin_note=normalized_admin_note, + reviewed_by=admin_user_id, + reviewed_at=_now_timestamp(), + ) + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index fab4ed8b..173b651a 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -29,6 +29,7 @@ class FieldBase: disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} requires_restart: bool = False # Whether changing this setting requires a container restart universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) + hidden_in_ui: bool = False # Keep field in schema/save path but hide default renderer def get_env_var_name(self) -> str: """Get the environment variable name for this field.""" @@ -117,6 +118,31 @@ class TableField(FieldBase): empty_message: str = "" +@dataclass +class CustomComponentField: + """Render a custom frontend component inside settings content.""" + + key: str + component: str # Frontend component registry key + label: str = "" + description: str = "" + bind_keys: List[str] = field(default_factory=list) # Related value keys this component edits + value_fields: List[Any] = field(default_factory=list) # Backing value schema for this component + wrap_in_field_wrapper: bool = False # Whether to render with standard FieldWrapper layout + disabled: bool = False + disabled_reason: str = "" + show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None + universal_only: bool = False + + def get_field_type(self) -> str: + return "CustomComponentField" + + def get_bind_keys(self) -> List[str]: + if self.bind_keys: + return self.bind_keys + return [getattr(f, "key") for f in self.value_fields if getattr(f, "key", None)] + + @dataclass class ActionButton: key: str # Action identifier @@ -144,6 +170,7 @@ class HeadingField: key: str # Unique identifier title: str # Heading title description: str = "" # Description text (supports markdown-style links) + description_by_auth_mode: Optional[Dict[str, str]] = None # Optional auth-mode specific description map link_url: str = "" # Optional URL for a link link_text: str = "" # Text for the link (defaults to URL if not provided) show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions @@ -164,6 +191,7 @@ SettingsField = Union[ TagListField, OrderableListField, TableField, + CustomComponentField, ActionButton, HeadingField, ] @@ -264,6 +292,12 @@ def get_all_settings_tabs() -> List[SettingsTab]: def _iter_value_fields(tab: SettingsTab): """Yield value-bearing fields for a tab.""" for field in tab.fields: + if isinstance(field, CustomComponentField): + for value_field in field.value_fields: + if isinstance(value_field, (ActionButton, HeadingField, CustomComponentField)): + continue + yield value_field + continue if isinstance(field, (ActionButton, HeadingField)): continue yield field @@ -395,11 +429,7 @@ def initialize_default_configs() -> bool: # Collect default values for all fields defaults = {} - for field in tab.fields: - # Skip non-value fields - if isinstance(field, (ActionButton, HeadingField)): - continue - + for field in _iter_value_fields(tab): # Only include fields that have a non-None default if field.default is not None: defaults[field.key] = field.default @@ -431,11 +461,7 @@ def sync_env_to_config() -> None: for tab in get_all_settings_tabs(): values_to_sync = {} - for field in tab.fields: - # Skip non-value fields - if isinstance(field, (ActionButton, HeadingField)): - continue - + for field in _iter_value_fields(tab): # Skip fields that don't support ENV vars if not getattr(field, 'env_supported', True): continue @@ -622,7 +648,7 @@ def migrate_legacy_settings() -> None: def get_setting_value(field: SettingsField, tab_name: str) -> Any: - if isinstance(field, (ActionButton, HeadingField)): + if isinstance(field, (ActionButton, HeadingField, CustomComponentField)): return None # Actions and headings don't have values # 1. Check environment variable (if supported for this field) @@ -677,7 +703,7 @@ def _parse_env_value(value: str, field: SettingsField) -> Any: def is_value_from_env(field: SettingsField) -> bool: """Check if a field's value comes from an environment variable.""" - if isinstance(field, (ActionButton, HeadingField)): + if isinstance(field, (ActionButton, HeadingField, CustomComponentField)): return False # UI-only settings never come from ENV (env_supported=False) if not getattr(field, 'env_supported', True): @@ -697,6 +723,36 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T Returns: Dict representation of the field. """ + # CustomComponentField has a custom structure - handle separately + if isinstance(field, CustomComponentField): + result: Dict[str, Any] = { + "key": field.key, + "label": field.label, + "type": field.get_field_type(), + "description": field.description, + "component": field.component, + "bindKeys": field.get_bind_keys(), + "wrapInFieldWrapper": field.wrap_in_field_wrapper, + "disabled": field.disabled, + "disabledReason": field.disabled_reason, + } + if field.value_fields: + bound_fields = [] + for value_field in field.value_fields: + serialized_bound_field = serialize_field( + value_field, + tab_name, + include_value=include_value, + ) + serialized_bound_field["hiddenInUi"] = True + bound_fields.append(serialized_bound_field) + result["boundFields"] = bound_fields + if field.show_when: + result["showWhen"] = field.show_when + if field.universal_only: + result["universalOnly"] = True + return result + # HeadingField has a different structure - handle separately if isinstance(field, HeadingField): result: Dict[str, Any] = { @@ -705,6 +761,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T "title": field.title, "description": field.description, } + if field.description_by_auth_mode: + result["descriptionByAuthMode"] = field.description_by_auth_mode if field.link_url: result["linkUrl"] = field.link_url result["linkText"] = field.link_text or field.link_url @@ -724,6 +782,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T "disabledReason": getattr(field, 'disabled_reason', ''), "requiresRestart": getattr(field, 'requires_restart', False), "userOverridable": getattr(field, 'user_overridable', False), + "hiddenInUi": getattr(field, 'hidden_in_ui', False), } # Add optional properties if set @@ -774,7 +833,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T result["style"] = field.style result["description"] = field.description - if include_value and not isinstance(field, (ActionButton, HeadingField)): + if include_value and not isinstance(field, (ActionButton, HeadingField, CustomComponentField)): value = get_setting_value(field, tab_name) # Ensure select values are serialized as strings so the frontend can @@ -953,7 +1012,10 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: return {"success": False, "message": f"Unknown settings tab: {tab_name}", "updated": [], "requiresRestart": False} # Build a map of field keys to fields (exclude non-value fields) - field_map = {f.key: f for f in tab.fields if not isinstance(f, (ActionButton, HeadingField))} + field_map = { + key: field + for key, (field, _) in get_settings_field_map(tab_name=tab_name).items() + } # Filter out values that are set via env vars or unknown values_to_save = {} diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index dd6cd300..178d524d 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -8,6 +8,13 @@ from typing import Any, Dict, List, Optional from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET from shelfmark.core.logger import setup_logger +from shelfmark.core.requests_service import ( + normalize_policy_mode, + normalize_request_level, + normalize_request_status, + validate_request_level_payload, + validate_status_transition, +) logger = setup_logger(__name__) @@ -28,6 +35,29 @@ CREATE TABLE IF NOT EXISTS user_settings ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, settings_json TEXT NOT NULL DEFAULT '{}' ); + +CREATE TABLE IF NOT EXISTS download_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + source_hint TEXT, + content_type TEXT NOT NULL, + request_level TEXT NOT NULL, + policy_mode TEXT NOT NULL, + book_data TEXT NOT NULL, + release_data TEXT, + note TEXT, + admin_note TEXT, + reviewed_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + reviewed_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at +ON download_requests (user_id, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at +ON download_requests (status, created_at DESC); """ @@ -278,3 +308,291 @@ class UserDB: conn.commit() finally: conn.close() + + @staticmethod + def _serialize_json(value: Any, field: str) -> Optional[str]: + if value is None: + return None + try: + return json.dumps(value) + except TypeError as exc: + raise ValueError(f"{field} must be JSON-serializable") from exc + + @staticmethod + def _parse_request_row(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]: + if row is None: + return None + + payload = dict(row) + for key in ("book_data", "release_data"): + raw_value = payload.get(key) + if raw_value is None: + payload[key] = None + continue + try: + payload[key] = json.loads(raw_value) + except (ValueError, TypeError): + payload[key] = None + return payload + + def create_request( + self, + *, + user_id: int, + content_type: str, + request_level: str, + policy_mode: str, + book_data: Dict[str, Any], + release_data: Optional[Dict[str, Any]] = None, + status: str = "pending", + source_hint: Optional[str] = None, + note: Optional[str] = None, + admin_note: Optional[str] = None, + reviewed_by: Optional[int] = None, + reviewed_at: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a download request row and return the created record.""" + if not isinstance(book_data, dict): + raise ValueError("book_data must be an object") + if release_data is not None and not isinstance(release_data, dict): + raise ValueError("release_data must be an object when provided") + if not content_type: + raise ValueError("content_type is required") + + normalized_status = normalize_request_status(status) + normalized_policy_mode = normalize_policy_mode(policy_mode) + normalized_request_level = validate_request_level_payload(request_level, release_data) + + with self._lock: + conn = self._connect() + try: + cursor = conn.execute( + """ + INSERT INTO download_requests ( + user_id, + status, + source_hint, + content_type, + request_level, + policy_mode, + book_data, + release_data, + note, + admin_note, + reviewed_by, + reviewed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + normalized_status, + source_hint, + content_type, + normalized_request_level, + normalized_policy_mode, + self._serialize_json(book_data, "book_data"), + self._serialize_json(release_data, "release_data"), + note, + admin_note, + reviewed_by, + reviewed_at, + ), + ) + conn.commit() + request_id = cursor.lastrowid + row = conn.execute( + "SELECT * FROM download_requests WHERE id = ?", + (request_id,), + ).fetchone() + parsed = self._parse_request_row(row) + if parsed is None: + raise ValueError(f"Request {request_id} not found after creation") + return parsed + finally: + conn.close() + + def get_request(self, request_id: int) -> Optional[Dict[str, Any]]: + """Get a request row by ID.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT * FROM download_requests WHERE id = ?", + (request_id,), + ).fetchone() + return self._parse_request_row(row) + finally: + conn.close() + + def list_requests( + self, + *, + user_id: Optional[int] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """List requests with optional user/status filters.""" + where_clauses: List[str] = [] + params: List[Any] = [] + + if user_id is not None: + where_clauses.append("user_id = ?") + params.append(user_id) + + if status is not None: + where_clauses.append("status = ?") + params.append(normalize_request_status(status)) + + query = "SELECT * FROM download_requests" + if where_clauses: + query += " WHERE " + " AND ".join(where_clauses) + query += " ORDER BY created_at DESC, id DESC" + + if limit is not None: + query += " LIMIT ?" + params.append(int(limit)) + if offset: + query += " OFFSET ?" + params.append(offset) + elif offset: + query += " LIMIT -1 OFFSET ?" + params.append(offset) + + conn = self._connect() + try: + rows = conn.execute(query, params).fetchall() + results: List[Dict[str, Any]] = [] + for row in rows: + parsed = self._parse_request_row(row) + if parsed is not None: + results.append(parsed) + return results + finally: + conn.close() + + _ALLOWED_REQUEST_UPDATE_COLUMNS = { + "status", + "source_hint", + "content_type", + "request_level", + "policy_mode", + "book_data", + "release_data", + "note", + "admin_note", + "reviewed_by", + "reviewed_at", + } + + def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]: + """Update request fields and return the updated record.""" + if not kwargs: + request = self.get_request(request_id) + if request is None: + raise ValueError(f"Request {request_id} not found") + return request + + for key in kwargs: + if key not in self._ALLOWED_REQUEST_UPDATE_COLUMNS: + raise ValueError(f"Invalid request column: {key}") + + with self._lock: + conn = self._connect() + try: + row = conn.execute( + "SELECT * FROM download_requests WHERE id = ?", + (request_id,), + ).fetchone() + current = self._parse_request_row(row) + if current is None: + raise ValueError(f"Request {request_id} not found") + + updates = dict(kwargs) + + if "status" in updates: + _, normalized_status = validate_status_transition( + current["status"], + updates["status"], + ) + updates["status"] = normalized_status + + if "policy_mode" in updates: + updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"]) + + if "content_type" in updates and not updates["content_type"]: + raise ValueError("content_type is required") + + candidate_request_level = updates.get("request_level", current["request_level"]) + candidate_release_data = ( + updates["release_data"] if "release_data" in updates else current["release_data"] + ) + candidate_status = updates.get("status", current["status"]) + normalized_request_level = normalize_request_level(candidate_request_level) + normalized_candidate_status = normalize_request_status(candidate_status) + + if normalized_request_level == "release" and candidate_release_data is None: + raise ValueError("request_level=release requires non-null release_data") + if ( + normalized_request_level == "book" + and candidate_release_data is not None + and normalized_candidate_status != "fulfilled" + ): + raise ValueError("request_level=book requires null release_data") + if "request_level" in updates: + updates["request_level"] = normalized_request_level + + if "book_data" in updates: + if not isinstance(updates["book_data"], dict): + raise ValueError("book_data must be an object") + updates["book_data"] = self._serialize_json(updates["book_data"], "book_data") + + if "release_data" in updates: + if updates["release_data"] is not None and not isinstance(updates["release_data"], dict): + raise ValueError("release_data must be an object when provided") + updates["release_data"] = self._serialize_json( + updates["release_data"], + "release_data", + ) + + set_clause = ", ".join(f"{column} = ?" for column in updates) + values = list(updates.values()) + [request_id] + conn.execute( + f"UPDATE download_requests SET {set_clause} WHERE id = ?", + values, + ) + conn.commit() + + updated_row = conn.execute( + "SELECT * FROM download_requests WHERE id = ?", + (request_id,), + ).fetchone() + parsed = self._parse_request_row(updated_row) + if parsed is None: + raise ValueError(f"Request {request_id} not found after update") + return parsed + finally: + conn.close() + + def count_pending_requests(self) -> int: + """Count all pending requests.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT COUNT(*) AS count FROM download_requests WHERE status = 'pending'" + ).fetchone() + return int(row["count"]) if row else 0 + finally: + conn.close() + + def count_user_pending_requests(self, user_id: int) -> int: + """Count pending requests for a specific user.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT COUNT(*) AS count FROM download_requests WHERE user_id = ? AND status = 'pending'", + (user_id,), + ).fetchone() + return int(row["count"]) if row else 0 + finally: + conn.close() diff --git a/shelfmark/main.py b/shelfmark/main.py index 295807d3..adb93181 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -3,6 +3,7 @@ import io import logging import os +import re import sqlite3 import time from datetime import datetime, timedelta @@ -36,6 +37,14 @@ from shelfmark.core.auth_modes import ( ) from shelfmark.core.cwa_user_sync import upsert_cwa_user from shelfmark.core.external_user_linking import upsert_external_user +from shelfmark.core.request_policy import ( + PolicyMode, + get_source_content_type_capabilities, + merge_request_policy_settings, + normalize_content_type, + normalize_source, + resolve_policy_mode, +) from shelfmark.core.utils import normalize_base_path from shelfmark.api.websocket import ws_manager @@ -206,6 +215,183 @@ def get_auth_mode() -> str: return "none" +def _load_users_request_policy_settings() -> dict[str, Any]: + """Load global request policy settings from users config.""" + from shelfmark.core.settings_registry import load_config_file + + return load_config_file("users") + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + return bool(value) + + +_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049) +_AUDIOBOOK_FORMAT_HINTS = frozenset( + { + "m4b", + "mp3", + "m4a", + "flac", + "ogg", + "wma", + "aac", + "wav", + "opus", + } +) + + +def _contains_audiobook_format_hint(value: Any) -> bool: + if not isinstance(value, str): + return False + + normalized = value.strip().lower() + if not normalized: + return False + + tokens = [token for token in re.split(r"[^a-z0-9]+", normalized) if token] + return any(token in _AUDIOBOOK_FORMAT_HINTS for token in tokens) + + +def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[str, bool]: + """Resolve release content type for policy checks and queue payload normalization.""" + extra = data.get("extra") + if not isinstance(extra, dict): + extra = {} + + explicit_content_type = data.get("content_type") + if explicit_content_type is None: + explicit_content_type = extra.get("content_type") + if explicit_content_type is not None: + return normalize_content_type(explicit_content_type), False + + categories = extra.get("categories") + if isinstance(categories, list): + min_cat, max_cat = _AUDIOBOOK_CATEGORY_RANGE + for raw_category in categories: + try: + category_id = int(raw_category) + except (TypeError, ValueError): + continue + if min_cat <= category_id <= max_cat: + return "audiobook", True + + candidates: list[Any] = [ + data.get("format"), + extra.get("format"), + extra.get("formats_display"), + data.get("title"), + ] + formats = extra.get("formats") + if isinstance(formats, list): + candidates.extend(formats) + else: + candidates.append(formats) + + if any(_contains_audiobook_format_hint(candidate) for candidate in candidates): + return "audiobook", True + + capabilities = get_source_content_type_capabilities() + supported = capabilities.get(normalize_source(source)) + if supported and len(supported) == 1: + return normalize_content_type(next(iter(supported))), True + + return "ebook", False + + +def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> PolicyMode | None: + """Resolve policy mode for current session, or None when policy guard is bypassed.""" + auth_mode = get_auth_mode() + if auth_mode == "none": + return None + if session.get("is_admin", True): + return None + if user_db is None: + return None + + global_settings = _load_users_request_policy_settings() + db_user_id = session.get("db_user_id") + user_settings: dict[str, Any] | None = None + if db_user_id is not None: + try: + user_settings = user_db.get_user_settings(int(db_user_id)) + except (TypeError, ValueError): + user_settings = None + + effective = merge_request_policy_settings(global_settings, user_settings) + if not _as_bool(effective.get("REQUESTS_ENABLED"), False): + return None + + resolved_mode = resolve_policy_mode( + source=source, + content_type=content_type, + global_settings=global_settings, + user_settings=user_settings, + ) + logger.debug( + "download policy resolve user=%s db_user_id=%s is_admin=%s source=%s content_type=%s mode=%s", + session.get("user_id"), + db_user_id, + bool(session.get("is_admin", False)), + source, + content_type, + resolved_mode.value, + ) + return resolved_mode + + +def _policy_block_response(mode: PolicyMode): + logger.debug( + "download policy guard user=%s db_user_id=%s mode=%s", + session.get("user_id"), + session.get("db_user_id"), + mode.value, + ) + if mode == PolicyMode.BLOCKED: + return ( + jsonify({ + "error": "Download not allowed by policy", + "code": "policy_blocked", + "required_mode": PolicyMode.BLOCKED.value, + }), + 403, + ) + return ( + jsonify({ + "error": "Download not allowed by policy", + "code": "policy_requires_request", + "required_mode": mode.value, + }), + 403, + ) + + +if user_db is not None: + try: + from shelfmark.core.request_routes import register_request_routes + + register_request_routes( + app, + user_db, + resolve_auth_mode=lambda: get_auth_mode(), + queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs), + ws_manager=ws_manager, + ) + except Exception as e: + logger.warning(f"Failed to register request routes: {e}") + + # Enable CORS in development mode for local frontend development if DEBUG: CORS(app, resources={ @@ -609,6 +795,13 @@ def api_download() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": "No book ID provided"}), 400 try: + policy_mode = _resolve_policy_mode_for_current_user( + source="direct_download", + content_type="ebook", + ) + if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD: + return _policy_block_response(policy_mode) + priority = int(request.args.get('priority', 0)) # Per-user download overrides db_user_id = session.get('db_user_id') @@ -653,12 +846,26 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: if 'source_id' not in data: return jsonify({"error": "source_id is required"}), 400 + source = data.get('source', 'direct_download') + resolved_content_type, inferred_content_type = _resolve_release_content_type(data, source) + policy_mode = _resolve_policy_mode_for_current_user( + source=source, + content_type=resolved_content_type, + ) + if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD: + return _policy_block_response(policy_mode) + + release_payload = data + if inferred_content_type and data.get("content_type") is None: + release_payload = dict(data) + release_payload["content_type"] = resolved_content_type + priority = data.get('priority', 0) # Per-user download overrides db_user_id = session.get('db_user_id') _username = session.get('user_id') success, error_msg = backend.queue_release( - data, priority, + release_payload, priority, user_id=db_user_id, username=_username, ) @@ -733,6 +940,36 @@ def api_health() -> Union[Response, Tuple[Response, int]]: return jsonify(response) + +def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, int | None, bool]: + """Resolve queue-status visibility from session state. + + Returns: + (is_admin, db_user_id, can_access_status) + """ + auth_mode = get_auth_mode() + if auth_mode == "none": + return True, None, True + + if require_authenticated and 'user_id' not in session: + return False, None, False + + is_admin = bool(session.get('is_admin', False)) + if is_admin: + return True, None, True + + raw_db_user_id = session.get('db_user_id') + try: + db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None + except (TypeError, ValueError): + db_user_id = None + + if db_user_id is None: + return False, None, False + + return False, db_user_id, True + + @app.route('/api/status', methods=['GET']) @login_required def api_status() -> Union[Response, Tuple[Response, int]]: @@ -743,10 +980,11 @@ def api_status() -> Union[Response, Tuple[Response, int]]: flask.Response: JSON object with queue status. """ try: - # Non-admin users only see their own downloads - user_id = None - if not session.get('is_admin', True): - user_id = session.get('db_user_id') + is_admin, db_user_id, can_access_status = _resolve_status_scope() + if not can_access_status: + return jsonify({}) + + user_id = None if is_admin else db_user_id status = backend.queue_status(user_id=user_id) return jsonify(status) except Exception as e: @@ -1246,12 +1484,8 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]: is_admin = get_auth_check_admin_status(auth_mode, users_config, session) display_name = None - if is_authenticated and session.get('db_user_id'): + if is_authenticated and session.get('db_user_id') and user_db is not None: try: - from shelfmark.core.user_db import UserDB - import os - user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")) - user_db.initialize() db_user = user_db.get_user(user_id=session['db_user_id']) if db_user: display_name = db_user.get("display_name") or None @@ -1921,16 +2155,17 @@ def handle_connect(): # Track the connection (triggers warmup callbacks on first connect) ws_manager.client_connected() - # Join appropriate room based on user session - is_admin = session.get('is_admin', True) - db_user_id = session.get('db_user_id') + # Join appropriate room based on authenticated user session + is_admin, db_user_id, can_access_status = _resolve_status_scope() ws_manager.join_user_room(request.sid, is_admin, db_user_id) # Send initial status to the newly connected client (filtered) try: - user_id = None - if not is_admin: - user_id = db_user_id + if not can_access_status: + emit('status_update', {}) + return + + user_id = None if is_admin else db_user_id status = backend.queue_status(user_id=user_id) emit('status_update', status) except Exception as e: @@ -1942,9 +2177,7 @@ def handle_disconnect(): logger.info("WebSocket client disconnected") # Leave room - is_admin = session.get('is_admin', True) - db_user_id = session.get('db_user_id') - ws_manager.leave_user_room(request.sid, is_admin, db_user_id) + ws_manager.leave_user_room(request.sid) # Track the disconnection ws_manager.client_disconnected() @@ -1953,9 +2186,14 @@ def handle_disconnect(): def handle_status_request(): """Handle manual status request from client.""" try: - user_id = None - if not session.get('is_admin', True): - user_id = session.get('db_user_id') + is_admin, db_user_id, can_access_status = _resolve_status_scope() + ws_manager.sync_user_room(request.sid, is_admin, db_user_id) + + if not can_access_status: + emit('status_update', {}) + return + + user_id = None if is_admin else db_user_id status = backend.queue_status(user_id=user_id) emit('status_update', status) except Exception as e: diff --git a/src/frontend/package.json b/src/frontend/package.json index d1309c74..2942273b 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/**/*.node.test.js", + "test:unit:build": "tsc -p tsconfig.tests.json" }, "dependencies": { "react": "^18.3.1", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index b9fccd46..4178b926 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -3,26 +3,44 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Book, Release, + RequestRecord, StatusData, AppConfig, ContentType, + ButtonStateInfo, + RequestPolicyMode, + CreateRequestPayload, } from './types'; -import { getBookInfo, getMetadataBookInfo, downloadBook, downloadRelease, cancelDownload, clearCompleted, getConfig } from './services/api'; +import { + getBookInfo, + getMetadataBookInfo, + downloadBook, + downloadRelease, + cancelDownload, + clearCompleted, + getConfig, + createRequest, + isApiResponseError, +} from './services/api'; import { useToast } from './hooks/useToast'; import { useRealtimeStatus } from './hooks/useRealtimeStatus'; import { useAuth } from './hooks/useAuth'; import { useSearch } from './hooks/useSearch'; import { useUrlSearch } from './hooks/useUrlSearch'; import { useDownloadTracking } from './hooks/useDownloadTracking'; +import { useRequestPolicy } from './hooks/useRequestPolicy'; +import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore'; +import { useRequests } from './hooks/useRequests'; import { Header } from './components/Header'; import { SearchSection } from './components/SearchSection'; import { AdvancedFilters } from './components/AdvancedFilters'; import { ResultsSection } from './components/ResultsSection'; import { DetailsModal } from './components/DetailsModal'; import { ReleaseModal } from './components/ReleaseModal'; -import { DownloadsSidebar } from './components/DownloadsSidebar'; +import { RequestConfirmationModal } from './components/RequestConfirmationModal'; import { ToastContainer } from './components/ToastContainer'; import { Footer } from './components/Footer'; +import { ActivitySidebar, requestToActivityItem } from './components/activity'; import { LoginPage } from './pages/LoginPage'; import { SettingsModal } from './components/settings'; import { ConfigSetupBanner } from './components/ConfigSetupBanner'; @@ -30,6 +48,19 @@ import { OnboardingModal } from './components/OnboardingModal'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; import { buildSearchQuery } from './utils/buildSearchQuery'; import { withBasePath } from './utils/basePath'; +import { + applyDirectPolicyModeToButtonState, + applyUniversalPolicyModeToButtonState, +} from './utils/requestPolicyUi'; +import { + buildDirectRequestPayload, + buildMetadataBookRequestData, + buildReleaseDataFromMetadataRelease, + getRequestSuccessMessage, + toContentType, +} from './utils/requestPayload'; +import { bookFromRequestData } from './utils/requestFulfil'; +import { policyTrace } from './utils/policyTrace'; import { SearchModeProvider } from './contexts/SearchModeContext'; import './styles.css'; @@ -47,6 +78,43 @@ const getInitialContentType = (): ContentType => { return 'ebook'; }; +const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']); + +const isPolicyGuardError = (error: unknown): boolean => { + return ( + isApiResponseError(error) && + error.status === 403 && + Boolean(error.code && POLICY_GUARD_ERROR_CODES.has(error.code)) + ); +}; + +const asRequestPolicyMode = (value: unknown): RequestPolicyMode | null => { + return value === 'download' || value === 'request_release' || value === 'request_book' || value === 'blocked' + ? value + : null; +}; + +const getPolicyGuardRequiredMode = (error: unknown): RequestPolicyMode | null => { + if (!isPolicyGuardError(error) || !isApiResponseError(error)) { + return null; + } + const explicitMode = asRequestPolicyMode(error.requiredMode); + if (explicitMode) { + return explicitMode; + } + if (error.code === 'policy_blocked') { + return 'blocked'; + } + return null; +}; + +const getErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +}; + function App() { const { toasts, showToast, removeToast } = useToast(); @@ -76,7 +144,7 @@ function App() { isAuthenticated, authRequired, authChecked, - isAdmin, + isAdmin: authCanAccessSettings, authMode, username, displayName, @@ -90,6 +158,15 @@ function App() { showToast, }); + // Re-request status after auth is established so the server can re-scope socket room membership. + useEffect(() => { + if (!authChecked || !isAuthenticated) { + return; + } + policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authCanAccessSettings, username }); + void fetchStatus(); + }, [authChecked, isAuthenticated, authCanAccessSettings, username, fetchStatus]); + // Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it const [contentType, setContentType] = useState(() => getInitialContentType()); @@ -101,6 +178,101 @@ function App() { } }, [contentType]); + const { + policy: requestPolicy, + getDefaultMode, + getSourceMode, + requestsEnabled: requestsPolicyEnabled, + allowNotes: allowRequestNotes, + refresh: refreshRequestPolicy, + } = useRequestPolicy({ + enabled: isAuthenticated, + isAdmin: authCanAccessSettings, + }); + + const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false; + + const { + requests, + pendingCount: pendingRequestCount, + isLoading: isRequestsLoading, + cancelRequest: cancelUserRequest, + fulfilRequest: fulfilSidebarRequest, + rejectRequest: rejectSidebarRequest, + } = useRequests({ + isAdmin: requestRoleIsAdmin, + enabled: isAuthenticated, + }); + + const dismissedRequestStorageKey = useMemo(() => { + const roleScope = requestRoleIsAdmin ? 'admin' : 'user'; + const userScope = username?.trim().toLowerCase() || 'anonymous'; + return `activity-dismissed-requests:${roleScope}:${userScope}`; + }, [requestRoleIsAdmin, username]); + + const [dismissedRequestIds, setDismissedRequestIds] = useState([]); + + useEffect(() => { + if (!isAuthenticated) { + setDismissedRequestIds([]); + return; + } + + try { + const raw = window.localStorage.getItem(dismissedRequestStorageKey); + if (!raw) { + setDismissedRequestIds([]); + return; + } + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + setDismissedRequestIds([]); + return; + } + + const ids = parsed.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + setDismissedRequestIds(ids); + } catch { + setDismissedRequestIds([]); + } + }, [dismissedRequestStorageKey, isAuthenticated]); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + try { + window.localStorage.setItem(dismissedRequestStorageKey, JSON.stringify(dismissedRequestIds)); + } catch { + // Ignore storage failures in restricted/private contexts. + } + }, [dismissedRequestIds, dismissedRequestStorageKey, isAuthenticated]); + + const requestItems = useMemo( + () => + requests + .filter((record) => !dismissedRequestIds.includes(record.id)) + .map((record) => requestToActivityItem(record, requestRoleIsAdmin ? 'admin' : 'user')) + .sort((left, right) => right.timestamp - left.timestamp), + [requests, requestRoleIsAdmin, dismissedRequestIds] + ); + + const showRequestsTab = useMemo(() => { + if (requestRoleIsAdmin) { + return true; + } + if (!isAuthenticated || !requestsPolicyEnabled) { + return false; + } + if (!requestPolicy) { + return false; + } + return !( + requestPolicy.defaults.ebook === 'download' && + requestPolicy.defaults.audiobook === 'download' + ); + }, [requestRoleIsAdmin, isAuthenticated, requestsPolicyEnabled, requestPolicy]); + // Search state and handlers const { books, @@ -131,11 +303,20 @@ function App() { contentType, }); + const [pendingRequestPayload, setPendingRequestPayload] = useState(null); + const [fulfillingRequest, setFulfillingRequest] = useState<{ + requestId: number; + book: Book; + contentType: ContentType; + } | null>(null); + // Wire up logout callback to clear search state const handleLogoutWithCleanup = useCallback(async () => { await handleLogout(); setBooks([]); clearTracking(); + setPendingRequestPayload(null); + setFulfillingRequest(null); }, [handleLogout, setBooks, clearTracking]); // UI state @@ -143,6 +324,22 @@ function App() { const [releaseBook, setReleaseBook] = useState(null); const [config, setConfig] = useState(null); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); + const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false); + const [headerHeight, setHeaderHeight] = useState(0); + const headerObserverRef = useRef(null); + const headerRef = useCallback((el: HTMLDivElement | null) => { + if (headerObserverRef.current) { + headerObserverRef.current.disconnect(); + headerObserverRef.current = null; + } + if (!el) return; + setHeaderHeight(el.getBoundingClientRect().height); + const observer = new ResizeObserver(() => { + setHeaderHeight(el.getBoundingClientRect().height); + }); + observer.observe(el); + headerObserverRef.current = observer; + }, []); const [settingsOpen, setSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); @@ -179,8 +376,13 @@ function App() { const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0; - return { ongoing, completed, errored }; - }, [currentStatus]); + return { + ongoing, + completed, + errored, + pendingRequests: pendingRequestCount, + }; + }, [currentStatus, pendingRequestCount]); // Compute visibility states @@ -317,6 +519,14 @@ function App() { } }, [isAuthenticated, loadConfig]); + const runSearchWithPolicyRefresh = useCallback( + (query: string, fields = searchFieldValues) => { + void refreshRequestPolicy(); + handleSearch(query, config, fields); + }, + [refreshRequestPolicy, handleSearch, config, searchFieldValues] + ); + // Execute URL-based search when params are present useEffect(() => { if ( @@ -370,7 +580,7 @@ function App() { searchMode, }); - handleSearch(query, config, searchFieldValues); + runSearchWithPolicyRefresh(query); } }, [ wasProcessed, @@ -378,7 +588,7 @@ function App() { config, advancedFilters, searchFieldValues, - handleSearch, + runSearchWithPolicyRefresh, setSearchInput, setAdvancedFilters, setShowAdvanced, @@ -437,14 +647,112 @@ function App() { setReleaseBook(book); }; - // Download book + const submitRequest = useCallback( + async (payload: CreateRequestPayload, successMessage: string): Promise => { + try { + await createRequest(payload); + showToast(successMessage, 'success'); + await refreshRequestPolicy({ force: true }); + return true; + } catch (error) { + console.error('Request creation failed:', error); + showToast(getErrorMessage(error, 'Failed to create request'), 'error'); + if (isPolicyGuardError(error)) { + await refreshRequestPolicy({ force: true }); + } + return false; + } + }, + [showToast, refreshRequestPolicy] + ); + + const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => { + setPendingRequestPayload(payload); + }, []); + + const handleConfirmRequest = useCallback( + async (payload: CreateRequestPayload): Promise => { + const success = await submitRequest(payload, getRequestSuccessMessage(payload)); + if (success) { + setPendingRequestPayload(null); + } + return success; + }, + [submitRequest] + ); + + const getDirectPolicyMode = useCallback((): RequestPolicyMode => { + return getSourceMode('direct_download', 'ebook'); + }, [getSourceMode]); + + const getUniversalDefaultPolicyMode = useCallback((): RequestPolicyMode => { + return getDefaultMode(contentType); + }, [getDefaultMode, contentType]); + + // Direct-mode action (download or release-level request based on policy). const handleDownload = async (book: Book): Promise => { + let mode = getDirectPolicyMode(); + policyTrace('direct.action:start', { + bookId: book.id, + contentType: 'ebook', + cachedMode: mode, + isAdmin: requestRoleIsAdmin, + }); + try { + const latestPolicy = await refreshRequestPolicy({ force: true }); + const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin; + mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, 'direct_download', 'ebook'); + policyTrace('direct.action:resolved', { + bookId: book.id, + resolvedMode: mode, + effectiveIsAdmin, + defaults: latestPolicy?.defaults ?? null, + requestsEnabled: latestPolicy?.requests_enabled ?? null, + }); + } catch (error) { + console.warn('Failed to refresh request policy before direct action:', error); + policyTrace('direct.action:refresh_failed', { + bookId: book.id, + mode, + message: error instanceof Error ? error.message : String(error), + }); + } + + if (mode === 'blocked') { + policyTrace('direct.action:block', { bookId: book.id, mode }); + showToast('Download blocked by policy', 'error'); + await refreshRequestPolicy({ force: true }); + return; + } + + if (mode === 'request_release' || mode === 'request_book') { + policyTrace('direct.action:request_modal', { bookId: book.id, mode }); + openRequestConfirmation(buildDirectRequestPayload(book, mode)); + return; + } + try { await downloadBook(book.id); await fetchStatus(); } catch (error) { console.error('Download failed:', error); - showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error'); + if (isPolicyGuardError(error)) { + const requiredMode = getPolicyGuardRequiredMode(error); + policyTrace('direct.action:policy_guard', { + bookId: book.id, + requiredMode, + code: isApiResponseError(error) ? error.code : null, + }); + if (requiredMode === 'request_release' || requiredMode === 'request_book') { + openRequestConfirmation(buildDirectRequestPayload(book, requiredMode)); + await refreshRequestPolicy({ force: true }); + return; + } + showToast('Download blocked by policy', 'error'); + await refreshRequestPolicy({ force: true }); + return; + } + showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); throw error; } }; @@ -471,10 +779,68 @@ function App() { } }; - // Open release modal + // Universal-mode "Get" action (open releases, request-book, or block by policy). const handleGetReleases = async (book: Book) => { + let mode = getUniversalDefaultPolicyMode(); + const normalizedContentType = toContentType(contentType); + policyTrace('universal.get:start', { + bookId: book.id, + contentType: normalizedContentType, + cachedMode: mode, + isAdmin: requestRoleIsAdmin, + }); + try { + const latestPolicy = await refreshRequestPolicy({ force: true }); + const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin; + mode = resolveDefaultModeFromPolicy(latestPolicy, effectiveIsAdmin, contentType); + policyTrace('universal.get:resolved', { + bookId: book.id, + contentType: normalizedContentType, + resolvedMode: mode, + effectiveIsAdmin, + defaults: latestPolicy?.defaults ?? null, + requestsEnabled: latestPolicy?.requests_enabled ?? null, + }); + } catch (error) { + console.warn('Failed to refresh request policy before universal action:', error); + policyTrace('universal.get:refresh_failed', { + bookId: book.id, + contentType: normalizedContentType, + mode, + message: error instanceof Error ? error.message : String(error), + }); + } + + if (mode === 'blocked') { + policyTrace('universal.get:block', { bookId: book.id, contentType: normalizedContentType }); + showToast('This title is unavailable by policy', 'error'); + return; + } + + if (mode === 'request_book') { + policyTrace('universal.get:request_modal', { + bookId: book.id, + requestLevel: 'book', + contentType: normalizedContentType, + }); + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: null, + context: { + source: '*', + content_type: normalizedContentType, + request_level: 'book', + }, + }); + return; + } + if (book.provider && book.provider_id) { try { + policyTrace('universal.get:open_release_modal', { + bookId: book.id, + contentType: normalizedContentType, + }); const fullBook = await getMetadataBookInfo(book.provider, book.provider_id); setReleaseBook({ ...book, @@ -485,16 +851,31 @@ function App() { }); } catch (error) { console.error('Failed to load book description, using search data:', error); + policyTrace('universal.get:open_release_modal_fallback', { + bookId: book.id, + contentType: normalizedContentType, + message: error instanceof Error ? error.message : String(error), + }); setReleaseBook(book); } } else { + policyTrace('universal.get:open_release_modal_no_provider', { + bookId: book.id, + contentType: normalizedContentType, + }); setReleaseBook(book); } }; - // Handle download from ReleaseModal + // Handle download from ReleaseModal (universal mode release rows). const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => { try { + policyTrace('release.action:start', { + bookId: book.id, + releaseId: release.source_id, + source: release.source, + contentType: toContentType(releaseContentType), + }); trackRelease(book.id, release.source_id); await downloadRelease({ @@ -520,11 +901,172 @@ function App() { await fetchStatus(); } catch (error) { console.error('Release download failed:', error); - showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error'); + if (isPolicyGuardError(error)) { + const requiredMode = getPolicyGuardRequiredMode(error); + const normalizedContentType = toContentType(releaseContentType); + policyTrace('release.action:policy_guard', { + bookId: book.id, + releaseId: release.source_id, + source: release.source, + requiredMode, + code: isApiResponseError(error) ? error.code : null, + contentType: normalizedContentType, + }); + if (requiredMode === 'request_release') { + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), + context: { + source: release.source || 'direct_download', + content_type: normalizedContentType, + request_level: 'release', + }, + }); + await refreshRequestPolicy({ force: true }); + return; + } + if (requiredMode === 'request_book') { + setReleaseBook(null); + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: null, + context: { + source: release.source || 'direct_download', + content_type: normalizedContentType, + request_level: 'book', + }, + }); + await refreshRequestPolicy({ force: true }); + return; + } + showToast('Download blocked by policy', 'error'); + await refreshRequestPolicy({ force: true }); + return; + } + showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); throw error; } }; + const handleReleaseRequest = useCallback( + async (book: Book, release: Release, releaseContentType: ContentType): Promise => { + void refreshRequestPolicy(); + const normalizedContentType = toContentType(releaseContentType); + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), + context: { + source: release.source || 'direct_download', + content_type: normalizedContentType, + request_level: 'release', + }, + }); + }, + [openRequestConfirmation, refreshRequestPolicy] + ); + + const handleRequestCancel = useCallback( + async (requestId: number) => { + try { + await cancelUserRequest(requestId); + showToast('Request cancelled', 'success'); + } catch (error) { + showToast(getErrorMessage(error, 'Failed to cancel request'), 'error'); + } + }, + [cancelUserRequest, showToast] + ); + + const handleRequestDismiss = useCallback((requestId: number) => { + setDismissedRequestIds((previous) => + previous.includes(requestId) ? previous : [...previous, requestId] + ); + }, []); + + const handleRequestReject = useCallback( + async (requestId: number, adminNote?: string) => { + if (!requestRoleIsAdmin) { + return; + } + + try { + await rejectSidebarRequest(requestId, adminNote); + showToast('Request rejected', 'success'); + } catch (error) { + showToast(getErrorMessage(error, 'Failed to reject request'), 'error'); + } + }, + [requestRoleIsAdmin, rejectSidebarRequest, showToast] + ); + + const handleRequestApprove = useCallback( + async (requestId: number, record: RequestRecord) => { + if (!requestRoleIsAdmin) { + return; + } + + if (record.request_level === 'release') { + try { + await fulfilSidebarRequest(requestId, record.release_data || undefined); + showToast('Request approved', 'success'); + await fetchStatus(); + } catch (error) { + showToast(getErrorMessage(error, 'Failed to approve request'), 'error'); + } + return; + } + + setReleaseBook(null); + setFulfillingRequest({ + requestId, + book: bookFromRequestData(record.book_data), + contentType: record.content_type, + }); + }, + [requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus] + ); + + const handleBrowseFulfilDownload = useCallback( + async (book: Book, release: Release, releaseContentType: ContentType) => { + if (!fulfillingRequest) { + return; + } + + try { + await fulfilSidebarRequest( + fulfillingRequest.requestId, + buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType)) + ); + showToast(`Request approved: ${book.title || 'Untitled'}`, 'success'); + setFulfillingRequest(null); + await fetchStatus(); + } catch (error) { + console.error('Browse fulfil failed:', error); + showToast(getErrorMessage(error, 'Failed to fulfil request'), 'error'); + throw error; + } + }, + [fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus] + ); + + const getDirectActionButtonState = useCallback( + (bookId: string): ButtonStateInfo => { + const baseState = getButtonState(bookId); + const mode = getDirectPolicyMode(); + return applyDirectPolicyModeToButtonState(baseState, mode); + }, + [getButtonState, getDirectPolicyMode] + ); + + const getUniversalActionButtonState = useCallback( + (bookId: string): ButtonStateInfo => { + const baseState = getUniversalButtonState(bookId); + const mode = getUniversalDefaultPolicyMode(); + return applyUniversalPolicyModeToButtonState(baseState, mode); + }, + [getUniversalButtonState, getUniversalDefaultPolicyMode] + ); + const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES; const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS; const defaultLanguageCodes = @@ -556,55 +1098,89 @@ function App() { defaultLanguage: defaultLanguageCodes, searchMode, }); - handleSearch(query, config, { ...searchFieldValues, series: seriesName }); - }, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, config, handleSearch]); + runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName }); + }, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]); + + const isBrowseFulfilMode = fulfillingRequest !== null; + const activeReleaseBook = fulfillingRequest?.book ?? releaseBook; + const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType; + const usePinnedMainScrollContainer = sidebarPinnedOpen; + + const handleReleaseModalClose = useCallback(() => { + if (isBrowseFulfilMode) { + setFulfillingRequest(null); + return; + } + setReleaseBook(null); + }, [isBrowseFulfilMode]); const mainAppContent = ( -
setDownloadsSidebarOpen(true)} - onSettingsClick={() => { - if (config?.settings_enabled) { - setSettingsOpen(true); - } else { - setConfigBannerOpen(true); - } - }} - isAdmin={isAdmin} - username={username} - displayName={displayName} - statusCounts={statusCounts} - onLogoClick={() => handleResetSearch(config)} - authRequired={authRequired} - isAuthenticated={isAuthenticated} - onLogout={handleLogoutWithCleanup} - onSearch={() => { - const query = buildSearchQuery({ - searchInput, - showAdvanced, - advancedFilters, - bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode, - }); - handleSearch(query, config, searchFieldValues); - }} - onAdvancedToggle={() => setShowAdvanced(!showAdvanced)} - isLoading={isSearching} - onShowToast={showToast} - onRemoveToast={removeToast} - contentType={contentType} - onContentTypeChange={setContentType} - /> +
+
setDownloadsSidebarOpen((prev) => !prev)} + onSettingsClick={() => { + if (config?.settings_enabled) { + setSettingsOpen(true); + } else { + setConfigBannerOpen(true); + } + }} + isAdmin={requestRoleIsAdmin} + canAccessSettings={authCanAccessSettings} + username={username} + displayName={displayName} + statusCounts={statusCounts} + onLogoClick={() => handleResetSearch(config)} + authRequired={authRequired} + isAuthenticated={isAuthenticated} + onLogout={handleLogoutWithCleanup} + onSearch={() => { + const query = buildSearchQuery({ + searchInput, + showAdvanced, + advancedFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode, + }); + runSearchWithPolicyRefresh(query); + }} + onAdvancedToggle={() => setShowAdvanced(!showAdvanced)} + isLoading={isSearching} + onShowToast={showToast} + onRemoveToast={removeToast} + contentType={contentType} + onContentTypeChange={setContentType} + /> +
- + -
+
handleSearch(query, config, searchFieldValues)} + onSearch={(query) => runSearchWithPolicyRefresh(query)} isLoading={isSearching} isInitialState={isInitialState} bookLanguages={bookLanguages} @@ -655,8 +1238,8 @@ function App() { onDetails={handleShowDetails} onDownload={handleDownload} onGetReleases={handleGetReleases} - getButtonState={getButtonState} - getUniversalButtonState={getUniversalButtonState} + getButtonState={getDirectActionButtonState} + getUniversalButtonState={getUniversalActionButtonState} sortValue={advancedFilters.sort} onSortChange={(value) => handleSortChange(value, config)} metadataSortOptions={config?.metadata_sort_options} @@ -673,43 +1256,70 @@ function App() { onDownload={handleDownload} onFindDownloads={handleFindDownloads} onSearchSeries={handleSearchSeries} - buttonState={getButtonState(selectedBook.id)} + buttonState={getDirectActionButtonState(selectedBook.id)} /> )} - {releaseBook && ( + {activeReleaseBook && ( setReleaseBook(null)} - onDownload={handleReleaseDownload} + book={activeReleaseBook} + onClose={handleReleaseModalClose} + onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload} + onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest} + getPolicyModeForSource={isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)} + onPolicyRefresh={() => refreshRequestPolicy({ force: true })} supportedFormats={supportedFormats} supportedAudiobookFormats={config?.supported_audiobook_formats || []} - contentType={contentType} + contentType={activeReleaseContentType} defaultLanguages={defaultLanguageCodes} bookLanguages={bookLanguages} currentStatus={currentStatus} defaultReleaseSource={config?.default_release_source} - onSearchSeries={handleSearchSeries} + onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries} + /> + )} + + {pendingRequestPayload && ( + setPendingRequestPayload(null)} /> )}
-
- +
+
+
+ - setDownloadsSidebarOpen(false)} status={currentStatus} + isAdmin={requestRoleIsAdmin} onClearCompleted={handleClearCompleted} onCancel={handleCancel} + requestItems={requestItems} + pendingRequestCount={pendingRequestCount} + showRequestsTab={showRequestsTab} + isRequestsLoading={isRequestsLoading} + onRequestCancel={showRequestsTab ? handleRequestCancel : undefined} + onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined} + onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined} + onRequestDismiss={showRequestsTab ? handleRequestDismiss : undefined} + onPinnedOpenChange={setSidebarPinnedOpen} + pinnedTopOffset={headerHeight} /> + + { @@ -149,6 +156,26 @@ export const BookDownloadButton = ({ ); } + if (isBlocked) { + if (variant === 'icon' && iconSizes) { + return ( + <> + + + + + + ); + } + return ( + + + + ); + } + if (showCircularProgress) { if (variant === 'icon') { const progressSize = iconVariantProgressSizes[size].mobile; diff --git a/src/frontend/src/components/BookGetButton.tsx b/src/frontend/src/components/BookGetButton.tsx index d62f3df4..47b8ec19 100644 --- a/src/frontend/src/components/BookGetButton.tsx +++ b/src/frontend/src/components/BookGetButton.tsx @@ -56,12 +56,13 @@ export const BookGetButton = ({ // Determine states based on buttonState const isCompleted = buttonState?.state === 'complete'; const hasError = buttonState?.state === 'error'; + const isBlocked = buttonState?.state === 'blocked'; const isInProgress = buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state); const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined; const showSpinner = (isInProgress && !showCircularProgress) || isLoading; // Disable button while loading metadata - const isDisabled = isLoading; + const isDisabled = isLoading || isBlocked; // Determine button styling based on state const getButtonClasses = () => { @@ -75,6 +76,11 @@ export const BookGetButton = ({ ? 'bg-red-600 text-white opacity-75' : 'bg-red-600 hover:bg-red-700'; } + if (isBlocked) { + return isIconVariant + ? 'text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-70' + : 'bg-gray-500 opacity-75 cursor-not-allowed'; + } if (isLoading) { // Show loading state (fetching metadata) return isIconVariant @@ -100,6 +106,7 @@ export const BookGetButton = ({ // Determine display text const getDisplayText = () => { + if (isBlocked) return buttonState?.text || 'Unavailable'; if (isCompleted) return 'Downloaded'; if (hasError) return 'Failed'; if (isLoading) return 'Loading'; @@ -107,6 +114,7 @@ export const BookGetButton = ({ if (buttonState?.state === 'locating') return 'Locating files'; if (buttonState?.state === 'resolving') return 'Resolving'; if (buttonState?.state === 'queued') return 'Queued'; + if (buttonState?.state === 'download' && buttonState.text) return buttonState.text; return 'Get'; }; @@ -128,6 +136,14 @@ export const BookGetButton = ({ ); } + if (isBlocked) { + return ( + + + + ); + } + if (showCircularProgress) { const progressSize = isIconVariant ? (size === 'sm' ? 16 : 20) : (size === 'sm' ? 12 : 16); return ; diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index f9517f60..ee645f4c 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -342,6 +342,8 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea 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-sky-700 hover:bg-sky-800 focus:ring-sky-500' }`} > diff --git a/src/frontend/src/components/DownloadsSidebar.tsx b/src/frontend/src/components/DownloadsSidebar.tsx index 8b1a8289..6591855b 100644 --- a/src/frontend/src/components/DownloadsSidebar.tsx +++ b/src/frontend/src/components/DownloadsSidebar.tsx @@ -20,19 +20,6 @@ const STATUS_STYLES: Record { if (!preview) { diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 15528e44..a9c3a1b7 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -1,18 +1,13 @@ import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; import { SearchBar, SearchBarHandle } from './SearchBar'; import { ContentType } from '../types'; +import { ActivityStatusCounts, getActivityBadgeState } from '../utils/activityBadge'; import { withBasePath } from '../utils/basePath'; export interface HeaderHandle { submitSearch: () => void; } -interface StatusCounts { - ongoing: number; - completed: number; - errored: number; -} - interface HeaderProps { calibreWebUrl?: string; audiobookLibraryUrl?: string; @@ -27,7 +22,8 @@ interface HeaderProps { onDownloadsClick?: () => void; onSettingsClick?: () => void; isAdmin?: boolean; - statusCounts?: StatusCounts; + canAccessSettings?: boolean; + statusCounts?: ActivityStatusCounts; onLogoClick?: () => void; authRequired?: boolean; isAuthenticated?: boolean; @@ -54,7 +50,8 @@ export const Header = forwardRef(({ onDownloadsClick, onSettingsClick, isAdmin = false, - statusCounts = { ongoing: 0, completed: 0, errored: 0 }, + canAccessSettings, + statusCounts = { ongoing: 0, completed: 0, errored: 0, pendingRequests: 0 }, onLogoClick, authRequired = false, isAuthenticated = false, @@ -66,6 +63,8 @@ export const Header = forwardRef(({ contentType = 'ebook', onContentTypeChange, }, ref) => { + const activityBadge = getActivityBadgeState(statusCounts, isAdmin); + const settingsEnabled = canAccessSettings ?? isAdmin; const searchBarRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -206,13 +205,13 @@ export const Header = forwardRef(({ )} - {/* Downloads Button */} + {/* Activity Button */} {onDownloadsClick && ( )} @@ -317,13 +309,13 @@ export const Header = forwardRef(({ {onSettingsClick && ( +
+ +
+
+
+
+ {preview.preview ? ( + {`${preview.title} + ) : ( +
+ No cover +
+ )} +
+
+

{preview.title}

+

{preview.author}

+ {(preview.year || preview.seriesLine) && ( +
+ {preview.year && ( + {preview.year} + )} + {preview.year && preview.seriesLine && ( + Β· + )} + {preview.seriesLine && ( + {preview.seriesLine} + )} +
+ )} + {preview.releaseLine && ( +

{preview.releaseLine}

+ )} +
+
+
+ + {allowNotes && ( +
+ +