From f852e7c941ea436bba0365547757e3236594da3f Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Thu, 17 Sep 2026 20:03:47 +1200 Subject: [PATCH] chore: standardize security and quality foundations --- .env.beta.example | 20 + .env.example | 18 + .gitea/workflows/ci-cd.yml | 63 +- .gitignore | 4 + Dockerfile | 5 + PRODUCTION.md | 2 + README.md | 24 +- backend/app/api_models.py | 58 + backend/app/clients/jellyfin.py | 7 - backend/app/clients/qbittorrent.py | 10 - backend/app/config.py | 1 + backend/app/db.py | 157 +- backend/app/logging_config.py | 29 +- backend/app/main.py | 2 + backend/app/routers/admin.py | 20 +- backend/app/routers/auth.py | 30 +- backend/app/routers/branding.py | 2 +- backend/app/routers/images.py | 2 +- backend/app/routers/portal.py | 8 +- backend/app/routers/recaps.py | 2 +- backend/app/routers/requests.py | 30 +- backend/app/schema_migrations.py | 116 + backend/app/services/arr.py | 21 + backend/app/services/newsletter_store.py | 2 +- backend/app/services/recap_store.py | 2 +- backend/app/services/snapshot.py | 30 +- backend/requirements-dev.txt | 4 + backend/tests/test_api_models.py | 24 + backend/tests/test_arr_helpers.py | 24 + backend/tests/test_logging_config.py | 25 + backend/tests/test_schema_migrations.py | 36 + frontend/app/MyRequests.tsx | 448 +- frontend/app/admin/SettingField.tsx | 168 +- frontend/app/admin/SettingsPage.tsx | 3211 +++++++------- frontend/app/admin/SettingsRegion.tsx | 41 +- frontend/app/admin/[section]/page.tsx | 54 +- frontend/app/admin/configNavigation.ts | 131 +- frontend/app/admin/diagnostics/page.tsx | 13 +- .../identities/DuplicateAccountRepair.tsx | 290 +- .../admin/identities/IdentityReviewPanel.tsx | 621 ++- .../admin/identities/ResolveIdentityLink.tsx | 360 +- frontend/app/admin/identities/page.tsx | 4 +- frontend/app/admin/invites/page.tsx | 2755 ++++++------ frontend/app/admin/issues/page.tsx | 4 +- frontend/app/admin/newsletters/page.tsx | 1033 ++++- frontend/app/admin/page.tsx | 141 +- frontend/app/admin/profiles/page.tsx | 5 +- frontend/app/admin/recaps/page.tsx | 618 ++- frontend/app/admin/requests-all/page.tsx | 164 +- frontend/app/admin/system/page.tsx | 207 +- frontend/app/changelog/page.tsx | 112 +- frontend/app/coming-soon/page.tsx | 43 +- frontend/app/coming-soon/style.css | 6 +- frontend/app/email-recaps/page.tsx | 183 +- frontend/app/feedback/page.tsx | 102 +- frontend/app/forgot-password/page.tsx | 76 +- frontend/app/globals.css | 108 - frontend/app/how-it-works/page.tsx | 227 +- frontend/app/insights/components.tsx | 314 +- frontend/app/insights/page.tsx | 386 +- .../insights/reports/EmailReportControl.tsx | 138 +- frontend/app/insights/reports/page.tsx | 662 ++- frontend/app/issues/confirm/[id]/page.tsx | 158 +- frontend/app/layout.tsx | 29 +- frontend/app/lib/api-client.test.ts | 23 + frontend/app/lib/api-client.ts | 52 + frontend/app/lib/auth.ts | 110 +- frontend/app/lib/features.ts | 59 +- frontend/app/lib/request-results.test.ts | 17 + frontend/app/lib/request-results.ts | 74 + frontend/app/lib/scrollLock.ts | 18 +- frontend/app/lib/viewMode.ts | 60 +- frontend/app/login/page.tsx | 282 +- .../app/new-requests/NewRequestClient.tsx | 837 ++-- frontend/app/new-requests/page.tsx | 8 +- frontend/app/newsletter-subscription/page.tsx | 199 +- frontend/app/not-found.tsx | 10 +- frontend/app/ops-redesign.css | 81 - frontend/app/page.tsx | 10 +- frontend/app/portal/IssueFlowStep.tsx | 66 +- frontend/app/portal/PortalClient.tsx | 3674 +++++++++-------- frontend/app/portal/issues/page.tsx | 5 +- frontend/app/portal/page.tsx | 4 +- frontend/app/portal/requests/page.tsx | 4 +- .../app/profile/MonthlyRecapPreference.tsx | 272 +- frontend/app/profile/NewsletterPreference.tsx | 240 +- frontend/app/profile/invites/page.tsx | 697 +++- frontend/app/profile/page.tsx | 632 ++- frontend/app/requests/[id]/LatestActivity.tsx | 205 +- .../app/requests/[id]/RequestLanguage.tsx | 102 +- frontend/app/requests/[id]/page.tsx | 1882 +++++---- frontend/app/reset-password/page.tsx | 161 +- frontend/app/signup/page.tsx | 271 +- frontend/app/styles/tokens.css | 48 + frontend/app/ui/AdminDiagnosticsPanel.tsx | 495 +-- frontend/app/ui/AdminShell.tsx | 29 +- frontend/app/ui/ApplicationChrome.tsx | 72 +- frontend/app/ui/AuthLayout.tsx | 34 +- frontend/app/ui/BrandingFavicon.tsx | 20 +- frontend/app/ui/BrandingLogo.tsx | 27 +- frontend/app/ui/FeatureGate.tsx | 72 +- frontend/app/ui/GlobalSearch.tsx | 237 +- frontend/app/ui/HeaderActions.tsx | 79 +- frontend/app/ui/HeaderIdentity.tsx | 94 +- frontend/app/ui/InviteDeliveryChoice.tsx | 64 +- frontend/app/ui/MagentMark.tsx | 7 +- frontend/app/ui/PageHeading.tsx | 16 +- frontend/app/ui/RequestStageFilter.tsx | 33 + frontend/app/ui/ResolutionChoice.tsx | 44 +- frontend/app/ui/SettingsNavigation.tsx | 45 +- frontend/app/ui/SiteStatus.tsx | 82 +- frontend/app/ui/UserViewBanner.tsx | 10 +- frontend/app/ui/WorkspaceNavigation.tsx | 162 +- frontend/app/users/FeatureControls.tsx | 163 +- frontend/app/users/[id]/page.tsx | 1039 ++--- frontend/app/users/page.tsx | 745 ++-- frontend/app/welcome/page.tsx | 107 +- frontend/app/workspace.css | 8 - frontend/biome.json | 56 +- frontend/next.config.js | 28 +- frontend/package-lock.json | 1078 ++++- frontend/package.json | 9 +- pyproject.toml | 15 + scripts/ci_backend_quality_gate.sh | 16 +- scripts/ci_container_smoke.sh | 35 + scripts/deploy_beta_ams_dev01.sh | 14 +- scripts/verify_build_metadata.py | 41 + 127 files changed, 17928 insertions(+), 10741 deletions(-) create mode 100644 .env.beta.example create mode 100644 .env.example create mode 100644 backend/app/api_models.py create mode 100644 backend/app/schema_migrations.py create mode 100644 backend/app/services/arr.py create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/test_api_models.py create mode 100644 backend/tests/test_arr_helpers.py create mode 100644 backend/tests/test_logging_config.py create mode 100644 backend/tests/test_schema_migrations.py create mode 100644 frontend/app/lib/api-client.test.ts create mode 100644 frontend/app/lib/api-client.ts create mode 100644 frontend/app/lib/request-results.test.ts create mode 100644 frontend/app/lib/request-results.ts create mode 100644 frontend/app/styles/tokens.css create mode 100644 frontend/app/ui/RequestStageFilter.tsx create mode 100644 pyproject.toml create mode 100644 scripts/ci_container_smoke.sh create mode 100644 scripts/verify_build_metadata.py diff --git a/.env.beta.example b/.env.beta.example new file mode 100644 index 0000000..35275e3 --- /dev/null +++ b/.env.beta.example @@ -0,0 +1,20 @@ +# Provision this as .env on the beta host. Do not copy production secrets or data. +APP_NAME=Magent Beta +CORS_ALLOW_ORIGIN=https://beta.grizzlyflix.co.nz +MAGENT_APPLICATION_URL=https://beta.grizzlyflix.co.nz +MAGENT_API_URL=https://beta.grizzlyflix.co.nz/api +SQLITE_PATH=/app/data/magent.db +LOG_FILE=/app/data/magent.log +LOG_FORMAT=json + +JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters +SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password + +AUTH_COOKIE_NAME=magent_beta_auth +AUTH_STATE_COOKIE_NAME=magent_beta_logged_in +AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz +AUTH_COOKIE_SECURE=true +AUTH_COOKIE_SAMESITE=strict +API_DOCS_ENABLED=false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ffc1d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env for local development. Never reuse these example values in a deployed environment. +APP_NAME=Magent +CORS_ALLOW_ORIGIN=http://localhost:3000 +MAGENT_APPLICATION_URL=http://localhost:3000 +MAGENT_API_URL=http://localhost:8000 +SQLITE_PATH=/app/data/magent.db +LOG_FILE=/app/data/magent.log +LOG_FORMAT=text + +# Generate independent values as documented in README.md. +JWT_SECRET=replace-with-at-least-32-random-characters +SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password + +AUTH_COOKIE_SECURE=false +AUTH_COOKIE_SAMESITE=strict +API_DOCS_ENABLED=false diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index fb5ed15..f42db87 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -6,6 +6,10 @@ on: - beta - main - prod + pull_request: + branches: + - beta + - main workflow_dispatch: concurrency: @@ -22,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: "3.12" + python-version: "3.14" - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -37,39 +41,40 @@ jobs: - name: Run backend quality gate run: bash scripts/ci_backend_quality_gate.sh + - name: Verify generated build metadata + run: python scripts/verify_build_metadata.py + + - name: Audit frontend production dependencies + working-directory: frontend + run: npm audit --omit=dev --package-lock-only --audit-level=high + + - name: Lint frontend + working-directory: frontend + run: npm run lint + + - name: Check frontend formatting + working-directory: frontend + run: npm run format:check + + - name: Type-check frontend + working-directory: frontend + run: npm run typecheck + + - name: Test frontend + working-directory: frontend + run: npm test + - name: Build frontend working-directory: frontend run: npm run build - deploy-prod: - if: github.ref_name == 'prod' - needs: verify - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - - name: Configure SSH key - env: - PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }} - PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} + - name: Validate Compose configuration run: | - set -euo pipefail - : "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}" - mkdir -p ~/.ssh - chmod 700 ~/.ssh - printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts + cp .env.example .env + docker compose -f docker-compose.yml config --quiet - - name: Deploy to AMS-DEV01 - env: - DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} - DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} - DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} - DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes - run: bash scripts/deploy_ams_dev01.sh + - name: Build and smoke-test container + run: bash scripts/ci_container_smoke.sh deploy-beta: if: github.ref_name == 'beta' @@ -97,6 +102,6 @@ jobs: env: DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} - PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} + BETA_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes run: bash scripts/deploy_beta_ams_dev01.sh diff --git a/.gitignore b/.gitignore index e963243..31a22c3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,12 @@ backend/__pycache__/ **/__pycache__/ *.pyc backend/.pytest_cache/ +.coverage +coverage.xml +htmlcov/ frontend/node_modules/ frontend/.next/ +*.tsbuildinfo *.log **/.pytest_cache/ .env.* diff --git a/Dockerfile b/Dockerfile index 471a461..b48eabb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,4 +60,9 @@ USER magent:magent EXPOSE 3000 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \ + && curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \ + || exit 1 + CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"] diff --git a/PRODUCTION.md b/PRODUCTION.md index d15b604..f94901b 100644 --- a/PRODUCTION.md +++ b/PRODUCTION.md @@ -18,6 +18,8 @@ from `main`; use `prod-` tags to identify an exact release. 1. Run the backend tests and frontend production build. Review only the intended changes, then commit and push `main`. + The repository workflow verifies `main` but intentionally does not deploy it; + production changes require the remaining explicit release steps below. 2. Build from a clean source export using the root Dockerfile. Never include `.env`, databases or bootstrap credentials in the build context. 3. Publish `rephl3xnz/magent:prod-` and `:latest` to Docker Hub. diff --git a/README.md b/README.md index 6e1341c..e4a42c2 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,19 @@ Admin panel: http://localhost:3000/admin Login uses the admin credentials above (or any other local user you create in SQLite). +### Local quality checks + +```bash +bash scripts/ci_backend_quality_gate.sh +cd frontend +npm ci +npm run lint +npm run format:check +npm run typecheck +npm test +npm run build +``` + ## Public Hosting Notes The frontend proxies `/api/*` to the backend container. Set: @@ -149,20 +162,23 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`. -- Push to `beta`: runs the backend unit-test quality gate and a production frontend build. -- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`. +- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`. +- Push to `main` or `prod`: runs the same verification without automatically changing production. +- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`. -The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests: +The beta deploy step ships tracked repository files over SSH, preserves beta's own `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests: - `http://127.0.0.1:8000/health` - `http://127.0.0.1:3000/login` Configure these Gitea Actions secrets before enabling the deploy job: +The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment. + - `PROD_SSH_PRIVATE_KEY`: private key for the deployment account. - `PROD_SSH_HOST`: target host, for example `AMS-DEV01`. - `PROD_SSH_USER`: target user, for example `zak`. -- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`. +- `PROD_DEPLOY_PATH`: beta app path, for example `/home/zak/magent-beta`. - `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts. ## Security and data handling diff --git a/backend/app/api_models.py b/backend/app/api_models.py new file mode 100644 index 0000000..2578834 --- /dev/null +++ b/backend/app/api_models.py @@ -0,0 +1,58 @@ +"""Shared HTTP request and error contracts.""" + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class StrictRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ErrorResponse(BaseModel): + detail: str + + +COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = { + 400: {"model": ErrorResponse, "description": "Invalid request"}, + 401: {"model": ErrorResponse, "description": "Authentication required"}, + 403: {"model": ErrorResponse, "description": "Permission denied"}, + 404: {"model": ErrorResponse, "description": "Resource not found"}, + 409: {"model": ErrorResponse, "description": "Request conflict"}, + 429: {"model": ErrorResponse, "description": "Rate limit exceeded"}, + 500: {"model": ErrorResponse, "description": "Unexpected server error"}, + 502: {"model": ErrorResponse, "description": "Upstream service error"}, + 503: {"model": ErrorResponse, "description": "Service unavailable"}, +} + + +class SignupRequest(StrictRequest): + invite_code: str = Field(min_length=1, max_length=256) + username: str = Field(min_length=1, max_length=100) + password: str = Field(min_length=1, max_length=1024) + email: Optional[str] = Field(default=None, max_length=320) + + +class ForgotPasswordRequest(StrictRequest): + identifier: Optional[str] = Field(default=None, max_length=320) + username: Optional[str] = Field(default=None, max_length=100) + email: Optional[str] = Field(default=None, max_length=320) + + +class PasswordResetRequest(StrictRequest): + token: str = Field(min_length=1, max_length=512) + new_password: str = Field(min_length=1, max_length=1024) + + +class ProfileEmailUpdateRequest(StrictRequest): + email: Optional[str] = Field(default=None, max_length=320) + + +class ChangePasswordRequest(StrictRequest): + current_password: str = Field(min_length=1, max_length=1024) + new_password: str = Field(min_length=1, max_length=1024) + + +def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]: + """Keep direct service-level tests compatible while FastAPI validates HTTP input.""" + return payload if isinstance(payload, dict) else payload.model_dump() diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index 9efed09..1cd5fa5 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -1,7 +1,6 @@ import re from typing import Any, Dict, Optional import httpx -import time from .base import ApiClient, _operation_error_message from ..services.operation_progress import finish_remote_call, start_remote_call @@ -186,7 +185,6 @@ class JellyfinClient(ApiClient): ) -> Optional[Dict[str, Any]]: if not self.base_url or not self.api_key: return None - started_at = time.perf_counter() operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…") url = f"{self.base_url}/Items" params = { @@ -214,7 +212,6 @@ class JellyfinClient(ApiClient): if isinstance(item, dict) and item.get('Id'): items[item['Id']] = item result = {'Items': list(items.values()), 'TotalRecordCount': len(items)} - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) finish_remote_call( operation_event_id, success=True, @@ -223,7 +220,6 @@ class JellyfinClient(ApiClient): ) return result except Exception as exc: - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None finish_remote_call( operation_event_id, @@ -277,7 +273,6 @@ class JellyfinClient(ApiClient): async def refresh_library(self, recursive: bool = True) -> None: if not self.base_url or not self.api_key: return None - started_at = time.perf_counter() operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…") url = f"{self.base_url}/Library/Refresh" headers = self._emby_headers() @@ -286,7 +281,6 @@ class JellyfinClient(ApiClient): async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, headers=headers, params=params) response.raise_for_status() - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) finish_remote_call( operation_event_id, success=True, @@ -294,7 +288,6 @@ class JellyfinClient(ApiClient): message="Jellyfin accepted the library refresh and is scanning for new media.", ) except Exception as exc: - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None finish_remote_call( operation_event_id, diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index f367faa..f576554 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -1,7 +1,6 @@ from typing import Any, Dict, Optional import httpx import logging -import time from .base import ApiClient, _operation_error_message from ..services.operation_progress import finish_remote_call, start_remote_call @@ -89,7 +88,6 @@ class QBittorrentClient(ApiClient): async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: if not self.base_url: return None - started_at = time.perf_counter() operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…") try: async with httpx.AsyncClient(timeout=10.0) as client: @@ -97,7 +95,6 @@ class QBittorrentClient(ApiClient): response = await client.get(f"{self.base_url}{path}", params=params) response.raise_for_status() result = response.json() - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) finish_remote_call( operation_event_id, success=True, @@ -106,7 +103,6 @@ class QBittorrentClient(ApiClient): ) return result except Exception as exc: - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None finish_remote_call( operation_event_id, @@ -119,7 +115,6 @@ class QBittorrentClient(ApiClient): async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]: if not self.base_url: return None - started_at = time.perf_counter() operation_event_id = start_remote_call("qBittorrent") try: async with httpx.AsyncClient(timeout=10.0) as client: @@ -127,7 +122,6 @@ class QBittorrentClient(ApiClient): response = await client.get(f"{self.base_url}{path}", params=params) response.raise_for_status() result = response.text.strip() - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) finish_remote_call( operation_event_id, success=True, @@ -136,7 +130,6 @@ class QBittorrentClient(ApiClient): ) return result except Exception as exc: - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None finish_remote_call( operation_event_id, @@ -149,14 +142,12 @@ class QBittorrentClient(ApiClient): async def _post_form(self, path: str, data: Dict[str, Any]) -> None: if not self.base_url: return None - started_at = time.perf_counter() operation_event_id = start_remote_call("qBittorrent") try: async with httpx.AsyncClient(timeout=10.0) as client: await self._login(client) response = await client.post(f"{self.base_url}{path}", data=data) response.raise_for_status() - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) finish_remote_call( operation_event_id, success=True, @@ -164,7 +155,6 @@ class QBittorrentClient(ApiClient): message=_torrent_action_message(path), ) except Exception as exc: - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None finish_remote_call( operation_event_id, diff --git a/backend/app/config.py b/backend/app/config.py index d5abe99..838c3e6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -67,6 +67,7 @@ class Settings(BaseSettings): default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME") ) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) + log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT")) log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE")) log_file_max_bytes: int = Field( default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES") diff --git a/backend/app/db.py b/backend/app/db.py index 32e550f..6cec65a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -13,6 +13,7 @@ from .config import settings from .models import Snapshot from .security import hash_password, verify_and_update_password, verify_password from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting +from .schema_migrations import run_schema_migrations logger = logging.getLogger(__name__) @@ -686,163 +687,9 @@ def init_db() -> None: ON user_activity (last_seen_at) """ ) - try: - conn.execute("ALTER TABLE users ADD COLUMN email TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT") - except sqlite3.OperationalError: - pass + run_schema_migrations(conn) _protect_legacy_signup_invite_codes(conn) _encrypt_legacy_sensitive_settings(conn) - try: - conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT") - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_portal_items_workflow - ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC) - """ - ) - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_portal_items_related_item - ON portal_items (related_item_id, updated_at DESC, id DESC) - """ - ) - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_users_profile_id - ON users (profile_id) - """ - ) - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_users_expires_at - ON users (expires_at) - """ - ) - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_users_username_nocase - ON users (username COLLATE NOCASE) - """ - ) - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_users_email_nocase - ON users (email COLLATE NOCASE) - """ - ) - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") - except sqlite3.OperationalError: - pass - try: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id - ON requests_cache (requested_by_id) - """ - ) - except sqlite3.OperationalError: - pass try: conn.execute("PRAGMA optimize") except sqlite3.OperationalError: diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py index 2b11901..d5a8e72 100644 --- a/backend/app/logging_config.py +++ b/backend/app/logging_config.py @@ -3,6 +3,7 @@ import json import logging import os import re +from datetime import datetime, timezone from logging.handlers import RotatingFileHandler from typing import Any, Mapping, Optional from urllib.parse import parse_qs @@ -39,6 +40,22 @@ class RequestContextFilter(logging.Filter): return True +class JsonLogFormatter(logging.Formatter): + """Stable JSON output for production log collectors.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "request_id": getattr(record, "request_id", "-"), + "message": record.getMessage(), + } + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + def bind_request_id(request_id: str) -> contextvars.Token[str]: return REQUEST_ID_CONTEXT.set(request_id or "-") @@ -150,6 +167,7 @@ def configure_logging( log_file_backup_count: int = 10, log_http_client_level: Optional[str] = "INFO", log_background_sync_level: Optional[str] = "INFO", + log_format: Optional[str] = "text", ) -> None: level_name = (log_level or "INFO").upper() level = getattr(logging, level_name, logging.INFO) @@ -176,10 +194,13 @@ def configure_logging( handlers.append(file_handler) context_filter = RequestContextFilter() - formatter = logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) + if str(log_format or "text").strip().lower() == "json": + formatter: logging.Formatter = JsonLogFormatter() + else: + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) for handler in handlers: handler.addFilter(context_filter) handler.setFormatter(formatter) diff --git a/backend/app/main.py b/backend/app/main.py index 15b6871..2334b61 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -259,6 +259,7 @@ async def startup() -> None: log_file_backup_count=settings.log_file_backup_count, log_http_client_level=settings.log_http_client_level, log_background_sync_level=settings.log_background_sync_level, + log_format=settings.log_format, ) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) _log_security_configuration_warnings() @@ -273,6 +274,7 @@ async def startup() -> None: log_file_backup_count=runtime.log_file_backup_count, log_http_client_level=runtime.log_http_client_level, log_background_sync_level=runtime.log_background_sync_level, + log_format=runtime.log_format, ) logger.info( "runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s", diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3b06d8f..6575de7 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -21,6 +21,7 @@ from ..auth import ( resolve_user_auth_provider, ) from ..config import normalize_banner_color, settings as env_settings +from ..api_models import COMMON_ERROR_RESPONSES from ..network_security import validate_notification_target_url from ..db import ( delete_setting, @@ -35,8 +36,6 @@ from ..db import ( get_user_by_id, get_user_by_username, get_user_request_stats, - create_user_if_missing, - set_user_jellyseerr_id, set_setting, set_user_blocked, delete_user_data_by_username, @@ -59,7 +58,6 @@ from ..db import ( cleanup_history, update_request_cache_title, repair_request_cache_titles, - delete_non_admin_users, list_user_profiles, get_user_profile, create_user_profile, @@ -73,6 +71,7 @@ from ..db import ( delete_signup_invite, get_signup_invite_by_code, disable_signup_invites_by_creator, + delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests ) from ..runtime import get_runtime_settings from ..clients.sonarr import SonarrClient @@ -81,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient from ..clients.jellyseerr import JellyseerrClient from ..services.jellyfin_sync import sync_jellyfin_users from ..services.user_cache import ( - build_jellyseerr_candidate_map, - extract_jellyseerr_user_email, - find_matching_jellyseerr_user, get_cached_jellyfin_users, get_cached_jellyseerr_users, - match_jellyseerr_user_id, save_jellyfin_users_cache, save_jellyseerr_users_cache, clear_user_import_caches, @@ -109,7 +104,12 @@ from ..logging_config import configure_logging from ..routers import requests as requests_router from ..routers.branding import save_branding_image -router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(require_admin)], + responses=COMMON_ERROR_RESPONSES, +) events_router = APIRouter(prefix="/admin/events", tags=["admin"]) logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" @@ -247,6 +247,7 @@ SETTING_KEYS: List[str] = [ "qbittorrent_username", "qbittorrent_password", "log_level", + "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", @@ -741,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: set_setting(key, value_to_store) updates += 1 changed_keys.append(key) - if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}: + if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}: touched_logging = True if touched_logging: runtime = get_runtime_settings() @@ -752,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: log_file_backup_count=runtime.log_file_backup_count, log_http_client_level=runtime.log_http_client_level, log_background_sync_level=runtime.log_background_sync_level, + log_format=runtime.log_format, ) logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys) return {"status": "ok", "updated": updates} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 202d97c..0caf5d6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -60,6 +60,15 @@ from ..auth import ( set_auth_cookies, ) from ..config import settings +from ..api_models import ( + COMMON_ERROR_RESPONSES, + ChangePasswordRequest, + ForgotPasswordRequest, + PasswordResetRequest, + ProfileEmailUpdateRequest, + SignupRequest, + request_data, +) from ..network_security import request_trusts_forwarded_headers from ..services.user_cache import ( build_jellyseerr_candidate_map, @@ -81,7 +90,7 @@ from ..services.password_reset import ( verify_password_reset_token, ) -router = APIRouter(prefix="/auth", tags=["auth"]) +router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES) logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" STREAM_TOKEN_TTL_SECONDS = 120 @@ -869,7 +878,8 @@ async def invite_details(code: str) -> dict: @router.post("/signup") -async def signup(payload: dict, response: Response) -> dict: +async def signup(payload: SignupRequest, response: Response) -> dict: + payload = request_data(payload) if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") invite_code = str(payload.get("invite_code") or "").strip() @@ -1054,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict: @router.post("/password/forgot") -async def forgot_password(payload: dict, request: Request) -> dict: +async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict: + payload = request_data(payload) if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") identifier = payload.get("identifier") or payload.get("username") or payload.get("email") @@ -1106,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict: @router.post("/password/reset") -async def password_reset(payload: dict) -> dict: +async def password_reset(payload: PasswordResetRequest) -> dict: + payload = request_data(payload) if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") token = payload.get("token") @@ -1169,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict: @router.put("/profile/email") -async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: +async def update_profile_email( + payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user) +) -> dict: + payload = request_data(payload) if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") username = str(current_user.get("username") or "").strip() @@ -1435,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get @router.post("/password") -async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: +async def change_password( + payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user) +) -> dict: + payload = request_data(payload) current_password = payload.get("current_password") if isinstance(payload, dict) else None new_password = payload.get("new_password") if isinstance(payload, dict) else None if not isinstance(current_password, str) or not isinstance(new_password, str): diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py index 6a47535..b2f5c9f 100644 --- a/backend/app/routers/branding.py +++ b/backend/app/routers/branding.py @@ -3,7 +3,7 @@ import warnings from io import BytesIO from typing import Any, Dict -from fastapi import APIRouter, HTTPException, UploadFile, File +from fastapi import APIRouter, HTTPException, UploadFile from fastapi.responses import FileResponse from PIL import Image, ImageDraw, ImageFont diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py index f7c149d..c64c66c 100644 --- a/backend/app/routers/images.py +++ b/backend/app/routers/images.py @@ -3,7 +3,7 @@ import re import mimetypes import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Response +from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, RedirectResponse import httpx diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py index 1b187af..6a48702 100644 --- a/backend/app/routers/portal.py +++ b/backend/app/routers/portal.py @@ -11,6 +11,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Query from ..auth import get_current_user +from ..api_models import COMMON_ERROR_RESPONSES from ..clients.jellyfin import JellyfinClient from ..db import ( add_portal_item_activity, @@ -34,7 +35,12 @@ from ..services.issue_resolution import ( from ..services.notifications import send_portal_notification from ..runtime import get_runtime_settings -router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)]) +router = APIRouter( + prefix="/portal", + tags=["portal"], + dependencies=[Depends(get_current_user), Depends(require_portal_access)], + responses=COMMON_ERROR_RESPONSES, +) logger = logging.getLogger(__name__) PORTAL_KINDS = {"request", "issue", "feature"} diff --git a/backend/app/routers/recaps.py b/backend/app/routers/recaps.py index 8401abd..0fea8bf 100644 --- a/backend/app/routers/recaps.py +++ b/backend/app/routers/recaps.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, ConfigDict, Field, field_validator from ..services.public_urls import magent_public_url -from ..auth import get_current_user, require_admin +from ..auth import require_admin from ..feature_guards import require_stats from ..services import email_recaps as recaps, recap_store as store diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index a921a95..8cfb934 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -20,6 +20,7 @@ from ..clients.sonarr import SonarrClient from ..clients.bazarr import BazarrClient from ..ai.triage import triage_snapshot from ..auth import get_current_user +from ..api_models import COMMON_ERROR_RESPONSES from ..runtime import get_runtime_settings from .images import cache_tmdb_image, is_tmdb_cached from ..db import ( @@ -30,7 +31,6 @@ from ..db import ( save_action, get_recent_actions, get_recent_snapshots, - get_cached_requests, get_cached_requests_since, get_cached_request_by_media_id, get_request_cache_lookup, @@ -62,6 +62,7 @@ from ..db import ( ) from ..services.media_repair import current_cycle_torrents from ..services.download_labels import label_episode_downloads +from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path from ..models import Snapshot, TriageResult, RequestType from ..services.snapshot import ( _summarize_qbit, @@ -70,7 +71,12 @@ from ..services.snapshot import ( jellyfin_item_matches_request, ) -router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)]) +router = APIRouter( + prefix="/requests", + tags=["requests"], + dependencies=[Depends(get_current_user), Depends(require_request_access)], + responses=COMMON_ERROR_RESPONSES, +) CACHE_TTL_SECONDS = 600 _detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} @@ -1753,7 +1759,6 @@ def _filter_arr_release_results(results: Any, include_rejected: bool = False) -> "approved": accepted, "rejected": item.get("rejected"), "temporarilyRejected": item.get("temporarilyRejected"), - "rejections": item.get("rejections"), "downloadAllowed": item.get("downloadAllowed"), "fullSeason": item.get("fullSeason"), "seasonNumber": item.get("seasonNumber"), @@ -1971,16 +1976,10 @@ def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: - if root_folder.isdigit(): - folders = await client.get_root_folders() - if isinstance(folders, list): - for folder in folders: - if folder.get("id") == int(root_folder): - path = folder.get("path") - if isinstance(path, str) and path: - return path - raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found") - return root_folder + try: + return await resolve_root_folder_path(client, root_folder, service_name) + except RootFolderNotFoundError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/{request_id}/issue-options") @@ -2979,7 +2978,6 @@ async def recent_requests( ) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - mode = (runtime.requests_data_source or "prefer_cache").lower() # Browsing is always local. Synchronization is owned by background workers. allow_remote = False username_norm = _normalize_username(user.get("username", "")) @@ -3007,8 +3005,6 @@ async def recent_requests( allow_title_hydrate = False allow_artwork_hydrate = False stage_cache = await asyncio.to_thread(get_request_stage_cache) - jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) - jellyfin_cache: Dict[str, bool] = {} results = [] for row in rows: status = row.get("status") @@ -3946,7 +3942,7 @@ async def action_grab( release_title = receipt.get('title') arr_error: Optional[str] = None try: - response = await arr_client.grab_release(str(guid), arr_indexer_id) + await arr_client.grab_release(str(guid), arr_indexer_id) action_message = ( f"{release_title or 'Selected release'} was sent through {service_label} for download and import." + (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '') diff --git a/backend/app/schema_migrations.py b/backend/app/schema_migrations.py new file mode 100644 index 0000000..287019f --- /dev/null +++ b/backend/app/schema_migrations.py @@ -0,0 +1,116 @@ +"""Transactional, versioned SQLite schema migrations for Magent.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Callable + + +MigrationStep = Callable[[sqlite3.Connection], None] + + +@dataclass(frozen=True) +class Migration: + version: int + name: str + apply: MigrationStep + + +def _column_names(conn: sqlite3.Connection, table: str) -> set[str]: + return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()} + + +def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None: + column = definition.split(maxsplit=1)[0].strip('"') + if column not in _column_names(conn, table): + conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}') + + +def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None: + for definition in ( + "email TEXT", + "last_login_at TEXT", + "is_blocked INTEGER NOT NULL DEFAULT 0", + "auth_provider TEXT NOT NULL DEFAULT 'local'", + "jellyfin_password_hash TEXT", + "last_jellyfin_auth_at TEXT", + "jellyseerr_user_id INTEGER", + "auto_search_enabled INTEGER NOT NULL DEFAULT 1", + "invite_management_enabled INTEGER NOT NULL DEFAULT 0", + "profile_id INTEGER", + "expires_at TEXT", + "invited_by_code TEXT", + "invited_at TEXT", + "auth_version INTEGER NOT NULL DEFAULT 1", + ): + _add_column(conn, "users", definition) + + for definition in ("recipient_email TEXT", "code_hint TEXT"): + _add_column(conn, "signup_invites", definition) + + for definition in ( + "related_item_id INTEGER", + "workflow_request_status TEXT", + "workflow_media_status TEXT", + "issue_type TEXT", + "issue_resolved_at TEXT", + "metadata_json TEXT", + ): + _add_column(conn, "portal_items", definition) + + _add_column(conn, "requests_cache", "requested_by_id INTEGER") + + statements = ( + "CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items " + "(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items " + "(related_item_id, updated_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)", + "CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)", + "CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)", + "CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)", + "CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)", + "CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at ON requests_cache " + "(requested_by_id, created_at DESC, request_id DESC)", + ) + for statement in statements: + conn.execute(statement) + + +MIGRATIONS = ( + Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes), +) + + +def run_schema_migrations(conn: sqlite3.Connection) -> list[int]: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ) + """ + ) + applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")} + completed: list[int] = [] + for migration in MIGRATIONS: + if migration.version in applied: + continue + savepoint = f"magent_migration_{migration.version}" + conn.execute(f"SAVEPOINT {savepoint}") + try: + migration.apply(conn) + conn.execute( + "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)", + (migration.version, migration.name, datetime.now(timezone.utc).isoformat()), + ) + conn.execute(f"RELEASE SAVEPOINT {savepoint}") + except Exception: + conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}") + conn.execute(f"RELEASE SAVEPOINT {savepoint}") + raise + completed.append(migration.version) + return completed diff --git a/backend/app/services/arr.py b/backend/app/services/arr.py new file mode 100644 index 0000000..1edff84 --- /dev/null +++ b/backend/app/services/arr.py @@ -0,0 +1,21 @@ +"""Shared Sonarr/Radarr configuration helpers.""" + +from typing import Any + + +class RootFolderNotFoundError(ValueError): + pass + + +async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: + configured = str(root_folder or "").strip() + if not configured.isdigit(): + return configured + folders = await client.get_root_folders() + if isinstance(folders, list): + for folder in folders: + if isinstance(folder, dict) and folder.get("id") == int(configured): + path = str(folder.get("path") or "").strip() + if path: + return path + raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found") diff --git a/backend/app/services/newsletter_store.py b/backend/app/services/newsletter_store.py index d4cdfee..6671a28 100644 --- a/backend/app/services/newsletter_store.py +++ b/backend/app/services/newsletter_store.py @@ -1,4 +1,3 @@ -from .public_urls import magent_public_url """Independent newsletter consent and immutable edition snapshots using the shared email queue.""" import hashlib @@ -11,6 +10,7 @@ from datetime import datetime, timedelta, timezone from .. import db from . import email_queue from .recap_store import read_one, transaction +from .public_urls import magent_public_url class Conflict(ValueError): diff --git a/backend/app/services/recap_store.py b/backend/app/services/recap_store.py index 6f0b673..9adb0f8 100644 --- a/backend/app/services/recap_store.py +++ b/backend/app/services/recap_store.py @@ -1,4 +1,3 @@ -from .public_urls import magent_public_url """Durable consent, schedule and delivery records for personal email recaps.""" import hashlib @@ -11,6 +10,7 @@ from datetime import datetime from .. import db from .monthly_reports import shift_month from . import email_queue +from .public_urls import magent_public_url def init_schema(conn: sqlite3.Connection) -> None: diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index f5dd1b7..256122a 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -32,6 +32,7 @@ from ..models import ActionOption, NormalizedState, RequestType, Snapshot, Timel from .collector_search import read_search_status from .media_repair import current_cycle_torrents, evaluate_media_repair from .download_labels import label_episode_downloads +from .arr import RootFolderNotFoundError, resolve_root_folder_path logger = logging.getLogger(__name__) @@ -1234,11 +1235,6 @@ async def build_snapshot(request_id: str) -> Snapshot: arr_item = None arr_queue = None episodes = None - media_status = jelly_request.get("media", {}).get("status") - try: - media_status_code = int(media_status) if media_status is not None else None - except (TypeError, ValueError): - media_status_code = None if snapshot.request_type == RequestType.tv: tvdb_id = jelly_request.get("media", {}).get("tvdbId") if tvdb_id: @@ -1390,11 +1386,15 @@ async def build_snapshot(request_id: str) -> Snapshot: if runtime.radarr_quality_profile_id and runtime.radarr_root_folder: radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) if radarr_client.configured(): - root_folder = await _resolve_root_folder_path( - radarr_client, runtime.radarr_root_folder, "Radarr" - ) + try: + root_folder = await resolve_root_folder_path( + radarr_client, runtime.radarr_root_folder, "Radarr" + ) + except RootFolderNotFoundError as exc: + logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc) + root_folder = "" tmdb_id = jelly_request.get("media", {}).get("tmdbId") - if tmdb_id: + if tmdb_id and root_folder: try: await radarr_client.add_movie( int(tmdb_id), @@ -1409,11 +1409,15 @@ async def build_snapshot(request_id: str) -> Snapshot: if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder: sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) if sonarr_client.configured(): - root_folder = await _resolve_root_folder_path( - sonarr_client, runtime.sonarr_root_folder, "Sonarr" - ) + try: + root_folder = await resolve_root_folder_path( + sonarr_client, runtime.sonarr_root_folder, "Sonarr" + ) + except RootFolderNotFoundError as exc: + logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc) + root_folder = "" tvdb_id = jelly_request.get("media", {}).get("tvdbId") - if tvdb_id: + if tvdb_id and root_folder: try: await sonarr_client.add_series( int(tvdb_id), diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..f222849 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +coverage==7.16.1 +pip-audit==2.10.1 +ruff==0.16.8 diff --git a/backend/tests/test_api_models.py b/backend/tests/test_api_models.py new file mode 100644 index 0000000..bf94c89 --- /dev/null +++ b/backend/tests/test_api_models.py @@ -0,0 +1,24 @@ +import unittest + +from pydantic import ValidationError + +from backend.app.api_models import PasswordResetRequest, SignupRequest + + +class ApiRequestModelTests(unittest.TestCase): + def test_signup_rejects_unknown_fields(self) -> None: + with self.assertRaises(ValidationError): + SignupRequest( + invite_code="invite", + username="viewer", + password="strong password", + unexpected="value", + ) + + def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None: + request = PasswordResetRequest(token="token", new_password=" leading and trailing ") + self.assertEqual(request.new_password, " leading and trailing ") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_arr_helpers.py b/backend/tests/test_arr_helpers.py new file mode 100644 index 0000000..df50431 --- /dev/null +++ b/backend/tests/test_arr_helpers.py @@ -0,0 +1,24 @@ +import unittest + +from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path + + +class _ArrClient: + async def get_root_folders(self): + return [{"id": 7, "path": "/media/tv"}] + + +class ArrHelperTests(unittest.IsolatedAsyncioTestCase): + async def test_resolves_numeric_root_folder_id(self) -> None: + self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv") + + async def test_preserves_configured_path(self) -> None: + self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies") + + async def test_rejects_missing_root_folder_id(self) -> None: + with self.assertRaises(RootFolderNotFoundError): + await resolve_root_folder_path(_ArrClient(), "8", "Sonarr") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_logging_config.py b/backend/tests/test_logging_config.py new file mode 100644 index 0000000..977f561 --- /dev/null +++ b/backend/tests/test_logging_config.py @@ -0,0 +1,25 @@ +import json +import logging +import unittest + +from backend.app.logging_config import JsonLogFormatter, RequestContextFilter, bind_request_id, reset_request_id + + +class JsonLoggingTests(unittest.TestCase): + def test_json_formatter_includes_request_context(self) -> None: + token = bind_request_id("request-123") + try: + record = logging.LogRecord("magent.test", logging.INFO, __file__, 1, "hello %s", ("world",), None) + RequestContextFilter().filter(record) + payload = json.loads(JsonLogFormatter().format(record)) + finally: + reset_request_id(token) + + self.assertEqual(payload["level"], "INFO") + self.assertEqual(payload["logger"], "magent.test") + self.assertEqual(payload["request_id"], "request-123") + self.assertEqual(payload["message"], "hello world") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_schema_migrations.py b/backend/tests/test_schema_migrations.py new file mode 100644 index 0000000..48eb913 --- /dev/null +++ b/backend/tests/test_schema_migrations.py @@ -0,0 +1,36 @@ +import sqlite3 +import unittest + +from backend.app.schema_migrations import run_schema_migrations + + +class SchemaMigrationTests(unittest.TestCase): + def setUp(self) -> None: + self.conn = sqlite3.connect(":memory:") + self.conn.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at TEXT)" + ) + self.conn.execute( + "CREATE TABLE signup_invites (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at TEXT, updated_at TEXT)" + ) + self.conn.execute("CREATE TABLE portal_items (id INTEGER PRIMARY KEY, kind TEXT, updated_at TEXT)") + self.conn.execute("CREATE TABLE requests_cache (request_id INTEGER PRIMARY KEY, created_at TEXT)") + + def tearDown(self) -> None: + self.conn.close() + + def test_migrations_are_versioned_and_idempotent(self) -> None: + self.assertEqual(run_schema_migrations(self.conn), [1]) + self.assertEqual(run_schema_migrations(self.conn), []) + + user_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(users)")} + self.assertIn("auth_version", user_columns) + self.assertIn("email", user_columns) + request_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(requests_cache)")} + self.assertIn("requested_by_id", request_columns) + applied = self.conn.execute("SELECT version, name FROM schema_migrations").fetchall() + self.assertEqual(applied, [(1, "legacy_columns_and_indexes")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/app/MyRequests.tsx b/frontend/app/MyRequests.tsx index 64f170a..22bd7d2 100644 --- a/frontend/app/MyRequests.tsx +++ b/frontend/app/MyRequests.tsx @@ -1,298 +1,261 @@ -'use client' +"use client"; -import PageHeading from './ui/PageHeading' +import PageHeading from "./ui/PageHeading"; -import { useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' -import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth' - -const normalizeRecentResults = (items: any[]) => - items - .filter((item: any) => item?.id) - .map((item: any) => { - const id = item.id - const rawTitle = item.title - const placeholder = - typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}` - return { - id, - title: !rawTitle || placeholder ? `Request #${id}` : rawTitle, - year: item.year, - type: item.type, - statusLabel: item.statusLabel, - artwork: item.artwork, - createdAt: item.createdAt ?? null, - } - }) - -const REQUEST_STAGE_OPTIONS = [ - { value: 'all', label: 'All stages' }, - { value: 'pending', label: 'Waiting' }, - { value: 'approved', label: 'Approved' }, - { value: 'in_progress', label: 'In progress' }, - { value: 'working', label: 'Working' }, - { value: 'partial', label: 'Partial' }, - { value: 'ready', label: 'Ready' }, - { value: 'declined', label: 'Declined' }, -] +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth"; +import { + normalizeRecentResults, + normalizeSearchResults, + type RecentRequest, + type RequestSearchResult, +} from "./lib/request-results"; +import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter"; export default function HomePage() { - const router = useRouter() - const [query, setQuery] = useState('') - const [recent, setRecent] = useState< - { - id: number - title: string - year?: number - type?: string - statusLabel?: string - artwork?: { poster_url?: string; backdrop_url?: string } - createdAt?: string | null - }[] - >([]) - const [recentError, setRecentError] = useState(null) - const [recentLoading, setRecentLoading] = useState(false) - const [searchResults, setSearchResults] = useState< - { - title: string - year?: number - type?: string - requestId?: number - statusLabel?: string - requestedBy?: string | null - accessible?: boolean - }[] - >([]) - const [searchError, setSearchError] = useState(null) - const [role, setRole] = useState(null) - const [recentDays, setRecentDays] = useState(90) - const [recentStage, setRecentStage] = useState('all') - const [authReady, setAuthReady] = useState(false) + const router = useRouter(); + const [query, setQuery] = useState(""); + const [recent, setRecent] = useState([]); + const [recentError, setRecentError] = useState(null); + const [recentLoading, setRecentLoading] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [searchError, setSearchError] = useState(null); + const [role, setRole] = useState(null); + const [recentDays, setRecentDays] = useState(90); + const [recentStage, setRecentStage] = useState("all"); + const [authReady, setAuthReady] = useState(false); const submit = (event: React.FormEvent) => { - event.preventDefault() - const trimmed = query.trim() - if (!trimmed) return + event.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; if (/^\d+$/.test(trimmed)) { - router.push(`/requests/${encodeURIComponent(trimmed)}`) - return + router.push(`/requests/${encodeURIComponent(trimmed)}`); + return; } - void runSearch(trimmed) - } + void runSearch(trimmed); + }; useEffect(() => { if (!getToken()) { - router.push('/login') - return + router.push("/login"); + return; } - let cancelled = false + let cancelled = false; const load = async () => { - setRecentLoading(true) - setRecentError(null) + setRecentLoading(true); + setRecentError(null); try { - const baseUrl = getApiBase() - const meResponse = await authFetch(`${baseUrl}/auth/me`) + const baseUrl = getApiBase(); + const meResponse = await authFetch(`${baseUrl}/auth/me`); if (!meResponse.ok) { if (meResponse.status === 401) { - clearToken() - router.push('/login') - return + clearToken(); + router.push("/login"); + return; } - throw new Error(`Auth failed: ${meResponse.status}`) + throw new Error(`Auth failed: ${meResponse.status}`); } - const me = await meResponse.json() - if (cancelled) return - const userRole = me?.role ?? null - setRole(userRole) - setAuthReady(true) - const take = userRole === 'admin' ? 50 : 6 + const me = await meResponse.json(); + if (cancelled) return; + const userRole = me?.role ?? null; + setRole(userRole); + setAuthReady(true); + const take = userRole === "admin" ? 50 : 6; const params = new URLSearchParams({ take: String(take), days: String(recentDays), - }) - if (recentStage !== 'all') { - params.set('stage', recentStage) + }); + if (recentStage !== "all") { + params.set("stage", recentStage); } - const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`) + const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`); if (!response.ok) { if (response.status === 401) { - clearToken() - router.push('/login') - return + clearToken(); + router.push("/login"); + return; } - throw new Error(`Recent requests failed: ${response.status}`) + throw new Error(`Recent requests failed: ${response.status}`); } - const data = await response.json() - if (cancelled) return + const data = await response.json(); + if (cancelled) return; if (Array.isArray(data?.results)) { - setRecent(normalizeRecentResults(data.results)) + setRecent(normalizeRecentResults(data.results)); } } catch (error) { - console.error(error) - if (!cancelled) setRecentError('Recent requests are not available right now.') + console.error(error); + if (!cancelled) setRecentError("Recent requests are not available right now."); } finally { - if (!cancelled) setRecentLoading(false) + if (!cancelled) setRecentLoading(false); } - } + }; - void load() - return () => { cancelled = true } - }, [recentDays, recentStage]) + void load(); + return () => { + cancelled = true; + }; + }, [recentDays, recentStage, router]); useEffect(() => { if (!authReady) { - return + return; } if (!getToken()) { - return + return; } - const baseUrl = getApiBase() - let closed = false - let source: EventSource | null = null + const baseUrl = getApiBase(); + let closed = false; + let source: EventSource | null = null; const connect = async () => { try { - const streamToken = await getEventStreamToken() - if (closed) return + const streamToken = await getEventStreamToken(); + if (closed) return; const params = new URLSearchParams({ stream_token: streamToken, recent_days: String(recentDays), - }) - if (recentStage !== 'all') { - params.set('recent_stage', recentStage) + }); + if (recentStage !== "all") { + params.set("recent_stage", recentStage); } - const streamUrl = `${baseUrl}/events/stream?${params.toString()}` - source = new EventSource(streamUrl) + const streamUrl = `${baseUrl}/events/stream?${params.toString()}`; + source = new EventSource(streamUrl); source.onmessage = (event) => { - if (closed) return + if (closed) return; try { - const payload = JSON.parse(event.data) - if (!payload || typeof payload !== 'object') { - return + const payload = JSON.parse(event.data); + if (!payload || typeof payload !== "object") { + return; } - if (payload.type === 'home_recent') { + if (payload.type === "home_recent") { if (Array.isArray(payload.results)) { - setRecent(normalizeRecentResults(payload.results)) - setRecentError(null) - setRecentLoading(false) - } else if (typeof payload.error === 'string' && payload.error.trim()) { - setRecentError('Recent requests are not available right now.') - setRecentLoading(false) + setRecent(normalizeRecentResults(payload.results)); + setRecentError(null); + setRecentLoading(false); + } else if (typeof payload.error === "string" && payload.error.trim()) { + setRecentError("Recent requests are not available right now."); + setRecentLoading(false); } - return + return; } } catch (error) { - console.error(error) + console.error(error); } - } - + }; } catch (error) { - if (closed) return - console.error(error) + if (closed) return; + console.error(error); } - } + }; - void connect() + void connect(); return () => { - closed = true - source?.close() - } - }, [authReady, recentDays, recentStage]) + closed = true; + source?.close(); + }; + }, [authReady, recentDays, recentStage]); const runSearch = async (term: string) => { try { - const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`) + const baseUrl = getApiBase(); + const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`); if (!response.ok) { if (response.status === 401) { - clearToken() - router.push('/login') - return + clearToken(); + router.push("/login"); + return; } - throw new Error(`Search failed: ${response.status}`) + throw new Error(`Search failed: ${response.status}`); } - const data = await response.json() + const data = await response.json(); if (Array.isArray(data?.results)) { - setSearchResults( - data.results.map((item: any) => ({ - title: item.title, - year: item.year, - type: item.type, - requestId: item.requestId, - statusLabel: item.statusLabel, - requestedBy: item.requestedBy ?? null, - accessible: Boolean(item.accessible), - })) - ) - setSearchError(null) + setSearchResults(normalizeSearchResults(data.results)); + setSearchError(null); } } catch (error) { - console.error(error) - setSearchError('Search failed. Try a request ID instead.') - setSearchResults([]) + console.error(error); + setSearchError("Search failed. Try a request ID instead."); + setSearchResults([]); } - } + }; const resolveArtworkUrl = (url?: string | null) => { - if (!url) return null - return url.startsWith('http') ? url : `${getApiBase()}${url}` - } + if (!url) return null; + return url.startsWith("http") ? url : `${getApiBase()}${url}`; + }; const formatRequestTime = (value?: string | null) => { - if (!value) return null - const date = new Date(value) - if (Number.isNaN(date.valueOf())) return value - return date.toLocaleString() - } + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + return date.toLocaleString(); + }; const activeRecentCount = recent.filter((item) => { - const label = String(item.statusLabel ?? '').toLowerCase() - return !label.includes('ready') && !label.includes('available') && !label.includes('declined') - }).length + const label = String(item.statusLabel ?? "").toLowerCase(); + return !label.includes("ready") && !label.includes("available") && !label.includes("declined"); + }).length; const readyRecentCount = recent.filter((item) => { - const label = String(item.statusLabel ?? '').toLowerCase() - return label.includes('ready') || label.includes('available') - }).length + const label = String(item.statusLabel ?? "").toLowerCase(); + return label.includes("ready") || label.includes("available"); + }).length; const requestCardState = (value?: string) => { - const label = String(value ?? '').toLowerCase() - if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 } - if (!/not |unavailable|waiting/.test(label) && (label.includes('ready') || label.includes('available'))) return { key: 'ready', label: value || 'Ready', progress: 100 } - if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 } - if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 } - return { key: 'waiting', label: value || 'Waiting', progress: 4 } - } + const label = String(value ?? "").toLowerCase(); + if (label.includes("partial")) return { key: "attention", label: value || "Partially ready", progress: 65 }; + if (!/not |unavailable|waiting/.test(label) && (label.includes("ready") || label.includes("available"))) + return { key: "ready", label: value || "Ready", progress: 100 }; + if (label.includes("declined") || label.includes("failed") || label.includes("error")) + return { key: "attention", label: value || "Needs attention", progress: 12 }; + if (label.includes("working") || label.includes("progress") || label.includes("download")) + return { key: "processing", label: value || "In progress", progress: 58 }; + return { key: "waiting", label: value || "Waiting", progress: 4 }; + }; return (
- - -
- setQuery(event.target.value)} - placeholder="Dune 2021 or 1289" - /> - -
- - } /> + + +
+ setQuery(event.target.value)} + placeholder="Dune 2021 or 1289" + /> + +
+ + } + /> {(searchError || searchResults.length > 0) && (
Search results -

{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}

+

+ {searchError + ? "Search unavailable" + : `${searchResults.length} match${searchResults.length === 1 ? "" : "es"} found`} +

-
@@ -302,17 +265,20 @@ export default function HomePage() {
{searchResults.map((item, index) => ( ))}
@@ -321,16 +287,25 @@ export default function HomePage() { )}
-
In view{recent.length}
-
In progress{activeRecentCount}
-
Ready{readyRecentCount}
+
+ In view + {recent.length} +
+
+ In progress + {activeRecentCount} +
+
+ Ready + {readyRecentCount} +
Request activity -

{role === 'admin' ? 'Recent requests' : 'My recent requests'}

+

{role === "admin" ? "Recent requests" : "My recent requests"}

{authReady && (
@@ -347,21 +322,7 @@ export default function HomePage() {
)}
- {authReady && ( -
- {REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => ( -