diff --git a/.github/workflows/codeql-lint.yml b/.github/workflows/codeql-lint.yml index 5773a4de..2ee7ee46 100644 --- a/.github/workflows/codeql-lint.yml +++ b/.github/workflows/codeql-lint.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - '*' + - '**' tags: - 'v*' paths: @@ -21,13 +21,14 @@ on: - 'deploy/docker/compose.yaml' - 'frontend/package-lock.json' - 'deploy/docker/Dockerfile' + - 'deploy/docker/**' - '.dockerignore' - 'scripts/start.sh' - '.release-please-manifest.json' - '.github/workflows/codeql-lint.yml' pull_request: branches: - - '*' + - '**' paths: - '**.py' - '**.js' @@ -42,6 +43,7 @@ on: - 'deploy/docker/compose.yaml' - 'frontend/package-lock.json' - 'deploy/docker/Dockerfile' + - 'deploy/docker/**' - '.dockerignore' - 'scripts/start.sh' - '.release-please-manifest.json' @@ -50,9 +52,7 @@ on: - cron: '25 4 * * 1' # Weekly on Monday at 04:25 UTC permissions: - security-events: write contents: read - packages: write concurrency: group: codeql-lint-${{ github.ref }} @@ -63,6 +63,9 @@ jobs: codeql-python: name: CodeQL - Python runs-on: ubuntu-latest + permissions: + security-events: write + contents: read steps: - name: Checkout code @@ -82,6 +85,9 @@ jobs: codeql-javascript: name: CodeQL - JavaScript runs-on: ubuntu-latest + permissions: + security-events: write + contents: read steps: - name: Checkout code @@ -106,11 +112,13 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: '3.11' + python-version: '3.13' cache: 'pip' - name: Install linting tools @@ -127,25 +135,29 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: '3.11' + python-version: '3.13' cache: 'pip' - name: Install dependencies run: | pip install -r requirements.txt + # pytest + httpx (Starlette's TestClient) — kept out of requirements.txt + # so the runtime image never installs a test stack. + pip install -r requirements-dev.txt # Develop-only extensions keep their extra deps in requirements-*.txt # (e.g. requirements-cl2k.txt) so requirements.txt stays identical to # main. Install any that exist — no-op on main. for extra in requirements-*.txt; do - if [ -e "$extra" ]; then pip install -r "$extra"; fi + if [ -e "$extra" ] && [ "$extra" != "requirements-dev.txt" ]; then + pip install -r "$extra" + fi done - # httpx is required by Starlette's TestClient (used in tests/test_api_smoke.py) - # but isn't a runtime dep, so it lives outside requirements.txt. - pip install httpx - name: Verify FastAPI app imports cleanly run: | @@ -199,6 +211,16 @@ jobs: working-directory: frontend run: npm run test:run + # Tailwind silently drops classes it cannot resolve, so the built CSS is + # the only place a dead utility shows up — check:classes reads dist/. + - name: Build frontend + working-directory: frontend + run: npm run build + + - name: Check for used-but-unemitted Tailwind classes + working-directory: frontend + run: npm run check:classes + # ---- Branch isolation guard ---- # Develop-only extensions (e.g. the CL2K poster maker) must never reach main. # On main (and PRs targeting main) fail if any extension code is present: @@ -222,9 +244,14 @@ jobs: echo "Not a main-bound ref — branch-isolation check not applicable." exit 0 fi - leaks=$(git ls-files -- '*cl2k*' \ + # :(icase) — git globs are case-sensitive (else Cl2kMakerPage.jsx slips + # through); cover both extensions dirs, poster_self_heal, and the fonts. + leaks=$(git ls-files -- \ + ':(icase)*cl2k*' \ + ':(icase)*poster_self_heal*' ':(icase)*posterselfheal*' ':(icase)*posterheal*' \ 'backend/extensions/*' ':!backend/extensions/__init__.py' \ - 'frontend/src/extensions/*' ':!frontend/src/extensions/index.js') + 'frontend/src/extensions/*' ':!frontend/src/extensions/index.js' \ + 'deploy/docker/fonts/*') if [ -n "$leaks" ]; then echo "::error::Develop-only extension files found on a main-bound ref:" echo "$leaks" @@ -232,6 +259,50 @@ jobs: fi echo "OK: no develop-only extension code present." + # ---- Develop invariant guard ---- + # Mirror of branch-isolation-guard for the develop half: develop may differ from + # main ONLY by added files plus an append-only deploy/docker/Dockerfile. + develop-invariant-guard: + name: Develop Invariant Guard + # Always runs (no job-level if) so it stays a satisfiable needs on every ref; + # the step no-ops on non-develop-bound refs. + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Assert develop only adds files (shared files byte-identical to main) + run: | + set -eu + # Only develop-bound refs carry the extension delta; pass on any other ref. + if [ "${GITHUB_REF}" != "refs/heads/develop" ] && [ "${GITHUB_BASE_REF:-}" != "develop" ]; then + echo "Not a develop-bound ref — develop-invariant check not applicable." + exit 0 + fi + git fetch --quiet origin main + # Merge-base diff (origin/main...HEAD): main being ahead of an unsynced + # develop must not false-positive; only develop's own delta is inspected. + bad=$(git diff origin/main...HEAD --name-status \ + | grep -Ev '^A[[:space:]]' \ + | grep -Ev '^M[[:space:]]+deploy/docker/Dockerfile$' || true) + if [ -n "$bad" ]; then + echo "::error::develop diverges from main beyond added files + an insertion-only Dockerfile:" + echo "$bad" + exit 1 + fi + # Pure-insertion hunks: no main line removed or edited. CL2K blocks are + # inserted MID-FILE (per build stage), so a byte-prefix check would false-fail. + if git diff origin/main...HEAD -- deploy/docker/Dockerfile | grep -q '^-[^-]'; then + echo "::error::deploy/docker/Dockerfile removes or edits lines present on main; develop may only insert CL2K blocks." + exit 1 + fi + echo "OK: develop differs from main only by added files + an insertion-only Dockerfile." + # ---- Docker Build (gated by all quality checks) ---- docker-validate: name: Docker Validate (PR) @@ -240,7 +311,7 @@ jobs: # needs-failure SKIPS this job — which GitHub reports as Success for a # required check, turning a hard gate into a free pass. Promote # "Frontend Tests" in branch protection to make it block instead. - needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard] + needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard, develop-invariant-guard] runs-on: ubuntu-latest steps: @@ -277,9 +348,12 @@ jobs: docker-push: name: Docker Build & Push if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, branch-isolation-guard] + needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, branch-isolation-guard, develop-invariant-guard] runs-on: ubuntu-latest timeout-minutes: 45 # see release-please.yml docker-version + permissions: + contents: read + packages: write # Serialize with the release-please docker-version job so concurrent pushes # to the same :latest tag don't race on GHCR auth. cancel-in-progress is # false because both jobs publish artifacts we want to keep — they just @@ -302,7 +376,23 @@ jobs: - name: Get the current branch name id: get_branch - run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT + run: | + name="${GITHUB_REF#refs/heads/}" + # Emit a branch tag only on branch refs; encode it collision-safe to + # match on-branch-create/delete (clean names pass through, else digest). + if [[ "$GITHUB_REF" == refs/heads/* ]]; then IS_BRANCH=true; else IS_BRANCH=false; fi + sanitized="${name//[^A-Za-z0-9_.-]/-}" + sanitized="$(printf '%s' "$sanitized" | sed -E 's/^[.-]+//')" + if [ "$sanitized" = "$name" ] && [ -n "$sanitized" ] && [ "${#sanitized}" -le 128 ]; then + BRANCH_TAG="$sanitized" + else + hash="$(printf '%s' "$name" | sha256sum | cut -c1-12)" + base="${sanitized:0:100}"; [ -n "$base" ] || base="branch" + BRANCH_TAG="${base}-${hash}" + fi + echo "BRANCH_NAME=${name}" >> "$GITHUB_OUTPUT" + echo "BRANCH_TAG=${BRANCH_TAG}" >> "$GITHUB_OUTPUT" + echo "IS_BRANCH=${IS_BRANCH}" >> "$GITHUB_OUTPUT" - name: Set build number run: echo "BUILD_NUMBER=$(git rev-list --count HEAD)" >> $GITHUB_ENV @@ -320,7 +410,7 @@ jobs: with: images: ghcr.io/${{ github.repository_owner }}/chub tags: | - type=ref,event=branch + type=raw,value=${{ steps.get_branch.outputs.BRANCH_TAG }},enable=${{ steps.get_branch.outputs.IS_BRANCH }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} @@ -341,7 +431,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} notify-failure: - needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, branch-isolation-guard, docker-push] + needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, branch-isolation-guard, develop-invariant-guard, docker-push] if: failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') uses: chodeus/chodeus-ops/.github/workflows/notify-discord.yml@aae195c19ec7069d2e91aca88107220ce807ef7e # main with: diff --git a/.github/workflows/dep-audit.yml b/.github/workflows/dep-audit.yml index 43ef8b6c..26a6fa11 100644 --- a/.github/workflows/dep-audit.yml +++ b/.github/workflows/dep-audit.yml @@ -10,10 +10,11 @@ on: schedule: - cron: "43 2 * * 1" pull_request: + # No Dockerfile here: this job scans lockfiles (below), not container base + # images, so triggering on it would imply base-image coverage it lacks. paths: - "requirements.txt" - "frontend/package-lock.json" - - "deploy/docker/Dockerfile" - ".github/workflows/dep-audit.yml" permissions: {} diff --git a/.github/workflows/on-branch-create.yml b/.github/workflows/on-branch-create.yml index 7a4aa16d..364516cc 100755 --- a/.github/workflows/on-branch-create.yml +++ b/.github/workflows/on-branch-create.yml @@ -11,15 +11,27 @@ permissions: jobs: docker-tag: runs-on: ubuntu-latest + timeout-minutes: 45 # caps a wedged arm64 build at 45m instead of the 6h default + # `create` sets github.ref to the DEFAULT branch, so key the shared ghcr-push + # group off the created branch (event.ref); ref_name is the dispatch fallback. + concurrency: + group: ghcr-push-refs/heads/${{ github.event_name == 'create' && github.event.ref || github.ref_name }} + cancel-in-progress: false # Run on `create` events for branches, and on manual dispatch (so a # CI fix can be re-tested against an existing branch without having # to delete + recreate it). if: github.event_name == 'workflow_dispatch' || github.event.ref_type == 'branch' + # `create` sets GITHUB_REF to the default branch; REF is the real target + # (event.ref on create, ref_name on dispatch). Kept out of run: shells. + env: + REF: ${{ github.event_name == 'create' && github.event.ref || github.ref_name }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + # Build the CREATED branch, not the default one github.ref points at. + ref: ${{ github.event_name == 'create' && github.event.ref || github.ref_name }} fetch-depth: 0 - name: Set up QEMU @@ -31,13 +43,21 @@ jobs: - name: Get the new branch name id: get_branch run: | - BRANCH_NAME="${GITHUB_REF#refs/heads/}" - # Docker tags can't contain `/` — convention-style names like - # feat/foo or fix/bar would otherwise fail with "invalid reference - # format". Keep BRANCH_NAME as the human-readable build-arg, but - # expose a sanitized BRANCH_TAG for the registry tag. - BRANCH_TAG="${BRANCH_NAME//\//-}" - echo "BRANCH_NAME=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" + # REF is the real branch (event.ref on create/delete, ref_name on + # dispatch); GITHUB_REF would be the default branch on create/delete. + name="${REF#refs/heads/}" + # Collision-safe tag (shared with codeql-lint docker-push): clean names + # <=128 chars pass through, else truncate+digest so feat/foo != feat-foo. + sanitized="${name//[^A-Za-z0-9_.-]/-}" + sanitized="$(printf '%s' "$sanitized" | sed -E 's/^[.-]+//')" + if [ "$sanitized" = "$name" ] && [ -n "$sanitized" ] && [ "${#sanitized}" -le 128 ]; then + BRANCH_TAG="$sanitized" + else + hash="$(printf '%s' "$name" | sha256sum | cut -c1-12)" + base="${sanitized:0:100}"; [ -n "$base" ] || base="branch" + BRANCH_TAG="${base}-${hash}" + fi + echo "BRANCH_NAME=${name}" >> "$GITHUB_OUTPUT" echo "BRANCH_TAG=${BRANCH_TAG}" >> "$GITHUB_OUTPUT" - name: Set build number diff --git a/.github/workflows/on-branch-delete.yml b/.github/workflows/on-branch-delete.yml index 551c5998..c3c6bd23 100755 --- a/.github/workflows/on-branch-delete.yml +++ b/.github/workflows/on-branch-delete.yml @@ -9,34 +9,108 @@ permissions: jobs: ghcr-delete-tag: runs-on: ubuntu-latest + timeout-minutes: 5 # a stalled GHCR request can't keep cleanup running if: github.event.ref_type == 'branch' + # Serialize with the create/push publisher for this branch (raw ref matches + # their ghcr-push group) so a delete never races a mid-publish. + concurrency: + group: ghcr-push-refs/heads/${{ github.event.ref }} + cancel-in-progress: false + # REF via env (not ${{ }} inlined into run:) so a crafted branch name can't inject shell. + env: + REF: ${{ github.event.ref }} steps: - - name: Get the deleted branch name - id: get_branch - run: echo "BRANCH_NAME=${{ github.event.ref }}" >> $GITHUB_OUTPUT - - name: Skip protected branches + id: guard run: | - BRANCH="${{ steps.get_branch.outputs.BRANCH_NAME }}" - if [[ "$BRANCH" == "main" || "$BRANCH" == "master" || "$BRANCH" == "experimental" ]]; then - echo "Skipping deletion for protected branch: $BRANCH" - exit 0 + if [[ "$REF" == "main" || "$REF" == "master" || "$REF" == "experimental" || "$REF" == "develop" ]]; then + echo "Skipping deletion for protected branch: $REF" + echo "protected=true" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve GHCR tag for the deleted branch + id: get_branch + if: steps.guard.outputs.protected != 'true' + run: | + # REF is the real branch (event.ref on create/delete, ref_name on + # dispatch); GITHUB_REF would be the default branch on create/delete. + name="${REF#refs/heads/}" + # Collision-safe tag (shared with codeql-lint docker-push): clean names + # <=128 chars pass through, else truncate+digest so feat/foo != feat-foo. + sanitized="${name//[^A-Za-z0-9_.-]/-}" + sanitized="$(printf '%s' "$sanitized" | sed -E 's/^[.-]+//')" + if [ "$sanitized" = "$name" ] && [ -n "$sanitized" ] && [ "${#sanitized}" -le 128 ]; then + BRANCH_TAG="$sanitized" + else + hash="$(printf '%s' "$name" | sha256sum | cut -c1-12)" + base="${sanitized:0:100}"; [ -n "$base" ] || base="branch" + BRANCH_TAG="${base}-${hash}" fi + echo "BRANCH_NAME=${name}" >> "$GITHUB_OUTPUT" + echo "BRANCH_TAG=${BRANCH_TAG}" >> "$GITHUB_OUTPUT" - - name: Delete tag from GHCR + - name: Delete tag version from GHCR (sole-reference safe) + if: steps.guard.outputs.protected != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ steps.get_branch.outputs.BRANCH_NAME }} + OWNER: ${{ github.repository_owner }} + TAG: ${{ steps.get_branch.outputs.BRANCH_TAG }} + PACKAGE: chub run: | - REPO="${{ github.repository_owner }}/chub" - DIGEST=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \ - "https://ghcr.io/v2/${REPO}/manifests/${TAG_NAME}" \ - -I | grep -i 'docker-content-digest:' | awk '{print $2}' | tr -d '\r') - if [[ -z "$DIGEST" ]]; then - echo "Tag not found on GHCR" + api="https://api.github.com" + hdr=(-H "Accept: application/vnd.github+json" + -H "Authorization: Bearer $GH_TOKEN" + -H "X-GitHub-Api-Version: 2022-11-28") + # GHCR packages hang off a user OR an org path; probe user first, then org. + scope="users/$OWNER" + probe=$(curl -s --connect-timeout 10 --max-time 30 -o /dev/null -w '%{http_code}' \ + "${hdr[@]}" "$api/$scope/packages/container/$PACKAGE/versions?per_page=1") + if [[ "$probe" == "404" ]]; then + scope="orgs/$OWNER" + probe=$(curl -s --connect-timeout 10 --max-time 30 -o /dev/null -w '%{http_code}' \ + "${hdr[@]}" "$api/$scope/packages/container/$PACKAGE/versions?per_page=1") + fi + if [[ "$probe" == "404" ]]; then + echo "Package $PACKAGE not found for $OWNER; nothing to delete."; exit 0 + fi + if [[ "$probe" != 2* ]]; then + echo "::error::Listing $PACKAGE versions failed with HTTP $probe"; exit 1 + fi + # A tag maps to exactly one version; page until we find the version whose + # tags include $TAG, capturing its id and full tag list. + vid=""; tags_json=""; page=1 + while [[ "$page" -le 100 ]]; do + body="$(mktemp)" + status=$(curl -s --connect-timeout 10 --max-time 30 -o "$body" -w '%{http_code}' \ + "${hdr[@]}" "$api/$scope/packages/container/$PACKAGE/versions?per_page=100&page=$page") + if [[ "$status" != 2* ]]; then + echo "::error::Listing $PACKAGE versions (page $page) failed with HTTP $status"; exit 1 + fi + match="$(jq -c --arg t "$TAG" '[.[] | select((.metadata.container.tags // []) | index($t))][0] // empty' "$body")" + if [[ -n "$match" ]]; then + vid="$(printf '%s' "$match" | jq -r '.id')" + tags_json="$(printf '%s' "$match" | jq -c '.metadata.container.tags // []')" + break + fi + if [[ "$(jq 'length' "$body")" -lt 100 ]]; then break; fi + page=$((page + 1)) + done + if [[ -z "$vid" ]]; then + echo "Tag not found on GHCR: $TAG; nothing to delete."; exit 0 + fi + # Deleting a version drops its manifest — shared-digest tags would go too, + # so only delete when $TAG is the SOLE tag on that version. + if [[ "$(printf '%s' "$tags_json" | jq 'length')" != "1" ]]; then + others="$(printf '%s' "$tags_json" | jq -r 'join(", ")')" + echo "Version $vid for $TAG also holds other tags: $others. Skipping to avoid removing them." exit 0 fi - curl -s -X DELETE -H "Authorization: Bearer $GH_TOKEN" \ - "https://ghcr.io/v2/${REPO}/manifests/${DIGEST}" \ - && echo "Deleted GHCR tag: ${TAG_NAME}" || echo "Failed to delete GHCR tag: ${TAG_NAME}" + del=$(curl -s --connect-timeout 10 --max-time 30 -o /dev/null -w '%{http_code}' -X DELETE \ + "${hdr[@]}" "$api/$scope/packages/container/$PACKAGE/versions/$vid") + # A concurrent delete may have already removed it, so treat 404 as success. + if [[ "$del" == 2* || "$del" == "404" ]]; then + echo "Deleted GHCR version $vid ($TAG) [$del]" + else + echo "::error::Deleting version $vid ($TAG) failed with HTTP $del"; exit 1 + fi diff --git a/.github/workflows/sync-develop.yml b/.github/workflows/sync-develop.yml index 68cbcfb4..9661422c 100644 --- a/.github/workflows/sync-develop.yml +++ b/.github/workflows/sync-develop.yml @@ -3,8 +3,10 @@ name: Sync develop from main # Open a back-merge PR from main into develop on every push to main, so develop # (the public app + develop-only extensions) keeps receiving everything that lands # on main — releases, fixes, dependency bumps. The branch model's sync direction -# is main -> develop and shared files are byte-identical on both, so the merge -# should never conflict while the invariant holds. An already-open base:develop / +# is main -> develop and shared files are byte-identical on both EXCEPT +# deploy/docker/Dockerfile, which develop extends with CL2K layers — so that file +# is the one EXPECTED merge conflict; resolve it by taking main's FROM/base lines +# and keeping develop's appended CL2K layers. An already-open base:develop / # head:main PR auto-tracks main's tip, so we only create one when none is open. # # The PR is a NOTIFICATION that develop has drifted, not a mergeable PR: develop @@ -55,7 +57,9 @@ jobs: --base develop \ --head main \ --title "chore: sync develop with main" \ - --body "\`develop\` has drifted behind \`main\` (releases, fixes, dependency bumps). **Do not merge this PR** — it reports the drift, it does not fix it. + --body "@coderabbitai ignore + + \`develop\` has drifted behind \`main\` (releases, fixes, dependency bumps). **Do not merge this PR** — it reports the drift, it does not fix it. \`develop\` requires branches be up to date, and \`head:main\` can never satisfy that without pulling develop's extension files into main, which the branch invariant forbids. Squash or rebase would also leave \`main\` unreachable from \`develop\`, so this workflow would just open another PR next push. diff --git a/.gitignore b/.gitignore index 07ff2bda..ffe474ec 100755 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ refs/ # Agent-generated planning docs — working notes, not project documentation docs/superpowers/ +design_handoff*/ diff --git a/Makefile b/Makefile index d0d36375..fe4054e2 100755 --- a/Makefile +++ b/Makefile @@ -22,9 +22,12 @@ bootstrap: install ui-install ## Setup everything install: ## Install backend dependencies @echo "Installing backend..." @test -d $(VENV) || $(PY) -m venv $(VENV) + @$(VENV)/bin/python -c 'import sys; sys.exit(sys.version_info < (3, 10))' || \ + { echo "ERROR: $(VENV) is $$($(VENV)/bin/python -V); requirements-dev.txt needs Python >= 3.10 (repo targets 3.13). Recreate the venv with a newer PY=."; exit 1; } @$(VENV)/bin/python -m pip install --upgrade pip @$(VENV)/bin/pip install -r requirements.txt - @$(VENV)/bin/pip install black isort ruff pytest + @$(VENV)/bin/pip install -r requirements-dev.txt + @$(VENV)/bin/pip install black isort ruff @echo "Backend ready" ui-install: ## Install UI dependencies diff --git a/README.md b/README.md index 9837bb1e..230f29c8 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A self-hosted, all-in-one media asset manager for your Plex/ARR stack. [![MIT License](https://img.shields.io/badge/license-MIT-463fbc?style=flat-square)](https://opensource.org/licenses/MIT) -[![Python](https://img.shields.io/badge/python-3.8%2B-1992f3?style=flat-square)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.13%2B-1992f3?style=flat-square)](https://www.python.org/) [![Docker Image](https://img.shields.io/badge/ghcr.io-chodeus%2Fchub-463fbc?style=flat-square&logo=docker&logoColor=white)](https://github.com/chodeus/chub/pkgs/container/chub) [![GitHub Issues](https://img.shields.io/github/issues/chodeus/chub?color=463fbc&style=flat-square)](https://github.com/chodeus/chub/issues) [![GitHub Stars](https://img.shields.io/github/stars/chodeus/chub?color=53e8f0&style=flat-square)](https://github.com/chodeus/chub/stargazers) diff --git a/backend/api/media.py b/backend/api/cache.py similarity index 75% rename from backend/api/media.py rename to backend/api/cache.py index 7d79e447..7cb72203 100755 --- a/backend/api/media.py +++ b/backend/api/cache.py @@ -1,16 +1,20 @@ -""" -Media cache management API endpoints for CHUB. - -Provides media cache operations including retrieval, deletion, -and cache refresh functionality for media, collections, and Plex data. -""" +"""Read/delete endpoints for the media, collection and Plex caches, plus the +background refresh trigger.""" from typing import Any, Optional from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse -from backend.api.utils import error, get_database, get_logger, ok +from backend.api.utils import ( + BODY_TOO_LARGE, + build_cache_refresh_payload, + error, + get_database, + get_logger, + ok, + read_request_json, +) from backend.util.database import ChubDB router = APIRouter( @@ -67,16 +71,7 @@ async def get_media_cache( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: - """ - Retrieve the media cache from the database. - - Returns cached media items including metadata, identifiers, and poster - information. Optional limit (max 500) and offset paginate the result; - without them the full cache is returned. - - Returns: - List of cached media items plus the total row count - """ + """Return media cache rows; without limit/offset the whole cache is returned.""" try: logger.debug("Serving GET /api/cache/media") @@ -91,7 +86,7 @@ async def get_media_cache( except Exception as e: logger.error(f"Error retrieving media cache: {e}") return error( - f"Error retrieving media cache: {str(e)}", + "Error retrieving media cache", code="MEDIA_CACHE_RETRIEVAL_ERROR", status_code=500, ) @@ -130,16 +125,7 @@ async def get_collection_cache( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: - """ - Retrieve the collection cache from the database. - - Returns cached collection items including metadata and identifiers. - Optional limit (max 500) and offset paginate the result; without them - the full cache is returned. - - Returns: - List of cached collection items plus the total row count - """ + """Return collection cache rows; without limit/offset the whole cache is returned.""" try: logger.debug("Serving GET /api/cache/collection") @@ -154,7 +140,7 @@ async def get_collection_cache( except Exception as e: logger.error(f"Error retrieving collection cache: {e}") return error( - f"Error retrieving collection cache: {str(e)}", + "Error retrieving collection cache", code="COLLECTION_CACHE_RETRIEVAL_ERROR", status_code=500, ) @@ -194,16 +180,7 @@ async def get_plex_cache( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: - """ - Retrieve the Plex media cache from the database. - - Returns cached Plex media items including Plex-specific identifiers and - library information. Optional limit (max 500) and offset paginate the - result; without them the full cache is returned. - - Returns: - List of cached Plex media items plus the total row count - """ + """Return Plex cache rows; without limit/offset the whole cache is returned.""" try: logger.debug("Serving GET /api/cache/plex") @@ -218,7 +195,7 @@ async def get_plex_cache( except Exception as e: logger.error(f"Error retrieving plex cache: {e}") return error( - f"Error retrieving plex cache: {str(e)}", + "Error retrieving plex cache", code="PLEX_CACHE_RETRIEVAL_ERROR", status_code=500, ) @@ -248,39 +225,25 @@ async def refresh_cache( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: - """ - Initiate background cache refresh for specified services. - - Creates a background job to refresh media cache data from - configured Radarr, Sonarr, and Plex instances with optional - library filtering and mapping updates. - - Request body should contain: - - arr_instances: List of Radarr/Sonarr instances to refresh - - plex_instances: List of Plex instances to refresh - - libraries: List of specific libraries to refresh - - update_mappings: Whether to update media mappings - - Returns: - Job ID for tracking the refresh operation - """ + """Enqueue a cache_refresh job; empty instance lists mean auto-discover all.""" try: - payload = await request.json() + payload = await read_request_json(request) + if payload is BODY_TOO_LARGE: + return error( + "Request body too large", + code="BODY_TOO_LARGE", + status_code=413, + ) + logger.debug(f"Serving POST /api/cache/refresh with payload: {payload}") - # Extract refresh configuration - arr_instances = payload.get("arr_instances", []) - plex_instances = payload.get("plex_instances", []) - libraries = payload.get("libraries", []) - update_mappings = payload.get("update_mappings", False) - - # Create a background job for cache refresh - job_payload = { - "arr_instances": arr_instances, - "plex_instances": plex_instances, - "libraries": libraries, - "update_mappings": update_mappings, - } + job_payload = build_cache_refresh_payload(payload) + if job_payload is None: + return error( + "Invalid request body", + code="INVALID_BODY", + status_code=400, + ) # Use existing job system result = db.worker.enqueue_job("jobs", job_payload, job_type="cache_refresh") @@ -302,7 +265,7 @@ async def refresh_cache( except Exception as e: logger.error(f"Error serving POST /api/cache/refresh: {e}") return error( - f"Error initiating cache refresh: {str(e)}", + "Error initiating cache refresh", code="CACHE_REFRESH_ERROR", status_code=500, ) @@ -331,18 +294,7 @@ async def refresh_cache( async def delete_media_cache_item( item_id: int, logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database) ) -> JSONResponse: - """ - Delete a media cache item by its ID. - - Permanently removes the specified media cache entry from the - database. This operation cannot be undone. - - Args: - item_id: The unique identifier of the media cache item to delete - - Returns: - Confirmation of deletion with the deleted item ID - """ + """Delete one media cache row; 404 when the id is unknown. Not undoable.""" try: logger.debug(f"Serving DELETE /api/cache/media/{item_id}") @@ -364,7 +316,7 @@ async def delete_media_cache_item( except Exception as e: logger.error(f"Error deleting media cache item {item_id}: {e}") return error( - f"Error deleting media cache item: {str(e)}", + "Error deleting media cache item", code="MEDIA_CACHE_DELETE_ERROR", status_code=500, ) @@ -393,18 +345,7 @@ async def delete_media_cache_item( async def delete_collection_cache_item( item_id: int, logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database) ) -> JSONResponse: - """ - Delete a collection cache item by its ID. - - Permanently removes the specified collection cache entry from the - database. This operation cannot be undone. - - Args: - item_id: The unique identifier of the collection cache item to delete - - Returns: - Confirmation of deletion with the deleted item ID - """ + """Delete one collection cache row; 404 when the id is unknown. Not undoable.""" try: logger.debug(f"Serving DELETE /api/cache/collection/{item_id}") @@ -426,7 +367,7 @@ async def delete_collection_cache_item( except Exception as e: logger.error(f"Error deleting collection cache item {item_id}: {e}") return error( - f"Error deleting collection cache item: {str(e)}", + "Error deleting collection cache item", code="COLLECTION_CACHE_DELETE_ERROR", status_code=500, ) diff --git a/backend/api/main.py b/backend/api/main.py index 5d6e5ed4..312f6dab 100755 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -1,8 +1,6 @@ -import os import threading import time from contextlib import asynccontextmanager -from pathlib import Path from typing import Any, AsyncGenerator from fastapi import APIRouter, FastAPI, HTTPException, Request @@ -19,12 +17,12 @@ from backend.api import ( auth as auth_router, border_replacerr as border_replacerr_router, + cache as cache_router, config as config_router, instances as instances_router, jobs as jobs_router, labelarr as labelarr_router, logs as logs_router, - media as media_router, media_api as media_api_router, modules as modules_router, nestarr as nestarr_router, @@ -45,6 +43,7 @@ load_config, ) from backend.util.database import ChubDB +from backend.util.helper import get_static_dir from backend.util.job_processor import process_job from backend.util.notification import install_error_notify_handler @@ -302,7 +301,7 @@ def shared_db_process_job(job, logger): if "startup_config" in locals() and startup_config is not None: from backend.util.maintenance import start_maintenance - app.state.maintenance_thread = start_maintenance(startup_config, logger) + app.state.maintenance_thread = start_maintenance(logger) except Exception as e: if log: log.error(f"Failed to start maintenance thread: {e}") @@ -415,9 +414,7 @@ def stop_worker_with_timeout( # Frontend static directory — configurable via STATIC_DIR env var. # Defaults to templates/ (local dev); Docker sets STATIC_DIR=/app/public. -STATIC_DIR = Path( - os.environ.get("STATIC_DIR", str(Path(__file__).parents[2] / "templates")) -) +STATIC_DIR = get_static_dir() app.mount( "/assets", @@ -514,7 +511,7 @@ async def handle_validation_exception( app.include_router(jobs_router.router) app.include_router(modules_router.router) app.include_router(logs_router.router) -app.include_router(media_router.router) +app.include_router(cache_router.router) app.include_router(media_api_router.router) app.include_router(posters_router.router) app.include_router(webhooks_router.router) diff --git a/backend/api/media_api.py b/backend/api/media_api.py index 5a90ed96..9b11df8d 100644 --- a/backend/api/media_api.py +++ b/backend/api/media_api.py @@ -16,10 +16,18 @@ from starlette.concurrency import run_in_threadpool from pydantic import BaseModel -from backend.api.utils import error, get_database, get_logger, ok +from backend.api.utils import ( + BODY_TOO_LARGE, + build_cache_refresh_payload, + error, + get_database, + get_logger, + ok, + read_request_json, +) from backend.util.arr import create_arr_client -from backend.util.config import load_config -from backend.util.database import ChubDB +from backend.util.config import ConfigError, load_config +from backend.util.database import ChubDB, escape_like from backend.util.ssrf_guard import is_safe_url, safe_external_get router = APIRouter( @@ -464,35 +472,29 @@ async def get_duplicates( # quality group (configured in general.duplicate_exclude_groups). # e.g. [["radarr", "radarr4k"]] means radarr+radarr4k pairs # are intentional quality copies, not real duplicates. - try: - from backend.util.config import load_config - - raw_groups = load_config().general.duplicate_exclude_groups - if raw_groups: - # Normalise: accept both [["a","b"]] and [{"instances":["a","b"]}] - exclude_sets = [] - for g in raw_groups: - if isinstance(g, dict): - exclude_sets.append(set(g.get("instances", []))) - else: - exclude_sets.append(set(g)) - exclude_sets = [s for s in exclude_sets if len(s) >= 2] - - if exclude_sets: - - def _not_excluded(dup): - raw = dup.get("instances", "") - instances = {s.strip() for s in raw.split(",") if s.strip()} - return not any( - instances.issubset(group) for group in exclude_sets - ) - - duplicates = [d for d in duplicates if _not_excluded(d)] - folder_collisions = [ - d for d in folder_collisions if _not_excluded(d) - ] - except Exception: - pass # Config not loaded — skip filtering + raw_groups = load_config().general.duplicate_exclude_groups + if raw_groups: + # Normalise: accept both [["a","b"]] and [{"instances":["a","b"]}]. + # The field is List[Any] — skip malformed entries, don't raise. + exclude_sets = [] + for g in raw_groups: + members = g.get("instances") if isinstance(g, dict) else g + if isinstance(members, (list, tuple, set)) and all( + isinstance(m, str) and m.strip() for m in members + ): + exclude_sets.append({m.strip() for m in members}) + exclude_sets = [s for s in exclude_sets if len(s) >= 2] + + if exclude_sets: + + def _not_excluded(dup): + """True when this group isn't wholly inside one exclude set.""" + raw = dup.get("instances", "") + instances = {s.strip() for s in raw.split(",") if s.strip()} + return not any(instances.issubset(group) for group in exclude_sets) + + duplicates = [d for d in duplicates if _not_excluded(d)] + folder_collisions = [d for d in folder_collisions if _not_excluded(d)] return ok( f"Found {len(duplicates)} duplicate groups, " @@ -505,6 +507,10 @@ def _not_excluded(dup): }, ) + except ConfigError: + # Deny: unfiltered results report intentional quality pairs as + # duplicates next to a bulk-delete button. main.py's handler answers. + raise except Exception as e: logger.error(f"Error finding duplicates: {e}") return error( @@ -519,7 +525,7 @@ def _not_excluded(dup): summary="Refresh media cache", description="Trigger a background cache refresh job. Accepts both frontend " "format (path, deep) and backend format (arr_instances, plex_instances, " - "libraries, update_mappings).", + "libraries).", responses={ 200: { "description": "Cache refresh job enqueued successfully", @@ -540,35 +546,25 @@ async def refresh_media( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: - """ - Trigger a background cache refresh job. - - Accepts two payload formats: - - Frontend format: ``path`` and ``deep`` keys trigger a full - refresh of all configured instances. - - Backend format: ``arr_instances``, ``plex_instances``, - ``libraries``, and ``update_mappings`` for targeted refresh. - - Returns: - Job ID for tracking the refresh operation - """ + """Enqueue a cache_refresh job; a {path, deep} body has no targeting, so refresh all.""" try: - try: - payload = ( - await request.json() - if request.headers.get("content-type") == "application/json" - else {} + payload = await read_request_json(request) + if payload is BODY_TOO_LARGE: + return error( + "Request body too large", + code="BODY_TOO_LARGE", + status_code=413, ) - except Exception: - payload = {} + logger.debug(f"Serving POST /api/media/refresh with payload: {payload}") - job_payload = { - "arr_instances": payload.get("arr_instances", []), - "plex_instances": payload.get("plex_instances", []), - "libraries": payload.get("libraries", []), - "update_mappings": payload.get("update_mappings", True), - } + job_payload = build_cache_refresh_payload(payload) + if job_payload is None: + return error( + "Invalid request body", + code="INVALID_BODY", + status_code=400, + ) result = db.worker.enqueue_job("jobs", job_payload, job_type="cache_refresh") @@ -2037,6 +2033,7 @@ async def generate_collection_from_tag( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: + """Build a poster_collection from every media row carrying `tag`.""" try: payload = await request.json() tag = (payload.get("tag") or "").strip() @@ -2050,8 +2047,8 @@ async def generate_collection_from_tag( media_rows = ( db.worker.execute_query( "SELECT id, tmdb_id, tvdb_id, imdb_id, season_number, title, year " - "FROM media_cache WHERE tags LIKE ?", - (f"%{tag}%",), + "FROM media_cache WHERE tags LIKE ? ESCAPE '\\'", + (f"%{escape_like(tag)}%",), fetch_all=True, ) or [] diff --git a/backend/api/modules.py b/backend/api/modules.py index ce860f66..168d3525 100755 --- a/backend/api/modules.py +++ b/backend/api/modules.py @@ -383,7 +383,7 @@ async def get_all_run_states( from backend.modules import MODULES for mod_name in MODULES: - job_status = orchestrator.get_module_status(mod_name) + job_status = orchestrator.get_module_status(mod_name, db=db) if job_status.get("running"): if mod_name in states_by_name: states_by_name[mod_name]["status"] = "running" @@ -428,6 +428,7 @@ async def module_events(request: Request): from backend.modules import MODULES async def event_generator(): + """Yield an SSE frame whenever any module's run state changes.""" previous_states = {} try: while True: @@ -439,6 +440,7 @@ async def event_generator(): # the event loop — otherwise each tick blocks the loop (and all # other requests/streams) for the duration of the queries. def _poll_states(): + """Read every module's current run state from the db.""" states_by_name = {} with ChubDB(request.app.state.logger, quiet=True) as db: run_states = db.run_state.get_all() @@ -454,7 +456,9 @@ def _poll_states(): orchestrator = request.app.state.module_orchestrator if orchestrator: for mod_name in MODULES: - job_status = orchestrator.get_module_status(mod_name) + job_status = orchestrator.get_module_status( + mod_name, db=db + ) if job_status.get("running"): job_id = job_status.get("job_id") job = ( diff --git a/backend/api/posters.py b/backend/api/posters.py index 01b184d7..f87f8462 100644 --- a/backend/api/posters.py +++ b/backend/api/posters.py @@ -18,7 +18,8 @@ from backend.api.utils import error, get_database, get_logger, get_module_logger, ok from backend.modules.sync_gdrive import SyncGDrive from backend.modules.unmatched_assets import UnmatchedAssets -from backend.util.database import ChubDB +from backend.util.database import ChubDB, escape_like +from backend.util.helper import get_static_dir router = APIRouter( prefix="/api/posters", @@ -1175,7 +1176,7 @@ def _optimize_posters_sync( @router.get( "/list", summary="List available poster files", - description="List available poster files from the templates/posters directory.", + description="List available poster files from the static posters directory.", responses={ 200: { "description": "Poster files listed successfully", @@ -1193,7 +1194,7 @@ def _optimize_posters_sync( ) async def list_poster_files(logger: Any = Depends(get_logger)) -> JSONResponse: """ - List available poster files from templates/posters directory. + List available poster files from the static posters directory. Returns just the filenames for dynamic discovery by the frontend. Used for default poster selection and asset management. @@ -1204,7 +1205,9 @@ async def list_poster_files(logger: Any = Depends(get_logger)) -> JSONResponse: try: logger.debug("Serving GET /api/posters/list") - posters_dir = Path(__file__).parents[2] / "templates" / "posters" + # Same directory main.py mounts at /posters — resolved via STATIC_DIR, + # which Docker points at /app/public (templates/ isn't in the image). + posters_dir = get_static_dir() / "posters" allowed_extensions = {".jpg", ".jpeg", ".png", ".webp"} if not posters_dir.exists(): @@ -2810,13 +2813,16 @@ def upload_collection_posters( summary="Backfill poster width/height", description="Walk poster_cache rows missing width/height and populate " "them by opening the file with PIL. Processes up to `limit` rows per call " - "so it can be run incrementally without blocking the event loop.", + "so it can be run incrementally.", ) -async def backfill_poster_dimensions( +def backfill_poster_dimensions( limit: int = 200, logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: + """Populate missing poster width/height for up to `limit` rows.""" + # Sync on purpose: the PIL/db loop blocks, so Starlette runs the whole + # endpoint in a threadpool instead of stalling the event loop. try: limit = max(1, min(limit, 2000)) rows = ( @@ -3624,7 +3630,7 @@ async def get_poster( 404: {"description": "Poster or file not found"}, }, ) -async def get_poster_thumbnail( +def get_poster_thumbnail( poster_id: int, width: int = Query(200, ge=50, le=500, description="Thumbnail width in pixels"), logger: Any = Depends(get_logger), @@ -3636,6 +3642,9 @@ async def get_poster_thumbnail( Generates a downsized JPEG thumbnail on first request and caches it in a .thumbnails subdirectory. Subsequent requests serve from cache. + Sync on purpose: the LANCZOS resize + JPEG encode are blocking, so + Starlette runs this in a threadpool instead of stalling the event loop. + Args: poster_id: The unique identifier of the poster width: Target thumbnail width (height scales proportionally) @@ -3710,7 +3719,7 @@ async def get_poster_thumbnail( 404: {"description": "Poster or file not found"}, }, ) -async def download_poster( +def download_poster( poster_id: int, size: Optional[int] = Query( None, ge=100, le=4000, description="Max dimension in pixels" @@ -3729,6 +3738,9 @@ async def download_poster( When size, format, or quality are specified, processes the image before serving. + Sync on purpose: the optional resize/encode is blocking, so Starlette + runs this in a threadpool instead of stalling the event loop. + Args: poster_id: The unique identifier of the poster to download size: Optional max dimension for resize @@ -3889,13 +3901,8 @@ async def delete_poster( # Find media items that were matched to this poster by original_file if full_path: # Escape LIKE metacharacters so a basename with %/_ can't - # unmatch the wrong media rows (mirror poster_cache). - basename_like = ( - os.path.basename(full_path) - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - ) + # unmatch the wrong media rows. + basename_like = escape_like(os.path.basename(full_path)) media_items = ( db.media.execute_query( "SELECT id, title, instance_name, asset_type, year, season_number FROM media_cache WHERE original_file LIKE ? ESCAPE '\\'", diff --git a/backend/api/system.py b/backend/api/system.py index 2c49a84e..885c082c 100755 --- a/backend/api/system.py +++ b/backend/api/system.py @@ -22,6 +22,7 @@ from starlette.concurrency import run_in_threadpool from backend.api.utils import error, get_database, get_logger, ok +from backend.util.backup import get_backup_dir, save_backup from backend.util.config import ( ConfigError, ChubConfig, @@ -552,107 +553,6 @@ async def test( # ==== Backup / Restore ==== -def _get_db_path() -> str: - """Get the SQLite database path.""" - config_dir = os.environ.get("CONFIG_DIR") or str( - Path(__file__).parents[2] / "config" - ) - return os.path.join(config_dir, "chub.db") - - -def _default_backup_dir() -> Path: - config_dir = os.environ.get("CONFIG_DIR") or str( - Path(__file__).parents[2] / "config" - ) - return Path(config_dir) / "backups" - - -def _get_backup_dir(logger: Any = None) -> Path: - """Resolve general.backup_dir, falling back to CONFIG_DIR/backups.""" - default = _default_backup_dir() - - configured = "" - config = None - try: - config = load_config() - configured = (getattr(config.general, "backup_dir", "") or "").strip() - except Exception as exc: - if logger: - logger.error(f"Could not read backup_dir from config: {exc}") - - if configured and config is not None: - if not is_path_allowed(configured, config): - if logger: - logger.error( - f"backup_dir '{configured}' is outside the allowed roots; " - f"backing up to {default} instead" - ) - else: - try: - target = Path(configured).expanduser().resolve() - target.mkdir(parents=True, exist_ok=True) - # Re-confine the RESOLVED target: is_path_allowed() authorised a - # path that a swapped symlink could since have re-pointed. - if is_path_allowed(str(target), config): - return target - if logger: - logger.error( - f"backup_dir '{configured}' resolved outside the allowed " - f"roots ({target}); backing up to {default} instead" - ) - except OSError as exc: - if logger: - logger.error( - f"backup_dir '{configured}' is not usable ({exc}); " - f"backing up to {default} instead" - ) - - default.mkdir(parents=True, exist_ok=True) - return default - - -def build_backup_bytes() -> bytes: - """Build a backup zip (config.yml + chub.db.sql dump) and return its bytes. - - Uses SQLite's backup API to safely snapshot the database while it may be in - use. Shared by the download endpoint and the auto-backup maintenance thread. - """ - config_path = get_config_path() - db_path = _get_db_path() - - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - if os.path.exists(config_path): - zf.write(config_path, "config.yml") - - if os.path.exists(db_path): - db_buf = io.BytesIO() - src = sqlite3.connect(db_path) - try: - mem = sqlite3.connect(":memory:") - src.backup(mem) - for line in mem.iterdump(): - db_buf.write(f"{line}\n".encode("utf-8")) - mem.close() - finally: - src.close() - db_buf.seek(0) - zf.writestr("chub.db.sql", db_buf.read()) - - return buf.getvalue() - - -def save_backup(logger: Any = None) -> Path: - """Write a timestamped backup into the backups directory; return its path.""" - data = build_backup_bytes() - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - backup_path = _get_backup_dir(logger) / f"chub-backup-{timestamp}.zip" - backup_path.write_bytes(data) - if logger: - logger.info(f"Backup created: {backup_path.name}") - return backup_path - - @router.post( "/backup", summary="Create backup", @@ -680,6 +580,8 @@ def create_backup( headers={"Content-Disposition": f"attachment; filename={backup_path.name}"}, ) + except ConfigError: + raise # deny — main.py's handler returns CONFIG_INVALID except Exception as e: logger.error(f"Backup creation failed: {e}") return error( @@ -697,7 +599,7 @@ def create_backup( async def list_backups(logger: Any = Depends(get_logger)) -> JSONResponse: """List backup files in the backups directory.""" try: - backup_dir = _get_backup_dir() + backup_dir = get_backup_dir() backups = [] for f in sorted(backup_dir.glob("chub-backup-*.zip"), reverse=True): stat = f.stat() @@ -709,6 +611,8 @@ async def list_backups(logger: Any = Depends(get_logger)) -> JSONResponse: } ) return ok(f"Found {len(backups)} backups", {"backups": backups}) + except ConfigError: + raise # deny — main.py's handler returns CONFIG_INVALID except Exception as e: logger.error(f"Error listing backups: {e}") return error("Error listing backups", code="BACKUP_LIST_ERROR", status_code=500) @@ -757,6 +661,7 @@ async def restore_backup( # The zip parse + yaml validation + file writes are blocking; run them # off the event loop so a large upload doesn't stall the whole server. def _do_restore() -> JSONResponse: + """Validate the zip and write config.yml back; blocking.""" buf = io.BytesIO(content) if not zipfile.is_zipfile(buf): @@ -822,7 +727,7 @@ def _do_restore() -> JSONResponse: # If DB dump is included, save it for reference if "chub.db.sql" in names: - backup_dir = _get_backup_dir() + backup_dir = get_backup_dir() sql_path = backup_dir / "restored-db.sql" sql_path.write_bytes(zf.read("chub.db.sql")) restored_items.append( @@ -837,6 +742,8 @@ def _do_restore() -> JSONResponse: return await run_in_threadpool(_do_restore) + except ConfigError: + raise # deny — main.py's handler returns CONFIG_INVALID except Exception as e: logger.error(f"Restore failed: {e}") return error("Restore failed", code="RESTORE_ERROR", status_code=500) diff --git a/backend/api/utils.py b/backend/api/utils.py index 2a63fff6..b9eafbd6 100755 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -1,5 +1,6 @@ # api/utils.py +import json from typing import Any, Optional from fastapi import Request @@ -78,3 +79,62 @@ def error( if data is not None: payload["data"] = data return JSONResponse(status_code=status_code, content=payload) + + +MAX_REQUEST_BODY_BYTES = 1024 * 1024 + +# Sentinel outcome, distinct from None (unparseable) and {} (no body). +BODY_TOO_LARGE = object() + + +async def read_request_json( + request: Request, max_bytes: int = MAX_REQUEST_BODY_BYTES +) -> Any: + """Parsed JSON body; {} when empty, None when unparseable, BODY_TOO_LARGE past the cap.""" + declared = request.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > max_bytes: + return BODY_TOO_LARGE + + # Chunked bodies carry no Content-Length, so the cap is re-checked per chunk + # rather than trusting the header — nothing is buffered past the limit. + chunks = [] + size = 0 + async for chunk in request.stream(): + size += len(chunk) + if size > max_bytes: + return BODY_TOO_LARGE + chunks.append(chunk) + + body = b"".join(chunks) + # Whitespace-only counts as no body — a stray newline shouldn't be a 400. + if not body.strip(): + return {} + try: + return json.loads(body) + except ValueError: + return None + + +CACHE_REFRESH_LIST_FIELDS = ("arr_instances", "plex_instances", "libraries") + + +def build_cache_refresh_payload(payload: Any) -> Optional[dict]: + """Validated cache_refresh job payload, or None if the body is the wrong shape.""" + # Unknown keys are dropped by construction, so the frontend's {path, deep} + # body still yields three empty lists — which the worker reads as "refresh all". + if not isinstance(payload, dict): + return None + job_payload = {} + for field in CACHE_REFRESH_LIST_FIELDS: + value = payload.get(field, []) + if not isinstance(value, list): + return None + # Names are stripped here; anything beyond "non-empty string" is the + # worker's and config layer's call, not this validator's. + names = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + return None + names.append(item.strip()) + job_payload[field] = names + return job_payload diff --git a/backend/util/backup.py b/backend/util/backup.py new file mode 100644 index 00000000..a0a33dfd --- /dev/null +++ b/backend/util/backup.py @@ -0,0 +1,128 @@ +"""Config + database backup archives. + +Owns where backups go and how they're built, so both the /api/backup routes and +the maintenance thread can use them without either importing the other. +""" + +import io +import os +import sqlite3 +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Any + +from backend.util.config import get_config_path, load_config +from backend.util.path_safety import is_path_allowed + +# Suffixes tried when two backups land in the same second (-1 .. -N). +_SAVE_COLLISION_RETRIES = 100 + + +def _get_db_path() -> str: + """Get the SQLite database path.""" + config_dir = os.environ.get("CONFIG_DIR") or str( + Path(__file__).parents[2] / "config" + ) + return os.path.join(config_dir, "chub.db") + + +def _default_backup_dir() -> Path: + """Fallback backup location: CONFIG_DIR/backups.""" + config_dir = os.environ.get("CONFIG_DIR") or str( + Path(__file__).parents[2] / "config" + ) + return Path(config_dir) / "backups" + + +def get_backup_dir(logger: Any = None) -> Path: + """Resolve general.backup_dir (default CONFIG_DIR/backups); propagates + ConfigError — callers must deny rather than guess a location.""" + default = _default_backup_dir() + + config = load_config() + configured = (getattr(config.general, "backup_dir", "") or "").strip() + + if configured: + if not is_path_allowed(configured, config): + if logger: + logger.error( + f"backup_dir '{configured}' is outside the allowed roots; " + f"backing up to {default} instead" + ) + else: + try: + target = Path(configured).expanduser().resolve() + target.mkdir(parents=True, exist_ok=True) + # Re-confine the RESOLVED target: is_path_allowed() authorised a + # path that a swapped symlink could since have re-pointed. + if is_path_allowed(str(target), config): + return target + if logger: + logger.error( + f"backup_dir '{configured}' resolved outside the allowed " + f"roots ({target}); backing up to {default} instead" + ) + except OSError as exc: + if logger: + logger.error( + f"backup_dir '{configured}' is not usable ({exc}); " + f"backing up to {default} instead" + ) + + default.mkdir(parents=True, exist_ok=True) + return default + + +def build_backup_bytes() -> bytes: + """Backup zip (config.yml + chub.db.sql dump) bytes; SQLite backup API snapshots + the db safely while in use. Shared by the download endpoint + maintenance thread.""" + config_path = get_config_path() + db_path = _get_db_path() + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + if os.path.exists(config_path): + zf.write(config_path, "config.yml") + + if os.path.exists(db_path): + db_buf = io.BytesIO() + src = sqlite3.connect(db_path) + try: + mem = sqlite3.connect(":memory:") + src.backup(mem) + for line in mem.iterdump(): + db_buf.write(f"{line}\n".encode("utf-8")) + mem.close() + finally: + src.close() + db_buf.seek(0) + zf.writestr("chub.db.sql", db_buf.read()) + + return buf.getvalue() + + +def save_backup(logger: Any = None) -> Path: + """Write a timestamped backup into the backups directory; return its path.""" + # Resolve the destination first — a config error must not cost a full dump. + directory = get_backup_dir(logger) + data = build_backup_bytes() + stem = f"chub-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + + for attempt in range(_SAVE_COLLISION_RETRIES): + suffix = "" if attempt == 0 else f"-{attempt}" + backup_path = directory / f"{stem}{suffix}.zip" + try: + # "xb", never write_bytes: the name is second-precision, so a + # concurrent API + maintenance backup would truncate each other. + with open(backup_path, "xb") as fh: + fh.write(data) + except FileExistsError: + continue + break + else: + raise OSError(f"No free backup filename for {stem} in {directory}") + + if logger: + logger.info(f"Backup created: {backup_path.name}") + return backup_path diff --git a/backend/util/config.py b/backend/util/config.py index d681bc3d..c0730eef 100755 --- a/backend/util/config.py +++ b/backend/util/config.py @@ -3,6 +3,7 @@ import pathlib import sys import tempfile +import threading from typing import Any, Dict, List, Literal, Optional, Union import yaml @@ -1112,6 +1113,49 @@ def get_config_path() -> str: return config_file_path +# Parsed configs keyed by path -> (file version, config). AuthMiddleware calls +# load_config() per request; re-reading + revalidating 35 models costs ~3ms. +_config_cache: Dict[str, tuple] = {} +_config_cache_lock = threading.Lock() +_CONFIG_CACHE_MAX = 8 + + +def _config_file_version(path: str) -> Optional[tuple]: + """Stat signature identifying a config file's contents; None if unreadable.""" + try: + st = os.stat(path) + except OSError: + return None + return (st.st_mtime_ns, st.st_size, st.st_ino) + + +def _cached_config(path: str, version: tuple) -> Optional[ChubConfig]: + """Return a private copy of the cached config for this file version, if any.""" + with _config_cache_lock: + entry = _config_cache.get(path) + if entry is None or entry[0] != version: + return None + # A copy, not the cached object: callers (auth/setup/notifications) mutate + # what load_config returns before saving, and must not poison the cache. + return entry[1].model_copy(deep=True) + + +def _cache_config(path: str, version: tuple, config: ChubConfig) -> None: + """Cache `config` under the file version it was parsed from.""" + if _config_file_version(path) != version: + return # rewritten while we read it (e.g. legacy migration) — don't cache + with _config_cache_lock: + if len(_config_cache) >= _CONFIG_CACHE_MAX: + _config_cache.clear() + _config_cache[path] = (version, config.model_copy(deep=True)) + + +def clear_config_cache() -> None: + """Drop every cached config (call after config.yml changes out of band).""" + with _config_cache_lock: + _config_cache.clear() + + def format_validation_errors(validation_error: ValidationError) -> List[str]: """Return one humanized "loc: msg" line per Pydantic field error. @@ -1194,14 +1238,22 @@ def load_config(path: Optional[str] = None) -> ChubConfig: Raises ConfigError subclasses on failure so callers can handle errors appropriately (API returns HTTP errors, CLI prints and exits). + + Repeat loads are served from a cache keyed on the file's mtime/size, so an + edit (or save_config) is picked up but an unchanged file isn't re-parsed. """ from backend.util.config_migrator import is_legacy_config config_path = path or get_config_path() - if not os.path.exists(config_path): + version = _config_file_version(config_path) + if version is None: return ChubConfig() + cached = _cached_config(config_path, version) + if cached is not None: + return cached + try: with open(config_path, "r") as f: raw = yaml.safe_load(f) @@ -1219,7 +1271,7 @@ def load_config(path: Optional[str] = None) -> ChubConfig: _backfill_setup_completed(raw) try: - return ChubConfig.model_validate(raw) + config = ChubConfig.model_validate(raw) except ValidationError as e: raise ConfigValidationError( f"Configuration validation failed in {config_path}", @@ -1228,6 +1280,9 @@ def load_config(path: Optional[str] = None) -> ChubConfig: except Exception as e: raise ConfigError(f"Unexpected configuration error: {e}") from e + _cache_config(config_path, version, config) + return config + def _auto_migrate_and_persist(raw: dict, config_path: str) -> dict: """Back up the original, migrate, write the migrated YAML back, log notes. @@ -1338,8 +1393,14 @@ def module_is_disabled(module_name: str, config: Optional[ChubConfig] = None) -> getattr(getattr(cfg, "general", None), "disabled_modules", None) or [] ) return module_name in disabled - except Exception: - return False + except Exception as exc: + # Fail CLOSED: these modules mutate the filesystem, so a config we + # can't read must skip the run, not green-light it. + _config_log.warning( + f"Could not determine disabled state for '{module_name}' " + f"({exc}); treating it as disabled" + ) + return True def save_config(config: ChubConfig, path: Optional[str] = None) -> None: @@ -1353,6 +1414,9 @@ def save_config(config: ChubConfig, path: Optional[str] = None) -> None: with os.fdopen(fd, "w") as f: yaml.safe_dump(config.model_dump(mode="python"), f, sort_keys=False) os.replace(tmp_path, config_path) + # The stat key catches this too; clearing makes it immediate even on + # a filesystem with coarse mtime granularity. + clear_config_cache() except BaseException: # Clean up temp file on any failure try: diff --git a/backend/util/database/__init__.py b/backend/util/database/__init__.py index da2d3c99..4e5c82b1 100644 --- a/backend/util/database/__init__.py +++ b/backend/util/database/__init__.py @@ -9,7 +9,7 @@ from .border_state import BorderState from .collection_cache import CollectionCache -from .db_base import DatabaseBase +from .db_base import DatabaseBase, escape_like from .holiday import HolidayStatus from .media_asset_matches import MediaAssetMatches from .media_cache import MediaCache @@ -414,5 +414,6 @@ def my_db_operation(db): "MediaCache", "MediaAssetMatches", "WebhookCache", + "escape_like", "with_database", ] diff --git a/backend/util/database/db_base.py b/backend/util/database/db_base.py index 58270793..3bf3062a 100755 --- a/backend/util/database/db_base.py +++ b/backend/util/database/db_base.py @@ -9,6 +9,27 @@ from backend.util.logger import Logger +# Files already schema-synced (by _schema_file_state) — every construction calls +# init_schema, else 24-table re-syncs N×. RLock: SchemaManager imports under it. +_SCHEMA_SYNCED: set = set() +_SCHEMA_SYNCED_LOCK = threading.RLock() + + +def _schema_file_state(db_path: str) -> Tuple[Optional[Tuple], int]: + """(identity, size) of the db FILE; (None, 0) when it doesn't exist yet.""" + try: + st = os.stat(db_path) + except OSError: + return None, 0 + return (os.path.abspath(db_path), st.st_dev, st.st_ino), st.st_size + + +def escape_like(value: str) -> str: + """Escape LIKE metacharacters so `value` matches literally.""" + # The SQL MUST carry ESCAPE '\\' too, or this silently does nothing. + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + class DatabaseBase: """Base class for all database operations with proper resource management.""" @@ -163,12 +184,27 @@ def execute_transaction( conn.commit() @staticmethod - def init_schema(db_path: str) -> None: - """Initialize database schema - called by ChubDB.""" + def init_schema(db_path: str, force: bool = False) -> None: + """Initialize database schema — skips files already synced this process.""" from .schema import SchemaManager - conn = sqlite3.connect(db_path) - try: - SchemaManager.init_database(conn) - finally: - conn.close() + # Held across the DDL, not just the lookup: two threads that both miss + # the set would otherwise run SchemaManager concurrently and collide. + with _SCHEMA_SYNCED_LOCK: + # size: a deleted+recreated db can REUSE the inode, so identity + # alone would skip a 0-byte file that has no schema yet. + identity, size = _schema_file_state(db_path) + if not force and size > 0 and identity in _SCHEMA_SYNCED: + return + + conn = sqlite3.connect(db_path, timeout=30) + try: + conn.execute("PRAGMA busy_timeout=30000") + SchemaManager.init_database(conn) + finally: + conn.close() + + # Re-stat: the file may not have existed before connect() created it. + identity, _ = _schema_file_state(db_path) + if identity is not None: + _SCHEMA_SYNCED.add(identity) diff --git a/backend/util/database/media_cache.py b/backend/util/database/media_cache.py index fc16ba88..f94b0656 100755 --- a/backend/util/database/media_cache.py +++ b/backend/util/database/media_cache.py @@ -4,7 +4,7 @@ from backend.util.helper import parse_search_id from backend.util.normalization import normalize_titles -from .db_base import DatabaseBase +from .db_base import DatabaseBase, escape_like # Library-health SQL fragments, shared across the stats queries so by_type, # totals and by_instance can't drift. Everything is counted in CONTENT UNITS — @@ -877,17 +877,9 @@ def search( # whose normalized form has the punctuation stripped. Raw # title LIKE is the case-insensitive fallback for exact # stored substrings. Same fix as poster_cache.search. - # Escape LIKE metacharacters so a query with %/_ matches literally - # (mirrors poster_cache.delete_by_path_prefix). - norm_esc = ( - normalize_titles(query) - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - ) - raw_esc = ( - query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - ) + # Escape LIKE metacharacters so a query with %/_ matches literally. + norm_esc = escape_like(normalize_titles(query)) + raw_esc = escape_like(query) sub = [ "normalized_title LIKE ? ESCAPE '\\'", "title LIKE ? ESCAPE '\\'", @@ -915,12 +907,7 @@ def search( if genres: genre_conditions = ["genre LIKE ? ESCAPE '\\'" for _ in genres] conditions.append(f"({' OR '.join(genre_conditions)})") - params.extend( - "%" - + g.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - + "%" - for g in genres - ) + params.extend(f"%{escape_like(g)}%" for g in genres) if year_min is not None: conditions.append("CAST(year AS INTEGER) >= ?") diff --git a/backend/util/database/poster_cache.py b/backend/util/database/poster_cache.py index 6300db68..529222cd 100755 --- a/backend/util/database/poster_cache.py +++ b/backend/util/database/poster_cache.py @@ -4,7 +4,7 @@ from backend.util.helper import parse_search_id from backend.util.normalization import normalize_titles -from .db_base import DatabaseBase +from .db_base import DatabaseBase, escape_like # Additional-artwork image_type values (everything that isn't a poster and @@ -79,13 +79,10 @@ def _title_search_clause(query: str) -> tuple: poster_cache.search / media_cache.search). """ - def esc(s: str) -> str: - return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - if "*" in query: parts = query.split("*") - norm_pat = "%".join(esc(normalize_titles(p)) for p in parts) - raw_pat = "%".join(esc(p) for p in parts) + norm_pat = "%".join(escape_like(normalize_titles(p)) for p in parts) + raw_pat = "%".join(escape_like(p) for p in parts) sub = [ "normalized_title LIKE ? ESCAPE '\\'", "title LIKE ? ESCAPE '\\'", @@ -97,8 +94,8 @@ def esc(s: str) -> str: "title LIKE ? ESCAPE '\\'", ] sub_params = [ - f"%{esc(normalize_titles(query))}%", - f"%{esc(query)}%", + f"%{escape_like(normalize_titles(query))}%", + f"%{escape_like(query)}%", ] tmdb, tvdb, imdb = parse_search_id(query) @@ -261,8 +258,8 @@ def has_rows_under_prefix(self, path_prefix: str) -> bool: """ prefix = path_prefix.rstrip("/") + "/" row = self.execute_query( - "SELECT 1 FROM poster_cache WHERE file LIKE ? LIMIT 1", - (prefix + "%",), + "SELECT 1 FROM poster_cache WHERE file LIKE ? ESCAPE '\\' LIMIT 1", + (escape_like(prefix) + "%",), fetch_one=True, ) return row is not None @@ -414,7 +411,7 @@ def delete_by_path_prefix(self, path_prefix: str) -> int: prefix = path_prefix.rstrip("/") + "/" # Escape LIKE metacharacters so a folder named e.g. `My_Movies` can't # match siblings via the `_`/`%` wildcards. - like = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like = escape_like(prefix) return self.execute_query( "DELETE FROM poster_cache WHERE file LIKE ? ESCAPE '\\'", (like + "%",) ) @@ -427,7 +424,7 @@ def delete_asset_rows_by_path_prefix(self, path_prefix: str) -> int: shared poster_cache. """ prefix = path_prefix.rstrip("/") + "/" - like = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like = escape_like(prefix) return self.execute_query( "DELETE FROM poster_cache WHERE file LIKE ? ESCAPE '\\' " "AND image_type != 'poster'", @@ -467,8 +464,14 @@ def search( # column so hyphenated / special-char searches ("x-men") still # find rows where the stored value collapsed to "xmen". Fall back # to a raw `title LIKE` so exact stored substrings still hit too. - sub = ["normalized_title LIKE ?", "title LIKE ?"] - sub_params: list = [f"%{normalize_titles(query)}%", f"%{query}%"] + sub = [ + "normalized_title LIKE ? ESCAPE '\\'", + "title LIKE ? ESCAPE '\\'", + ] + sub_params: list = [ + f"%{escape_like(normalize_titles(query))}%", + f"%{escape_like(query)}%", + ] # Also match an id pasted from a filename tag ({tmdb-…}/{tvdb-…}/ # {imdb-tt…}) or a bare IMDb id, so users can search by id. tmdb, tvdb, imdb = parse_search_id(query) diff --git a/backend/util/helper.py b/backend/util/helper.py index 02fe75e0..ae13b3b3 100755 --- a/backend/util/helper.py +++ b/backend/util/helper.py @@ -345,6 +345,14 @@ def get_config_dir() -> str: return str(config_dir) +def get_static_dir() -> Path: + """Get the frontend static directory (STATIC_DIR env, else templates/).""" + static_dir = os.environ.get("STATIC_DIR") + if static_dir: + return Path(static_dir) + return Path(__file__).resolve().parents[2] / "templates" + + def extract_year(text: str) -> Optional[int]: """Extract first 4-digit year from text, returns None if not found.""" try: diff --git a/backend/util/job_processor.py b/backend/util/job_processor.py index ec8fa276..34bda4dc 100755 --- a/backend/util/job_processor.py +++ b/backend/util/job_processor.py @@ -1214,10 +1214,10 @@ def _process_cache_refresh_job( arr_instances = payload.get("arr_instances", []) plex_instances = payload.get("plex_instances", []) libraries = payload.get("libraries", []) - update_mappings = payload.get("update_mappings", False) + # sync_all_databases() always refreshes plex mappings — no opt-out. log.info( - f"[JOB:{job_id}] Refresh config - ARR: {len(arr_instances)}, Plex: {len(plex_instances)}, Libraries: {len(libraries)}, Mappings: {update_mappings}" + f"[JOB:{job_id}] Refresh config - ARR: {len(arr_instances)}, Plex: {len(plex_instances)}, Libraries: {len(libraries)}" ) # Construct instance_map from payload data for Connector diff --git a/backend/util/maintenance.py b/backend/util/maintenance.py index d1a50f79..ee68cb7a 100644 --- a/backend/util/maintenance.py +++ b/backend/util/maintenance.py @@ -9,12 +9,17 @@ running. All work is wrapped so a transient failure never kills the thread. """ +import fnmatch import os import stat import threading import time from pathlib import Path +from backend.util.backup import get_backup_dir, save_backup +from backend.util.config import load_config +from backend.util.path_safety import is_path_allowed + def _log_base() -> Path: """Resolve the logs base directory the same way the logger does.""" @@ -52,42 +57,69 @@ def prune_old_logs(retention_days: int, logger=None) -> int: return removed -def prune_old_backups(backup_dir: Path, keep: int, logger=None) -> int: - """Keep only the newest `keep` chub-backup archives. Returns the count removed.""" +def prune_old_backups(backup_dir: Path, keep: int, config, logger=None) -> int: + """Keep only the newest `keep` chub-backup archives; returns the count removed. + `config` is REQUIRED — it re-confines the RESOLVED root right before deleting.""" if keep <= 0: return 0 + try: - backups = sorted( - backup_dir.glob("chub-backup-*.zip"), - key=lambda f: f.stat().st_mtime, - reverse=True, - ) + root = backup_dir.resolve(strict=True) except OSError: return 0 + # Authorise BEFORE opening anything: a component of backup_dir can be + # re-pointed after get_backup_dir() cleared it, so resolve() may land out. + if not is_path_allowed(str(root), config): + if logger: + logger.error(f"Refusing to prune '{root}': outside the allowed roots") + return 0 + try: - root = backup_dir.resolve(strict=True) dir_fd = os.open(str(root), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) except OSError: return 0 removed = 0 try: - for old in backups[keep:]: + # The path can be swapped between the check and the open, so pin the + # descriptor to the directory that was actually authorised. + opened, authorised = os.fstat(dir_fd), os.stat(root) + if (opened.st_dev, opened.st_ino) != (authorised.st_dev, authorised.st_ino): + if logger: + logger.error(f"Refusing to prune '{root}': it changed under us") + return 0 + + entries = [] + for name in os.listdir(dir_fd): + if not fnmatch.fnmatch(name, "chub-backup-*.zip"): + continue try: - # NEVER resolve-then-unlink: that deletes a symlink's TARGET. - # lstat + S_ISREG rejects links and dirs; unlinking by name - # against dir_fd stops a swapped parent redirecting the delete. - st = os.lstat(os.path.join(str(root), old.name)) - if not stat.S_ISREG(st.st_mode): - if logger: - logger.warning(f"Skipping non-regular backup entry: {old}") - continue - os.unlink(old.name, dir_fd=dir_fd) + # ONE nofollow stat through dir_fd: following a link to read + # its mtime would stat a target the link owner chose. + st = os.lstat(name, dir_fd=dir_fd) + except OSError as e: + if logger: + logger.debug(f"Could not read backup {root / name}: {e}") + continue + entries.append((st.st_mtime, name, stat.S_ISREG(st.st_mode))) + entries.sort(key=lambda entry: entry[0], reverse=True) + + for _mtime, name, is_regular in entries[keep:]: + # NEVER resolve-then-unlink: that deletes a symlink's TARGET. + # S_ISREG rejects links/dirs; dir_fd anchors the unlink. + if not is_regular: + if logger: + logger.warning(f"Skipping non-regular backup entry: {root / name}") + continue + try: + os.unlink(name, dir_fd=dir_fd) removed += 1 except OSError as e: if logger: - logger.debug(f"Could not prune backup {old}: {e}") + logger.debug(f"Could not prune backup {root / name}: {e}") + except OSError: + return removed finally: os.close(dir_fd) if removed and logger: @@ -97,12 +129,9 @@ def prune_old_backups(backup_dir: Path, keep: int, logger=None) -> int: def run_auto_backup(keep: int, logger=None) -> None: """Write one backup and trim the directory to `keep` archives.""" - # Imported lazily — backend.api.system pulls in FastAPI/router state that we - # don't want to import at module load (this util is imported early). - from backend.api.system import _get_backup_dir, save_backup - save_backup(logger) - prune_old_backups(_get_backup_dir(logger), keep, logger) + root = get_backup_dir(logger) + prune_old_backups(root, keep, load_config(), logger) def _run_once(config, logger) -> None: @@ -123,24 +152,22 @@ def _run_once(config, logger) -> None: logger.error(f"Log pruning failed: {e}") -def start_maintenance(config, logger, interval: int = 86400) -> threading.Thread: - """Start the daily maintenance daemon thread and return it. - - Config is loaded fresh each pass (the reference on config-reload is replaced, - not mutated) so settings changes take effect without a restart. The first - pass runs after `interval` so a just-restarted container doesn't back up on - every boot. - """ +def start_maintenance(logger, interval: int = 86400) -> threading.Thread: + """Start the daily maintenance daemon thread and return it. Config is loaded + fresh each pass; the first pass waits `interval` (no backup on every boot).""" def loop(): + """Wake once per `interval` and run a pass against freshly loaded config.""" while True: time.sleep(interval) try: - from backend.util.config import load_config - current = load_config() - except Exception: - current = config + except Exception as e: + # Never fall back to the startup snapshot: these passes delete + # files, and stale retention/paths would delete the wrong ones. + if logger: + logger.warning(f"Maintenance pass skipped, config unreadable: {e}") + continue _run_once(current, logger) thread = threading.Thread(target=loop, name="maintenance", daemon=True) diff --git a/backend/util/module_orchestrator.py b/backend/util/module_orchestrator.py index 631d63f5..52c7fb85 100755 --- a/backend/util/module_orchestrator.py +++ b/backend/util/module_orchestrator.py @@ -1,6 +1,6 @@ # util/module_orchestrator.py import time -from typing import Any, Dict +from typing import Any, Dict, Optional from backend.modules import MODULES from backend.util.database import ChubDB @@ -226,14 +226,22 @@ def run_module_cli(self, module_names: list) -> None: self._log("error", f"Error in run_modules_cli: {e}", "cli", exc_info=True) raise - def get_module_status(self, module_name: str) -> Dict[str, Any]: + def get_module_status( + self, module_name: str, db: Optional[ChubDB] = None + ) -> Dict[str, Any]: """ Get current status of a module by checking active jobs. + + Pass `db` to reuse an open context — callers that poll every module on a + tick would otherwise open one ChubDB per module per tick. """ try: - # Always use a new context to avoid race conditions with FastAPI lifespan - with ChubDB(self.logger, quiet=True) as db: + if db is not None: running_job = db.worker.get_running_module_job(module_name) + else: + # Otherwise a new context, to avoid racing the FastAPI lifespan. + with ChubDB(self.logger, quiet=True) as own_db: + running_job = own_db.worker.get_running_module_job(module_name) if running_job: return { diff --git a/backend/util/plex.py b/backend/util/plex.py index 91f05ed4..6848b0eb 100755 --- a/backend/util/plex.py +++ b/backend/util/plex.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional import plexapi -from plexapi import utils as plexutils +import plexapi.utils as plexutils from plexapi.exceptions import NotFound from pathvalidate import sanitize_filename from plexapi.server import PlexServer diff --git a/docs/architecture.md b/docs/architecture.md index a874d04e..e15d016a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,7 +50,7 @@ All state lives in `chub.db` (SQLite) and the configured filesystem volumes. The backend/ ├── api/ # FastAPI routers (one per resource) │ ├── auth.py -│ ├── media.py │ media_api.py +│ ├── cache.py │ media_api.py │ ├── posters.py │ ├── modules.py │ ├── jobs.py @@ -62,7 +62,8 @@ backend/ │ ├── logs.py │ ├── labelarr.py │ nestarr.py │ ├── config.py -│ └── server.py # FastAPI app factory + router registration +│ ├── main.py # FastAPI app + router registration + SPA static mounts +│ └── server.py # uvicorn launcher (daemon thread) + app.state injection ├── modules/ # Scheduled/on-demand work units │ ├── poster_renamerr.py │ ├── asset_renamerr.py # clear logo / square art / background @@ -76,6 +77,7 @@ backend/ │ ├── renameinatorr.py │ ├── health_checkarr.py │ ├── nestarr.py +│ ├── plex_maintenance.py │ └── sync_gdrive.py └── util/ ├── base_module.py # ChubModule ABC + cooperative cancellation @@ -137,8 +139,7 @@ Each module class is registered in `backend/modules/__init__.py`'s `MODULES` dic - `job_processor.py` owns a **cancel registry** mapping `job_id` → `threading.Event`. - `DELETE /api/modules/{name}/execution/{job_id}` sets the event. - Long-running modules check `self.is_cancelled()` inside loops and exit early. -- Currently wired: `upgradinatorr`, `jduparr`, `nohl`, `sync_gdrive`, `unmatched_assets`. -- Not yet wired (tracked in `CLAUDE.md`): `poster_renamerr`, `labelarr`, `nestarr`, `renameinatorr`, `health_checkarr`, `border_replacerr`, `poster_cleanarr`. +- Wired in every module except `border_replacerr`. ### Lifecycle @@ -185,10 +186,10 @@ persist result to jobs table, emit SSE update `backend/api/webhooks.py` + `backend/util/webhook_processor.py`. End-user setup (where to copy the URL from, how to wire it into Sonarr/Radarr, accepted events, troubleshooting) lives in the [Webhooks wiki page](https://github.com/chodeus/chub/wiki/Webhooks); this section covers the internal model. -- Accepts Sonarr/Radarr/Tautulli event payloads. The accepted-event allow-list is `Download`, `MovieAdded`, `SeriesAdd`, `EpisodeFileImported`, `MovieFileImported`. `Test` events are 200-acked separately. Anything else (Grab, *FileDelete, Rename, HealthIssue, *Delete) is 200-acked and dropped before dedup. +- Accepts Sonarr/Radarr/Tautulli event payloads. The accepted-event allow-list is `Download`, `MovieAdded`, `SeriesAdd`. `Test` events are 200-acked separately. Anything else (Grab, *FileDelete, Rename, HealthIssue, *Delete) is 200-acked and dropped before dedup. - Optional shared-secret auth: if `general.webhook_secret` is set, requests must provide either `X-Webhook-Secret` header or `?secret=` query param (HMAC-compared). If unset, webhooks are accepted unauthenticated (matches Sonarr/Radarr's default posture). - Dedup is persistent: `webhook_cache` table with a 600s TTL keyed on `(item_type, sha256(identifying fields))`. Survives restart so Sonarr/Radarr retries (minutes apart) coalesce. -- Sonarr `Download` / `EpisodeFileImported` payloads carry `episodes[*].seasonNumber`; the validator extracts it and the webhook job narrows `stored_media` to `(show row + matching season row)` so the renamer only re-walks what actually changed. +- Sonarr `Download` payloads carry `episodes[*].seasonNumber`; the validator extracts it and the webhook job narrows `stored_media` to `(show row + matching season row)` so the renamer only re-walks what actually changed. - Each radarr/sonarr/lidarr `InstanceDetail` carries `webhook_force_reupload` (default `False`). When set, webhook-triggered uploads from that instance bypass the uploader's hash-equal short-circuit so an unchanged-on-disk poster is still re-pushed to Plex. - Recently-added retry: after enqueue, `wait_for_plex_availability` polls each Plex section's recently-added list with `webhook_initial_delay` warmup + `webhook_max_retries × webhook_retry_delay` (defaults 30s + 10×30s = ~5.5 min). - Each inbound webhook creates a job with origin metadata — enables downstream filtering and auditing. @@ -214,15 +215,16 @@ Schema is defined in `backend/util/database/schema.py`; additive changes use `ad | --- | --- | | `media_cache` | Unified Radarr/Sonarr/Lidarr item cache with `created_at` for time-window queries | | `media_edit_history` | Audit trail: every inline metadata edit (field, old, new, edited_by, ts) | -| `collection_cache` | Plex collection snapshots + poster_collection-from-tag output | -| `plex_cache` | Plex library items keyed by ratingKey | +| `collections_cache` | Plex collection snapshots + poster_collection-from-tag output | +| `plex_media_cache` | Plex library items keyed by ratingKey | | `poster_cache` | File poster index with `width`, `height`, `created_at` | | `jobs` | Queue + history; supports filtering/pagination | | `system_health_snapshots` | 6-hour cadence; 30-day retention | | `run_state` | Latest per-module run state (drives SSE + dashboard) | -| `users` | bcrypt-hashed auth | +| `webhook_cache` | Inbound-webhook dedup keys, 600s TTL | -Per-table helpers live in `backend/util/database/*.py` (one file per concern). +Per-table helpers live in `backend/util/database/*.py` (one file per concern). The +single admin credential is not a table — it lives in `config.yml` under `auth`. --- @@ -231,9 +233,10 @@ Per-table helpers live in `backend/util/database/*.py` (one file per concern). ### Stack - React 19 + Vite 8 (dev server on `:5174`, proxies `/api` to `:8000`) -- React Router DOM 7 +- React Router 8 - PropTypes for runtime validation (no TypeScript) -- CSS custom properties for theming (no Tailwind, no CSS-in-JS) +- Tailwind v4, CSS-first: config lives in `css/tailwind.css` (`@theme` / `@source`), there is no `tailwind.config.js`. Preflight is deliberately not imported +- CSS custom properties carry the light/dark palette (no CSS-in-JS) - Context API for state (no Redux/Zustand) ### Provider tree (`App.jsx`) @@ -279,16 +282,16 @@ ToastProvider ### CSS layers ```css -@layer reset, base, components, utilities, pages; +@layer reset, theme, base, components, utilities, pages; ``` -Utilities always win over component rules. Stylelint enforces BEM naming (`.block__element--modifier`) in `components/`; the `utilities/` tree is excluded. +Declared in `css/tailwind.css`, which `main.jsx` imports first — that declaration sets the real cascade order. Utilities always win over component rules. `stylelint.config.js` enforces BEM naming (`.block__element--modifier`) across `src/**/*.css`, with the pattern widened to accept Tailwind utility forms and `tailwind.css` itself exempted. ### Build / deploy - `vite build` emits to `frontend/dist/`. -- `scripts/build.sh` copies `dist/` into `backend/api/templates/` so FastAPI can serve it as static files. -- FastAPI mounts `/assets` and a SPA catch-all that returns `index.html` for any non-`/api/*` path. +- `build_frontend.sh` copies `dist/` into repo-root `templates/`, the default `get_static_dir()` root. The Docker image instead copies it to `/app/public` and sets `STATIC_DIR` there — `templates/` is not in the image. +- FastAPI mounts `/assets`, `/icons`, `/img` and `/posters`, plus a SPA catch-all that returns `index.html` for any path outside those and `/api/*`. --- diff --git a/frontend/package.json b/frontend/package.json index 7f313a26..788ef9e6 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "test:run": "vitest run", "lint": "eslint src --ext .js,.jsx && node scripts/check-field-types.js", "check:schema": "node scripts/check-field-types.js", + "check:classes": "node scripts/check-tailwind-classes.mjs", "format": "prettier --write \"src/**/*.{js,jsx,css,json,md}\"", "stylelint": "stylelint 'src/**/*.css' --fix" }, diff --git a/frontend/public/img/favicon-colored.svg b/frontend/public/img/favicon-colored.svg deleted file mode 100644 index c2eabc39..00000000 --- a/frontend/public/img/favicon-colored.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/scripts/check-tailwind-classes.mjs b/frontend/scripts/check-tailwind-classes.mjs new file mode 100644 index 00000000..a3508b1a --- /dev/null +++ b/frontend/scripts/check-tailwind-classes.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// Fails when a class used in src/ is missing from the built CSS — Tailwind +// silently drops unresolvable candidates. Run after `npm run build` (reads dist/). +import fs from 'node:fs'; +import path from 'node:path'; + +const SRC = 'src'; +const DIST = 'dist/assets'; + +/** Classes deliberately absent from the Tailwind build (each needs a reason). */ +const ALLOWLIST = new Set([ + // Google's Material Symbols webfont ships this one; base.css only sizes it. + 'material-symbols-outlined', +]); + +/** Every .js/.jsx file Tailwind scans (mirrors @source in src/css/tailwind.css). */ +function sourceFiles(dir) { + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...sourceFiles(p)); + // .test. files hold fixture markup (this checker's own included) — not UI. + else if (/\.(js|jsx)$/.test(p) && !/\.test\./.test(p)) out.push(p); + } + return out; +} + +/** Class selectors from the built CSS, with CSS escapes (\: \/ \[) removed. */ +function emittedClasses() { + const files = fs.existsSync(DIST) ? fs.readdirSync(DIST).filter(f => f.endsWith('.css')) : []; + if (files.length === 0) { + console.error(`check:classes — no CSS in ${DIST}. Run \`npm run build\` first.`); + process.exit(2); + } + const set = new Set(); + for (const f of files) { + const css = fs.readFileSync(path.join(DIST, f), 'utf8'); + for (const m of css.matchAll(/\.((?:\\[^\s]|[a-zA-Z0-9_-])+)/g)) { + set.add(m[1].replace(/\\(.)/g, '$1')); + } + } + return set; +} + +/** Slice the expression after a `className=` / `…Classes =` match, so only + * strings that really hold classes are scanned (not class-shaped prop enums). */ +function sliceRegion(text, start) { + const open = { '{': '}', '[': ']' }; + const c = text[start]; + if (c === "'" || c === '"' || c === '`') { + for (let i = start + 1; i < text.length; i++) { + if (text[i] === '\\') i++; + else if (text[i] === c) return text.slice(start, i + 1); + } + return text.slice(start); + } + if (!open[c]) return ''; + const stack = [c]; + let i = start + 1; + while (i < text.length && stack.length) { + const ch = text[i]; + const top = stack[stack.length - 1]; + if (top === "'" || top === '"') { + if (ch === '\\') i++; + else if (ch === top) stack.pop(); + } else if (top === '`') { + if (ch === '\\') i++; + else if (ch === '`') stack.pop(); + else if (ch === '$' && text[i + 1] === '{') { + stack.push('{'); + i++; + } + } else if (ch === '/' && text[i + 1] === '/') { + i = text.indexOf('\n', i); + if (i < 0) break; + } else if (ch === '/' && text[i + 1] === '*') { + i = text.indexOf('*/', i) + 1; + if (i < 1) break; + } else if (ch === "'" || ch === '"' || ch === '`' || open[ch]) { + stack.push(ch); + } else if (ch === '}' || ch === ']') { + stack.pop(); + } + i++; + } + return text.slice(start, i); +} + +const REGION_START = /(?:className|\b[A-Za-z_$][\w$]*(?:Class|Classes|ClassName))\s*[=:]\s*/g; +const TOKEN = /^[a-zA-Z][a-zA-Z0-9_:/.%[\]-]*$/; + +/** Class tokens in one file; template-literal fragments touching a `${}` are + * dropped (`log-block--${lvl}` yields `log-block--`, not a class). */ +function usedClasses(text) { + const out = new Set(); + const strings = + /'([^'\\\n]*(?:\\.[^'\\\n]*)*)'|"([^"\\\n]*(?:\\.[^"\\\n]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/gs; + for (const start of text.matchAll(REGION_START)) { + // In a className expression a quoted object key IS a class (clsx + // conditionals); in a *Classes map it's a lookup key — skip only there. + const isAttr = start[0].startsWith('className'); + const region = sliceRegion(text, start.index + start[0].length); + for (const m of region.matchAll(strings)) { + // Key-colon has no space before it (prettier), a ternary's does. + if (!isAttr && region[m.index + m[0].length] === ':') continue; + const chunks = (m[1] ?? m[2] ?? m[3]).split(/\$\{[^}]*\}/s); + chunks.forEach((chunk, i) => { + const parts = chunk.split(/\s+/).filter(Boolean); + // A token glued to an interpolation is a fragment, not a class: + // `log-block--${lvl}` yields `log-block--`. + if (i > 0 && !/^\s/.test(chunk)) parts.shift(); + if (i < chunks.length - 1 && !/\s$/.test(chunk)) parts.pop(); + for (const p of parts) if (TOKEN.test(p)) out.add(p); + }); + } + } + return out; +} + +/** True when the class shares a `-` prefix with an emitted one — a live-family + * utility (`bg-canvas`), not a bespoke component class (`item-counter`). */ +function inLiveFamily(cls, prefixes) { + const bits = cls.split(':').pop().split('-'); + for (let i = bits.length - 1; i > 0; i--) { + if (prefixes.has(bits.slice(0, i).join('-') + '-')) return true; + } + return false; +} + +const emitted = emittedClasses(); +const prefixes = new Set(); +for (const cls of emitted) { + const bits = cls.split(':').pop().split('-'); + for (let i = 1; i < bits.length; i++) prefixes.add(bits.slice(0, i).join('-') + '-'); +} + +const missing = new Map(); +for (const file of sourceFiles(SRC)) { + for (const token of usedClasses(fs.readFileSync(file, 'utf8'))) { + if (emitted.has(token) || ALLOWLIST.has(token)) continue; + if (!token.includes('-') || !inLiveFamily(token, prefixes)) continue; + if (!missing.has(token)) missing.set(token, new Set()); + missing.get(token).add(file); + } +} + +if (missing.size === 0) { + console.log(`check:classes — OK (${emitted.size} classes emitted).`); + process.exit(0); +} +console.error(`check:classes — ${missing.size} class(es) used in ${SRC}/ but not emitted:\n`); +for (const [cls, files] of [...missing].sort()) { + console.error(` ${cls}\n ${[...files].join('\n ')}`); +} +console.error('\nDefine the token/utility in src/css/tailwind.css, or use the intended class.'); +process.exit(1); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2512e336..ff0d5856 100755 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -62,24 +62,103 @@ const BorderPreviewPage = React.lazy(() => import('./pages/poster/BorderPreviewP const UnmatchedAssetsPage = React.lazy(() => import('./pages/poster/UnmatchedAssetsPage.jsx')); const PosterStatsPage = React.lazy(() => import('./pages/poster/PosterStatsPage.jsx')); -// Lazy-loaded dev pages -const ErrorTestPage = React.lazy(() => import('./pages/dev/ErrorTestPage.jsx')); -const FieldTestPage = React.lazy(() => import('./pages/dev/FieldTestPage.jsx')); -const ApiTestPage = React.lazy(() => import('./pages/dev/ApiTestPage.jsx')); -const ToolbarTestPage = React.lazy(() => import('./pages/dev/ToolbarTestPage.jsx')); -const ToolbarCompoundTest = React.lazy(() => import('./pages/dev/ToolbarCompoundTest.jsx')); -const SpinnerTestPage = React.lazy(() => import('./pages/dev/SpinnerTestPage.jsx')); -const SettingsMockPage = React.lazy(() => import('./pages/dev/SettingsMockPage.jsx')); -const ArrayObjectFieldPage = React.lazy(() => import('./pages/dev/ArrayObjectFieldPage.jsx')); -const AccordionTestPage = React.lazy(() => import('./pages/dev/AccordionTestPage.jsx')); -const StatsPrimitivesTestPage = React.lazy(() => import('./pages/dev/StatsPrimitivesTestPage.jsx')); -const ButtonPrimitivesTestPage = React.lazy( - () => import('./pages/dev/ButtonPrimitivesTestPage.jsx') -); -const CardPrimitivesTestPage = React.lazy(() => import('./pages/dev/CardPrimitivesTestPage.jsx')); -const FormCompoundsTest = React.lazy(() => import('./pages/dev/FormCompoundsTest.jsx')); -const ModalsTestPage = React.lazy(() => import('./pages/dev/ModalsTestPage.jsx')); -const LogPerformance = React.lazy(() => import('./pages/dev/LogPerformance.jsx')); +// Dev-only pages. The React.lazy() calls MUST stay inside this conditional — +// DEV is statically false in prod, so vite drops the routes AND their chunks. +const devRoutes = import.meta.env.DEV + ? [ + { + path: 'dev/error', + pageName: 'Error Test', + pageDescription: 'Error handling demonstration page', + Component: React.lazy(() => import('./pages/dev/ErrorTestPage.jsx')), + }, + { + path: 'dev/fields', + pageName: 'Field Test', + pageDescription: 'Field system development testing interface', + Component: React.lazy(() => import('./pages/dev/FieldTestPage.jsx')), + }, + { + path: 'dev/api', + pageName: 'API Test', + pageDescription: 'API Testing', + Component: React.lazy(() => import('./pages/dev/ApiTestPage.jsx')), + }, + { + path: 'dev/toolbar', + pageName: 'Toolbar Test', + pageDescription: 'Toolbar overflow testing', + Component: React.lazy(() => import('./pages/dev/ToolbarTestPage.jsx')), + }, + { + path: 'dev/toolbar-compound', + pageName: 'Toolbar Compound Pattern Test', + pageDescription: 'Toolbar compound component pattern testing', + Component: React.lazy(() => import('./pages/dev/ToolbarCompoundTest.jsx')), + }, + { + path: 'dev/spinner', + pageName: 'Spinner Test', + pageDescription: 'Spinner component testing and development', + Component: React.lazy(() => import('./pages/dev/SpinnerTestPage.jsx')), + }, + { + path: 'dev/settings', + pageName: 'Settings Mock', + pageDescription: 'Settings accordion interface mockup and design exploration', + Component: React.lazy(() => import('./pages/dev/SettingsMockPage.jsx')), + }, + { + path: 'dev/array-object-field', + pageName: 'Array Object Field', + pageDescription: 'Unified ArrayObjectField component demonstration', + Component: React.lazy(() => import('./pages/dev/ArrayObjectFieldPage.jsx')), + }, + { + path: 'dev/accordion', + pageName: 'Accordion Test', + pageDescription: 'AccordionItem compound component validation and testing', + Component: React.lazy(() => import('./pages/dev/AccordionTestPage.jsx')), + }, + { + path: 'dev/stats', + pageName: 'Statistics Primitives Test', + pageDescription: 'Statistics System primitive composition and layout testing', + Component: React.lazy(() => import('./pages/dev/StatsPrimitivesTestPage.jsx')), + }, + { + path: 'dev/buttons', + pageName: 'Button Primitives Test', + pageDescription: 'Button System primitive composition and component testing', + Component: React.lazy(() => import('./pages/dev/ButtonPrimitivesTestPage.jsx')), + }, + { + path: 'dev/card', + pageName: 'Card Primitives Test', + pageDescription: 'Card System primitive composition and variant testing', + Component: React.lazy(() => import('./pages/dev/CardPrimitivesTestPage.jsx')), + }, + { + path: 'dev/form-compounds', + pageName: 'Form Compounds Test', + pageDescription: + 'Form System compound composition validation (Header, Section, Actions)', + Component: React.lazy(() => import('./pages/dev/FormCompoundsTest.jsx')), + }, + { + path: 'dev/modals', + pageName: 'Modal Test', + pageDescription: 'Modal System comprehensive testing and real-world examples', + Component: React.lazy(() => import('./pages/dev/ModalsTestPage.jsx')), + }, + { + path: 'dev/log-performance', + pageName: 'Log Performance Test', + pageDescription: 'Phase 2 Log Output component performance validation', + Component: React.lazy(() => import('./pages/dev/LogPerformance.jsx')), + }, + ] + : []; const SuspenseFallback = () => ; @@ -499,172 +578,30 @@ const App = () => { } /> - {/* Development Routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Development routes — dev builds only */} + {devRoutes.map( + ({ + path, + pageName, + pageDescription, + Component, + }) => ( + + + + } + /> + ) + )} { return null; } - const baseClasses = 'w-px h-5 bg-text-secondary mx-3 flex-shrink-0 self-center'; + const baseClasses = 'w-px h-5 bg-border-light mx-3 flex-shrink-0 self-center'; return