collapsarr 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- collapsarr-0.2.0/.env.example +49 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/Dockerfile +4 -2
- {collapsarr-0.1.4 → collapsarr-0.2.0}/PKG-INFO +71 -18
- {collapsarr-0.1.4 → collapsarr-0.2.0}/README.md +68 -17
- collapsarr-0.2.0/collapsarr/auth/__init__.py +20 -0
- collapsarr-0.2.0/collapsarr/auth/enforcement.py +245 -0
- collapsarr-0.2.0/collapsarr/auth/routes.py +220 -0
- collapsarr-0.2.0/collapsarr/auth/session.py +186 -0
- collapsarr-0.2.0/collapsarr/config.py +204 -0
- collapsarr-0.2.0/collapsarr/database.py +131 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/main.py +21 -3
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/settings/__init__.py +16 -2
- collapsarr-0.2.0/collapsarr/settings/env_seed.py +59 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/settings/models.py +68 -3
- collapsarr-0.2.0/collapsarr/settings/passwords.py +100 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/settings/routes.py +33 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/settings/service.py +69 -6
- collapsarr-0.2.0/frontend/dist/assets/index-CuYlNmcT.css +1 -0
- collapsarr-0.2.0/frontend/dist/assets/index-Dv5U12vj.js +68 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/dist/index.html +2 -2
- collapsarr-0.2.0/frontend/src/api/auth.ts +108 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/client.ts +42 -1
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/AppShell.tsx +6 -0
- collapsarr-0.2.0/frontend/src/components/OnboardingPanel.tsx +106 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/Sidebar.tsx +25 -1
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/settings/GeneralSection.tsx +190 -1
- collapsarr-0.2.0/frontend/src/pages/LoginPage.tsx +114 -0
- collapsarr-0.2.0/frontend/src/pages/SetupPage.tsx +114 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/routes/router.tsx +8 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/styles/global.css +103 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/apiClient.test.ts +67 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/fileDetailPage.test.tsx +2 -0
- collapsarr-0.2.0/frontend/src/test/loginPage.test.tsx +107 -0
- collapsarr-0.2.0/frontend/src/test/onboardingPanel.test.tsx +131 -0
- collapsarr-0.2.0/frontend/src/test/settingsGeneral.test.tsx +291 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/settingsPage.test.tsx +2 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/settingsTargets.test.tsx +2 -0
- collapsarr-0.2.0/frontend/src/test/setupPage.test.tsx +88 -0
- collapsarr-0.2.0/frontend/src/test/sidebarLogout.test.tsx +41 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/types/settings.ts +24 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/pyproject.toml +3 -1
- collapsarr-0.2.0/tests/test_auth.py +607 -0
- collapsarr-0.2.0/tests/test_config.py +174 -0
- collapsarr-0.2.0/tests/test_database.py +230 -0
- collapsarr-0.2.0/tests/test_env_seed.py +218 -0
- collapsarr-0.2.0/tests/test_passwords.py +66 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_settings_routes.py +28 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_settings_service.py +207 -2
- collapsarr-0.1.4/.env.example +0 -16
- collapsarr-0.1.4/collapsarr/auth.py +0 -96
- collapsarr-0.1.4/collapsarr/config.py +0 -95
- collapsarr-0.1.4/collapsarr/database.py +0 -82
- collapsarr-0.1.4/frontend/dist/assets/index-Dfozi1oM.js +0 -68
- collapsarr-0.1.4/frontend/dist/assets/index-J2qCuzXj.css +0 -1
- collapsarr-0.1.4/frontend/src/test/settingsGeneral.test.tsx +0 -145
- collapsarr-0.1.4/tests/test_auth.py +0 -84
- collapsarr-0.1.4/tests/test_config.py +0 -51
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.dockerignore +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.gitattributes +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.github/brand/icon-512.png +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.github/brand/social-preview.png +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.github/brand/social-preview.svg +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.github/workflows/release.yml +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/.gitignore +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/LICENSE +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/__main__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/client.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/files.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/models.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/service.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/arr/webhooks.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/downmix/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/downmix/apply.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/downmix/pipeline.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/downmix/probe.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/downmix/remux.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/downmix/targets.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/frontend.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/health.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/failure_notify.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/history.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/models.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/queue.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/jobs/scheduler.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/media/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/media/models.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/media/routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/media/service.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/notify/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/notify/dispatch.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/notify/models.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/notify/routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/collapsarr/notify/service.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/docker-entrypoint.sh +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/README.md +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/dist/apple-touch-icon.png +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/dist/favicon-32.png +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/dist/favicon.svg +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/eslint.config.js +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/index.html +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/package-lock.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/package.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/public/apple-touch-icon.png +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/public/favicon-32.png +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/public/favicon.svg +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/activity.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/health.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/instances.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/notifiers.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/settings.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/api/wanted.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/HealthBanner.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/PlaceholderView.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/icons.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/settings/ConnectSection.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/settings/InstancesSection.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/settings/PathMappingsPanel.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/components/settings/TargetsSection.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/main.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/pages/ActivityPage.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/pages/FileDetailPage.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/pages/SettingsPage.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/pages/WantedPage.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/routes/nav.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/styles/theme.css +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/activityPage.test.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/appShell.test.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/healthBanner.test.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/settingsConnect.test.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/settingsInstances.test.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/setup.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/test/wantedPage.test.tsx +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/types/activity.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/types/health.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/types/instances.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/types/notifiers.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/types/wanted.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/src/vite-env.d.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/tsconfig.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/frontend/vite.config.ts +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/hatch_build.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/__init__.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/conftest.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/radarr_movie_list.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/radarr_status_ok.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/radarr_webhook_on_import.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/radarr_webhook_on_upgrade.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/sonarr_episodefiles_series1.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/sonarr_series_list.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/sonarr_status_ok.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/sonarr_webhook_on_import.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/sonarr_webhook_on_upgrade.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/sonarr_webhook_test.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/arr/unauthorized.json +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/downmix/multi_lang.mkv +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/downmix/stereo_eng.mkv +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/downmix/surround_51.mkv +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/downmix/surround_71.mkv +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/downmix/untagged_language.mkv +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/fixtures/downmix/video_audio_subs.mkv +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_arr_client.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_arr_files.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_arr_models.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_arr_routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_arr_service.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_arr_webhooks.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_downmix_apply.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_downmix_pipeline.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_downmix_probe.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_downmix_remux.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_downmix_targets.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_frontend.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_health.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_health_check.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_jobs_failure_notify.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_jobs_history.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_jobs_queue.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_jobs_routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_jobs_scheduler.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_main.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_media_service.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_notify_dispatch.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_notify_routes.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_notify_service.py +0 -0
- {collapsarr-0.1.4 → collapsarr-0.2.0}/tests/test_wanted_routes.py +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Collapsarr configuration.
|
|
2
|
+
# Copy to `.env` and adjust as needed. All variables are optional — the values
|
|
3
|
+
# below are the built-in defaults. Environment variables take precedence.
|
|
4
|
+
#
|
|
5
|
+
# On a bare-metal/PyPI install, none of these need to be set: Collapsarr picks
|
|
6
|
+
# a writable, OS-appropriate data directory automatically (see
|
|
7
|
+
# COLLAPSARR_DATA_DIR below). The Docker image sets COLLAPSARR_DATA_DIR itself
|
|
8
|
+
# to point at the /config volume.
|
|
9
|
+
|
|
10
|
+
# Root directory for application data (the SQLite database today; logs and
|
|
11
|
+
# backups later). Created automatically if it doesn't exist. Defaults to the
|
|
12
|
+
# OS user-data directory, e.g. ~/.local/share/collapsarr on Linux,
|
|
13
|
+
# ~/Library/Application Support/collapsarr on macOS,
|
|
14
|
+
# %LOCALAPPDATA%\collapsarr on Windows.
|
|
15
|
+
# COLLAPSARR_DATA_DIR=/config
|
|
16
|
+
|
|
17
|
+
# Filesystem path to the SQLite database file. Defaults to
|
|
18
|
+
# <COLLAPSARR_DATA_DIR>/collapsarr.db; set this to override that location
|
|
19
|
+
# directly.
|
|
20
|
+
# COLLAPSARR_DATABASE_PATH=/config/collapsarr.db
|
|
21
|
+
|
|
22
|
+
# Full SQLAlchemy database URL. When set, overrides COLLAPSARR_DATABASE_PATH.
|
|
23
|
+
# COLLAPSARR_DATABASE_URL=sqlite:////config/collapsarr.db
|
|
24
|
+
|
|
25
|
+
# API server bind address and port.
|
|
26
|
+
COLLAPSARR_HOST=0.0.0.0
|
|
27
|
+
COLLAPSARR_PORT=8282
|
|
28
|
+
|
|
29
|
+
# Log level (DEBUG, INFO, WARNING, ERROR).
|
|
30
|
+
COLLAPSARR_LOG_LEVEL=INFO
|
|
31
|
+
|
|
32
|
+
# First-boot credential seed for headless/declarative deploys (Docker,
|
|
33
|
+
# automation): set BOTH together and a fresh install is seeded with this
|
|
34
|
+
# credential on first boot -- hashed before it's persisted, never stored or
|
|
35
|
+
# logged in plaintext -- and skips the interactive /setup page entirely.
|
|
36
|
+
# One-shot: this never overwrites a credential that already exists, even if
|
|
37
|
+
# left set on a later boot. This is also the supported password-recovery/
|
|
38
|
+
# lockout escape hatch, but only for a FRESH or already-locked-out install
|
|
39
|
+
# with no credential configured yet -- it does not help recover a forgotten
|
|
40
|
+
# password once a credential is already set (that takes clearing the
|
|
41
|
+
# existing one directly, e.g. in the database).
|
|
42
|
+
# COLLAPSARR_AUTH_USERNAME=admin
|
|
43
|
+
# COLLAPSARR_AUTH_PASSWORD=change-me
|
|
44
|
+
|
|
45
|
+
# Optional, only applied when the credential above is actually seeded.
|
|
46
|
+
# COLLAPSARR_AUTH_METHOD: forms (default) | basic
|
|
47
|
+
# COLLAPSARR_AUTH_REQUIRED: local_bypass (default) | enabled
|
|
48
|
+
# COLLAPSARR_AUTH_METHOD=forms
|
|
49
|
+
# COLLAPSARR_AUTH_REQUIRED=local_bypass
|
|
@@ -65,10 +65,12 @@ COPY docker-entrypoint.sh /docker-entrypoint.sh
|
|
|
65
65
|
RUN chmod +x /docker-entrypoint.sh
|
|
66
66
|
|
|
67
67
|
# Runtime configuration. COLLAPSARR_* mirror the app defaults (collapsarr/config.py);
|
|
68
|
-
# PUID/PGID are consumed by the entrypoint.
|
|
68
|
+
# PUID/PGID are consumed by the entrypoint. COLLAPSARR_DATA_DIR points at the
|
|
69
|
+
# /config volume; database_path derives to /config/collapsarr.db (COL-46), so
|
|
70
|
+
# existing /config volumes keep landing the DB at the same path on upgrade.
|
|
69
71
|
ENV COLLAPSARR_HOST=0.0.0.0 \
|
|
70
72
|
COLLAPSARR_PORT=8282 \
|
|
71
|
-
|
|
73
|
+
COLLAPSARR_DATA_DIR=/config \
|
|
72
74
|
PUID=1000 \
|
|
73
75
|
PGID=1000
|
|
74
76
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: collapsarr
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An *arr-family companion app that adds downmixed (stereo/2.1/5.1) audio tracks to media missing them, via FFmpeg.
|
|
5
5
|
Project-URL: Homepage, https://github.com/JovinJovinsson/Collapsarr
|
|
6
6
|
Project-URL: Repository, https://github.com/JovinJovinsson/Collapsarr
|
|
@@ -14,6 +14,8 @@ Classifier: Topic :: Multimedia :: Sound/Audio
|
|
|
14
14
|
Requires-Python: >=3.12
|
|
15
15
|
Requires-Dist: fastapi>=0.110
|
|
16
16
|
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: itsdangerous>=2.0
|
|
18
|
+
Requires-Dist: platformdirs>=4.0
|
|
17
19
|
Requires-Dist: pydantic-settings>=2.2
|
|
18
20
|
Requires-Dist: pydantic>=2.6
|
|
19
21
|
Requires-Dist: sqlalchemy>=2.0
|
|
@@ -91,27 +93,30 @@ services:
|
|
|
91
93
|
restart: unless-stopped
|
|
92
94
|
```
|
|
93
95
|
|
|
94
|
-
Then open `http://localhost:8282
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
Then open `http://localhost:8282` — you'll land on a one-time credential setup
|
|
97
|
+
page (see [Authentication](#authentication) for how login is enforced,
|
|
98
|
+
including the caveat if you're putting Collapsarr behind a reverse proxy).
|
|
99
|
+
`restart: unless-stopped` above means the container comes back automatically
|
|
100
|
+
whenever it stops unexpectedly or the Docker daemon restarts (e.g. after a
|
|
101
|
+
host reboot) — see [Running on startup](#running-on-startup) if you need it
|
|
102
|
+
to survive a reboot on a bare-metal/PyPI install instead.
|
|
99
103
|
|
|
100
104
|
**PyPI (bare-metal):**
|
|
101
105
|
|
|
102
106
|
```bash
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
pipx install collapsarr
|
|
108
|
+
collapsarr
|
|
105
109
|
```
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
you
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
[
|
|
111
|
+
No flags, no config file needed — Collapsarr stores its SQLite database
|
|
112
|
+
under your platform's standard per-user data directory by default (e.g.
|
|
113
|
+
`~/.local/share/collapsarr/collapsarr.db` on Linux; native per-OS locations
|
|
114
|
+
on macOS/Windows), creating it automatically if it doesn't exist. Set
|
|
115
|
+
`COLLAPSARR_DATA_DIR` if you'd rather it live somewhere else — see
|
|
116
|
+
[Configuration](#configuration). Requires FFmpeg on `PATH` — see
|
|
117
|
+
[Requirements](#requirements) below. Open `http://localhost:8282`; see
|
|
118
|
+
[Configuration](#configuration) for the full list of environment variables,
|
|
119
|
+
and [Running on startup](#running-on-startup) for a systemd unit.
|
|
115
120
|
|
|
116
121
|
## Requirements
|
|
117
122
|
|
|
@@ -130,6 +135,49 @@ full list of environment variables, and
|
|
|
130
135
|
|
|
131
136
|
Verify with `ffmpeg -version`. Official builds/source: [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
|
132
137
|
|
|
138
|
+
## Authentication
|
|
139
|
+
|
|
140
|
+
Collapsarr requires a one-time credential setup (`/setup`, first run) and,
|
|
141
|
+
after that, logging in (`/login`) before the UI/API is usable — *except* from
|
|
142
|
+
a caller Collapsarr considers "local". The **Login requirement** setting
|
|
143
|
+
(Settings → General, `auth_required` in the API) controls this:
|
|
144
|
+
|
|
145
|
+
| Mode | Behaviour |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| **Disabled for local addresses** (`local_bypass`, default) | A caller connecting from a loopback (`127.0.0.1`/`::1`) or private-range (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, etc.) address reaches the UI and API with no setup and no login. Anyone connecting from a routable/public address still has to authenticate normally. |
|
|
148
|
+
| **Always required** (`enabled`) | Every caller is challenged, regardless of address. |
|
|
149
|
+
|
|
150
|
+
**Reverse-proxy limitation:** local-address classification looks only at the
|
|
151
|
+
*direct* TCP connection Collapsarr accepted — never an `X-Forwarded-For` (or
|
|
152
|
+
similar) header, since that's supplied by the client and trivially spoofable.
|
|
153
|
+
If Collapsarr sits behind a reverse proxy (nginx, Traefik, Cloudflare Tunnel,
|
|
154
|
+
etc.), every request's direct peer is the proxy itself, which usually *is*
|
|
155
|
+
local — meaning **every** client, including ones out on the internet, would
|
|
156
|
+
be classified as local and skip authentication entirely. **If you run
|
|
157
|
+
Collapsarr behind a reverse proxy, set the Login requirement to "Always
|
|
158
|
+
required" (`auth_required=enabled`)** until a future release adds
|
|
159
|
+
trusted-proxy support (a stubbed-out capability today).
|
|
160
|
+
|
|
161
|
+
**Headless deploys — seeding a credential without the setup page:** a
|
|
162
|
+
declarative/automated deploy (Docker Compose, Ansible, etc.) has no human
|
|
163
|
+
available to click through `/setup`. Set `COLLAPSARR_AUTH_USERNAME` and
|
|
164
|
+
`COLLAPSARR_AUTH_PASSWORD` (together — see
|
|
165
|
+
[Configuration](#configuration)) and a fresh install seeds that credential on
|
|
166
|
+
first boot instead — hashed before it's persisted, never stored or logged in
|
|
167
|
+
plaintext — and comes up already past the setup gate. `COLLAPSARR_AUTH_METHOD`
|
|
168
|
+
and `COLLAPSARR_AUTH_REQUIRED` are honoured at the same time if set, otherwise
|
|
169
|
+
the seeded credential gets the same defaults `/setup` would (`forms`,
|
|
170
|
+
`local_bypass`).
|
|
171
|
+
|
|
172
|
+
This doubles as the supported password-recovery/lockout escape hatch, but
|
|
173
|
+
**only for a fresh or already-locked-out install with no credential
|
|
174
|
+
configured yet** — seeding runs once and never overwrites a credential that
|
|
175
|
+
already exists, even if the environment variables are still set on a later
|
|
176
|
+
boot. It does **not** help recover a *forgotten* password once a credential
|
|
177
|
+
is already set; that requires clearing the existing `auth_username`/
|
|
178
|
+
`auth_password_hash` first (e.g. directly in the database) so the instance
|
|
179
|
+
has no credential again, at which point re-seeding (or `/setup`) applies.
|
|
180
|
+
|
|
133
181
|
## Development
|
|
134
182
|
|
|
135
183
|
```bash
|
|
@@ -158,11 +206,16 @@ directory. See [`.env.example`](.env.example).
|
|
|
158
206
|
|
|
159
207
|
| Variable | Default | Description |
|
|
160
208
|
| --- | --- | --- |
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
209
|
+
| `COLLAPSARR_DATA_DIR` | *(OS user-data dir)* | Root directory for application data — the SQLite database today, logs/backups later. Defaults to `platformdirs.user_data_dir("collapsarr")` (e.g. `~/.local/share/collapsarr` on Linux). Created automatically if missing. The Docker image sets this to `/config` (its mounted volume) — see [Quick start](#quick-start). |
|
|
210
|
+
| `COLLAPSARR_DATABASE_PATH` | *(derived from `COLLAPSARR_DATA_DIR`)* | SQLite database file path. Set this to override the location directly, independent of `COLLAPSARR_DATA_DIR`. |
|
|
211
|
+
| `COLLAPSARR_DATABASE_URL` | *(derived from path)* | Full SQLAlchemy URL override — takes precedence over both of the above. |
|
|
163
212
|
| `COLLAPSARR_HOST` | `0.0.0.0` | API server bind address. |
|
|
164
213
|
| `COLLAPSARR_PORT` | `8282` | API server bind port. |
|
|
165
214
|
| `COLLAPSARR_LOG_LEVEL` | `INFO` | Log level. |
|
|
215
|
+
| `COLLAPSARR_AUTH_USERNAME` | *(unset)* | First-boot credential seed: UI username. Set together with `COLLAPSARR_AUTH_PASSWORD` — see [Authentication](#authentication). |
|
|
216
|
+
| `COLLAPSARR_AUTH_PASSWORD` | *(unset)* | First-boot credential seed: UI password. Hashed before being persisted; never stored or logged in plaintext. |
|
|
217
|
+
| `COLLAPSARR_AUTH_METHOD` | *(unset — `forms`)* | Optional, only applied when the seed credential above is actually seeded: `forms` or `basic`. |
|
|
218
|
+
| `COLLAPSARR_AUTH_REQUIRED` | *(unset — `local_bypass`)* | Optional, only applied when the seed credential above is actually seeded: `enabled` or `local_bypass`. |
|
|
166
219
|
|
|
167
220
|
## Running on startup
|
|
168
221
|
|
|
@@ -65,27 +65,30 @@ services:
|
|
|
65
65
|
restart: unless-stopped
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Then open `http://localhost:8282
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
Then open `http://localhost:8282` — you'll land on a one-time credential setup
|
|
69
|
+
page (see [Authentication](#authentication) for how login is enforced,
|
|
70
|
+
including the caveat if you're putting Collapsarr behind a reverse proxy).
|
|
71
|
+
`restart: unless-stopped` above means the container comes back automatically
|
|
72
|
+
whenever it stops unexpectedly or the Docker daemon restarts (e.g. after a
|
|
73
|
+
host reboot) — see [Running on startup](#running-on-startup) if you need it
|
|
74
|
+
to survive a reboot on a bare-metal/PyPI install instead.
|
|
73
75
|
|
|
74
76
|
**PyPI (bare-metal):**
|
|
75
77
|
|
|
76
78
|
```bash
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
pipx install collapsarr
|
|
80
|
+
collapsarr
|
|
79
81
|
```
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
you
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
[
|
|
83
|
+
No flags, no config file needed — Collapsarr stores its SQLite database
|
|
84
|
+
under your platform's standard per-user data directory by default (e.g.
|
|
85
|
+
`~/.local/share/collapsarr/collapsarr.db` on Linux; native per-OS locations
|
|
86
|
+
on macOS/Windows), creating it automatically if it doesn't exist. Set
|
|
87
|
+
`COLLAPSARR_DATA_DIR` if you'd rather it live somewhere else — see
|
|
88
|
+
[Configuration](#configuration). Requires FFmpeg on `PATH` — see
|
|
89
|
+
[Requirements](#requirements) below. Open `http://localhost:8282`; see
|
|
90
|
+
[Configuration](#configuration) for the full list of environment variables,
|
|
91
|
+
and [Running on startup](#running-on-startup) for a systemd unit.
|
|
89
92
|
|
|
90
93
|
## Requirements
|
|
91
94
|
|
|
@@ -104,6 +107,49 @@ full list of environment variables, and
|
|
|
104
107
|
|
|
105
108
|
Verify with `ffmpeg -version`. Official builds/source: [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
|
106
109
|
|
|
110
|
+
## Authentication
|
|
111
|
+
|
|
112
|
+
Collapsarr requires a one-time credential setup (`/setup`, first run) and,
|
|
113
|
+
after that, logging in (`/login`) before the UI/API is usable — *except* from
|
|
114
|
+
a caller Collapsarr considers "local". The **Login requirement** setting
|
|
115
|
+
(Settings → General, `auth_required` in the API) controls this:
|
|
116
|
+
|
|
117
|
+
| Mode | Behaviour |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| **Disabled for local addresses** (`local_bypass`, default) | A caller connecting from a loopback (`127.0.0.1`/`::1`) or private-range (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, etc.) address reaches the UI and API with no setup and no login. Anyone connecting from a routable/public address still has to authenticate normally. |
|
|
120
|
+
| **Always required** (`enabled`) | Every caller is challenged, regardless of address. |
|
|
121
|
+
|
|
122
|
+
**Reverse-proxy limitation:** local-address classification looks only at the
|
|
123
|
+
*direct* TCP connection Collapsarr accepted — never an `X-Forwarded-For` (or
|
|
124
|
+
similar) header, since that's supplied by the client and trivially spoofable.
|
|
125
|
+
If Collapsarr sits behind a reverse proxy (nginx, Traefik, Cloudflare Tunnel,
|
|
126
|
+
etc.), every request's direct peer is the proxy itself, which usually *is*
|
|
127
|
+
local — meaning **every** client, including ones out on the internet, would
|
|
128
|
+
be classified as local and skip authentication entirely. **If you run
|
|
129
|
+
Collapsarr behind a reverse proxy, set the Login requirement to "Always
|
|
130
|
+
required" (`auth_required=enabled`)** until a future release adds
|
|
131
|
+
trusted-proxy support (a stubbed-out capability today).
|
|
132
|
+
|
|
133
|
+
**Headless deploys — seeding a credential without the setup page:** a
|
|
134
|
+
declarative/automated deploy (Docker Compose, Ansible, etc.) has no human
|
|
135
|
+
available to click through `/setup`. Set `COLLAPSARR_AUTH_USERNAME` and
|
|
136
|
+
`COLLAPSARR_AUTH_PASSWORD` (together — see
|
|
137
|
+
[Configuration](#configuration)) and a fresh install seeds that credential on
|
|
138
|
+
first boot instead — hashed before it's persisted, never stored or logged in
|
|
139
|
+
plaintext — and comes up already past the setup gate. `COLLAPSARR_AUTH_METHOD`
|
|
140
|
+
and `COLLAPSARR_AUTH_REQUIRED` are honoured at the same time if set, otherwise
|
|
141
|
+
the seeded credential gets the same defaults `/setup` would (`forms`,
|
|
142
|
+
`local_bypass`).
|
|
143
|
+
|
|
144
|
+
This doubles as the supported password-recovery/lockout escape hatch, but
|
|
145
|
+
**only for a fresh or already-locked-out install with no credential
|
|
146
|
+
configured yet** — seeding runs once and never overwrites a credential that
|
|
147
|
+
already exists, even if the environment variables are still set on a later
|
|
148
|
+
boot. It does **not** help recover a *forgotten* password once a credential
|
|
149
|
+
is already set; that requires clearing the existing `auth_username`/
|
|
150
|
+
`auth_password_hash` first (e.g. directly in the database) so the instance
|
|
151
|
+
has no credential again, at which point re-seeding (or `/setup`) applies.
|
|
152
|
+
|
|
107
153
|
## Development
|
|
108
154
|
|
|
109
155
|
```bash
|
|
@@ -132,11 +178,16 @@ directory. See [`.env.example`](.env.example).
|
|
|
132
178
|
|
|
133
179
|
| Variable | Default | Description |
|
|
134
180
|
| --- | --- | --- |
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
181
|
+
| `COLLAPSARR_DATA_DIR` | *(OS user-data dir)* | Root directory for application data — the SQLite database today, logs/backups later. Defaults to `platformdirs.user_data_dir("collapsarr")` (e.g. `~/.local/share/collapsarr` on Linux). Created automatically if missing. The Docker image sets this to `/config` (its mounted volume) — see [Quick start](#quick-start). |
|
|
182
|
+
| `COLLAPSARR_DATABASE_PATH` | *(derived from `COLLAPSARR_DATA_DIR`)* | SQLite database file path. Set this to override the location directly, independent of `COLLAPSARR_DATA_DIR`. |
|
|
183
|
+
| `COLLAPSARR_DATABASE_URL` | *(derived from path)* | Full SQLAlchemy URL override — takes precedence over both of the above. |
|
|
137
184
|
| `COLLAPSARR_HOST` | `0.0.0.0` | API server bind address. |
|
|
138
185
|
| `COLLAPSARR_PORT` | `8282` | API server bind port. |
|
|
139
186
|
| `COLLAPSARR_LOG_LEVEL` | `INFO` | Log level. |
|
|
187
|
+
| `COLLAPSARR_AUTH_USERNAME` | *(unset)* | First-boot credential seed: UI username. Set together with `COLLAPSARR_AUTH_PASSWORD` — see [Authentication](#authentication). |
|
|
188
|
+
| `COLLAPSARR_AUTH_PASSWORD` | *(unset)* | First-boot credential seed: UI password. Hashed before being persisted; never stored or logged in plaintext. |
|
|
189
|
+
| `COLLAPSARR_AUTH_METHOD` | *(unset — `forms`)* | Optional, only applied when the seed credential above is actually seeded: `forms` or `basic`. |
|
|
190
|
+
| `COLLAPSARR_AUTH_REQUIRED` | *(unset — `local_bypass`)* | Optional, only applied when the seed credential above is actually seeded: `enabled` or `local_bypass`. |
|
|
140
191
|
|
|
141
192
|
## Running on startup
|
|
142
193
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Authentication surface: first-run setup, Forms login, session enforcement.
|
|
2
|
+
|
|
3
|
+
The full auth model (COL-50): a single operator credential (COL-49) gates the
|
|
4
|
+
whole UI behind a signed-cookie session, while ``/api`` still accepts the
|
|
5
|
+
Sonarr/Radarr-style API key for webhooks and tooling. See the submodules:
|
|
6
|
+
|
|
7
|
+
* :mod:`~collapsarr.auth.session` -- the signed-cookie session middleware and
|
|
8
|
+
its login/logout helpers.
|
|
9
|
+
* :mod:`~collapsarr.auth.enforcement` -- the request-gating middleware that
|
|
10
|
+
supersedes the old opt-in ``api_key_middleware``.
|
|
11
|
+
* :mod:`~collapsarr.auth.routes` -- the ``/api/auth`` endpoints.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .enforcement import enforce_auth_middleware
|
|
17
|
+
from .routes import router as auth_router
|
|
18
|
+
from .session import SessionMiddleware
|
|
19
|
+
|
|
20
|
+
__all__ = ["SessionMiddleware", "auth_router", "enforce_auth_middleware"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Request-gating middleware for the whole app surface (COL-50).
|
|
2
|
+
|
|
3
|
+
This is the successor to the old opt-in ``api_key_middleware`` (COL-26). Auth is
|
|
4
|
+
now **unconditional**, not gated on a ``ui_auth_enabled`` toggle: the app still
|
|
5
|
+
launches with zero config, but the UI cannot be used until a credential exists,
|
|
6
|
+
and once it does, every UI route needs a session.
|
|
7
|
+
|
|
8
|
+
Routing decision, per request:
|
|
9
|
+
|
|
10
|
+
* ``/health`` -- always open (the Sonarr/Radarr ``/ping``-style liveness probe).
|
|
11
|
+
* Static bundle assets (any path whose final segment has a file extension, e.g.
|
|
12
|
+
``/assets/index-abc.js``, ``/favicon.svg``) -- always open, so the ``/setup``
|
|
13
|
+
and ``/login`` pages can load their JS/CSS before a session exists. These are
|
|
14
|
+
the SPA's public bundle, not sensitive data (which lives behind ``/api``).
|
|
15
|
+
* ``/api/auth/{setup,login,status}`` -- always open, so the first-run and login
|
|
16
|
+
flows can run with no session yet. (``/api/auth/logout`` is *not* here: it
|
|
17
|
+
needs a session, and enforcement below supplies that.)
|
|
18
|
+
* ``/api/*`` -- open with a valid session **or** the API key (``X-Api-Key``
|
|
19
|
+
header or ``?apikey=`` query param, preserving the Sonarr/Radarr webhook
|
|
20
|
+
convention); otherwise ``401``.
|
|
21
|
+
* Everything else is a browser (SPA navigation) route:
|
|
22
|
+
* While **no credential exists** -- the first-run gate -- only ``/setup`` is
|
|
23
|
+
served; every other route ``303``-redirects to ``/setup``.
|
|
24
|
+
* Once a **credential exists**, a valid session serves the route; otherwise
|
|
25
|
+
it ``303``-redirects to ``/login`` (and ``/login``/``/setup`` themselves
|
|
26
|
+
redirect a logged-in user back into the app, and pre-credential ``/login``
|
|
27
|
+
redirects to ``/setup``).
|
|
28
|
+
|
|
29
|
+
Required-mode (COL-51): the routing table above is what ``auth_required=
|
|
30
|
+
"enabled"`` gets. When ``auth_required="local_bypass"`` (the default -- see
|
|
31
|
+
:class:`collapsarr.settings.models.GlobalSettings`) *and* the caller's direct
|
|
32
|
+
peer address is loopback or a private range, every check above is skipped --
|
|
33
|
+
the request passes straight through, same as ``/health``. A caller whose peer
|
|
34
|
+
address is routable/public is challenged exactly per the table, regardless of
|
|
35
|
+
mode. This makes a LAN/localhost self-hoster's install frictionless (no
|
|
36
|
+
setup, no login, no API key) while any routable-address client -- including
|
|
37
|
+
one pretending to be local -- must still authenticate.
|
|
38
|
+
|
|
39
|
+
Classification (:func:`_client_is_local`) reads only the literal peer address
|
|
40
|
+
the ASGI server accepted the connection from (``request.client``) -- it never
|
|
41
|
+
parses ``X-Forwarded-For`` or any other client-suppliable header, which any
|
|
42
|
+
caller could forge. The consequence: behind a reverse proxy, the proxy's own
|
|
43
|
+
address is what gets classified, not its upstream client's -- an install
|
|
44
|
+
behind a reverse proxy should set ``auth_required="enabled"`` until a later
|
|
45
|
+
ticket adds trusted-proxy support (see the README's Authentication section).
|
|
46
|
+
|
|
47
|
+
The Basic auth method (COL-52) slots in at the browser-route branch below:
|
|
48
|
+
when ``auth_method="basic"``, an unauthenticated browser request gets a ``401``
|
|
49
|
+
+ ``WWW-Authenticate: Basic`` challenge instead of a redirect to ``/login``; a
|
|
50
|
+
valid ``Authorization: Basic`` header verified against the same credential
|
|
51
|
+
(:func:`collapsarr.settings.service.verify_auth_password` -- COL-49's single
|
|
52
|
+
operator credential, shared with Forms) mints a normal session
|
|
53
|
+
(:func:`collapsarr.auth.session.log_in`), so every check *after* that --
|
|
54
|
+
including ``/api``'s session-or-key rule -- is untouched. Only the initial UI
|
|
55
|
+
challenge's transport differs between the two methods.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import base64
|
|
61
|
+
import ipaddress
|
|
62
|
+
import secrets
|
|
63
|
+
from collections.abc import Awaitable, Callable
|
|
64
|
+
|
|
65
|
+
from fastapi import Request, Response
|
|
66
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
67
|
+
|
|
68
|
+
from ..settings.models import AUTH_METHOD_BASIC, AUTH_REQUIRED_LOCAL_BYPASS
|
|
69
|
+
from ..settings.service import get_global_settings, verify_auth_password
|
|
70
|
+
from .session import is_authenticated, log_in
|
|
71
|
+
|
|
72
|
+
API_KEY_HEADER = "X-Api-Key"
|
|
73
|
+
"""Request header carrying the API key (Sonarr/Radarr convention)."""
|
|
74
|
+
|
|
75
|
+
API_KEY_QUERY = "apikey"
|
|
76
|
+
"""Query-parameter fallback for callers that cannot set a custom header."""
|
|
77
|
+
|
|
78
|
+
AUTHORIZATION_HEADER = "authorization"
|
|
79
|
+
BASIC_SCHEME_PREFIX = "basic "
|
|
80
|
+
BASIC_REALM = "Collapsarr"
|
|
81
|
+
"""``WWW-Authenticate`` realm presented in the Basic auth challenge."""
|
|
82
|
+
|
|
83
|
+
HEALTH_PATH = "/health"
|
|
84
|
+
SETUP_PATH = "/setup"
|
|
85
|
+
LOGIN_PATH = "/login"
|
|
86
|
+
APP_ROOT = "/"
|
|
87
|
+
API_PREFIX = "/api"
|
|
88
|
+
|
|
89
|
+
OPEN_API_PATHS = frozenset({"/api/auth/setup", "/api/auth/login", "/api/auth/status"})
|
|
90
|
+
"""``/api`` endpoints reachable before any session/credential exists."""
|
|
91
|
+
|
|
92
|
+
_REDIRECT_STATUS = 303
|
|
93
|
+
"""``See Other``: gate redirects are GET navigations, so switch method to GET."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_key(request: Request) -> str | None:
|
|
97
|
+
"""Return the presented API key from the header, then query string, if any."""
|
|
98
|
+
header = request.headers.get(API_KEY_HEADER)
|
|
99
|
+
if header:
|
|
100
|
+
return header
|
|
101
|
+
return request.query_params.get(API_KEY_QUERY) or None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_basic_credentials(request: Request) -> tuple[str, str] | None:
|
|
105
|
+
"""Decode an ``Authorization: Basic <base64>`` header into
|
|
106
|
+
``(username, password)``, or ``None`` if it is absent or malformed."""
|
|
107
|
+
header = request.headers.get(AUTHORIZATION_HEADER)
|
|
108
|
+
if header is None or not header.lower().startswith(BASIC_SCHEME_PREFIX):
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
decoded = base64.b64decode(header[len(BASIC_SCHEME_PREFIX) :].strip()).decode("utf-8")
|
|
112
|
+
except (ValueError, UnicodeDecodeError):
|
|
113
|
+
return None
|
|
114
|
+
username, separator, password = decoded.partition(":")
|
|
115
|
+
if not separator:
|
|
116
|
+
return None
|
|
117
|
+
return username, password
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _basic_challenge() -> Response:
|
|
121
|
+
"""The ``401`` + ``WWW-Authenticate: Basic`` response for an
|
|
122
|
+
unauthenticated request under the Basic auth method (COL-52)."""
|
|
123
|
+
return JSONResponse(
|
|
124
|
+
status_code=401,
|
|
125
|
+
content={"detail": "Basic authentication required."},
|
|
126
|
+
headers={"WWW-Authenticate": f'Basic realm="{BASIC_REALM}"'},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _client_is_local(request: Request) -> bool:
|
|
131
|
+
"""Whether the request's direct TCP peer is loopback or a private-range address.
|
|
132
|
+
|
|
133
|
+
Reads ``request.client`` -- the literal address the ASGI server accepted
|
|
134
|
+
the connection from -- and nothing else. In particular this deliberately
|
|
135
|
+
does **not** consult ``X-Forwarded-For`` (or any other header): those are
|
|
136
|
+
supplied by the client and trivially spoofable, so trusting them here
|
|
137
|
+
would let any external caller claim to be local and bypass auth entirely.
|
|
138
|
+
The tradeoff (documented in the module docstring and the README) is that
|
|
139
|
+
an install behind a reverse proxy sees the proxy's own peer address, not
|
|
140
|
+
its upstream client's -- trusted-proxy support is a later stub.
|
|
141
|
+
"""
|
|
142
|
+
client = request.client
|
|
143
|
+
if client is None:
|
|
144
|
+
return False
|
|
145
|
+
try:
|
|
146
|
+
address = ipaddress.ip_address(client.host)
|
|
147
|
+
except ValueError:
|
|
148
|
+
# Not a literal IP address (seen in some non-network test harnesses) --
|
|
149
|
+
# treat conservatively as not local.
|
|
150
|
+
return False
|
|
151
|
+
return address.is_loopback or address.is_private
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_static_asset(path: str) -> bool:
|
|
155
|
+
"""Whether ``path`` names a bundled static file (has a file extension).
|
|
156
|
+
|
|
157
|
+
SPA navigation routes are extensionless (``/``, ``/wanted``, ``/setup``);
|
|
158
|
+
built assets carry a hashed filename with an extension
|
|
159
|
+
(``/assets/index-abc.js``, ``/favicon.svg``). Serving the public JS/CSS
|
|
160
|
+
bundle unauthenticated is what lets the login/setup pages render at all.
|
|
161
|
+
"""
|
|
162
|
+
return "." in path.rsplit("/", 1)[-1]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def enforce_auth_middleware(
|
|
166
|
+
request: Request,
|
|
167
|
+
call_next: Callable[[Request], Awaitable[Response]],
|
|
168
|
+
) -> Response:
|
|
169
|
+
"""Gate every request per the routing table in the module docstring."""
|
|
170
|
+
path = request.url.path
|
|
171
|
+
|
|
172
|
+
if path == HEALTH_PATH or path in OPEN_API_PATHS or _is_static_asset(path):
|
|
173
|
+
return await call_next(request)
|
|
174
|
+
|
|
175
|
+
session_factory = request.app.state.session_factory
|
|
176
|
+
with session_factory() as session:
|
|
177
|
+
settings = get_global_settings(session)
|
|
178
|
+
credential_set = settings.auth_username is not None
|
|
179
|
+
expected_key = settings.api_key
|
|
180
|
+
auth_required = settings.auth_required
|
|
181
|
+
auth_method = settings.auth_method
|
|
182
|
+
auth_username = settings.auth_username
|
|
183
|
+
|
|
184
|
+
if auth_required == AUTH_REQUIRED_LOCAL_BYPASS and _client_is_local(request):
|
|
185
|
+
# local_bypass + a loopback/private-range peer: trust the network,
|
|
186
|
+
# skip every check below (first-run gate, session, API key) same as
|
|
187
|
+
# /health. A non-local peer falls through to the normal routing.
|
|
188
|
+
return await call_next(request)
|
|
189
|
+
|
|
190
|
+
authed = is_authenticated(request)
|
|
191
|
+
|
|
192
|
+
if path.startswith(API_PREFIX):
|
|
193
|
+
if authed:
|
|
194
|
+
return await call_next(request)
|
|
195
|
+
provided = _extract_key(request)
|
|
196
|
+
if provided is not None and secrets.compare_digest(provided, expected_key):
|
|
197
|
+
return await call_next(request)
|
|
198
|
+
return JSONResponse(
|
|
199
|
+
status_code=401,
|
|
200
|
+
content={"detail": "Invalid or missing API key."},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# --- Browser (SPA navigation) routes -------------------------------------
|
|
204
|
+
if not credential_set:
|
|
205
|
+
# First-run gate: only the setup page is reachable.
|
|
206
|
+
if path == SETUP_PATH:
|
|
207
|
+
return await call_next(request)
|
|
208
|
+
return RedirectResponse(url=SETUP_PATH, status_code=_REDIRECT_STATUS)
|
|
209
|
+
assert auth_username is not None # credential_set is True past this point
|
|
210
|
+
|
|
211
|
+
if path == SETUP_PATH:
|
|
212
|
+
# Credential already exists -- setup is done.
|
|
213
|
+
if authed:
|
|
214
|
+
return RedirectResponse(url=APP_ROOT, status_code=_REDIRECT_STATUS)
|
|
215
|
+
if auth_method == AUTH_METHOD_BASIC:
|
|
216
|
+
return _basic_challenge()
|
|
217
|
+
return RedirectResponse(url=LOGIN_PATH, status_code=_REDIRECT_STATUS)
|
|
218
|
+
|
|
219
|
+
if auth_method == AUTH_METHOD_BASIC:
|
|
220
|
+
# No Forms /login page under this method -- every other browser route
|
|
221
|
+
# (including /login itself, if visited directly) is challenged or
|
|
222
|
+
# passed the same way.
|
|
223
|
+
if authed:
|
|
224
|
+
return await call_next(request)
|
|
225
|
+
creds = _parse_basic_credentials(request)
|
|
226
|
+
if creds is not None and creds[0] == auth_username:
|
|
227
|
+
with session_factory() as session:
|
|
228
|
+
password_ok = verify_auth_password(session, creds[1])
|
|
229
|
+
if password_ok:
|
|
230
|
+
# Same credential core as Forms (COL-49) -- mint a session so
|
|
231
|
+
# this browser's subsequent /api calls keep working the
|
|
232
|
+
# unchanged session-or-key way; only the initial UI challenge
|
|
233
|
+
# differs.
|
|
234
|
+
log_in(request, auth_username, remember=False)
|
|
235
|
+
return await call_next(request)
|
|
236
|
+
return _basic_challenge()
|
|
237
|
+
|
|
238
|
+
if path == LOGIN_PATH:
|
|
239
|
+
if authed:
|
|
240
|
+
return RedirectResponse(url=APP_ROOT, status_code=_REDIRECT_STATUS)
|
|
241
|
+
return await call_next(request)
|
|
242
|
+
|
|
243
|
+
if authed:
|
|
244
|
+
return await call_next(request)
|
|
245
|
+
return RedirectResponse(url=LOGIN_PATH, status_code=_REDIRECT_STATUS)
|