diff --git a/entrypoint.sh b/entrypoint.sh index d722f203..3532cbc3 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -164,7 +164,9 @@ make_writable() { # Fix any misowned subdirectories/files (e.g., from previous runs as root) if [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then echo "Checking for misowned files/directories in $folder" - find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \ + # Stay on the same filesystem to avoid traversing mounted subpaths + # (for example read-only bind mounts under /app in dev setups). + find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \ -exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true fi test_write $folder || echo "Failed to test write to ${folder}, continuing..." @@ -174,7 +176,9 @@ fix_misowned() { folder=$1 mkdir -p $folder echo "Checking for misowned files/directories in $folder" - find "$folder" \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \ + # Stay on the same filesystem to avoid traversing mounted subpaths + # (for example read-only bind mounts under /app in dev setups). + find "$folder" -xdev \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \ -exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true } diff --git a/requirements-base.txt b/requirements-base.txt index 28cda0d3..3211adea 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -15,3 +15,4 @@ rarfile qbittorrent-api transmission-rpc authlib>=1.6.6,<1.7 +apprise>=1.9.0 diff --git a/shelfmark/bypass/internal_bypasser.py b/shelfmark/bypass/internal_bypasser.py index 0c5fced2..8e6ac21f 100644 --- a/shelfmark/bypass/internal_bypasser.py +++ b/shelfmark/bypass/internal_bypasser.py @@ -128,63 +128,72 @@ def _should_extract_cookie(name: str, extract_all: bool) -> bool: return is_cf or is_ddg +def _store_extracted_cookies( + *, + url: str, + cookies: list[Any], + user_agent: Optional[str] = None, +) -> None: + """Store filtered bypass cookies (and optional UA) for a URL domain.""" + parsed = urlparse(url) + domain = parsed.hostname or "" + if not domain: + return + + base_domain = _get_base_domain(domain) + extract_all = base_domain in FULL_COOKIE_DOMAINS + + cookies_found: dict[str, dict[str, Any]] = {} + for cookie in cookies: + name = getattr(cookie, "name", "") or "" + if not _should_extract_cookie(name, extract_all): + continue + expires = getattr(cookie, "expires", None) + if expires is not None and expires <= 0: + expires = None + cookies_found[name] = { + "value": getattr(cookie, "value", ""), + "domain": getattr(cookie, "domain", None) or domain, + "path": getattr(cookie, "path", None) or "/", + "expiry": expires, + "secure": bool(getattr(cookie, "secure", True)), + "httpOnly": True, + } + + if not cookies_found: + return + + with _cf_cookies_lock: + _cf_cookies[base_domain] = cookies_found + if user_agent: + _cf_user_agents[base_domain] = user_agent + logger.debug(f"Stored UA for {base_domain}: {str(user_agent)[:60]}...") + else: + logger.debug(f"No UA captured for {base_domain}") + + cookie_type = "all" if extract_all else "protection" + logger.debug(f"Extracted {len(cookies_found)} {cookie_type} cookies for {base_domain}") + + async def _extract_cookies_from_cdp(driver, page, url: str) -> None: """Extract cookies from a CDP browser after successful bypass.""" try: - parsed = urlparse(url) - domain = parsed.hostname or "" - if not domain: - return - - base_domain = _get_base_domain(domain) - extract_all = base_domain in FULL_COOKIE_DOMAINS - try: all_cookies = await driver.cookies.get_all(requests_cookie_format=True) except Exception as e: logger.debug(f"Failed to get cookies via CDP: {e}") return - cookies_found = {} - for cookie in all_cookies: - name = getattr(cookie, "name", "") or "" - if not _should_extract_cookie(name, extract_all): - continue - expires = getattr(cookie, "expires", None) - if expires is not None and expires <= 0: - expires = None - cookies_found[name] = { - "value": getattr(cookie, "value", ""), - "domain": getattr(cookie, "domain", None) or domain, - "path": getattr(cookie, "path", None) or "/", - "expiry": expires, - "secure": bool(getattr(cookie, "secure", True)), - "httpOnly": True, - } - - if not cookies_found: - return - try: user_agent = await page.evaluate("navigator.userAgent") except Exception: user_agent = None - with _cf_cookies_lock: - _cf_cookies[base_domain] = cookies_found - if user_agent: - _cf_user_agents[base_domain] = user_agent - logger.debug(f"Stored UA for {base_domain}: {str(user_agent)[:60]}...") - else: - logger.debug(f"No UA captured for {base_domain}") - - cookie_type = "all" if extract_all else "protection" - logger.debug(f"Extracted {len(cookies_found)} {cookie_type} cookies for {base_domain}") + _store_extracted_cookies(url=url, cookies=all_cookies, user_agent=user_agent) except Exception as e: logger.debug(f"Failed to extract cookies: {e}") - def get_cf_cookies_for_domain(domain: str) -> dict[str, str]: """Get stored cookies for a domain. Returns empty dict if none available.""" if not domain: diff --git a/shelfmark/config/notifications_settings.py b/shelfmark/config/notifications_settings.py new file mode 100644 index 00000000..b452cd88 --- /dev/null +++ b/shelfmark/config/notifications_settings.py @@ -0,0 +1,337 @@ +"""Notifications settings tab registration.""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import urlsplit + +from shelfmark.core.notifications import NotificationEvent, send_test_notification +from shelfmark.core.settings_registry import ( + ActionButton, + HeadingField, + TableField, + load_config_file, + register_on_save, + register_settings, +) + +_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$") + +_ROUTE_EVENT_ALL = "all" +_ADMIN_EVENT_OPTIONS = [ + {"value": NotificationEvent.REQUEST_CREATED.value, "label": "New request submitted"}, + {"value": NotificationEvent.REQUEST_FULFILLED.value, "label": "Request approved"}, + {"value": NotificationEvent.REQUEST_REJECTED.value, "label": "Request rejected"}, + {"value": NotificationEvent.DOWNLOAD_COMPLETE.value, "label": "Download complete"}, + {"value": NotificationEvent.DOWNLOAD_FAILED.value, "label": "Download failed"}, +] +_ROUTE_EVENT_OPTIONS = [ + {"value": _ROUTE_EVENT_ALL, "label": "All"}, + *_ADMIN_EVENT_OPTIONS, +] +_ROUTE_EVENT_ORDER = [option["value"] for option in _ROUTE_EVENT_OPTIONS] +_ROUTE_EVENT_INDEX = {event: index for index, event in enumerate(_ROUTE_EVENT_ORDER)} +_ALLOWED_ROUTE_EVENTS = set(_ROUTE_EVENT_ORDER) + +_DEFAULT_ROUTE_ROWS = [{"event": [_ROUTE_EVENT_ALL], "url": ""}] + + +def _looks_like_apprise_url(url: str) -> bool: + split = urlsplit(url) + if not split.scheme: + return False + if not _URL_SCHEME_RE.match(split.scheme): + return False + return " " not in url + + +def _coerce_route_rows(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, list): + return [row for row in value if isinstance(row, dict)] + if isinstance(value, dict): + return [value] + return [] + + +def _coerce_route_event_values(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, (tuple, set)): + return list(value) + return [value] + + +def _normalize_route_events(value: Any) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + + for raw_event in _coerce_route_event_values(value): + event = str(raw_event or "").strip().lower() + if not event or event not in _ALLOWED_ROUTE_EVENTS: + continue + if event in seen: + continue + seen.add(event) + normalized.append(event) + + if _ROUTE_EVENT_ALL in seen: + return [_ROUTE_EVENT_ALL] + + return sorted(normalized, key=lambda event: _ROUTE_EVENT_INDEX[event]) + + +def _normalize_routes(value: Any) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + seen: set[tuple[tuple[str, ...], str]] = set() + + for row in _coerce_route_rows(value): + events = _normalize_route_events(row.get("event")) + if not events: + continue + + url = str(row.get("url") or "").strip() + key = (tuple(events), url) + if key in seen: + continue + seen.add(key) + + normalized.append({"event": events, "url": url}) + + return normalized + + +def _count_invalid_route_events(value: Any) -> int: + invalid = 0 + for row in _coerce_route_rows(value): + raw_events = _coerce_route_event_values(row.get("event")) + if not raw_events: + invalid += 1 + continue + + for raw_event in raw_events: + event = str(raw_event or "").strip().lower() + if not event or event not in _ALLOWED_ROUTE_EVENTS: + invalid += 1 + return invalid + + +def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int: + return sum(1 for row in routes if row["url"] and not _looks_like_apprise_url(row["url"])) + + +def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]: + return routes if routes else [dict(row) for row in _DEFAULT_ROUTE_ROWS] + + +def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]: + urls: list[str] = [] + seen: set[str] = set() + for row in routes: + url = row.get("url", "") + if not url: + continue + if url in seen: + continue + seen.add(url) + urls.append(url) + return urls + + +def build_notification_test_result(routes_input: Any, *, scope_label: str) -> dict[str, Any]: + invalid_event_count = _count_invalid_route_events(routes_input) + if invalid_event_count: + return { + "success": False, + "message": ( + f"Found {invalid_event_count} invalid {scope_label} notification route event value(s). " + "Fix route events before running a test." + ), + } + + normalized_routes = _normalize_routes(routes_input) + invalid_url_count = _count_invalid_route_urls(normalized_routes) + if invalid_url_count: + return { + "success": False, + "message": ( + f"Found {invalid_url_count} invalid {scope_label} notification URL(s). " + "Fix route URLs before running a test." + ), + } + + urls = _extract_unique_route_urls(normalized_routes) + if not urls: + return { + "success": False, + "message": f"Add at least one {scope_label} notification URL route first.", + } + + return send_test_notification(urls) + + +def normalize_notification_routes(value: Any) -> list[dict[str, Any]]: + """Normalize route table rows for notification preferences.""" + return _normalize_routes(value) + + +def is_valid_notification_url(url: str) -> bool: + """Shared URL validation for notifications preferences.""" + return _looks_like_apprise_url(url) + + +def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]: + existing = load_config_file("notifications") + effective: dict[str, Any] = dict(existing) + effective.update(values) + + admin_routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", []) + invalid_admin_event_count = _count_invalid_route_events(admin_routes_input) + if invalid_admin_event_count: + return { + "error": True, + "message": ( + f"Found {invalid_admin_event_count} invalid global notification route event value(s)." + ), + "values": values, + } + + normalized_admin_routes = _normalize_routes(admin_routes_input) + invalid_admin_url_count = _count_invalid_route_urls(normalized_admin_routes) + if invalid_admin_url_count: + return { + "error": True, + "message": ( + f"Found {invalid_admin_url_count} invalid global notification URL(s). " + "Use URL values with a valid scheme, e.g. discord://... or ntfys://..." + ), + "values": values, + } + + user_routes_input = effective.get("USER_NOTIFICATION_ROUTES", []) + invalid_user_event_count = _count_invalid_route_events(user_routes_input) + if invalid_user_event_count: + return { + "error": True, + "message": ( + f"Found {invalid_user_event_count} invalid personal notification route event value(s)." + ), + "values": values, + } + + normalized_user_routes = _normalize_routes(user_routes_input) + invalid_user_url_count = _count_invalid_route_urls(normalized_user_routes) + if invalid_user_url_count: + return { + "error": True, + "message": ( + f"Found {invalid_user_url_count} invalid personal notification URL(s). " + "Use URL values with a valid scheme, e.g. discord://... or ntfys://..." + ), + "values": values, + } + + admin_routes_touched = "ADMIN_NOTIFICATION_ROUTES" in values + if admin_routes_touched: + values["ADMIN_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_admin_routes) + + user_routes_touched = "USER_NOTIFICATION_ROUTES" in values + if user_routes_touched: + values["USER_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_user_routes) + + return {"error": False, "values": values} + + +def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]: + persisted = load_config_file("notifications") + effective: dict[str, Any] = dict(persisted) + if isinstance(current_values, dict): + effective.update(current_values) + + routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", []) + return build_notification_test_result(routes_input, scope_label="global") + + +register_on_save("notifications", _on_save_notifications) + + +@register_settings("notifications", "Notifications", icon="bell", order=7) +def notifications_settings(): + """Global notifications settings.""" + return [ + HeadingField( + key="notifications_heading", + title="Global Notifications", + description=( + "Global notifications send selected events for all users to configured routes. " + "Users can manage personal notifications in User Preferences." + ), + ), + TableField( + key="ADMIN_NOTIFICATION_ROUTES", + label="", + description=( + "Create one route per URL. Start with All, then add event-specific routes " + "for targeted delivery. Need format examples? " + "[View Apprise URL formats](https://appriseit.com/services/)." + ), + columns=[ + { + "key": "event", + "label": "Event", + "type": "multiselect", + "options": _ROUTE_EVENT_OPTIONS, + "defaultValue": [_ROUTE_EVENT_ALL], + "placeholder": "Select events...", + }, + { + "key": "url", + "label": "Notification URL", + "type": "text", + "placeholder": "e.g. ntfys://ntfy.sh/shelfmark", + }, + ], + default=[dict(row) for row in _DEFAULT_ROUTE_ROWS], + add_label="Add Route", + empty_message="No routes configured.", + ), + ActionButton( + key="test_admin_notification", + label="Test Notification", + description="Send a test notification to all configured global route URLs.", + style="primary", + callback=_test_admin_notification_action, + ), + TableField( + key="USER_NOTIFICATION_ROUTES", + label="", + description=( + "Create one route per URL. Start with All, then add event-specific routes " + "for targeted delivery. Need format examples? " + "[View Apprise URL formats](https://appriseit.com/services/)." + ), + columns=[ + { + "key": "event", + "label": "Event", + "type": "multiselect", + "options": _ROUTE_EVENT_OPTIONS, + "defaultValue": [_ROUTE_EVENT_ALL], + "placeholder": "Select events...", + }, + { + "key": "url", + "label": "Notification URL", + "type": "text", + "placeholder": "e.g. ntfys://ntfy.sh/username-topic", + }, + ], + default=[dict(row) for row in _DEFAULT_ROUTE_ROWS], + add_label="Add Route", + empty_message="No routes configured.", + user_overridable=True, + hidden_in_ui=True, + ), + ] diff --git a/shelfmark/config/security_handlers.py b/shelfmark/config/security_handlers.py index 5f563234..a340d137 100644 --- a/shelfmark/config/security_handlers.py +++ b/shelfmark/config/security_handlers.py @@ -3,6 +3,7 @@ import os from typing import Any, Callable +from shelfmark.core.utils import normalize_http_url from shelfmark.core.user_db import UserDB @@ -20,10 +21,27 @@ def on_save_security( values: dict[str, Any], ) -> dict[str, Any]: """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} + normalized_values = values.copy() - return {"error": False, "values": values} + discovery_url = normalized_values.get("OIDC_DISCOVERY_URL") + if discovery_url is not None: + normalized_values["OIDC_DISCOVERY_URL"] = normalize_http_url( + str(discovery_url), + default_scheme="https", + ) + + proxy_logout_url = normalized_values.get("PROXY_AUTH_LOGOUT_URL") + if proxy_logout_url is not None: + normalized_values["PROXY_AUTH_LOGOUT_URL"] = normalize_http_url( + str(proxy_logout_url), + default_scheme="https", + strip_trailing_slash=False, + ) + + if normalized_values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin(): + return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values} + + return {"error": False, "values": normalized_values} def test_oidc_connection( diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py index db3782cc..76efb986 100644 --- a/shelfmark/core/admin_settings_routes.py +++ b/shelfmark/core/admin_settings_routes.py @@ -4,30 +4,21 @@ from typing import Any, Callable from flask import Flask, jsonify, request +from shelfmark.config.notifications_settings import ( + build_notification_test_result, + is_valid_notification_url, + normalize_notification_routes, +) from shelfmark.core.settings_registry import load_config_file +from shelfmark.core.user_settings_overrides import ( + build_user_preferences_payload as _build_user_preferences_payload, + get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields, + get_settings_registry as _get_settings_registry, +) 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 - - -def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]: - settings_registry = _get_settings_registry() - tab = settings_registry.get_settings_tab(tab_name) - if not tab: - return [] - overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name) - return [(field.key, field) for field in tab.fields if field.key in overridable_map] - - def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: settings_registry = _get_settings_registry() field_map = settings_registry.get_settings_field_map() @@ -59,11 +50,48 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li valid[key] = normalized_rules continue + if key == "USER_NOTIFICATION_ROUTES": + normalized_routes = normalize_notification_routes(value) + invalid_count = sum( + 1 + for row in normalized_routes + if row.get("url") and not is_valid_notification_url(str(row.get("url"))) + ) + if invalid_count: + errors.append( + ( + f"Invalid value for {key}: found {invalid_count} invalid URL(s). " + "Use URL values with a valid scheme, e.g. discord://... or ntfys://..." + ) + ) + continue + valid[key] = normalized_routes + continue + valid[key] = value return valid, errors +def build_user_notification_test_response( + *, + user_id: int, + payload: Any, +) -> tuple[dict[str, Any], int]: + from shelfmark.core.config import config as app_config + + routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id) + if isinstance(payload, dict): + if "USER_NOTIFICATION_ROUTES" in payload: + routes_input = payload.get("USER_NOTIFICATION_ROUTES") + elif "routes" in payload: + routes_input = payload.get("routes") + + result = build_notification_test_result(routes_input, scope_label="personal") + status_code = 200 if result.get("success", False) else 400 + return result, status_code + + def register_admin_settings_routes( app: Flask, user_db: UserDB, @@ -101,54 +129,40 @@ def register_admin_settings_routes( if not user: return jsonify({"error": "User not found"}), 404 - from shelfmark.core import settings_registry - from shelfmark.core.config import config as app_config - - ordered_fields = _get_ordered_user_overridable_fields("downloads") - if not ordered_fields: + try: + payload = _build_user_preferences_payload(user_db, user_id, "downloads") + except ValueError: return jsonify({"error": "Downloads settings tab not found"}), 500 - download_config = load_config_file("downloads") - user_settings = user_db.get_user_settings(user_id) - ordered_keys = [key for key, _ in ordered_fields] + return jsonify(payload) - fields_payload: list[dict[str, Any]] = [] - global_values: dict[str, Any] = {} - effective: dict[str, dict[str, Any]] = {} + @app.route("/api/admin/users//notification-preferences", methods=["GET"]) + @require_admin + def admin_get_notification_preferences(user_id): + user = user_db.get_user(user_id=user_id) + if not user: + return jsonify({"error": "User not found"}), 404 - for key, field in ordered_fields: - serialized = settings_registry.serialize_field(field, "downloads", include_value=False) - serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field)) - fields_payload.append(serialized) + try: + payload = _build_user_preferences_payload(user_db, user_id, "notifications") + except ValueError: + return jsonify({"error": "Notifications settings tab not found"}), 500 - global_values[key] = app_config.get(key, field.default) + return jsonify(payload) - source = "default" - value = app_config.get(key, field.default, user_id=user_id) - if field.env_supported and settings_registry.is_value_from_env(field): - source = "env_var" - elif key in user_settings and user_settings[key] is not None: - source = "user_override" - value = user_settings[key] - elif key in download_config: - source = "global_config" + @app.route("/api/admin/users//notification-preferences/test", methods=["POST"]) + @require_admin + def admin_test_notification_preferences(user_id): + user = user_db.get_user(user_id=user_id) + if not user: + return jsonify({"error": "User not found"}), 404 - effective[key] = {"value": value, "source": source} - - user_overrides = { - key: user_settings[key] - for key in ordered_keys - if key in user_settings and user_settings[key] is not None - } - - return jsonify({ - "tab": "downloads", - "keys": ordered_keys, - "fields": fields_payload, - "globalValues": global_values, - "userOverrides": user_overrides, - "effective": effective, - }) + payload = request.get_json(silent=True) + result, status_code = build_user_notification_test_response( + user_id=user_id, + payload=payload, + ) + return jsonify(result), status_code @app.route("/api/admin/settings/overrides-summary", methods=["GET"]) @require_admin diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index 44e0a5a6..763ebf97 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -84,6 +84,7 @@ class Config: # This handles cases where config is accessed before settings are registered try: import shelfmark.config.settings # noqa: F401 - main app settings + import shelfmark.config.notifications_settings # noqa: F401 - notifications settings import shelfmark.release_sources # noqa: F401 - plugin settings import shelfmark.metadata_providers # noqa: F401 - plugin settings except ImportError: diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 4b037e89..9fdc4343 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -12,7 +12,16 @@ logger = setup_logger(__name__) # Known variable tokens, sorted longest-first to avoid partial matches # e.g., "SeriesPosition" must match before "Series" -KNOWN_TOKENS = ['seriesposition', 'partnumber', 'subtitle', 'author', 'series', 'title', 'year', 'user'] +KNOWN_TOKENS = [ + 'seriesposition', + 'partnumber', + 'subtitle', + 'author', + 'series', + 'title', + 'year', + 'user', +] # Match any {...} block for template parsing BRACE_PATTERN = re.compile(r'\{([^}]+)\}') @@ -89,46 +98,74 @@ def parse_naming_template( # Normalize metadata keys to lowercase for case-insensitive matching normalized = {k.lower(): v for k, v in metadata.items()} - def replace_block(match: re.Match) -> str: - content = match.group(1) + def find_token(content: str) -> tuple[Optional[str], int]: content_lower = content.lower() - - # Find which known token appears in this block (longest first) for token in KNOWN_TOKENS: idx = content_lower.find(token) if idx != -1: - prefix = content[:idx] - suffix = content[idx + len(token):] + return token, idx + return None, -1 - # Get the value for this token - value = normalized.get(token) + def token_value(token: str) -> str: + value = normalized.get(token) + if token == 'seriesposition': + value = format_series_position(value) + if value is None: + return "" + return str(value).strip() - # Special handling for series position - if token == 'seriesposition': - value = format_series_position(value) + def render_block(content: str) -> Optional[str]: + token, idx = find_token(content) + if token is None: + return None - # Convert to string - if value is None: - value = "" - else: - value = str(value).strip() + prefix = content[:idx] + suffix = content[idx + len(token):] + value = token_value(token) + if not value: + return "" - # If value is empty, return empty string (no prefix/suffix) - if not value: - return "" + if not allow_path_separators: + value = value.replace("/", "_") + value = sanitize_filename(value) + return f"{prefix}{value}{suffix}" - if not allow_path_separators: - value = value.replace("/", "_") - # Sanitize the value - value = sanitize_filename(value) + # Process brace blocks in order so we can support conditional literal blocks like: + # { - Part }{PartNumber} + matches = list(BRACE_PATTERN.finditer(template)) + if not matches: + result = template + else: + parts: list[str] = [] + cursor = 0 + for idx, match in enumerate(matches): + parts.append(template[cursor:match.start()]) + content = match.group(1) + rendered = render_block(content) - return f"{prefix}{value}{suffix}" + if rendered is not None: + parts.append(rendered) + else: + conditional_literal = False + include_literal = False + if idx + 1 < len(matches) and match.end() == matches[idx + 1].start(): + next_content = matches[idx + 1].group(1) + next_token, _next_idx = find_token(next_content) + if next_token is not None: + conditional_literal = True + include_literal = bool(token_value(next_token)) + if include_literal: + parts.append(content) + elif not conditional_literal: + # Preserve blocks that look like literal text, but treat bare unknown + # placeholders as missing variables. + if re.search(r"\s", content): + parts.append(match.group(0)) - # No known token found → return original block unchanged - return match.group(0) + cursor = match.end() - # Replace all tokens - result = BRACE_PATTERN.sub(replace_block, template) + parts.append(template[cursor:]) + result = "".join(parts) # Clean up any double slashes that might result from empty tokens result = re.sub(r'/+', '/', result) diff --git a/shelfmark/core/notifications.py b/shelfmark/core/notifications.py new file mode 100644 index 00000000..ef3ff773 --- /dev/null +++ b/shelfmark/core/notifications.py @@ -0,0 +1,380 @@ +"""Apprise notification dispatch for global and per-user events.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from enum import Enum +from typing import Any, Iterable + +try: + import apprise +except Exception: # pragma: no cover - exercised in tests via monkeypatch + apprise = None # type: ignore[assignment] + +from shelfmark.core.config import config as app_config +from shelfmark.core.logger import setup_logger + +logger = setup_logger(__name__) + +# Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent. +_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="Notify") +_ROUTE_EVENT_ALL = "all" +_APPRISE_APP_ID = "Shelfmark" +_APPRISE_APP_DESC = "Shelfmark notifications" +_APPRISE_LOGO_URL = ( + "https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png" +) + + +class NotificationEvent(str, Enum): + """Global notification event identifiers.""" + + REQUEST_CREATED = "request_created" + REQUEST_FULFILLED = "request_fulfilled" + REQUEST_REJECTED = "request_rejected" + DOWNLOAD_COMPLETE = "download_complete" + DOWNLOAD_FAILED = "download_failed" + + +@dataclass +class NotificationContext: + """Context used to render notification templates.""" + + event: NotificationEvent + title: str + author: str + username: str | None = None + content_type: str | None = None + format: str | None = None + source: str | None = None + admin_note: str | None = None + error_message: str | None = None + + +def _normalize_urls(value: Any) -> list[str]: + if value is None: + return [] + + raw_values: list[Any] + if isinstance(value, list): + raw_values = value + elif isinstance(value, str): + # Support legacy/manual configs. + raw_values = [segment for part in value.splitlines() for segment in part.split(",")] + else: + raw_values = [value] + + normalized: list[str] = [] + seen: set[str] = set() + for raw_url in raw_values: + url = str(raw_url or "").strip() + if not url: + continue + if url in seen: + continue + seen.add(url) + normalized.append(url) + return normalized + + +def _normalize_routes(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + + allowed_events = {_ROUTE_EVENT_ALL, *(event.value for event in NotificationEvent)} + normalized: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + + for row in value: + if not isinstance(row, dict): + continue + + raw_events = row.get("event") + if isinstance(raw_events, list): + event_values = raw_events + elif isinstance(raw_events, (tuple, set)): + event_values = list(raw_events) + else: + event_values = [raw_events] + + url = str(row.get("url") or "").strip() + if not url: + continue + + row_events: list[str] = [] + for raw_event in event_values: + event = str(raw_event or "").strip().lower() + if event not in allowed_events: + continue + if event in row_events: + continue + row_events.append(event) + + if _ROUTE_EVENT_ALL in row_events: + row_events = [_ROUTE_EVENT_ALL] + + for event in row_events: + key = (event, url) + if key in seen: + continue + seen.add(key) + + normalized.append({"event": event, "url": url}) + + return normalized + + +def _resolve_admin_routes() -> list[dict[str, str]]: + return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", [])) + + +def _normalize_user_id(value: Any) -> int | None: + try: + user_id = int(value) + except (TypeError, ValueError): + return None + if user_id < 1: + return None + return user_id + + +def _resolve_user_routes(user_id: int | None) -> list[dict[str, str]]: + normalized_user_id = _normalize_user_id(user_id) + if normalized_user_id is None: + return [] + + return _normalize_routes( + app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id) + ) + + +def _resolve_route_urls_for_event( + routes: list[dict[str, str]], + event: NotificationEvent, +) -> list[str]: + selected: list[str] = [] + seen: set[str] = set() + event_value = event.value + + for row in routes: + row_event = row.get("event", "") + if row_event not in {_ROUTE_EVENT_ALL, event_value}: + continue + + url = row.get("url", "") + if not url or url in seen: + continue + + seen.add(url) + selected.append(url) + + return selected + + +def _resolve_notify_type(event: NotificationEvent) -> Any: + if apprise is None: + fallback = { + NotificationEvent.REQUEST_CREATED: "info", + NotificationEvent.REQUEST_FULFILLED: "success", + NotificationEvent.REQUEST_REJECTED: "warning", + NotificationEvent.DOWNLOAD_COMPLETE: "success", + NotificationEvent.DOWNLOAD_FAILED: "failure", + } + return fallback[event] + + mapping = { + NotificationEvent.REQUEST_CREATED: apprise.NotifyType.INFO, + NotificationEvent.REQUEST_FULFILLED: apprise.NotifyType.SUCCESS, + NotificationEvent.REQUEST_REJECTED: apprise.NotifyType.WARNING, + NotificationEvent.DOWNLOAD_COMPLETE: apprise.NotifyType.SUCCESS, + NotificationEvent.DOWNLOAD_FAILED: apprise.NotifyType.FAILURE, + } + return mapping[event] + + +def _clean_text(value: Any, fallback: str) -> str: + text = str(value or "").strip() + return text or fallback + + +def _render_message(context: NotificationContext) -> tuple[str, str]: + event = context.event + title = _clean_text(context.title, "Unknown title") + author = _clean_text(context.author, "Unknown author") + username = _clean_text(context.username, "A user") + + if event == NotificationEvent.REQUEST_CREATED: + return "New Request", f'{username} requested "{title}" by {author}' + if event == NotificationEvent.REQUEST_FULFILLED: + return "Request Approved", f'Request for "{title}" by {author} was approved.' + if event == NotificationEvent.REQUEST_REJECTED: + note = _clean_text(context.admin_note, "") + note_line = f"\nNote: {note}" if note else "" + return "Request Rejected", f'Request for "{title}" by {author} was rejected.{note_line}' + if event == NotificationEvent.DOWNLOAD_COMPLETE: + return "Download Complete", f'"{title}" by {author} downloaded successfully.' + + error_message = _clean_text(context.error_message, "") + error_line = f"\nError: {error_message}" if error_message else "" + return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}' + + +def _dispatch_to_apprise( + urls: Iterable[str], + *, + title: str, + body: str, + notify_type: Any, +) -> dict[str, Any]: + normalized_urls = _normalize_urls(list(urls)) + if not normalized_urls: + return {"success": False, "message": "No notification URLs configured"} + + if apprise is None: + return {"success": False, "message": "Apprise is not installed"} + + apobj = _create_apprise_client() + if apobj is None: + return {"success": False, "message": "Apprise is not installed"} + valid_urls = 0 + invalid_urls = 0 + for url in normalized_urls: + try: + added = bool(apobj.add(url)) + except Exception: + added = False + if added: + valid_urls += 1 + else: + invalid_urls += 1 + + if valid_urls == 0: + return { + "success": False, + "message": "No valid notification URLs configured", + } + + try: + delivered = bool(apobj.notify(title=title, body=body, notify_type=notify_type)) + except Exception as exc: + return {"success": False, "message": f"Notification send failed: {type(exc).__name__}: {exc}"} + + if not delivered: + return {"success": False, "message": "Notification delivery failed"} + + message = f"Notification sent to {valid_urls} URL(s)" + if invalid_urls: + message += f" ({invalid_urls} invalid URL(s) skipped)" + return {"success": True, "message": message} + + +def _create_apprise_client() -> Any: + if apprise is None: + return None + + apprise_cls = getattr(apprise, "Apprise", None) + if apprise_cls is None: + return None + + apprise_asset_cls = getattr(apprise, "AppriseAsset", None) + if apprise_asset_cls is None: + return apprise_cls() + + try: + asset = apprise_asset_cls( + app_id=_APPRISE_APP_ID, + app_desc=_APPRISE_APP_DESC, + image_url_logo=_APPRISE_LOGO_URL, + ) + except TypeError: + # Support older Apprise versions that do not expose image_url_logo. + asset = apprise_asset_cls( + app_id=_APPRISE_APP_ID, + app_desc=_APPRISE_APP_DESC, + ) + except Exception: + return apprise_cls() + + try: + return apprise_cls(asset=asset) + except Exception: + return apprise_cls() + + +def _send_admin_event(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> dict[str, Any]: + title, body = _render_message(context) + notify_type = _resolve_notify_type(event) + return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type) + + +def notify_admin(event: NotificationEvent, context: NotificationContext) -> None: + """Send a global admin notification for an event if subscribed.""" + routes = _resolve_admin_routes() + urls = _resolve_route_urls_for_event(routes, event) + if not urls: + return + + try: + _executor.submit(_dispatch_admin_async, event, context, urls) + except Exception as exc: + logger.warning("Failed to queue admin notification '%s': %s", event.value, exc) + + +def notify_user(user_id: int | None, event: NotificationEvent, context: NotificationContext) -> None: + """Send a per-user notification for an event if subscribed.""" + normalized_user_id = _normalize_user_id(user_id) + if normalized_user_id is None: + return + + routes = _resolve_user_routes(normalized_user_id) + urls = _resolve_route_urls_for_event(routes, event) + if not urls: + return + + try: + _executor.submit(_dispatch_user_async, normalized_user_id, event, context, urls) + except Exception as exc: + logger.warning( + "Failed to queue user notification '%s' for user_id=%s: %s", + event.value, + normalized_user_id, + exc, + ) + + +def _dispatch_admin_async(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> None: + result = _send_admin_event(event, context, urls) + if not result.get("success", False): + logger.warning("Admin notification failed for event '%s': %s", event.value, result.get("message")) + + +def _dispatch_user_async( + user_id: int, + event: NotificationEvent, + context: NotificationContext, + urls: list[str], +) -> None: + result = _send_admin_event(event, context, urls) + if not result.get("success", False): + logger.warning( + "User notification failed for event '%s' (user_id=%s): %s", + event.value, + user_id, + result.get("message"), + ) + + +def send_test_notification(urls: list[str]) -> dict[str, Any]: + """Send a synchronous test notification to the provided URLs.""" + normalized_urls = _normalize_urls(urls) + if not normalized_urls: + return {"success": False, "message": "No notification URLs configured"} + + test_context = NotificationContext( + event=NotificationEvent.REQUEST_CREATED, + title="Shelfmark Test Notification", + author="Shelfmark", + username="Shelfmark", + ) + return _send_admin_event(NotificationEvent.REQUEST_CREATED, test_context, normalized_urls) diff --git a/shelfmark/core/oidc_routes.py b/shelfmark/core/oidc_routes.py index 953b808c..8c4a7d02 100644 --- a/shelfmark/core/oidc_routes.py +++ b/shelfmark/core/oidc_routes.py @@ -6,6 +6,7 @@ Business logic remains in oidc_auth.py. from typing import Any +from authlib.jose.errors import InvalidClaimError from authlib.integrations.flask_client import OAuth from flask import Flask, jsonify, redirect, request, session @@ -119,7 +120,40 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: return jsonify({"error": "Authentication failed"}), 400 client, config = _get_oidc_client() - token = client.authorize_access_token() + try: + token = client.authorize_access_token() + except InvalidClaimError as e: + claim_name = getattr(e, "claim_name", "unknown") + discovery_url = str(config.get("OIDC_DISCOVERY_URL", "")) + provider_issuer = "" + try: + metadata = client.load_server_metadata() + if isinstance(metadata, dict): + provider_issuer = str(metadata.get("issuer", "")) + except Exception as metadata_error: + logger.debug(f"OIDC metadata lookup failed during claim diagnostics: {metadata_error}") + + logger.error( + "OIDC callback claim validation failed: claim=%s error=%s discovery_url=%s provider_issuer=%s", + claim_name, + e, + discovery_url or "", + provider_issuer or "", + ) + if claim_name == "iss": + return ( + jsonify( + { + "error": ( + "OIDC issuer validation failed. Verify your discovery URL and IdP issuer/" + "external URL configuration." + ) + } + ), + 400, + ) + + return jsonify({"error": f"OIDC token claim validation failed: {claim_name}"}), 400 claims = _normalize_claims(token.get("userinfo")) # If userinfo isn't present in token payload, request it explicitly. diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 3e0f76b3..07f644ff 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -25,6 +25,12 @@ from shelfmark.core.requests_service import ( reject_request, ) from shelfmark.core.activity_service import ActivityService, build_request_item_key +from shelfmark.core.notifications import ( + NotificationContext, + NotificationEvent, + notify_admin, + notify_user, +) from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_db import UserDB @@ -188,6 +194,127 @@ def _record_terminal_request_snapshot( logger.warning("Failed to record terminal request snapshot for request %s: %s", request_id, exc) +def _normalize_optional_text(value: Any) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def _resolve_title_from_book_data(book_data: Any) -> str: + if isinstance(book_data, dict): + title = _normalize_optional_text(book_data.get("title")) + if title is not None: + return title + return "Unknown title" + + +def _resolve_request_title(request_row: dict[str, Any]) -> str: + return _resolve_title_from_book_data(request_row.get("book_data")) + + +def _format_user_label(username: str | None, user_id: int | None = None) -> str: + normalized_username = _normalize_optional_text(username) + if normalized_username is not None: + return normalized_username + if user_id is not None and user_id > 0: + return f"user#{user_id}" + return "unknown user" + + +def _resolve_request_username( + user_db: UserDB, + *, + request_row: dict[str, Any], + fallback_username: str | None = None, +) -> str | None: + normalized_fallback = _normalize_optional_text(fallback_username) + raw_user_id = request_row.get("user_id") + try: + request_user_id = int(raw_user_id) + except (TypeError, ValueError): + return normalized_fallback + + requester = user_db.get_user(user_id=request_user_id) + if not isinstance(requester, dict): + return normalized_fallback + return _normalize_optional_text(requester.get("username")) or normalized_fallback + + +def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]: + release_data = request_row.get("release_data") + if isinstance(release_data, dict): + source = normalize_source(release_data.get("source") or request_row.get("source_hint")) + release_format = _normalize_optional_text( + release_data.get("format") + or release_data.get("filetype") + or release_data.get("extension") + ) + return source, release_format + return normalize_source(request_row.get("source_hint")), None + + +def _resolve_request_user_id(request_row: dict[str, Any]) -> int | None: + raw_user_id = request_row.get("user_id") + try: + user_id = int(raw_user_id) + except (TypeError, ValueError): + return None + return user_id if user_id > 0 else None + + +def _notify_admin_for_request_event( + user_db: UserDB, + *, + event: NotificationEvent, + request_row: dict[str, Any], + fallback_username: str | None = None, +) -> None: + book_data = request_row.get("book_data") + if not isinstance(book_data, dict): + book_data = {} + + source, release_format = _resolve_request_source_and_format(request_row) + context = NotificationContext( + event=event, + title=str(book_data.get("title") or "Unknown title"), + author=str(book_data.get("author") or "Unknown author"), + username=_resolve_request_username( + user_db, + request_row=request_row, + fallback_username=fallback_username, + ), + content_type=normalize_content_type( + request_row.get("content_type") or book_data.get("content_type") + ), + format=release_format, + source=source, + admin_note=_normalize_optional_text(request_row.get("admin_note")), + error_message=None, + ) + + owner_user_id = _resolve_request_user_id(request_row) + try: + notify_admin(event, context) + except Exception as exc: + logger.warning( + "Failed to trigger admin notification for request event '%s': %s", + event.value, + exc, + ) + if owner_user_id is None: + return + try: + notify_user(owner_user_id, event, context) + except Exception as exc: + logger.warning( + "Failed to trigger user notification for request event '%s' (user_id=%s): %s", + event.value, + owner_user_id, + exc, + ) + + def register_request_routes( app: Flask, user_db: UserDB, @@ -251,26 +378,6 @@ def register_request_routes( } ) - 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, @@ -302,6 +409,8 @@ def register_request_routes( db_user_id, db_gate = _require_db_user_id() if db_gate is not None or db_user_id is None: return db_gate + actor_username = _normalize_optional_text(session.get("user_id")) + actor_label = _format_user_label(actor_username, db_user_id) data = request.get_json(silent=True) if not isinstance(data, dict): @@ -320,6 +429,7 @@ def register_request_routes( book_data = data.get("book_data") if not isinstance(book_data, dict): return jsonify({"error": "book_data must be an object"}), 400 + request_title = _resolve_title_from_book_data(book_data) content_type = normalize_content_type( context.get("content_type") @@ -332,6 +442,11 @@ def register_request_routes( db_user_id=db_user_id, ) if not requests_enabled: + logger.debug( + "Request not created for '%s' by %s: requests are disabled", + request_title, + actor_label, + ) return _error_response( "Request workflow is disabled by policy", 403, @@ -366,6 +481,11 @@ def register_request_routes( ) if resolved_mode == PolicyMode.BLOCKED: + logger.debug( + "Request blocked by policy for '%s' by %s", + request_title, + actor_label, + ) return _error_response( "Requesting is blocked by policy", 403, @@ -376,6 +496,11 @@ def register_request_routes( if resolved_mode == PolicyMode.REQUEST_BOOK: requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else "" if requested_level != "book": + logger.debug( + "Request not created for '%s' by %s: policy requires book-level requests", + request_title, + actor_label, + ) return _error_response( "Policy requires book-level requests", 403, @@ -402,8 +527,14 @@ def register_request_routes( event_payload = { "request_id": created["id"], "status": created["status"], - "title": (created.get("book_data") or {}).get("title") or "Unknown title", + "title": _resolve_request_title(created), } + logger.info( + "Request created #%s for '%s' by %s", + created["id"], + event_payload["title"], + actor_label, + ) _emit_request_event( ws_manager, event_name="new_request", @@ -417,6 +548,13 @@ def register_request_routes( room=f"user_{db_user_id}", ) + _notify_admin_for_request_event( + user_db, + event=NotificationEvent.REQUEST_CREATED, + request_row=created, + fallback_username=actor_username, + ) + return jsonify(created), 201 @app.route("/api/requests", methods=["GET"]) @@ -468,8 +606,15 @@ def register_request_routes( event_payload = { "request_id": updated["id"], "status": updated["status"], - "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + "title": _resolve_request_title(updated), } + actor_label = _format_user_label(_normalize_optional_text(session.get("user_id")), db_user_id) + logger.info( + "Request cancelled #%s for '%s' by %s", + updated["id"], + event_payload["title"], + actor_label, + ) _emit_request_event( ws_manager, event_name="request_update", @@ -567,8 +712,20 @@ def register_request_routes( event_payload = { "request_id": updated["id"], "status": updated["status"], - "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + "title": _resolve_request_title(updated), } + admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id) + requester_label = _format_user_label( + _resolve_request_username(user_db, request_row=updated), + _resolve_request_user_id(updated), + ) + logger.info( + "Request fulfilled #%s for '%s' by %s (requested by %s)", + updated["id"], + event_payload["title"], + admin_label, + requester_label, + ) _emit_request_event( ws_manager, event_name="request_update", @@ -582,6 +739,12 @@ def register_request_routes( room="admins", ) + _notify_admin_for_request_event( + user_db, + event=NotificationEvent.REQUEST_FULFILLED, + request_row=updated, + ) + return jsonify(updated) @app.route("/api/admin/requests//reject", methods=["POST"]) @@ -619,8 +782,20 @@ def register_request_routes( event_payload = { "request_id": updated["id"], "status": updated["status"], - "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + "title": _resolve_request_title(updated), } + admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id) + requester_label = _format_user_label( + _resolve_request_username(user_db, request_row=updated), + _resolve_request_user_id(updated), + ) + logger.info( + "Request rejected #%s for '%s' by %s (requested by %s)", + updated["id"], + event_payload["title"], + admin_label, + requester_label, + ) _emit_request_event( ws_manager, event_name="request_update", @@ -634,4 +809,10 @@ def register_request_routes( room="admins", ) + _notify_admin_for_request_event( + user_db, + event=NotificationEvent.REQUEST_REJECTED, + request_row=updated, + ) + return jsonify(updated) diff --git a/shelfmark/core/requests_service.py b/shelfmark/core/requests_service.py index 629d5ed1..63889d1b 100644 --- a/shelfmark/core/requests_service.py +++ b/shelfmark/core/requests_service.py @@ -356,7 +356,11 @@ def cancel_request( ) try: - return user_db.update_request(request_id, status="cancelled") + return user_db.update_request( + request_id, + expected_current_status="pending", + status="cancelled", + ) except ValueError as exc: raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc @@ -391,6 +395,7 @@ def reject_request( try: return user_db.update_request( request_id, + expected_current_status="pending", status="rejected", admin_note=normalized_admin_note, reviewed_by=admin_user_id, @@ -466,6 +471,7 @@ def fulfil_request( try: return user_db.update_request( request_id, + expected_current_status="pending", status="fulfilled", release_data=selected_release_data, delivery_state="queued", diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index 039040d2..ad05d090 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -7,7 +7,10 @@ from flask import Flask, jsonify, request, session from werkzeug.security import generate_password_hash from shelfmark.config.env import CWA_DB_PATH -from shelfmark.core.admin_settings_routes import validate_user_settings +from shelfmark.core.admin_settings_routes import ( + build_user_notification_test_response, + validate_user_settings, +) from shelfmark.core.auth_modes import ( AUTH_SOURCE_BUILTIN, AUTH_SOURCE_CWA, @@ -19,6 +22,10 @@ from shelfmark.core.auth_modes import ( ) from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import load_config_file +from shelfmark.core.user_settings_overrides import ( + build_user_preferences_payload as _build_user_preferences_payload, + get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields, +) from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) @@ -101,78 +108,6 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A return payload -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 - - -def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]: - settings_registry = _get_settings_registry() - tab = settings_registry.get_settings_tab(tab_name) - if not tab: - return [] - overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name) - return [(field.key, field) for field in tab.fields if field.key in overridable_map] - - -def _build_delivery_preferences_payload(user_db: UserDB, user_id: int) -> dict[str, Any]: - from shelfmark.core.config import config as app_config - - settings_registry = _get_settings_registry() - ordered_fields = _get_ordered_user_overridable_fields("downloads") - if not ordered_fields: - raise ValueError("Downloads settings tab not found") - - download_config = load_config_file("downloads") - user_settings = user_db.get_user_settings(user_id) - ordered_keys = [key for key, _ in ordered_fields] - - fields_payload: list[dict[str, Any]] = [] - global_values: dict[str, Any] = {} - effective: dict[str, dict[str, Any]] = {} - - for key, field in ordered_fields: - serialized = settings_registry.serialize_field(field, "downloads", include_value=False) - serialized["fromEnv"] = bool( - field.env_supported and settings_registry.is_value_from_env(field) - ) - fields_payload.append(serialized) - - global_values[key] = app_config.get(key, field.default) - - source = "default" - value = app_config.get(key, field.default, user_id=user_id) - if field.env_supported and settings_registry.is_value_from_env(field): - source = "env_var" - elif key in user_settings and user_settings[key] is not None: - source = "user_override" - value = user_settings[key] - elif key in download_config: - source = "global_config" - - effective[key] = {"value": value, "source": source} - - user_overrides = { - key: user_settings[key] - for key in ordered_keys - if key in user_settings and user_settings[key] is not None - } - - return { - "tab": "downloads", - "keys": ordered_keys, - "fields": fields_payload, - "globalValues": global_values, - "userOverrides": user_overrides, - "effective": effective, - } - - def register_self_user_routes(app: Flask, user_db: UserDB) -> None: """Register self-service user endpoints.""" @@ -188,25 +123,51 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: serialized_user["settings"] = user_db.get_user_settings(user_id) try: - delivery_preferences = _build_delivery_preferences_payload(user_db, user_id) + delivery_preferences = _build_user_preferences_payload(user_db, user_id, "downloads") except ValueError: return jsonify({"error": "Downloads settings tab not found"}), 500 except Exception as exc: logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}") delivery_preferences = None + try: + notification_preferences = _build_user_preferences_payload(user_db, user_id, "notifications") + except ValueError: + return jsonify({"error": "Notifications settings tab not found"}), 500 + except Exception as exc: + logger.warning(f"Failed to build user notification preferences for user_id={user_id}: {exc}") + notification_preferences = None + user_overridable_keys = sorted( set(delivery_preferences.get("keys", []) if delivery_preferences else []) + | set(notification_preferences.get("keys", []) if notification_preferences else []) ) return jsonify( { "user": serialized_user, "deliveryPreferences": delivery_preferences, + "notificationPreferences": notification_preferences, "userOverridableKeys": user_overridable_keys, } ) + @app.route("/api/users/me/notification-preferences/test", methods=["POST"]) + @_require_authenticated_user + def users_me_test_notification_preferences(): + user_id, _user, user_error = _get_current_user(user_db) + if user_error: + return user_error + if user_id is None: + return jsonify({"error": "User not found"}), 404 + + payload = request.get_json(silent=True) + result, status_code = build_user_notification_test_response( + user_id=user_id, + payload=payload, + ) + return jsonify(result), status_code + @app.route("/api/users/me", methods=["PUT"]) @_require_authenticated_user def users_me_update(): @@ -292,6 +253,8 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: allowed_user_settings_keys = { key for key, _field in _get_ordered_user_overridable_fields("downloads") + } | { + key for key, _field in _get_ordered_user_overridable_fields("notifications") } disallowed_keys = sorted( key for key in settings_payload if key not in allowed_user_settings_keys diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 51f51e91..506562bb 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -169,7 +169,6 @@ class UserDB: conn.execute("PRAGMA journal_mode=WAL") finally: conn.close() - logger.info(f"User database initialized at {self._db_path}") def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None: """Ensure users.auth_source exists and backfill historical rows.""" @@ -617,12 +616,21 @@ class UserDB: "delivery_updated_at", } - def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]: + def update_request( + self, + request_id: int, + expected_current_status: Optional[str] = None, + **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") + if expected_current_status is not None: + normalized_expected_status = normalize_request_status(expected_current_status) + if request["status"] != normalized_expected_status: + raise ValueError("Request state changed before update") return request for key in kwargs: @@ -640,6 +648,11 @@ class UserDB: if current is None: raise ValueError(f"Request {request_id} not found") + if expected_current_status is not None: + normalized_expected_status = normalize_request_status(expected_current_status) + if current["status"] != normalized_expected_status: + raise ValueError("Request state changed before update") + updates = dict(kwargs) if "status" in updates: diff --git a/shelfmark/core/user_settings_overrides.py b/shelfmark/core/user_settings_overrides.py new file mode 100644 index 00000000..aeb0f0a0 --- /dev/null +++ b/shelfmark/core/user_settings_overrides.py @@ -0,0 +1,78 @@ +"""Shared helpers for user-overridable settings metadata and payloads.""" + +from typing import Any + +from shelfmark.core.settings_registry import load_config_file +from shelfmark.core.user_db import UserDB + + +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.notifications_settings # noqa: F401 + import shelfmark.config.users_settings # noqa: F401 + from shelfmark.core import settings_registry + + return settings_registry + + +def get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]: + settings_registry = get_settings_registry() + tab = settings_registry.get_settings_tab(tab_name) + if not tab: + return [] + overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name) + return [(field.key, field) for field in tab.fields if field.key in overridable_map] + + +def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) -> dict[str, Any]: + from shelfmark.core.config import config as app_config + + settings_registry = get_settings_registry() + ordered_fields = get_ordered_user_overridable_fields(tab_name) + if not ordered_fields: + tab_label = tab_name.capitalize() + raise ValueError(f"{tab_label} settings tab not found") + + tab_config = load_config_file(tab_name) + user_settings = user_db.get_user_settings(user_id) + ordered_keys = [key for key, _ in ordered_fields] + + fields_payload: list[dict[str, Any]] = [] + global_values: dict[str, Any] = {} + effective: dict[str, dict[str, Any]] = {} + + for key, field in ordered_fields: + serialized = settings_registry.serialize_field(field, tab_name, include_value=False) + serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field)) + fields_payload.append(serialized) + + global_values[key] = app_config.get(key, field.default) + + source = "default" + value = app_config.get(key, field.default, user_id=user_id) + if field.env_supported and settings_registry.is_value_from_env(field): + source = "env_var" + elif key in user_settings and user_settings[key] is not None: + source = "user_override" + value = user_settings[key] + elif key in tab_config: + source = "global_config" + + effective[key] = {"value": value, "source": source} + + user_overrides = { + key: user_settings[key] + for key in ordered_keys + if key in user_settings and user_settings[key] is not None + } + + return { + "tab": tab_name, + "keys": ordered_keys, + "fields": fields_payload, + "globalValues": global_values, + "userOverrides": user_overrides, + "effective": effective, + } diff --git a/shelfmark/main.py b/shelfmark/main.py index adec9709..74bcd47f 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -49,6 +49,7 @@ from shelfmark.core.requests_service import ( sync_delivery_states_from_queue_status, ) from shelfmark.core.activity_service import ActivityService, build_download_item_key +from shelfmark.core.notifications import NotificationContext, NotificationEvent, notify_admin, notify_user from shelfmark.core.utils import normalize_base_path from shelfmark.api.websocket import ws_manager @@ -1012,7 +1013,73 @@ def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None: return None +def _normalize_optional_text(value: Any) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEvent | None: + if status in {QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.DONE}: + return NotificationEvent.DOWNLOAD_COMPLETE + if status == QueueStatus.ERROR: + return NotificationEvent.DOWNLOAD_FAILED + return None + + +def _notify_admin_for_terminal_download_status(*, task_id: str, status: QueueStatus, task: Any) -> None: + event = _queue_status_to_notification_event(status) + if event is None: + return + + raw_owner_user_id = getattr(task, "user_id", None) + try: + owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None + except (TypeError, ValueError): + owner_user_id = None + + content_type = _normalize_optional_text(getattr(task, "content_type", None)) + context = NotificationContext( + event=event, + title=str(getattr(task, "title", "Unknown title") or "Unknown title"), + author=str(getattr(task, "author", "Unknown author") or "Unknown author"), + username=_normalize_optional_text(getattr(task, "username", None)), + content_type=normalize_content_type(content_type) if content_type is not None else None, + format=_normalize_optional_text(getattr(task, "format", None)), + source=normalize_source(getattr(task, "source", None)), + error_message=( + _normalize_optional_text(getattr(task, "status_message", None)) + if event == NotificationEvent.DOWNLOAD_FAILED + else None + ), + ) + try: + notify_admin(event, context) + except Exception as exc: + logger.warning( + "Failed to trigger admin notification for download %s (%s): %s", + task_id, + status.value, + exc, + ) + if owner_user_id is None: + return + try: + notify_user(owner_user_id, event, context) + except Exception as exc: + logger.warning( + "Failed to trigger user notification for download %s (%s, user_id=%s): %s", + task_id, + status.value, + owner_user_id, + exc, + ) + + def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None: + _notify_admin_for_terminal_download_status(task_id=task_id, status=status, task=task) + if activity_service is None: return @@ -1106,8 +1173,7 @@ def _is_graduated_request_download(task_id: str, *, user_id: int) -> bool: return False -if activity_service is not None: - backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot) +backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot) def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None: @@ -2124,6 +2190,7 @@ def api_settings_get_all() -> Union[Response, Tuple[Response, int]]: # This triggers the @register_settings decorators import shelfmark.config.settings # noqa: F401 import shelfmark.config.security # noqa: F401 + import shelfmark.config.notifications_settings # noqa: F401 data = serialize_all_settings(include_values=True) return jsonify(data) @@ -2153,6 +2220,7 @@ def api_settings_get_tab(tab_name: str) -> Union[Response, Tuple[Response, int]] # Ensure settings are registered import shelfmark.config.settings # noqa: F401 import shelfmark.config.security # noqa: F401 + import shelfmark.config.notifications_settings # noqa: F401 tab = get_settings_tab(tab_name) if not tab: @@ -2188,6 +2256,7 @@ def api_settings_update_tab(tab_name: str) -> Union[Response, Tuple[Response, in # Ensure settings are registered import shelfmark.config.settings # noqa: F401 import shelfmark.config.security # noqa: F401 + import shelfmark.config.notifications_settings # noqa: F401 tab = get_settings_tab(tab_name) if not tab: @@ -2234,6 +2303,7 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons # Ensure settings are registered import shelfmark.config.settings # noqa: F401 import shelfmark.config.security # noqa: F401 + import shelfmark.config.notifications_settings # noqa: F401 # Get current form values if provided (for testing with unsaved values) current_values = request.get_json(silent=True) or {} diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 240ce5f3..c21b5e8e 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -947,6 +947,10 @@ function App() { [openRequestConfirmation, refreshRequestPolicy] ); + const handleReleaseModalPolicyRefresh = useCallback(() => { + return refreshRequestPolicy({ force: true }); + }, [refreshRequestPolicy]); + const handleRequestCancel = useCallback( async (requestId: number) => { try { @@ -978,12 +982,20 @@ function App() { ); const handleRequestApprove = useCallback( - async (requestId: number, record: RequestRecord) => { + async ( + requestId: number, + record: RequestRecord, + options?: { + browseOnly?: boolean; + } + ) => { if (!requestRoleIsAdmin) { return; } - if (record.request_level === 'release') { + const shouldBrowse = Boolean(options?.browseOnly) || record.request_level === 'book'; + + if (!shouldBrowse && record.request_level === 'release') { try { await fulfilSidebarRequest(requestId, record.release_data || undefined); await refreshActivitySnapshot(); @@ -1252,7 +1264,7 @@ function App() { onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload} onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest} getPolicyModeForSource={isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)} - onPolicyRefresh={() => refreshRequestPolicy({ force: true })} + onPolicyRefresh={handleReleaseModalPolicyRefresh} supportedFormats={supportedFormats} supportedAudiobookFormats={config?.supported_audiobook_formats || []} contentType={activeReleaseContentType} diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index ee645f4c..0e891a91 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { Book, ButtonStateInfo, isMetadataBook } from '../types'; import { isUserCancelledError } from '../utils/errors'; @@ -122,7 +123,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400'; const infoValueClass = 'text-gray-900 dark:text-gray-100'; - return ( + const modal = (
{ @@ -355,4 +356,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
); + + if (typeof document === 'undefined') { + return modal; + } + + return createPortal(modal, document.body); }; diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 58bf6bb5..9a19279f 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { Book, Release, @@ -687,7 +688,7 @@ export const ReleaseModal = ({ useEffect(() => { if (!book || !onPolicyRefresh) return; void onPolicyRefresh(); - }, [book, onPolicyRefresh]); + }, [book?.id, onPolicyRefresh]); // Close handler with animation const handleClose = useCallback(() => { @@ -1319,7 +1320,7 @@ export const ReleaseModal = ({ const currentTabError = errorBySource[activeTab] ?? null; const isInitialLoading = currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError); - return ( + const modal = (
{ @@ -1971,4 +1972,10 @@ export const ReleaseModal = ({
); + + if (typeof document === 'undefined') { + return modal; + } + + return createPortal(modal, document.body); }; diff --git a/src/frontend/src/components/activity/ActivityCard.tsx b/src/frontend/src/components/activity/ActivityCard.tsx index aab8493c..9bd656fb 100644 --- a/src/frontend/src/components/activity/ActivityCard.tsx +++ b/src/frontend/src/components/activity/ActivityCard.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { RequestRecord } from '../../types'; import { withBasePath } from '../../utils/basePath'; import { Tooltip } from '../shared/Tooltip'; @@ -10,15 +10,34 @@ import { getProgressConfig, } from './activityStyles'; +interface RequestApproveOptions { + browseOnly?: boolean; +} + +type RequestApproveHandler = ( + requestId: number, + record: RequestRecord, + options?: RequestApproveOptions +) => Promise | void; + interface ActivityCardProps { item: ActivityItem; isAdmin: boolean; onDownloadCancel?: (bookId: string) => void; onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void; onRequestCancel?: (requestId: number) => void; - onRequestApprove?: (requestId: number, record: RequestRecord) => void; - onRequestReject?: (requestId: number) => void; + onRequestApprove?: RequestApproveHandler; + onRequestReviewApprove?: RequestApproveHandler; + onRequestReject?: (requestId: number, adminNote?: string) => Promise | void; + onRequestRejectConfirm?: (requestId: number, adminNote?: string) => Promise | void; onRequestDismiss?: (requestId: number) => void; + showRequestDetailsToggle?: boolean; + isRequestDetailsOpen?: boolean; + onRequestDetailsToggle?: () => void; + onRequestDetailsOpen?: () => void; + isRequestRejectOpen?: boolean; + onRequestRejectClose?: () => void; + isSelected?: boolean; } const BookFallback = () => ( @@ -142,6 +161,68 @@ const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' }) => { ); }; +const asRecord = (value: unknown): Record => { + if (value && typeof value === 'object') { + return value as Record; + } + return {}; +}; + +const toOptionalText = (value: unknown): string | undefined => { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return undefined; +}; + +const toSourceLabel = (value: unknown): string => { + const text = toOptionalText(value); + if (!text) { + return 'Any Source'; + } + const normalized = text.trim().toLowerCase(); + if (normalized === '*' || normalized === 'any' || normalized === 'all') { + return 'Any Source'; + } + return text + .split(/[_\s-]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +}; + +const formatDateTime = (isoDate: string): string => { + const parsed = Date.parse(isoDate); + if (!Number.isFinite(parsed)) { + return isoDate; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(parsed); +}; + +const hasAttachedReleaseData = (record: RequestRecord): boolean => { + if (record.request_level !== 'release') { + return false; + } + if (!record.release_data || typeof record.release_data !== 'object') { + return false; + } + return Object.keys(record.release_data).length > 0; +}; + +const DetailField = ({ label, value }: { label: string; value: string }) => ( +
+

{label}

+

{value}

+
+); + +const MAX_ADMIN_NOTE_LENGTH = 1000; + export const ActivityCard = ({ item, isAdmin, @@ -149,13 +230,27 @@ export const ActivityCard = ({ onDownloadDismiss, onRequestCancel, onRequestApprove, + onRequestReviewApprove, onRequestReject, + onRequestRejectConfirm, onRequestDismiss, + showRequestDetailsToggle = false, + isRequestDetailsOpen = false, + onRequestDetailsToggle, + onRequestDetailsOpen, + isRequestRejectOpen = false, + onRequestRejectClose, + isSelected = false, }: ActivityCardProps) => { const model = useMemo(() => buildActivityCardModel(item, isAdmin), [item, isAdmin]); const noteLine = model.noteLine; const badgeRefs = useRef>({}); + const titleLineRef = useRef(null); const [badgeOverflow, setBadgeOverflow] = useState>({}); + const [titleOverflow, setTitleOverflow] = useState(false); + const [isReviewSubmitting, setIsReviewSubmitting] = useState(false); + const [rejectNote, setRejectNote] = useState(''); + const [isRejectSubmitting, setIsRejectSubmitting] = useState(false); useLayoutEffect(() => { const measureBadgeOverflow = () => { @@ -200,6 +295,50 @@ export const ActivityCard = ({ return () => observer.disconnect(); }, [model.badges]); + useLayoutEffect(() => { + const measureTitleOverflow = () => { + const element = titleLineRef.current; + const nextOverflow = Boolean( + element && element.scrollWidth - element.clientWidth > 1 + ); + setTitleOverflow((current) => (current === nextOverflow ? current : nextOverflow)); + }; + + measureTitleOverflow(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureTitleOverflow); + return () => window.removeEventListener('resize', measureTitleOverflow); + } + + const observer = new ResizeObserver(measureTitleOverflow); + if (titleLineRef.current) { + observer.observe(titleLineRef.current); + } + + return () => observer.disconnect(); + }, [item.title, item.author]); + + const reviewRecord = item.requestRecord; + const reviewApproveHandler = onRequestReviewApprove || onRequestApprove; + const isDetailsExpanded = isRequestDetailsOpen || isRequestRejectOpen; + + useEffect(() => { + if (!isRequestDetailsOpen) { + setIsReviewSubmitting(false); + return; + } + }, [isRequestDetailsOpen, reviewRecord?.id, reviewRecord?.updated_at]); + + useEffect(() => { + if (!isRequestRejectOpen) { + setRejectNote(''); + setIsRejectSubmitting(false); + return; + } + setRejectNote(''); + }, [isRequestRejectOpen, reviewRecord?.id, reviewRecord?.updated_at]); + const runAction = (action: ActivityCardAction) => { switch (action.kind) { case 'download-remove': @@ -210,6 +349,16 @@ export const ActivityCard = ({ onDownloadDismiss?.(action.bookId, action.linkedRequestId); break; case 'request-approve': + if (showRequestDetailsToggle && hasAttachedReleaseData(action.record)) { + if (!isRequestDetailsOpen) { + if (onRequestDetailsOpen) { + onRequestDetailsOpen(); + } else if (onRequestDetailsToggle) { + onRequestDetailsToggle(); + } + } + break; + } onRequestApprove?.(action.requestId, action.record); break; case 'request-reject': @@ -248,6 +397,80 @@ export const ActivityCard = ({ const actions = model.actions.filter(hasActionHandler); + const bookData = asRecord(reviewRecord?.book_data); + const releaseData = asRecord(reviewRecord?.release_data); + const bookTitle = toOptionalText(bookData.title) || 'Unknown title'; + const fileTitle = toOptionalText(releaseData.title) || bookTitle; + const fileFormat = + toOptionalText(releaseData.format) || + toOptionalText(releaseData.filetype) || + toOptionalText(releaseData.extension) || + 'Unknown'; + const fileSize = toOptionalText(releaseData.size) || 'Unknown'; + const sourceLabel = toSourceLabel( + releaseData.source_display_name || releaseData.source || reviewRecord?.source_hint + ); + + const hasAttachedRelease = + reviewRecord?.request_level === 'release' && Object.keys(releaseData).length > 0; + const requiresBrowseBeforeApprove = + reviewRecord?.request_level === 'book' || !hasAttachedRelease; + const showSourceField = reviewRecord?.request_level === 'release'; + + const approveLabel = + requiresBrowseBeforeApprove + ? 'Browse Releases To Approve' + : 'Approve Attached File'; + + const provider = toOptionalText(bookData.provider)?.toLowerCase(); + const providerId = toOptionalText(bookData.provider_id); + const canBrowseAlternatives = Boolean(provider && providerId && provider !== 'direct_download'); + + const handleReviewApprove = async () => { + if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) { + return; + } + + setIsReviewSubmitting(true); + try { + if (requiresBrowseBeforeApprove) { + await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true }); + return; + } + + await reviewApproveHandler(reviewRecord.id, reviewRecord); + } finally { + setIsReviewSubmitting(false); + } + }; + + const handleReviewBrowseAlternatives = async () => { + if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) { + return; + } + + setIsReviewSubmitting(true); + try { + await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true }); + } finally { + setIsReviewSubmitting(false); + } + }; + + const canShowInlineReview = Boolean(isRequestDetailsOpen && reviewRecord && reviewApproveHandler); + const rejectConfirmHandler = onRequestRejectConfirm || onRequestReject; + const canShowInlineReject = Boolean( + isRequestRejectOpen && + item.requestId && + rejectConfirmHandler + ); + const requestedAt = reviewRecord ? formatDateTime(reviewRecord.created_at) : ''; + const requestType = reviewRecord?.content_type === 'audiobook' ? 'Audiobook' : 'Book'; + const titleAuthorLine = item.author ? `${item.title} — ${item.author}` : item.title; + const titleLineClassName = isDetailsExpanded + ? 'text-sm leading-tight min-w-0 whitespace-normal break-words' + : 'text-sm truncate leading-tight min-w-0'; + const titleNode = item.kind === 'download' && item.visualStatus === 'complete' && @@ -263,8 +486,32 @@ export const ActivityCard = ({ item.title ); + const handleInlineRejectConfirm = async () => { + if (!item.requestId || !rejectConfirmHandler || isRejectSubmitting) { + return; + } + + setIsRejectSubmitting(true); + try { + const trimmed = rejectNote.trim(); + await rejectConfirmHandler(item.requestId, trimmed || undefined); + } finally { + setIsRejectSubmitting(false); + } + }; + return ( -
+
+ {isSelected && ( +