Compare commits

..
125 Commits
Author SHA1 Message Date
Assclaw 0f4071c970 fix(auth): accept configured public origin for sign-in
Magent CI/CD / verify (push) Successful in 4m15s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-19 12:26:09 +12:00
Assclaw a6a4a9aa24 fix: apply CSP nonce to Next scripts
Magent CI/CD / verify (push) Successful in 4m41s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:51:23 +12:00
Assclaw 91a950a3b0 fix: enforce isolated beta deployment path
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:41:34 +12:00
Assclaw 2525a9eb25 fix: preserve readable deployment source modes
Magent CI/CD / verify (push) Successful in 3m36s
Magent CI/CD / deploy-beta (push) Successful in 1m35s
2026-09-17 22:34:17 +12:00
Assclaw 8a6fe71446 fix: permit beta data ownership setup
Magent CI/CD / verify (push) Successful in 3m39s
Magent CI/CD / deploy-beta (push) Failing after 1m44s
2026-09-17 22:27:07 +12:00
Assclaw 6ab79efc35 fix: use Docker volume for CI smoke data
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Failing after 1m53s
2026-09-17 22:11:28 +12:00
Assclaw f852e7c941 chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 20:03:47 +12:00
Assclaw 5639dbcb83 security: harden data auth and deployment 2026-09-17 18:31:35 +12:00
Assclaw a6d1c73837 chore: patch vulnerable dependencies
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 15:22:26 +12:00
Assclaw aed1bf9256 feat: add seasons from request details
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-16 17:15:40 +12:00
Assclaw 05a540ecbb fix: keep site banner off login page
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 56s
2026-09-16 14:37:00 +12:00
Assclaw d75f36c691 feat: customize site banners and login notices
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-16 14:22:38 +12:00
Assclaw 3465343a69 feat: route ready-title issues through guided workflow
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-15 15:46:09 +12:00
Assclaw 4ba1a5763e feat: simplify ready requests and add global search
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 48s
2026-09-15 15:15:35 +12:00
Assclaw dd51332f3c Wait for Arr download queue hand-off before reporting search outcome
Magent CI/CD / verify (push) Successful in 2m10s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-15 14:15:24 +12:00
Assclaw 6a84e68a03 Show request submission progress in a focused follow-up dialog
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 55s
2026-09-15 14:02:08 +12:00
Assclaw c194db167a Illustrate monthly reports and add viewing pattern breakdowns
Magent CI/CD / verify (push) Successful in 1m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-13 22:21:02 +12:00
Assclaw e232335ca9 Organize diagnostics into horizontal rows and group notification controls
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 50s
2026-09-13 22:08:41 +12:00
Assclaw 8df02fdfd7 Feather the top edge of request artwork
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-13 21:57:26 +12:00
Assclaw ce756c1a65 Restore monitoring on recheck and prevent release-search proxy timeouts
Magent CI/CD / verify (push) Successful in 2m1s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:47:36 +12:00
Assclaw b3b83bda4f Feather request artwork into the page at the sides and bottom
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:27:05 +12:00
Assclaw 0eecab4e0e Add cinematic title artwork backgrounds to request pages
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 51s
2026-09-13 19:22:06 +12:00
Assclaw 765b0d2033 Match Jellyfin punctuation variants using provider metadata and strict fallback
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 17:36:31 +12:00
Assclaw 1debf6053c Keep last verified stage visible while background refresh catches up
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 16:25:00 +12:00
Assclaw d7e5c75cb1 Serve recent stages from SQLite with configurable background refresh
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 16:18:30 +12:00
Assclaw dcd8082c8d Filter recent requests by resolved display stage before pagination
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 12:28:52 +12:00
Assclaw e3332bec1f Explain search outcomes and provide a direct version selection action
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 12:16:28 +12:00
Assclaw f57058eb26 Show a clear spinner while request searches are working
Magent CI/CD / verify (push) Successful in 2m0s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 23:11:14 +12:00
Assclaw 8734f461bb Replace request activity feed with simple current progress
Magent CI/CD / verify (push) Successful in 1m57s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 23:06:14 +12:00
Assclaw 835d9c8de3 Tidy release picker profile toggle and repair display text
Magent CI/CD / verify (push) Successful in 2m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 22:56:21 +12:00
Assclaw 956fb3ecb1 Inherit email link addresses from hosting and proxy settings
Magent CI/CD / verify (push) Successful in 2m2s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 22:46:46 +12:00
Assclaw 4f7853b17b Align missing-episode searches and permit reviewed profile overrides
Magent CI/CD / deploy-beta (push) Successful in 50s
Magent CI/CD / verify (push) Successful in 2m4s
Magent CI/CD / deploy-prod (push) Skipped
2026-09-11 20:26:57 +12:00
Assclaw de25255ea8 Reconcile verified account IDs and make language repairs observable
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 19:56:32 +12:00
Assclaw 38169b881e Support original-language movie requests and fix repair dialog layout
Magent CI/CD / verify (push) Successful in 1m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 18:05:18 +12:00
Assclaw 52c85daae3 Add reviewed duplicate account consolidation and prevent duplicate imports
Magent CI/CD / verify (push) Successful in 11m16s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m33s
2026-09-11 16:38:53 +12:00
Assclaw df6fe58278 Normalize portal kinds before checking feature access
Magent CI/CD / verify (push) Successful in 11m13s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s
2026-09-11 12:32:51 +12:00
Assclaw ec0a866ef3 Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-11 12:31:25 +12:00
Assclaw e2be8b3872 Remove changelog link from user menu
Magent CI/CD / verify (push) Successful in 11m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 5m59s
2026-09-10 17:12:30 +12:00
Assclaw b286ca3c42 Let users email personal reports on demand
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-10 16:15:39 +12:00
Assclaw 6e473fd0a7 Unify user management and add reviewed identity repairs
Magent CI/CD / verify (push) Canceled after 9m3s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-10 16:06:30 +12:00
Assclaw 9856c7fb90 Collapse request discovery after selecting a title
Magent CI/CD / verify (push) Successful in 11m0s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m11s
2026-09-10 15:47:50 +12:00
Assclaw df651eb312 Use admin quality defaults throughout the request pipeline
Magent CI/CD / verify (push) Successful in 10m49s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m6s
2026-09-10 15:20:23 +12:00
Assclaw 77f2c1b42a Add reviewed resolution for missing user identity links
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m29s
2026-09-10 15:02:04 +12:00
Assclaw b310e86f80 Align user directory headings with account rows
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m47s
2026-09-10 13:46:11 +12:00
Assclaw 747a330b19 Center the user directory and group management tools in a dialog
Magent CI/CD / verify (push) Canceled after 9m23s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-10 13:36:37 +12:00
Assclaw b0f8c89db7 Add Grizzlyflix newsletters with curated editions and weekly delivery
Magent CI/CD / verify (push) Successful in 10m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m14s
2026-09-09 23:42:52 +12:00
Assclaw e014baadc3 Preserve monthly recap destinations through sign-in
Magent CI/CD / verify (push) Successful in 10m36s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m26s
2026-09-09 22:42:58 +12:00
Assclaw 1979e02cde Add opt-in monthly email recaps with scheduling and delivery history
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-09 22:39:22 +12:00
Assclaw 333a799e21 Add personal monthly viewing reports and CSV exports
Magent CI/CD / verify (push) Successful in 10m58s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m42s
2026-09-09 16:25:35 +12:00
Assclaw 437836243c Remove page numbering from navigation and page links
Magent CI/CD / verify (push) Successful in 10m28s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m0s
2026-09-08 16:36:38 +12:00
Assclaw e7e4c9eff3 Fix viewing-history artwork and add transcode playback metrics
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m9s
2026-09-08 16:15:16 +12:00
Assclaw 12611a9819 Add admin review and confirmation of cross-service user IDs
Magent CI/CD / verify (push) Successful in 10m29s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 54s
2026-09-08 15:41:45 +12:00
Assclaw 2976145dd8 Add private Jellystat viewing stats to Magent beta
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m16s
2026-09-08 11:25:05 +12:00
Assclaw a3b5759708 Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:59:51 +12:00
Assclaw 13edcb8136 Prevent non-admin issue responses exposing account identities
Magent CI/CD / verify (push) Successful in 10m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:45:19 +12:00
Assclaw 131b5fc5c7 Add private Prometheus API metrics and Grafana performance dashboard
Magent CI/CD / verify (push) Successful in 10m24s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:23:35 +12:00
Assclaw bd1f2cb1cb Make request status badges prominent and easier to scan
Magent CI/CD / verify (push) Successful in 10m32s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 17:31:16 +12:00
Assclaw edca300d27 Add bulk enable invite access to user directory
Magent CI/CD / verify (push) Successful in 10m27s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 16:19:12 +12:00
Assclaw 4034a8f72a Add post-login welcome and friendly how-it-works guide
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 16:08:37 +12:00
Assclaw 0637860b95 Show imported Seerr accounts while preserving linked-user deduplication
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 15:20:10 +12:00
Assclaw 697fc235ee Serve resilient launch cover independently of application upstream
Magent CI/CD / verify (push) Failing after 50m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 13:46:09 +12:00
Assclaw 62ee07f92b Record verified coming-soon cutover and rollback procedure
Magent CI/CD / verify (push) Canceled after 14m20s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-07 13:31:44 +12:00
Assclaw 458ef53f47 Exclude production bootstrap credentials from source and build context
Magent CI/CD / verify (push) Canceled after 1m56s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-07 13:29:43 +12:00
Assclaw c2685f43a7 Prepare clean production setup and coming-soon cover
Magent CI/CD / verify (push) Successful in 11m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 13:14:22 +12:00
Assclaw 98d8b197a9 Link invite trace accounts to admin user profiles
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m54s
2026-09-06 22:47:16 +12:00
Assclaw 625f9ad7f0 Discover Sonarr episode downloads and keep live tracker updating
Magent CI/CD / verify (push) Canceled after 1m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:46:01 +12:00
Assclaw bd668715a3 Keep diagnostic health cards compact beside database details
Magent CI/CD / verify (push) Canceled after 6m1s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:39:59 +12:00
Assclaw 212ac560ec Tidy invite delivery choices and align email fields
Magent CI/CD / verify (push) Canceled after 1m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:38:17 +12:00
Assclaw a32928b1c5 Make verified repair acceptance prominent and simplify confirmation email
Magent CI/CD / verify (push) Canceled after 5m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:32:21 +12:00
Assclaw 74c49fad5b Compact request activity into a live summary and modal history 2026-09-06 22:20:33 +12:00
Assclaw 009bb35032 Track repair collection cycles and reconcile request availability
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s
2026-09-06 22:06:30 +12:00
Assclaw 8d720de500 Streamline issue wizard and make selections explicit
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m2s
2026-09-06 21:44:44 +12:00
Assclaw 1851fa9753 Show actual collector search activity in request pipeline
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m4s
2026-09-06 21:14:42 +12:00
Assclaw dec1dd902c Unify page layouts, compact headers and shared UI styling 2026-09-06 19:52:39 +12:00
Assclaw 4d67567d4c Simplify navigation and modernize profile and sign-in
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m58s
2026-09-06 18:24:18 +12:00
Assclaw b5e4c57e93 Simplify settings workspace and fix responsive admin controls
Magent CI/CD / verify (push) Successful in 11m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-06 17:42:28 +12:00
Assclaw f65e1b114c Apply Stitch media operations redesign
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-09-01 22:58:48 +12:00
Assclaw 32bfa20ab7 Add wrong download repair option
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 22:42:07 +12:00
Assclaw 0b59289a2e Automate repair completion confirmation
Magent CI/CD / verify (push) Canceled after 4m31s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:37:23 +12:00
Assclaw ded794a819 Add self-service profile email management
Magent CI/CD / verify (push) Canceled after 10m22s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:26:53 +12:00
Assclaw c6d449dc17 Move invite flow to client navigation 2026-09-01 22:20:48 +12:00
Assclaw 06d944c9d9 Add admin user email management
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 20s
2026-09-01 22:09:20 +12:00
Assclaw 0ac53b7f59 Add quality-aware release picker modal
Magent CI/CD / verify (push) Canceled after 7m6s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:02:22 +12:00
Assclaw 2dbe11e6bc Rework invite creation as guided flow
Magent CI/CD / verify (push) Canceled after 8m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:53:27 +12:00
Assclaw 3aac40ba0f Keep issue dialog above site navigation
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 21:33:02 +12:00
Assclaw b6c48a0be7 Clarify completed repair handoff
Magent CI/CD / verify (push) Canceled after 9m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:22:55 +12:00
Assclaw 3fc52f70c7 Show live repair progress on requests
Magent CI/CD / verify (push) Canceled after 4m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:17:50 +12:00
Assclaw 6391fbfd81 Monitor media before issue repair searches
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 20:50:16 +12:00
Assclaw 0ed22dd315 Separate invites from profile navigation
Magent CI/CD / verify (push) Successful in 10m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 19:51:02 +12:00
Assclaw 7ed0f4b103 Add global admin user-view preview
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 17:04:25 +12:00
Assclaw b3c41f6dea Add safe user preview for invites
Magent CI/CD / verify (push) Successful in 10m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 16:51:32 +12:00
Assclaw 976d24217b Fix top-level invites navigation
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 15:28:28 +12:00
Assclaw c49a149cfd Refresh invite operations workspace
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 15:12:37 +12:00
Assclaw 5de14b1cb7 Add admin issue deletion workflow
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 14:58:58 +12:00
Assclaw 16876e1cf0 Advance issue status from repair actions
Magent CI/CD / verify (push) Successful in 10m30s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Canceled after 5s
2026-09-01 14:48:08 +12:00
Assclaw c7a56f2525 Add issue workflow progress tracker
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 14:44:17 +12:00
Assclaw 87a4aae246 Move issue history into sidebar modal
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 14:28:27 +12:00
Assclaw e58614305e Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 13:44:13 +12:00
Assclaw 2adbed7259 Add issue resolution confirmation workflow
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 12s
2026-09-01 11:40:43 +12:00
Assclaw 393b8c2a88 Add prominent watch button to ready requests
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 11:16:43 +12:00
Assclaw b0eff9ffcf Clarify and live-refresh media indexing state
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 26s
2026-09-01 09:41:15 +12:00
Assclaw ae6cee5d0b Explain remote service responses in plain English
Magent CI/CD / verify (push) Successful in 10m20s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-31 22:28:56 +12:00
Assclaw 906a777b95 Fix issue search result card layout
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-31 22:14:33 +12:00
Assclaw ec8145a58a Add exact media replacement from issues
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 22:05:11 +12:00
Assclaw f8770cb44a Build guided media issue workflow
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 21:46:43 +12:00
Assclaw 0e04d219a0 Reorganize admin configuration sections
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-31 16:32:24 +12:00
Assclaw 3402e53c31 Protect advanced request diagnostics for non-admins
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 16:09:17 +12:00
Assclaw ecf9b230c1 Separate new requests from request tracking
Magent CI/CD / verify (push) Canceled after 7m35s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 16:02:00 +12:00
Assclaw 8f810e0f36 Isolate sparse request recheck regression test
Magent CI/CD / verify (push) Successful in 10m11s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-31 15:23:49 +12:00
Assclaw 82d87d968e Fix sparse request metadata hydration 2026-08-31 15:21:47 +12:00
Assclaw 372f4a1bfc Fix Seerr transport payload forwarding
Magent CI/CD / verify (push) Canceled after 9m15s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:14:32 +12:00
Assclaw 547ed754e6 Handle Seerr CSRF on request creation
Magent CI/CD / verify (push) Canceled after 2m30s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:11:57 +12:00
Assclaw a55369190b Move request service icons beside card content
Magent CI/CD / verify (push) Canceled after 6m44s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:05:01 +12:00
Assclaw ee81749b43 Add Sonarr and Radarr request icons
Magent CI/CD / verify (push) Canceled after 5m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:59:23 +12:00
Assclaw 9dfea25d56 Redesign the request portal flow
Magent CI/CD / verify (push) Canceled after 5m59s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:53:15 +12:00
Assclaw 963506d098 Show live feedback for remote request actions
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-30 21:23:20 +12:00
Assclaw 02245d365e Add live request pipeline recheck
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m56s
2026-08-30 19:50:09 +12:00
Assclaw 2cbd9fe73f Hide landing page live update indicator
Magent CI/CD / verify (push) Successful in 11m5s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-08-30 19:24:56 +12:00
Assclaw 9db32481bd Resolve Arr metadata before adding media
Magent CI/CD / verify (push) Successful in 11m12s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-30 16:32:14 +12:00
Assclaw 3815dfea60 Move fleet health into admin settings and tidy landing page
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 18s
2026-08-29 22:53:48 +12:00
Assclaw c073581639 Mark available downloads complete
Magent CI/CD / verify (push) Successful in 10m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-29 22:41:50 +12:00
Assclaw 06a000bb06 Balance manual results across missing seasons
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-29 22:17:39 +12:00
Assclaw 391cd41d71 Route manual downloads through Arr collectors
Magent CI/CD / verify (push) Canceled after 3m43s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-29 22:13:46 +12:00
Assclaw 96fc43365f Add live download progress updates
Magent CI/CD / verify (push) Successful in 12m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-29 20:52:49 +12:00
Assclaw 655e2f8158 Start Magent beta overhaul
Magent CI/CD / verify (push) Successful in 13m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-29 20:27:17 +12:00
158 changed files with 5218 additions and 7620 deletions
+1
View File
@@ -0,0 +1 @@
0803262237
+17 -41
View File
@@ -1,44 +1,20 @@
# Release builds accept only application sources and explicit build inputs.
# Local configuration, databases, backups, Git metadata and tool caches must
# never be sent to the builder, even if new directories are added to the repo.
**
!Dockerfile
!.dockerignore
!LICENSE
!backend/
!backend/requirements.txt
!backend/app/
!backend/app/**
!frontend/
!frontend/package.json
!frontend/package-lock.json
!frontend/next-env.d.ts
!frontend/next.config.js
!frontend/proxy.ts
!frontend/tsconfig.json
!frontend/app/
!frontend/app/**
!frontend/public/
!frontend/public/**
!docker/
!docker/supervisord.conf
!docker/requirements-runtime.txt
!data/
.git
.env
.env.*
.venv/
**/.pytest_cache/
stitch_magent_media_operations_redesign/
*.tar
*.tar.gz
*.zip
bootstrap-admin.json
release.tar
*.log
data/*
!data/branding/
!data/branding/**
# Defense in depth for accidental private/generated files under allowed paths.
**/.env
**/.env.*
**/__pycache__
frontend/node_modules/
frontend/.next/
backend/__pycache__/
**/__pycache__/
**/*.pyc
**/*.log
**/*.db
**/*.db-*
**/*.sqlite
**/*.sqlite3
**/bootstrap-admin.json
**/bootstrap-secrets.json
**/.magent-secrets-*
**/node_modules
**/.next
+20
View File
@@ -0,0 +1,20 @@
# Provision this as .env on the beta host. Do not copy production secrets or data.
APP_NAME=Magent Beta
CORS_ALLOW_ORIGIN=https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL=https://beta.grizzlyflix.co.nz
MAGENT_API_URL=https://beta.grizzlyflix.co.nz/api
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=json
JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters
SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password
AUTH_COOKIE_NAME=magent_beta_auth
AUTH_STATE_COOKIE_NAME=magent_beta_logged_in
AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+4 -23
View File
@@ -1,37 +1,18 @@
# Copy to .env for a fresh install; never replace an existing deployment's keys.
# See docs/PUBLIC_RELEASE.md. The localhost settings below are for local HTTP only.
# Never deploy the example secret placeholders.
# Copy to .env for local development. Never reuse these example values in a deployed environment.
APP_NAME=Magent
# Public Docker Hub template: choose a published prod-<commit> tag or sha256 digest.
# Intentionally no default: do not silently pull a mutable or incompatible image.
MAGENT_IMAGE=
MAGENT_BIND_ADDRESS=127.0.0.1
MAGENT_HTTP_PORT=3000
# For public hosting set BOTH URLs to your exact HTTPS origin (no trailing slash),
# for example https://magent.example.com, and AUTH_COOKIE_SECURE=true below.
CORS_ALLOW_ORIGIN=http://localhost:3000
MAGENT_APPLICATION_URL=http://localhost:3000
# Backend address is internal to the combined container, not a browser endpoint.
MAGENT_API_URL=http://127.0.0.1:8000
MAGENT_API_URL=http://localhost:8000
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=text
# Generate independent values as documented in docs/PUBLIC_RELEASE.md.
# Keep both unchanged when upgrading or restoring an offline data-volume backup.
# Generate independent values as documented in README.md.
JWT_SECRET=replace-with-at-least-32-random-characters
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
ADMIN_USERNAME=admin
# Recommended fresh install: generate a separate random setup token. Open /setup
# to create the administrator and connect your apps; remove this after finishing.
SETUP_TOKEN=replace-with-a-separate-random-setup-token
# Alternatively pre-create the first admin with a unique password (12+ chars).
# Leave blank to create the account using the setup wizard and SETUP_TOKEN.
ADMIN_PASSWORD=
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
# false is ONLY for local HTTP; public HTTPS deployments must use true.
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+106
View File
@@ -0,0 +1,106 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- main
- prod
pull_request:
branches:
- beta
- main
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Verify generated build metadata
run: python scripts/verify_build_metadata.py
- name: Audit frontend production dependencies
working-directory: frontend
run: npm audit --omit=dev --package-lock-only --audit-level=high
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Check frontend formatting
working-directory: frontend
run: npm run format:check
- name: Type-check frontend
working-directory: frontend
run: npm run typecheck
- name: Test frontend
working-directory: frontend
run: npm test
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Validate Compose configuration
run: |
cp .env.example .env
docker compose -f docker-compose.yml config --quiet
- name: Build and smoke-test container
run: bash scripts/ci_container_smoke.sh
deploy-beta:
if: github.ref_name == 'beta'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy beta to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_beta_ams_dev01.sh
+11 -12
View File
@@ -1,10 +1,14 @@
.env
.env.*
!.env.example
bootstrap-admin.json
.venv/
.security-test-venv*/
data/
!data/branding/
!data/branding/**
backend/__pycache__/
**/__pycache__/
*.pyc
**/.pytest_cache/
backend/.pytest_cache/
.coverage
coverage.xml
htmlcov/
@@ -12,15 +16,10 @@ frontend/node_modules/
frontend/.next/
*.tsbuildinfo
*.log
*.db
*.db-*
*.sqlite*
*.magent-backup
bootstrap-admin.json
bootstrap-secrets.json
.magent-secrets-*
data/*
!data/branding/
**/.pytest_cache/
.env.*
!.env.example
!.env.*.example
*.tar
*.tar.gz
*.zip
+26 -54
View File
@@ -1,12 +1,8 @@
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS frontend-builder
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
WORKDIR /frontend
# GNU cp is needed only to collect third-party notices in the builder.
RUN apk add --no-cache coreutils
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
@@ -20,77 +16,53 @@ COPY frontend/next.config.js ./next.config.js
COPY frontend/proxy.ts ./proxy.ts
COPY frontend/tsconfig.json ./tsconfig.json
# Keep dependency notices outside the traced bundle: file tracing deliberately
# omits many license files that still need to accompany redistributed packages.
RUN npm run build \
&& npm prune --omit=dev \
&& mkdir /licenses \
&& npm ls --omit=dev --all --json > /licenses/dependencies.json \
&& find node_modules -type f \
\( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'copyright*' \) \
-exec cp --parents -t /licenses {} +
RUN npm run build
FROM python:3.14-alpine@sha256:016508ba505da24f7139765bc4bb669df4e88eb2f12eeadd571bf2f88d7533df AS runtime
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
MAGENT_MANAGED_SECRETS=auto \
SQLITE_PATH=/app/data/magent.db \
API_DOCS_ENABLED=false \
NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
NODE_ENV=production
# Keep curl for existing deployments that override the image healthcheck.
# Copy only Node's runtime binary: npm, headers and the NodeSource installer
# are build tools, not dependencies of the standalone frontend server.
RUN apk upgrade --no-cache \
&& apk add --no-cache curl libstdc++
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend-builder /usr/local/LICENSE /usr/local/share/doc/nodejs/LICENSE
RUN node --version
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ARG MAGENT_UID=1000
ARG MAGENT_GID=1000
RUN addgroup -g ${MAGENT_GID} magent \
&& adduser -D -u ${MAGENT_UID} -G magent -s /sbin/nologin magent \
&& install -d -o magent -g magent -m 0700 /app/data \
&& install -d -o magent -g magent -m 0755 /app/frontend/.next/cache
RUN groupadd --gid ${MAGENT_GID} magent \
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
COPY backend/requirements.txt docker/requirements-runtime.txt /tmp/requirements/
RUN pip install --no-cache-dir --no-compile \
-r /tmp/requirements/requirements.txt \
-r /tmp/requirements/requirements-runtime.txt \
&& pip uninstall -y pip \
&& rm /tmp/requirements/requirements.txt /tmp/requirements/requirements-runtime.txt \
&& rmdir /tmp/requirements
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=magent:magent backend/app ./app
COPY --chown=magent:magent data/branding /app/data/branding
# Next's traced standalone output excludes the full dev/build dependency tree.
COPY --chown=magent:magent --from=frontend-builder /frontend/.next/standalone /app/frontend
COPY --chown=magent:magent --from=frontend-builder /frontend/.next/static /app/frontend/.next/static
COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
COPY LICENSE /usr/share/licenses/magent/LICENSE
COPY --from=frontend-builder /licenses /usr/share/licenses/magent/frontend
LABEL org.opencontainers.image.title="Magent" \
org.opencontainers.image.description="Self-hosted media requests, issues and viewing insights" \
org.opencontainers.image.licenses="MIT"
COPY --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
RUN chown -R magent:magent /app
USER magent:magent
EXPOSE 3000 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error --max-time 2 http://127.0.0.1:3000/login >/dev/null \
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|| exit 1
ENTRYPOINT ["python", "-m", "app.container_bootstrap"]
CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Magent contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+71
View File
@@ -0,0 +1,71 @@
# Production
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
The root `Dockerfile` is the supported build entry point. Source releases come
from `main`; use `prod-<short-commit>` tags to identify an exact release.
## Live deployment
- Host: GRZ-DKR01 (`10.30.1.81`).
- Container and Compose service: `magent`; Compose project: `arrstack`.
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data``/app/data`.
- Public URL: `https://magent.grizzlyflix.co.nz`.
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
## Release checklist
1. Run the backend tests and frontend production build. Review only the intended
changes, then commit and push `main`.
The repository workflow verifies `main` but intentionally does not deploy it;
production changes require the remaining explicit release steps below.
2. Build from a clean source export using the root Dockerfile. Never include
`.env`, databases or bootstrap credentials in the build context.
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
Confirm their digests match.
4. Pull the new image before stopping production. Keep the old image under a
rollback tag and back up the current Compose configuration.
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
and its WAL files are consistent. Protect backups: they contain private data.
6. Recreate only this service with `docker compose -p arrstack -f
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
Confirm that Compose selects the intended image before running this command.
7. Check container health, the API `/health` endpoint, public login, the changed
feature, database integrity and account counts. Do not trigger bulk permission
changes, email sends or user imports as a deployment smoke test.
Include browser-origin POST checks for both `/api/auth/login` and
`/api/auth/jellyfin/login`: an empty form with `Origin` set to the public URL
must reach input validation (422), while an unrelated origin must return 403.
GET-only login/health checks do not detect origin-policy lockouts. Set
`CORS_ALLOW_ORIGIN` to the exact public origin; the state-change guard also
accepts the explicitly configured Hosting & proxy public URL, never a URL
inferred from request Host or forwarded headers.
For rollback, select the saved image and recreate only Magent. Restore data only
if needed; doing so can discard activity since the backup. Never restore a whole
shared Compose or Caddy file without checking for unrelated changes first.
## Build metadata
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
display build number as the frontend package files. `.env` should have exactly
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
tags identify the deployed source commit independently of this display value.
`scripts/process1.ps1` is a local development workflow: it updates metadata,
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
It is **not** the production deployment command. Its build-number helper can be
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
## Fresh instances and historical notes
`scripts/prepare_production_settings.py` exports only allowlisted connection and
SMTP settings for a fresh instance. Do not use it to replace a live database.
`docker-compose.production.yml` is the separate fresh-instance template, not the
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
+192 -70
View File
@@ -1,104 +1,226 @@
# Magent
Self-hosted media requests, viewing stats and issue management for Jellyfin,
Seerr, Sonarr, Radarr and related services. Magent combines a Python/FastAPI API,
a Next.js frontend and SQLite in one non-root container.
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
## Install
## How it works
Paste [compose.yml](compose.yml) into a Portainer **Docker Standalone** stack.
It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no
environment variables or Dockerfile on the user's machine.
1) Requests are pulled from Seerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
5) Optional AI triage summarizes the likely cause and safest next steps.
**Image availability:** the managed-install image is published on Docker Hub.
Only Linux/amd64 has been validated. `latest` is mutable; record the resolved
image digest before updating, or pin an immutable release tag.
## Core features
1. Deploy the stack and wait for the container to become healthy.
2. In its console, select `/bin/ash` and user `magent`, then run:
- Request search by title/year or request ID.
- Recent requests list with posters and status.
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders.
- Health status for each service in the pipeline.
- Cache and sync controls (full sync, delta sync, scheduled syncs).
- Local database for speed and audit history.
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
- Docker-first deployment for easy hosting.
```sh
python -m app.container_bootstrap setup-token
## Quick start (Docker - primary)
Docker is the recommended way to run Magent. It includes the backend and frontend with sane defaults.
```bash
docker compose up --build
```
3. Open the Docker host's address on port 3000. Confirm the browser-facing URL
in setup and use the token to create the first administrator.
The **Get setup token** button shows the console instructions and lets you
copy the command; it never reveals the token to public visitors.
4. Connect your apps, choose preferences and finish setup. Optional apps can
be skipped. Save an encrypted backup afterwards.
Then open:
Keep the Compose security block unchanged. Database storage is fixed at
`/app/data/magent.db` and API docs are disabled in managed installs. CORS and
cookie security follow the confirmed URL. Use HTTPS before public access.
- Frontend: http://localhost:3000
- Backend: http://localhost:8000
See [Portainer setup](docs/PORTAINER.md),
[all environment options](docs/ENVIRONMENT.md),
[backup and restore](docs/installation-and-recovery.md) and
[advanced installation/upgrades](docs/PUBLIC_RELEASE.md).
Existing installations must retain their original data volume and signing/
encryption keys; this fresh-install template is not an automatic migration.
### Docker setup steps
## Build and test
1) Create `.env` with your service URLs and API keys.
2) Run `docker compose up --build`.
3) Log in at http://localhost:3000.
4) Visit Settings to confirm service health.
The source tree contains everything needed to build the application:
### Docker environment variables (sample)
```sh
docker compose -f compose.yml -f compose.build.yml up -d --build
```bash
JELLYSEERR_URL="http://localhost:5055"
JELLYSEERR_API_KEY="..."
SONARR_URL="http://localhost:8989"
SONARR_API_KEY="..."
SONARR_QUALITY_PROFILE_ID="1"
SONARR_ROOT_FOLDER="/tv"
RADARR_URL="http://localhost:7878"
RADARR_API_KEY="..."
RADARR_QUALITY_PROFILE_ID="1"
RADARR_ROOT_FOLDER="/movies"
PROWLARR_URL="http://localhost:9696"
PROWLARR_API_KEY="..."
QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="replace-with-at-least-32-random-characters"
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
JWT_EXP_MINUTES="120"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
For a disposable verification run, without touching an existing installation:
## Screenshots
```sh
docker build -t magent:review .
bash scripts/ci_container_smoke.sh magent:review
MAGENT_SMOKE_MANAGED=true bash scripts/ci_container_smoke.sh magent:review
```
Add screenshots here once available:
Unit checks require Python 3.14 and Node 24:
- `docs/screenshots/home.png`
- `docs/screenshots/request-timeline.png`
- `docs/screenshots/settings.png`
- `docs/screenshots/profile.png`
```sh
## Local development (secondary)
Use this only when you need to modify code locally.
### Backend (FastAPI)
```bash
cd backend
python -m venv .venv
. .venv/bin/activate
pip install -r backend/requirements-dev.txt
python -m unittest discover -s backend/tests -p 'test_*.py'
python scripts/check_environment_docs.py
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
Environment variables (sample):
```bash
$env:JELLYSEERR_URL="http://localhost:5055"
$env:JELLYSEERR_API_KEY="..."
$env:SONARR_URL="http://localhost:8989"
$env:SONARR_API_KEY="..."
$env:SONARR_QUALITY_PROFILE_ID="1"
$env:SONARR_ROOT_FOLDER="/tv"
$env:RADARR_URL="http://localhost:7878"
$env:RADARR_API_KEY="..."
$env:RADARR_QUALITY_PROFILE_ID="1"
$env:RADARR_ROOT_FOLDER="/movies"
$env:PROWLARR_URL="http://localhost:9696"
$env:PROWLARR_API_KEY="..."
$env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
$env:JWT_EXP_MINUTES="120"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
### Frontend (Next.js)
```bash
cd frontend
npm install
npm run dev
```
Open http://localhost:3000
Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other local user you create in SQLite).
### Local quality checks
```bash
bash scripts/ci_backend_quality_gate.sh
cd frontend
npm ci
npm test
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
```
On Windows, activate `.venv\Scripts\Activate.ps1` instead. Do not point tests
at live services or use production credentials.
## Public Hosting Notes
## How it is organised
The frontend proxies `/api/*` to the backend container. Set:
- `backend/app/routers/`: authenticated API endpoints and administration.
- `backend/app/clients/`: media-service clients; `services/`: request states,
synchronisation, notifications, setup and encrypted backups.
- `backend/app/db.py` and `schema_migrations.py`: SQLite persistence/migrations.
- `frontend/app/`: pages and shared interface components; `frontend/proxy.ts`:
browser security headers and request nonces.
- `backend/tests/` and frontend `*.test.*`: synthetic regression tests.
- `Dockerfile` and `docker/`: multi-stage build and process supervision.
- `compose.yml`: prebuilt-image install; `compose.build.yml`: source override.
- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
Requests are cached from Seerr, joined to collector/download/library evidence,
normalised into a user-facing state and displayed by the frontend. App settings
are stored in SQLite; sensitive settings are encrypted with installation-specific
keys. Integrations are optional and are configured through the setup wizard.
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
This `release` branch intentionally excludes internal deployment scripts,
environment files, runtime data, development reports and prior Git history.
It contains no workflow that automatically deploys or publishes an image.
## Gitea CI/CD
## Contributing and security
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
Keep changes focused, add regression tests and run the checks above. Never
commit tokens, database exports, backups or real user information.
See [SECURITY.md](SECURITY.md) for reporting guidance and deployment precautions.
- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
- Push to `main` or `prod`: runs the same verification without automatically changing production.
- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`.
Licensed under [MIT](LICENSE). Third-party dependency licences remain applicable.
The beta deploy step ships tracked repository files over SSH, preserves beta's own `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
- `http://127.0.0.1:8000/health`
- `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job:
The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment.
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
Beta always deploys to the isolated `/home/<deployment-user>/magent-beta` directory; the production path secret is intentionally ignored.
## Security and data handling
Generate independent signing and settings-encryption secrets before first startup:
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots
- `GET /requests/{id}/actions?limit=10` recent action logs
## Troubleshooting
### Login fails
- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
### Services show as down
- Check the URLs and API keys in Settings.
- Verify containers can reach each service (network/DNS).
### No recent requests
- Confirm Seerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
- Run `docker compose up --build` again.
- If needed, run `docker compose down` first, then rebuild.
-28
View File
@@ -1,28 +0,0 @@
# Security
## Reporting a vulnerability
Do not post passwords, access tokens, encryption keys, database exports, backup
files or live exploit details in public issues, discussions or container logs.
Use the repository hosting platform's private vulnerability-reporting feature
if the release owner has enabled it. Otherwise contact the maintainer privately
through the platform where you obtained this release before sending sensitive
details. This repository does not currently advertise a dedicated reporting
address; the release owner must establish one before a broad public launch.
Include the image tag/digest, affected version, a minimal reproduction using
synthetic data, and the security impact. Remove deployment credentials and
personal data from attachments. Do not test against systems you do not own or
have permission to assess.
## Deployment precautions
Follow [the public installation guide](docs/PUBLIC_RELEASE.md): use HTTPS for
public access, independent random secrets, a protected persistent data volume,
and the exact browser-facing origin. Keep the original signing/encryption keys
when upgrading. Do not disable origin checks or run as root to work around a
deployment failure.
Use a reviewed immutable release image and retain a tested backup. Check the
release's declared architecture support and migration notes. The project has
not declared an LTS support window or a guaranteed security-response SLA.
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.env
+1 -8
View File
@@ -5,7 +5,6 @@ from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .installation_origin import managed_runtime
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
@@ -48,14 +47,8 @@ def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
secure = bool(settings.auth_cookie_secure)
if managed_runtime():
from .services.public_urls import magent_public_url
# Follow the persisted operator-selected URL immediately, including
# first login after setup; a restart is not required to protect cookies.
secure = magent_public_url().startswith("https://")
return {
"secure": secure,
"secure": bool(settings.auth_cookie_secure),
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -15,9 +15,9 @@ def _availability_message(result: Any) -> str:
or (isinstance(items, list) and len(items) > 0)
)
return (
"Jellyfin returned possible matches. Magent still needs to check the exact title and file."
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
if available
else "Jellyfin did not find this title in its library search."
else "Grizzlyflix did not find this title in its library search."
)
-1
View File
@@ -51,7 +51,6 @@ class Settings(BaseSettings):
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
-258
View File
@@ -1,258 +0,0 @@
"""Persistent secrets for fresh image-only container installations.
Runs before importing application settings. Existing environment-managed
deployments are unchanged. Secrets are never printed during normal startup.
"""
import base64
import binascii
from contextlib import closing
import json
import os
from pathlib import Path
import re
import secrets
import sqlite3
import stat
import sys
import tempfile
from urllib.parse import urlsplit
from .installation_origin import normalize_application_origin
DATA_DIRECTORY = Path("/app/data")
STATE_FILENAME = "bootstrap-secrets.json"
SECRET_NAMES = ("JWT_SECRET", "SETTINGS_ENCRYPTION_KEY", "SETUP_TOKEN")
MAX_STATE_BYTES = 4096
class BootstrapError(ValueError):
"""An operator-actionable error that never includes a secret value."""
def managed_mode(environment: dict) -> bool:
value = environment.get("MAGENT_MANAGED_SECRETS", "false").strip().lower()
if value == "auto":
# Existing explicitly keyed installations retain their environment and
# JWT-derived encryption behaviour. Fresh image-only installs opt in.
return not bool(environment.get("JWT_SECRET", "").strip())
if value not in {"true", "false", "1", "0", "yes", "no", ""}:
raise BootstrapError("MAGENT_MANAGED_SECRETS must be auto, true or false.")
return value in {"true", "1", "yes"}
def _data_paths(environment: dict, directory: Path) -> tuple[Path, Path]:
directory = directory.absolute()
if not directory.is_dir() or any(part.is_symlink() for part in (directory, *directory.parents)):
raise BootstrapError("Managed installation requires a real, writable /app/data volume; symlinks are not allowed.")
if os.name == "posix":
metadata = directory.stat()
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) & 0o022:
raise BootstrapError("Managed data volume must belong to the runtime user and not be writable by other users.")
database = directory / "magent.db"
configured = Path(environment.get("SQLITE_PATH") or str(database)).absolute()
if configured != database:
raise BootstrapError("Managed installation requires SQLITE_PATH=/app/data/magent.db; retain manual keys for custom paths.")
if os.path.lexists(database) and (database.is_symlink() or not database.is_file()):
raise BootstrapError("Managed database must be a regular file, not a symlink or directory.")
return directory / STATE_FILENAME, database
def _read_state(path: Path) -> dict:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
with os.fdopen(descriptor, "rb") as handle:
metadata = os.fstat(handle.fileno())
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_STATE_BYTES:
raise BootstrapError("Managed secrets file must be a small regular file.")
if os.name == "posix" and (
metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o600
):
raise BootstrapError("Managed secrets file must belong to the runtime user with permissions 0600.")
state = json.loads(handle.read(MAX_STATE_BYTES + 1))
except FileNotFoundError:
raise
except (OSError, ValueError, UnicodeError) as exc:
if isinstance(exc, BootstrapError):
raise
raise BootstrapError("Cannot read managed secrets. Restore the original file; keys will not be regenerated.") from None
if not isinstance(state, dict) or set(state) != {"version", *SECRET_NAMES} or type(state["version"]) is not int or state["version"] != 1:
raise BootstrapError("Invalid managed secrets format. Restore the original file; keys will not be regenerated.")
for key in SECRET_NAMES:
if not isinstance(state[key], str):
raise BootstrapError("Invalid managed secret values. Restore the original file.")
for key in ("JWT_SECRET", "SETUP_TOKEN"):
if not re.fullmatch(r"[A-Za-z0-9_-]{64}", state[key]) or len(set(state[key])) < 2:
raise BootstrapError("Invalid managed token. Restore the original file.")
try:
decoded = base64.b64decode(state["SETTINGS_ENCRYPTION_KEY"], altchars=b"-_", validate=True)
except (ValueError, binascii.Error):
raise BootstrapError("Invalid managed encryption key. Restore the original file.") from None
if len(decoded) != 32 or base64.urlsafe_b64encode(decoded).decode() != state["SETTINGS_ENCRYPTION_KEY"]:
raise BootstrapError("Invalid managed encryption key. Restore the original file.")
if state["JWT_SECRET"] == state["SETUP_TOKEN"]:
raise BootstrapError("Managed signing and setup tokens must be independent.")
return state
def _sync_directory(directory: Path) -> None:
if os.name == "posix":
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _create_state(path: Path) -> dict:
state = {
"version": 1,
"JWT_SECRET": secrets.token_urlsafe(48),
"SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(secrets.token_bytes(32)).decode(),
"SETUP_TOKEN": secrets.token_urlsafe(48),
}
descriptor, temporary_name = tempfile.mkstemp(prefix=".magent-secrets-", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(state, handle, separators=(",", ":"))
handle.flush()
os.fsync(handle.fileno())
try:
# Publish an entirely written file without replacing another
# initializer's state. Both callers subsequently read the winner.
os.link(temporary, path)
_sync_directory(path.parent)
except FileExistsError:
pass
finally:
temporary.unlink(missing_ok=True)
return _read_state(path)
def _saved_origin(database: Path) -> str:
if not database.exists():
return ""
try:
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
if not connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'").fetchone():
return ""
row = connection.execute("SELECT value FROM settings WHERE key='magent_application_url'").fetchone()
return str(row[0] or "") if row else ""
except sqlite3.Error:
raise BootstrapError("Cannot read the saved application address. Check the existing database; no keys were changed.") from None
def _configure_origin(environment: dict, database: Path) -> None:
value = environment.get("MAGENT_APPLICATION_URL", "")
saved = _saved_origin(database)
if saved:
value = saved
if not value:
# No network address is trusted automatically. The token-authorized
# first-admin transaction will save the explicitly confirmed origin.
environment.setdefault("CORS_ALLOW_ORIGIN", "http://localhost:3000")
environment.setdefault("AUTH_COOKIE_SECURE", "false")
return
try:
parsed = urlsplit(value)
valid = (
bool(value) and not any(c.isspace() or ord(c) < 33 or ord(c) == 127 for c in value)
and parsed.scheme in {"http", "https"} and parsed.hostname
and parsed.username is None and parsed.password is None and not parsed.path
and "?" not in value and "#" not in value and "\\" not in value and "*" not in value
and (parsed.port is None or 1 <= parsed.port <= 65535)
)
except ValueError:
valid = False
if not valid:
raise BootstrapError("Set MAGENT_APPLICATION_URL to the exact http(s) browser origin, with no path or trailing slash.")
if not saved and environment.get("CORS_ALLOW_ORIGIN") not in (None, "", value):
raise BootstrapError("CORS_ALLOW_ORIGIN must match MAGENT_APPLICATION_URL for a managed install.")
value = normalize_application_origin(value)
environment["MAGENT_APPLICATION_URL"] = value
environment["CORS_ALLOW_ORIGIN"] = value
secure = environment.get("AUTH_COOKIE_SECURE", "").strip().lower()
if not secure:
environment["AUTH_COOKIE_SECURE"] = str(parsed.scheme == "https").lower()
elif secure not in {"true", "false", "1", "0"}:
raise BootstrapError("AUTH_COOKIE_SECURE must be true or false.")
elif parsed.scheme == "https" and secure in {"false", "0"}:
raise BootstrapError("HTTPS managed installations require AUTH_COOKIE_SECURE=true.")
elif parsed.scheme == "http" and secure in {"true", "1"}:
raise BootstrapError("Secure cookies require an HTTPS application URL.")
def prepare_environment(environment: dict, directory: Path = DATA_DIRECTORY) -> dict:
prepared = dict(environment)
if not managed_mode(prepared):
return prepared
if not prepared.get("JWT_SECRET", "").strip():
prepared.pop("JWT_SECRET", None)
path, database = _data_paths(prepared, directory)
_configure_origin(prepared, database)
if prepared.get("API_DOCS_ENABLED", "false").strip().lower() not in {"", "false", "0"}:
raise BootstrapError("API_DOCS_ENABLED is fixed to false for managed installations.")
try:
state = _read_state(path)
except FileNotFoundError:
# Never add independent encryption to an existing JWT-derived database
# or invent replacement keys after a lost secrets file.
if any(os.path.lexists(str(database) + suffix) for suffix in ("", "-wal", "-shm", "-journal")):
raise BootstrapError("Existing database has no managed secrets file. Restore its original keys or use the existing manual deployment.") from None
if any(prepared.get(key) for key in SECRET_NAMES):
raise BootstrapError("Fresh managed installs generate their own keys. Remove manual key variables or disable managed mode.") from None
state = _create_state(path)
for key in SECRET_NAMES:
if prepared.get(key) and prepared[key] != state[key]:
raise BootstrapError(f"{key} conflicts with the persistent managed value. Keys will not be replaced.")
prepared[key] = state[key]
prepared["SQLITE_PATH"] = str(database)
prepared["API_DOCS_ENABLED"] = "false"
prepared["MAGENT_MANAGED_SECRETS"] = "true"
prepared["MAGENT_RUNTIME_MANAGED"] = "1"
return prepared
def setup_token(environment: dict, directory: Path = DATA_DIRECTORY) -> str:
if not managed_mode(environment):
raise BootstrapError("Managed secrets are disabled. Use the SETUP_TOKEN from your deployment configuration.")
path, database = _data_paths(environment, directory)
state = _read_state(path) # This read-only command never generates keys.
if database.is_symlink() or not database.is_file():
raise BootstrapError("Database is not initialized. Wait for the container to become healthy.")
try:
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
row = connection.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
admin = connection.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
except sqlite3.Error:
raise BootstrapError("Cannot verify setup state. No setup token will be displayed.") from None
if row is None or row[0] != 0 or admin is not None:
raise BootstrapError("Initial administrator setup is no longer available. Sign in with the existing administrator.")
return state["SETUP_TOKEN"]
def main() -> int:
try:
if sys.argv[1:] == ["setup-token"]:
print(setup_token(dict(os.environ)))
return 0
if len(sys.argv) < 2:
raise BootstrapError("Pass the container startup command, or setup-token from the operator console.")
environment = prepare_environment(dict(os.environ))
if managed_mode(environment):
print("Managed installation secrets loaded. For first setup, run in the container console: "
"python -m app.container_bootstrap setup-token", flush=True)
os.execvpe(sys.argv[1], sys.argv[1:], environment)
except (BootstrapError, OSError):
# Never include unexpected I/O details or environment values in logs.
error = sys.exc_info()[1]
message = str(error) if isinstance(error, BootstrapError) else "Cannot access managed installation files or start the runtime. Check volume permissions and original keys."
print(f"Magent startup: {message}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-4
View File
@@ -929,10 +929,6 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str
def ensure_admin_user() -> None:
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
return
# Environment credentials bootstrap only the first administrator. In
# particular, do not inject a destination host's account into a restored DB.
if has_admin_user():
return
existing = get_user_by_username(settings.admin_username)
if existing:
return
-32
View File
@@ -1,32 +0,0 @@
"""Origin validation shared by first-install setup and container startup."""
import os
from urllib.parse import urlsplit
def managed_runtime() -> bool:
# Set by the entrypoint, never by an HTTP header or a database setting.
return os.environ.get("MAGENT_RUNTIME_MANAGED") == "1"
def normalize_application_origin(value: str) -> str:
if not isinstance(value, str) or not value or any(
c.isspace() or ord(c) < 33 or ord(c) == 127 or c in '<>"\\*?#' for c in value
):
raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.")
try:
parsed = urlsplit(value)
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
or parsed.username is not None or parsed.password is not None
or parsed.path not in {"", "/"} or parsed.netloc.endswith(":")):
raise ValueError
port = parsed.port
if port is not None and not 1 <= port <= 65535:
raise ValueError
host = parsed.hostname.encode("idna").decode("ascii").lower()
if ":" in host:
host = f"[{host}]"
suffix = f":{port}" if port is not None and port != (443 if parsed.scheme == "https" else 80) else ""
return f"{parsed.scheme}://{host}{suffix}"
except (ValueError, UnicodeError):
raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.") from None
+9 -68
View File
@@ -6,8 +6,7 @@ import uuid
from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import settings
@@ -34,10 +33,6 @@ from .routers.insights import router as insights_router
from .routers.identities import router as identities_router
from .routers.recaps import router as recaps_router
from .routers.newsletters import router as newsletters_router
from .routers.backups import router as backups_router
from .routers.setup import router as setup_router
from .services.backups import apply_pending_restore
from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.email_recaps import run_email_recap_loop
@@ -57,13 +52,11 @@ from .logging_config import (
)
from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
from .request_limits import InstallationBodyLimitMiddleware
from .secret_storage import validate_secret_storage_configuration
from .services.request_origins import ConfiguredOriginCORSMiddleware, can_claim_initial_origin, is_allowed_request_origin
from .services.request_origins import is_allowed_request_origin
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
_background_started = False
app = FastAPI(
title=settings.app_name,
@@ -73,29 +66,12 @@ app = FastAPI(
)
app.add_middleware(
ConfiguredOriginCORSMiddleware,
CORSMiddleware,
allow_origins=[settings.cors_allow_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(InstallationBodyLimitMiddleware)
@app.exception_handler(RequestValidationError)
async def installation_validation_error(request: Request, exc: RequestValidationError):
if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
# Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
# includes rejected raw input. Never echo tokens/passwords/passphrases.
return JSONResponse(
status_code=422,
content={"detail": [
{key: error[key] for key in ("type", "loc", "msg") if key in error}
for error in exc.errors()
]},
headers={"Cache-Control": "no-store"},
)
return await request_validation_exception_handler(request, exc)
@app.middleware("http")
@@ -113,11 +89,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "")
initial_origin_claim = (
request.method.upper() == "POST" and request.url.path == "/setup/bootstrap"
and can_claim_initial_origin()
)
if origin and not is_allowed_request_origin(origin) and not initial_origin_claim:
if origin and not is_allowed_request_origin(origin):
record_api(request, 403, 0.0)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403)
@@ -249,9 +221,9 @@ def _log_security_configuration_warnings() -> None:
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if admin_password == "adminadmin":
if not admin_password or admin_password == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
@@ -272,11 +244,8 @@ def _enforce_secure_startup_configuration() -> None:
_enforce_secret_configuration()
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
if is_setup_required() and setup_token_configured():
return
raise RuntimeError(
"First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
"or a secure ADMIN_PASSWORD, until an admin account exists."
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
)
@@ -295,9 +264,6 @@ async def startup() -> None:
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
_enforce_secret_configuration()
# Restore offline, before any schema migration, database reader or worker.
apply_pending_restore()
initialize_setup_state()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
@@ -320,22 +286,9 @@ async def startup() -> None:
runtime.log_background_sync_level,
runtime.requests_data_source,
)
app.state.on_setup_complete = _start_background_tasks
await _start_background_tasks()
logger.info("startup complete")
async def _start_background_tasks() -> None:
global _background_started
if _background_started:
return
if is_setup_required():
logger.info("Background imports and automation paused until setup is complete")
return
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
logger.info("Background imports and automation disabled by configuration")
logger.info("Background imports and automation paused for initial setup")
return
_background_started = True
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("request-local-stages", run_local_request_stage_loop)
@@ -345,17 +298,7 @@ async def _start_background_tasks() -> None:
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
_launch_background_task("email-recaps", run_email_recap_loop)
_launch_background_task("newsletters", run_newsletter_loop)
@app.on_event("shutdown")
async def shutdown() -> None:
global _background_started
for task in _background_tasks:
task.cancel()
if _background_tasks:
await asyncio.gather(*_background_tasks, return_exceptions=True)
_background_tasks.clear()
_background_started = False
logger.info("startup complete")
app.include_router(requests_router)
@@ -374,5 +317,3 @@ app.include_router(insights_router)
app.include_router(identities_router)
app.include_router(recaps_router)
app.include_router(newsletters_router)
app.include_router(backups_router)
app.include_router(setup_router)
-50
View File
@@ -1,50 +0,0 @@
"""Bound security-sensitive request bodies before JSON/multipart parsing."""
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart
# envelope; count streamed chunks as well as checking the untrusted header.
RESTORE_BODY_LIMIT = 34 * 1024 * 1024
BOOTSTRAP_BODY_LIMIT = 16 * 1024
class InstallationBodyLimitMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope.get("method") != "POST":
await self.app(scope, receive, send)
return
path = scope.get("path", "").rstrip("/")
limit = {
"/admin/backups/restore": RESTORE_BODY_LIMIT,
"/admin/backups/export": BOOTSTRAP_BODY_LIMIT,
"/setup/bootstrap": BOOTSTRAP_BODY_LIMIT,
}.get(path)
if limit is None:
await self.app(scope, receive, send)
return
headers = dict(scope.get("headers", []))
try:
length = int(headers.get(b"content-length", b"0"))
except ValueError:
length = -1
if length < 0 or length > limit:
await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send)
return
received = 0
async def bounded_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > limit:
raise HTTPException(status_code=413, detail="Request body is too large.")
return message
await self.app(scope, bounded_receive, send)
-6
View File
@@ -680,12 +680,6 @@ async def list_settings() -> Dict[str, Any]:
@router.put("/settings")
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
from ..installation_origin import managed_runtime, normalize_application_origin
if managed_runtime() and "magent_application_url" in payload:
try:
payload = {**payload, "magent_application_url": normalize_application_origin(payload["magent_application_url"])}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updates = 0
touched_logging = False
changed_keys: List[str] = []
-85
View File
@@ -1,85 +0,0 @@
"""Administrator-only encrypted backup downloads and staged restores."""
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from starlette.concurrency import run_in_threadpool
from ..auth import require_admin
from ..db import get_rate_limit_status, record_rate_limit_event
from ..services import backups
def _no_store(response: Response) -> None:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
router = APIRouter(
prefix="/admin/backups", tags=["backups"],
dependencies=[Depends(require_admin), Depends(_no_store)],
)
class ExportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
passphrase: SecretStr = Field(min_length=12, max_length=1024)
include_cache: bool = False
def _rate_limit(user: dict) -> None:
key = str(user["username"])
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
if exceeded:
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
record_rate_limit_event("backups", key)
@router.get("")
def status() -> dict:
return backups.backup_status()
@router.post("/export")
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
_rate_limit(user)
try:
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
except backups.BackupError as exc:
raise HTTPException(400, str(exc)) from exc
return Response(content, media_type="application/octet-stream", headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store", "Pragma": "no-cache",
})
@router.post("/restore", status_code=202)
async def restore(
file: UploadFile = File(...),
passphrase: str = Form(..., min_length=12, max_length=1024),
confirmation: Literal["RESTORE"] = Form(...),
user: dict = Depends(require_admin),
) -> dict:
_rate_limit(user)
try:
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
except backups.BackupError as exc:
raise HTTPException(400, str(exc)) from exc
finally:
await file.close()
return {
"status": "staged", "restart_required": True, "backup": metadata,
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
}
@router.delete("/restore")
def cancel() -> dict:
try:
backups.cancel_restore()
except backups.BackupError as exc:
raise HTTPException(409, str(exc)) from exc
return {"status": "cancelled"}
-92
View File
@@ -1,92 +0,0 @@
"""Initial install bootstrap and authenticated setup wizard endpoints."""
from inspect import isawaitable
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import Field, SecretStr
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
from ..auth import _extract_client_ip, require_admin
from ..services import setup as setup_service
from ..installation_origin import normalize_application_origin
from ..services.request_origins import can_claim_initial_origin
router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
class BootstrapRequest(StrictRequest):
setup_token: SecretStr = Field(min_length=1, max_length=1024)
username: str = Field(min_length=1, max_length=100)
password: SecretStr = Field(min_length=1, max_length=1024)
application_url: str | None = Field(default=None, max_length=2048)
class SetupProgress(StrictRequest):
step: setup_service.SetupStep
@router.get("/status")
def public_status(response: Response) -> dict:
response.headers["Cache-Control"] = "no-store"
return setup_service.get_public_setup_status()
@router.post("/bootstrap", status_code=201)
def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
status = setup_service.get_public_setup_status()
if not status["needs_admin"]:
raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Too many setup attempts. Try again later.",
headers={"Retry-After": str(retry_after)},
)
try:
application_url = payload.application_url
if application_url is not None:
application_url = normalize_application_origin(application_url)
origin = request.headers.get("origin", "")
if not origin or application_url != normalize_application_origin(origin):
raise HTTPException(status_code=403, detail="The site address must match the address open in your browser.")
elif can_claim_initial_origin():
raise HTTPException(status_code=400, detail="Confirm the application URL to create the administrator.")
setup_service.bootstrap_administrator(
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value(),
application_url=application_url,
)
except setup_service.InvalidSetupTokenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except setup_service.SetupUnavailableError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"status": "created", "username": payload.username.strip()}
@router.get("/state", dependencies=[Depends(require_admin)])
def get_state() -> dict:
return setup_service.get_setup_state()
@router.put("/state", dependencies=[Depends(require_admin)])
def update_state(payload: SetupProgress) -> dict:
return setup_service.update_setup_step(payload.step)
@router.post("/complete", dependencies=[Depends(require_admin)])
async def finish_setup(request: Request) -> dict:
try:
state = setup_service.complete_setup()
except setup_service.SetupUnavailableError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Startup owns worker lifecycle. Its callback must be idempotent so retries
# after a network interruption cannot start duplicate import/automation jobs.
callback = getattr(request.app.state, "on_setup_complete", None)
if callback is not None:
result = callback()
if isawaitable(result):
await result
return state
+1 -1
View File
@@ -15,7 +15,7 @@ SENSITIVE_SETTING_KEYS = frozenset(
"magent_notify_telegram_bot_token", "magent_notify_push_token",
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key",
"prowlarr_api_key", "qbittorrent_password", "discord_webhook_url",
"prowlarr_api_key", "qbittorrent_password",
}
)
-647
View File
@@ -1,647 +0,0 @@
"""Encrypted, portable backups and restart-only SQLite restores.
Restore is deliberately a two-step operation: the authenticated request validates
and stages it, then a single backend process applies it before opening the DB.
A durable journal and a private rollback copy protect interrupted installations.
"""
from __future__ import annotations
from contextlib import closing, contextmanager
from datetime import datetime, timezone
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import shutil
import sqlite3
import stat
import tempfile
import threading
import time
from typing import Any, BinaryIO, Iterator
import uuid
import zipfile
import zlib
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from pydantic import TypeAdapter
from ..config import Settings, settings
from ..db import _db_path
from ..installation_origin import managed_runtime, normalize_application_origin
from ..schema_migrations import MIGRATIONS
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
FORMAT_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
MAX_ENTRIES = 20_000
MAGIC = b"MAGENT-BACKUP\x00\x01"
_LOCK = threading.Lock()
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
# Host identity, process controls and local file locations belong to the target.
_LOCAL_FIELDS = {
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
}
class BackupError(ValueError):
"""A safe-to-display backup validation or state error."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _assets_root() -> Path:
# Matches the image and branding routers, independently of SQLITE_PATH.
return Path.cwd() / "data"
def _control_root() -> Path:
return Path(_db_path()).absolute().parent / "backups"
def _private_dir(path: Path) -> None:
if path.is_symlink():
raise BackupError("Backup directories must not be symbolic links")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
path.chmod(0o700)
def _write_private(path: Path, content: bytes) -> None:
with path.open("xb") as handle:
path.chmod(0o600)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
def _write_json(path: Path, data: dict) -> None:
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
try:
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
os.replace(temporary, path)
_sync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def _sync_directory(path: Path) -> None:
if os.name != "nt":
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sync_tree(path: Path) -> None:
for parent, _directories, files in os.walk(path, topdown=False):
for filename in files:
with (Path(parent) / filename).open("r+b") as handle:
os.fsync(handle.fileno())
_sync_directory(Path(parent))
@contextmanager
def _exclusive_operation() -> Iterator[None]:
if not _LOCK.acquire(blocking=False):
raise BackupError("Another backup or restore operation is in progress")
handle = None
locked = False
try:
root = _control_root()
_private_dir(root)
handle = (root / "operation.lock").open("a+b")
os.chmod(handle.name, 0o600)
# OS locks are released even if a process crashes; support the dev host too.
if os.name == "nt":
import msvcrt
handle.seek(0)
if not handle.read(1):
handle.write(b"0")
handle.flush()
handle.seek(0)
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
else:
import fcntl
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
locked = True
yield
finally:
if handle is not None:
if locked:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
_LOCK.release()
def validate_passphrase(passphrase: str) -> None:
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
def _key(passphrase: str, salt: bytes) -> bytes:
validate_passphrase(passphrase)
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
def _encrypt(content: bytes, passphrase: str) -> bytes:
salt, nonce = os.urandom(16), os.urandom(12)
header = MAGIC + salt + nonce
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
def _decrypt(content: bytes, passphrase: str) -> bytes:
header_size = len(MAGIC) + 28
if len(content) > MAX_UPLOAD_BYTES:
raise BackupError("Backup exceeds the 32 MiB upload limit")
if len(content) < header_size + 16 or not content.startswith(MAGIC):
raise BackupError("This is not a supported encrypted Magent backup")
salt = content[len(MAGIC):len(MAGIC) + 16]
nonce = content[len(MAGIC) + 16:header_size]
try:
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
except InvalidTag as exc:
raise BackupError("Incorrect passphrase or damaged backup") from exc
def _database_copy(source: Path, destination: Path) -> None:
if not source.is_file() or source.is_symlink():
raise BackupError("The configured database is unavailable or is a symbolic link")
deadline = time.monotonic() + 60
def progress(_status: int, _remaining: int, _total: int) -> None:
if time.monotonic() > deadline:
raise BackupError("Database is too busy to back up; try again shortly")
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
with closing(sqlite3.connect(destination)) as dst:
destination.chmod(0o600)
src.backup(dst, pages=256, progress=progress, sleep=0.05)
dst.execute("PRAGMA journal_mode=DELETE")
def _portable_database(path: Path) -> None:
"""Materialize env-backed settings and remove source-specific encryption."""
with closing(sqlite3.connect(path)) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
# init_db recreates application-owned triggers after restoration; never
# distribute executable schema objects in a data backup.
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
quoted = str(trigger).replace('"', '""')
conn.execute(f'DROP TRIGGER "{quoted}"')
overrides = dict(conn.execute("SELECT key, value FROM settings"))
for key, default in settings.model_dump().items():
if key in _LOCAL_FIELDS:
continue
value = overrides.get(key)
value = default if value is None else decrypt_setting_value(key, value)
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(key, "" if value is None else str(value), _now()),
)
for key in _LOCAL_FIELDS:
conn.execute("DELETE FROM settings WHERE key=?", (key,))
# Future secret keys may not yet be exposed through Settings.
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
conn.commit()
conn.execute("VACUUM")
def _asset_allowed(name: str, include_cache: bool) -> bool:
parts = PurePosixPath(name).parts
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
return True
return bool(
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
and parts[4] not in {".", ".."}
)
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
root = _assets_root()
for directory in ("branding", "artwork") if include_cache else ("branding",):
base = root / directory
if not base.exists():
continue
if base.is_symlink() or root.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
for parent, directories, files in os.walk(base, followlinks=False):
if any((Path(parent) / name).is_symlink() for name in directories + files):
raise BackupError("Symbolic links are not supported in backup assets")
for filename in files:
path = Path(parent) / filename
archive_name = "files/" + path.relative_to(root).as_posix()
if _asset_allowed(archive_name, include_cache):
yield path, archive_name
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
validate_passphrase(passphrase)
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
directory = Path(temporary)
directory.chmod(0o700)
database = directory / "database.sqlite3"
_database_copy(Path(_db_path()).absolute(), database)
_portable_database(database)
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
archive_path = directory / "payload.zip"
manifest = {
"format_version": FORMAT_VERSION, "created_at": _now(),
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
"files": {},
}
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive_path.chmod(0o600)
total = 0
for path, name in files:
digest = hashlib.sha256()
size = 0
with path.open("rb") as source, archive.open(name, "w") as destination:
while chunk := source.read(1024 * 1024):
total += len(chunk)
size += len(chunk)
if total > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
digest.update(chunk)
destination.write(chunk)
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
archive.writestr("manifest.json", json.dumps(manifest))
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return encrypted, f"magent-backup-{stamp}.magent-backup"
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
try:
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
conn.execute("PRAGMA trusted_schema=OFF")
deadline = time.monotonic() + 30
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
raise BackupError("Backup database failed its integrity check")
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
if len(schema) > 500 or any(
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
for kind, _name, sql in schema
):
raise BackupError("Backup contains an unsupported database schema")
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise BackupError("Backup database contains broken references")
required = {
"settings": {"key", "value", "updated_at"},
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
"signup_invites": {"id", "code", "enabled"},
"requests_cache": {"request_id", "payload_json"},
"schema_migrations": {"version", "name", "applied_at"},
"password_reset_tokens": {"id", "token_hash"},
}
for table, fields in required.items():
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup does not contain a compatible Magent database")
optional = {
"installation_setup": {"id", "completed", "step", "completed_at"},
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
}
table_names = {name for kind, name, _sql in schema if kind == "table"}
for table, fields in optional.items():
if table in table_names:
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup setup state has an incompatible schema")
# An admin can stage a restore only after target initialization. Its
# schema is a trusted reference for *all* runtime columns, including
# versioned migrations that init_db will not rerun on a restored DB.
target = Path(_db_path()).absolute()
if target.is_file() and target != path:
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
continue
quoted = str(table).replace('"', '""')
expected = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
}
actual = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
}
if expected != actual:
raise BackupError("Backup is missing database columns required by this installation")
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
if versions != {migration.version for migration in MIGRATIONS}:
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
if not conn.execute(
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
).fetchone():
raise BackupError("Backup must contain an active administrator account")
values = dict(conn.execute("SELECT key,value FROM settings"))
if _LOCAL_FIELDS.intersection(values):
raise BackupError("Backup contains host-specific configuration")
# Pydantic checks the types of portable settings without reading env values.
for key, value in values.items():
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
value = decrypt_setting_value(key, value)
if key in Settings.model_fields and value not in {None, ""}:
field = Settings.model_fields[key]
TypeAdapter(field.rebuild_annotation()).validate_python(value)
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup database or configuration is invalid") from exc
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ENTRIES:
raise BackupError("Backup contains too many files")
names = [entry.filename for entry in entries]
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
raise BackupError("Backup manifest is missing or contains duplicate files")
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
for entry in entries:
parts = PurePosixPath(entry.filename).parts
mode = entry.external_attr >> 16
if (
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
or str(PurePosixPath(entry.filename)) != entry.filename
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
):
raise BackupError("Backup contains an unsafe archive entry")
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
raise BackupError("Backup manifest is too large")
manifest = json.loads(archive.read("manifest.json"))
if (
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
or not isinstance(manifest.get("include_cache"), bool)
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
or not isinstance(manifest.get("files"), dict)
or set(manifest["files"]) != set(names) - {"manifest.json"}
):
raise BackupError("Backup manifest is invalid or unsupported")
extracted_bytes = 0
for entry in entries:
name = entry.filename
if name == "manifest.json":
continue
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
raise BackupError("Backup contains an unsupported file")
expected = manifest["files"][name]
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
raise BackupError("Backup file does not match its manifest")
target = directory.joinpath(*PurePosixPath(name).parts)
_private_dir(target.parent)
digest = hashlib.sha256()
with archive.open(entry) as source, target.open("xb") as destination:
target.chmod(0o600)
while chunk := source.read(1024 * 1024):
extracted_bytes += len(chunk)
if extracted_bytes > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
digest.update(chunk)
destination.write(chunk)
destination.flush()
os.fsync(destination.fileno())
if digest.hexdigest() != expected.get("sha256"):
raise BackupError("Backup file failed its checksum")
_validate_database(directory / "database.sqlite3")
return manifest
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup archive is invalid or damaged") from exc
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
validate_passphrase(passphrase)
with _exclusive_operation():
root = _control_root()
pending = root / "pending"
if pending.exists():
raise BackupError("A restore is already staged; cancel it before uploading another")
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
destination_origin = None
if managed_runtime():
from .public_urls import magent_public_url
try:
destination_origin = normalize_application_origin(magent_public_url())
except ValueError:
raise BackupError("Configure a valid destination application address before restoring a backup") from None
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
stage = Path(temporary)
stage.chmod(0o700)
manifest = _extract_archive(payload, stage)
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
if value and str(value).startswith("enc:v1:"):
raise BackupError("Backup settings are not portable")
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
if destination_origin is not None:
# The backup's hostname must not replace this installation's
# trusted browser origin or change its cookie policy.
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES ('magent_application_url',?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(destination_origin, _now()),
)
# Do not revive reset links or existing browser sessions. Invites remain intact.
conn.execute("DELETE FROM password_reset_tokens")
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
if not manifest["include_cache"]:
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
conn.commit()
# Remove plaintext secret remnants from replaced/free SQLite pages.
conn.execute("VACUUM")
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
metadata["staged_at"] = _now()
_write_json(stage / "metadata.json", metadata)
# Stage survives reboot; it contains only secrets encrypted for this installation.
os.replace(stage, pending)
_sync_directory(root)
return metadata
def backup_status() -> dict[str, Any]:
root = _control_root()
pending_path = root / "pending" / "metadata.json"
last_path = root / "last-restore.json"
return {
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
"max_expanded_bytes": MAX_EXPANDED_BYTES,
"include_cache_default": False,
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
}
def cancel_restore() -> None:
with _exclusive_operation():
pending = _control_root() / "pending"
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
if pending.exists():
shutil.rmtree(pending)
def _replace_file(source: Path, target: Path) -> None:
_private_dir(target.parent)
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
try:
shutil.copyfile(source, temporary)
temporary.chmod(0o600)
with temporary.open("r+b") as handle:
os.fsync(handle.fileno())
os.replace(temporary, target)
_sync_directory(target.parent)
finally:
temporary.unlink(missing_ok=True)
def _replace_assets(source: Path, target: Path) -> None:
if target.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
if target.exists():
shutil.rmtree(target)
if source.exists():
shutil.copytree(source, target, copy_function=shutil.copyfile)
for parent, _directories, files in os.walk(target):
Path(parent).chmod(0o700)
for filename in files:
(Path(parent) / filename).chmod(0o600)
_sync_tree(target)
if target.parent.exists():
_sync_directory(target.parent)
def _recover(journal: dict, root: Path) -> None:
rollback_name = journal.get("rollback_directory", "")
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
raise BackupError("Restore recovery journal is invalid")
rollback = root / rollback_name
database = Path(_db_path()).absolute()
if journal["had_database"]:
_replace_file(rollback / "database.sqlite3", database)
else:
database.unlink(missing_ok=True)
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
for name in journal["asset_roots"]:
if name not in {"branding", "artwork"}:
raise BackupError("Restore recovery journal is invalid")
_replace_assets(rollback / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
"message": "An interrupted or failed restore was rolled back automatically.",
})
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
pending = root / "pending"
if pending.exists():
shutil.rmtree(pending)
(root / "restore-journal.json").unlink()
_sync_directory(root)
def apply_pending_restore() -> bool:
"""Call once before init_db, with no other backend processes using the DB."""
with _exclusive_operation():
root = _control_root()
journal_path = root / "restore-journal.json"
if journal_path.exists():
journal = json.loads(journal_path.read_text())
if journal.get("phase") in {"complete", "rolled_back"}:
if (root / "pending").exists():
shutil.rmtree(root / "pending")
journal_path.unlink()
_sync_directory(root)
return journal["phase"] == "complete"
_recover(journal, root)
return False
pending = root / "pending"
if not pending.exists():
return False
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
metadata = json.loads((pending / "metadata.json").read_text())
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
database = Path(_db_path()).absolute()
rollback = root / ("rollback-" + uuid.uuid4().hex)
_private_dir(rollback)
# Ensure all disk-space/permission failures in backup happen before replacement.
if database.exists():
_database_copy(database, rollback / "database.sqlite3")
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
# Reject links anywhere before copying or deleting the controlled asset trees.
list(_asset_files(metadata["include_cache"]))
for name in names:
source = _assets_root() / name
if source.exists():
shutil.copytree(source, rollback / "files" / name)
_sync_tree(rollback)
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
_write_json(journal_path, journal)
try:
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
_replace_file(pending / "database.sqlite3", database)
for name in names:
_replace_assets(pending / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
"backup_created_at": metadata["created_at"],
})
_write_json(journal_path, {**journal, "phase": "complete"})
except Exception:
_recover(journal, root)
raise
shutil.rmtree(pending)
journal_path.unlink()
_sync_directory(root)
return True
+3 -3
View File
@@ -213,11 +213,11 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Ready to try again? Magent issue #{item['id']}"
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
body_text = (
"Your repair looks ready to test.\n\n"
f"{item.get('title') or 'Your reported issue'}\n\n"
"Please try the affected content in Jellyfin. Is it fixed?\n\n"
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
f"YES — it works: {issue_url}#yes\n"
f"NO — still broken: {issue_url}#no\n\n"
"Confirm your answer in Magent. You may need to sign in first.\n"
@@ -227,7 +227,7 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
body_html = (
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">MAGENT</p>'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
+9 -9
View File
@@ -16,12 +16,12 @@ def description(entry):
def render_confirmation(username, url):
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from your media library."
return {'subject': 'Confirm your Magent newsletter subscription',
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from Grizzlyflix."
return {'subject': 'Confirm your Grizzlyflix newsletter subscription',
'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
'body_html': document(title='Your next watch starts here.', intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">A weekly look at new movies and TV updates, with posters and links to watch.</p>',
action='Confirm newsletter subscription', url=url, kicker='NEW IN YOUR LIBRARY',
action='Confirm newsletter subscription', url=url, kicker='NEW ON GRIZZLYFLIX',
footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
@@ -58,17 +58,17 @@ def render(content, images, public_url, playback_url, unsubscribe_url, *, previe
body.append(f'''<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;border-bottom:1px solid #363338"><tr>
<td width="92" valign="top" style="padding:18px 12px 18px 0">{poster}</td><td valign="top" style="padding:18px 0;overflow-wrap:anywhere">
<h3 style="margin:0 0 8px;font-size:16px;line-height:1.4;color:#eee8f2">{esc(entry['title'])}</h3><p style="font-size:12px;line-height:1.6;color:#a69fac;margin:0 0 10px">{esc(details)}</p>{copy}
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Jellyfin &#8599;</a></td></tr></table>''')
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Grizzlyflix &#8599;</a></td></tr></table>''')
lines += [entry['title'], details, watch, '']
if not titles:
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in your media library.</p>')
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in Grizzlyflix.</p>')
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
footer = f'You subscribed to the Magent newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
footer = f'You subscribed to the Grizzlyflix newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
subject = ('[Test] ' if test else '') + content['subject']
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Jellyfin: {playback_url}', '',
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Grizzlyflix: {playback_url}', '',
f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
f'Email preferences: {public_url}/profile#newsletters']),
'body_html': document(title='Whats new in your library',
'body_html': document(title='Whats new on Grizzlyflix',
intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
content=''.join(body), action='Explore Jellyfin', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
content=''.join(body), action='Explore Grizzlyflix', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
'inline_images': attachments}
+1 -1
View File
@@ -322,7 +322,7 @@ def complete_weekly(config, content, now: datetime, failure=''):
conn.execute('''INSERT OR IGNORE INTO newsletter_editions
(id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
(identity, f"Whats new in your library · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
(identity, f"Whats new on Grizzlyflix · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
if not empty:
+1 -1
View File
@@ -133,7 +133,7 @@ async def create_draft(user, days):
end = datetime.now(timezone.utc)
config = store.settings()
content = await collect(end - timedelta(days=days), end, config['limit_titles'])
return store.create_edition(content, f"Whats new in your library · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
return store.create_edition(content, f"Whats new on Grizzlyflix · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
def require_edition(identity, revision=None):
-3
View File
@@ -1,7 +1,6 @@
"""Configured public email links, independent of request Host/forwarded headers."""
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
from ..installation_origin import managed_runtime
def valid_public_url(value):
@@ -22,8 +21,6 @@ def magent_public_url(legacy_url=''):
runtime = get_runtime_settings()
proxy = getattr(runtime, 'magent_proxy_base_url', None)
application = getattr(runtime, 'magent_application_url', None)
if managed_runtime():
return valid_public_url(application)
if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
return valid_public_url(proxy)
if str(application or '').strip():
-22
View File
@@ -6,10 +6,8 @@ Never infer a trusted origin from request Host or forwarded headers.
"""
from urllib.parse import urlsplit
from starlette.middleware.cors import CORSMiddleware
from ..config import settings
from ..installation_origin import managed_runtime
from .public_urls import magent_public_url, valid_public_url
@@ -38,26 +36,6 @@ def is_allowed_request_origin(origin: str) -> bool:
candidate = _origin(origin)
if candidate is None:
return False
if managed_runtime():
# The operator confirms this address using the first-install token.
# No localhost fallback remains trusted after a managed installation.
return candidate == _origin(magent_public_url(), configured_url=True)
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
return True
return candidate == _origin(magent_public_url(), configured_url=True)
def can_claim_initial_origin() -> bool:
if not managed_runtime() or magent_public_url():
return False
from .setup import get_public_setup_status
return get_public_setup_status()["needs_admin"]
class ConfiguredOriginCORSMiddleware(CORSMiddleware):
"""Keep CORS response/preflight policy aligned with managed origin checks."""
def is_allowed_origin(self, origin: str) -> bool:
if managed_runtime():
return is_allowed_request_origin(origin)
return super().is_allowed_origin(origin)
-206
View File
@@ -1,206 +0,0 @@
"""Persistent, operator-authorized first-install setup.
Initialize the marker before the main schema: an existing users table identifies
an upgraded installation, while a new database must finish the setup wizard.
The marker and first administrator are protected by SQLite write transactions.
"""
from datetime import datetime, timezone
import hmac
from math import ceil
from time import time
from typing import Literal
from .. import db
from ..config import settings
from ..security import hash_password, validate_password_policy
from ..installation_origin import normalize_application_origin
SetupStep = Literal["administrator", "apps", "preferences", "review"]
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
BOOTSTRAP_IP_ATTEMPTS = 5
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
class SetupUnavailableError(ValueError):
"""Setup has finished, or another administrator already exists."""
class InvalidSetupTokenError(ValueError):
"""The operator's setup token was absent or did not match."""
def initialize_setup_state() -> None:
"""Run once before init_db; subsequent calls preserve progress."""
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
existing_install = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
).fetchone() is not None
conn.execute(
"""CREATE TABLE IF NOT EXISTS installation_setup (
id INTEGER PRIMARY KEY CHECK (id = 1),
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
step TEXT NOT NULL,
completed_at TEXT
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
scope TEXT NOT NULL,
key_hash TEXT NOT NULL,
occurred_at REAL NOT NULL
)"""
)
conn.execute(
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
VALUES (1, ?, ?, ?)""",
(
int(existing_install),
"review" if existing_install else "administrator",
datetime.now(timezone.utc).isoformat() if existing_install else None,
),
)
def get_setup_state() -> dict:
with db._connect() as conn:
# Old databases and isolated callers without startup initialization are
# already installed. A missing marker must never open public bootstrap.
table = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
).fetchone()
row = conn.execute(
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
).fetchone() if table else None
if row is None:
return {"completed": True, "step": "review", "completed_at": None}
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
def is_setup_required() -> bool:
return not get_setup_state()["completed"]
def get_public_setup_status() -> dict:
required = is_setup_required()
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
def setup_token_configured() -> bool:
"""Reject missing values and obvious examples, without claiming to measure entropy."""
token = str(getattr(settings, "setup_token", "") or "").strip()
placeholder = token.casefold().replace("_", "-")
return (
len(token) >= 32
and len(set(token)) > 1
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
)
def consume_bootstrap_attempt(client_ip: str) -> int | None:
"""Atomically reserve one attempt; return Retry-After when limited.
The IP is keyed using the existing HMAC helper, never stored in clear text.
A shared cap limits distributed attempts and expensive password hashing.
"""
now = time()
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
limits = (
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
)
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
(cutoff,),
)
retry_after = 0
for scope, key, maximum in limits:
count, oldest = conn.execute(
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
(scope, key, cutoff),
).fetchone()
if count >= maximum:
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
if retry_after:
return retry_after
conn.executemany(
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
[(scope, key, now) for scope, key, _ in limits],
)
return None
def bootstrap_administrator(setup_token: str, username: str, password: str, *, application_url: str | None = None) -> None:
"""Claim fresh setup exactly once using the deployment's setup token."""
expected = str(getattr(settings, "setup_token", "") or "")
if not setup_token_configured() or not hmac.compare_digest(
setup_token.encode("utf-8"), expected.encode("utf-8")
):
raise InvalidSetupTokenError("Invalid setup token.")
username = username.strip()
if not username or len(username) > 100 or any(
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
):
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
if len(password) > 1024:
raise ValueError("Password must contain no more than 1024 characters.")
password = validate_password_policy(password)
if application_url is not None:
application_url = normalize_application_origin(application_url)
if not is_setup_required() or db.has_admin_user():
raise SetupUnavailableError("Initial administrator setup is no longer available.")
password_hash = hash_password(password)
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
if setup is None or setup[0] or admin:
raise SetupUnavailableError("Initial administrator setup is no longer available.")
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
raise SetupUnavailableError("That username already exists.")
conn.execute(
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
VALUES (?, ?, 'admin', 'local', ?)""",
(username, password_hash, datetime.now(timezone.utc).isoformat()),
)
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
if application_url is not None:
conn.execute(
"""INSERT INTO settings (key, value, updated_at) VALUES ('magent_application_url', ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(application_url, datetime.now(timezone.utc).isoformat()),
)
def update_setup_step(step: SetupStep) -> dict:
if step not in SETUP_STEPS:
raise ValueError("Invalid setup step.")
if not is_setup_required():
return get_setup_state()
with db._connect() as conn:
conn.execute(
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
)
return get_setup_state()
def complete_setup() -> dict:
if not is_setup_required():
return get_setup_state()
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
raise SetupUnavailableError("Create an administrator before completing setup.")
conn.execute(
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
WHERE id = 1 AND completed = 0""",
(datetime.now(timezone.utc).isoformat(),),
)
return get_setup_state()
+13 -13
View File
@@ -582,9 +582,9 @@ def _build_repair_activity(
message = (
f"{collector} now reports the replacement file as collected. "
+ (
"It is also available in Jellyfin."
"It is also available in Grizzlyflix."
if jellyfin_found
else "Jellyfin is indexing the updated file now."
else "Grizzlyflix is indexing the updated file now."
)
)
state = "complete" if jellyfin_found else "indexing"
@@ -658,7 +658,7 @@ def _build_repair_activity(
"label": "Updated media available",
"state": available_step_state,
"detail": (
"The repaired title is available in Jellyfin."
"The repaired title is available in Grizzlyflix."
if jellyfin_found and collection_complete
else (
"The media server is indexing the replacement."
@@ -922,22 +922,22 @@ def _build_presentation(
available_label = "Partially available"
available_state = "partial"
available_state_label = "Partly ready"
available_summary = f"{available} of {total} episodes are ready to watch in Jellyfin."
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
elif jellyfin_found:
available_label = "Available to watch"
available_state = "complete"
available_state_label = "Ready"
available_summary = "This title is ready to watch in Jellyfin."
available_summary = "This title is ready to watch in Grizzlyflix."
elif arr_state == "available":
available_label = "Adding to Jellyfin"
available_label = "Adding to Grizzlyflix"
available_state = "active"
available_state_label = "Indexing"
available_summary = "The download is complete. Jellyfin is indexing this title now."
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
else:
available_label = "Media server"
available_state = "waiting"
available_state_label = "Waiting"
available_summary = "This title has not reached Jellyfin yet."
available_summary = "This title has not reached Grizzlyflix yet."
display_download = dict(download)
if fully_available:
@@ -1026,13 +1026,13 @@ def _apply_repair_presentation(
search = (arr_details.get("search") or {}).get("state")
pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]}
if imported:
label = "Replacement collected — updating Jellyfin"
meaning = "The replacement has been imported. Waiting for Jellyfin to index the updated file."
label = "Replacement collected — updating Grizzlyflix"
meaning = "The replacement has been imported. Waiting for Grizzlyflix to index the updated file."
snapshot.state = NormalizedState.importing
pipeline["download"].update(state="complete", summary="The replacement has been imported.", torrents=[], visible=False)
pipeline["available"].update(label="Updating Jellyfin", state="active", stateLabel="Indexing", summary=meaning)
pipeline["available"].update(label="Updating Grizzlyflix", state="active", stateLabel="Indexing", summary=meaning)
snapshot.presentation["nextStep"] = {
"title": "Wait for the updated file", "description": "This page will update when Jellyfin confirms the replacement.", "actionIds": [],
"title": "Wait for the updated file", "description": "This page will update when Grizzlyflix confirms the replacement.", "actionIds": [],
}
elif unavailable:
label = "Repair status temporarily unavailable"
@@ -1073,7 +1073,7 @@ def _apply_repair_presentation(
if has_unaffected and catalog_found and isinstance(jellyfin_item, dict) and jellyfin_item.get("Id"):
link = f"{public_url.rstrip('/')}/web/index.html#!/details?id={quote(str(jellyfin_item['Id']))}" if public_url else None
pipeline["available"].update(label="Partially available", state="partial", stateLabel="Repair in progress",
summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Jellyfin to index the repaired episodes.", link=link)
summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Grizzlyflix to index the repaired episodes.", link=link)
snapshot.raw["jellyfin"].update(partial=True, link=link)
+4 -4
View File
@@ -448,7 +448,7 @@ class OperationMessageTests(unittest.TestCase):
)
self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}),
"Jellyfin did not find this title in its library search.",
"Grizzlyflix did not find this title in its library search.",
)
def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
@@ -868,7 +868,7 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(available_stage["state"], "complete")
self.assertEqual(available_stage["stateLabel"], "Ready")
self.assertEqual(available_stage["label"], "Available to watch")
self.assertEqual(available_stage["summary"], "This title is ready to watch in Jellyfin.")
self.assertEqual(available_stage["summary"], "This title is ready to watch in Grizzlyflix.")
self.assertEqual(available_stage["link"], "https://media.test/title/3909")
def test_partially_available_content_keeps_missing_download_attention(self) -> None:
@@ -940,10 +940,10 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(download_stage["state"], "complete")
self.assertEqual(available_stage["state"], "active")
self.assertEqual(available_stage["stateLabel"], "Indexing")
self.assertEqual(available_stage["label"], "Adding to Jellyfin")
self.assertEqual(available_stage["label"], "Adding to Grizzlyflix")
self.assertEqual(
available_stage["summary"],
"The download is complete. Jellyfin is indexing this title now.",
"The download is complete. Grizzlyflix is indexing this title now.",
)
-336
View File
@@ -1,336 +0,0 @@
from contextlib import closing
import io
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import zipfile
from cryptography.fernet import Fernet
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.config import settings
from backend.app.routers import backups as backup_router
from backend.app.services import backups
PASSPHRASE = "test backup passphrase with spaces"
class BackupTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.database = self.root / "magent.db"
for key, value in {
"sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
"settings_encryption_key": Fernet.generate_key().decode(),
"jwt_secret": "source-installation-signing-secret-for-backup-tests",
"admin_username": "backup-admin", "admin_password": "a secure initial password",
"jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
"discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
}.items():
context = patch.object(settings, key, value)
context.start()
self.addCleanup(context.stop)
context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
context.start()
self.addCleanup(context.stop)
db.init_db()
db.set_setting("sonarr_api_key", "database-integration-secret")
db.set_setting("site_login_message", "Restored configuration")
db.set_setting("installation_setup", "complete")
with closing(sqlite3.connect(self.database)) as conn, conn:
conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
conn.execute(
"INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
)
self.assets = self.root / "assets"
(self.assets / "branding").mkdir(parents=True)
(self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
(self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
def export(self, include_cache=True):
content, filename = backups.create_backup(PASSPHRASE, include_cache)
self.assertTrue(filename.endswith(".magent-backup"))
return content
def rewrite_archive(self, content, change):
decrypted = backups._decrypt(content, PASSPHRASE)
with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
change(files)
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
for name, value in files.items():
archive.writestr(name, value)
return backups._encrypt(output.getvalue(), PASSPHRASE)
def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
content = self.export()
self.assertNotIn(b"database-integration-secret", content)
self.assertNotIn(b"environment-integration-secret", content)
original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
db.set_setting("site_login_message", "Live data before restart")
settings.settings_encryption_key = Fernet.generate_key().decode()
settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
# Simulate a different host with different env-backed integration settings.
settings.jellyfin_api_key = "destination-env-value"
metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(metadata["include_cache"])
self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
self.assertIsNotNone(backups.backup_status()["pending_restore"])
staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
self.assertNotIn(b"database-integration-secret", staged_bytes)
self.assertNotIn(b"environment-integration-secret", staged_bytes)
self.assertNotIn(b"legacy-private-token", staged_bytes)
(self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
self.assertTrue(backups.apply_pending_restore())
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
self.assertEqual(db.get_setting("installation_setup"), "complete")
self.assertIsNone(db.get_setting("setup_token"))
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
with closing(sqlite3.connect(self.database)) as conn, conn:
self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
status = backups.backup_status()
self.assertIsNone(status["pending_restore"])
self.assertEqual(status["last_restore"]["status"], "restored")
self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
self.assertFalse(backups.apply_pending_restore())
def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
with closing(sqlite3.connect(self.database)) as writer:
writer.execute("PRAGMA journal_mode=WAL")
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
writer.commit()
self.assertTrue(Path(str(self.database) + "-wal").exists())
content = self.export()
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(backups.apply_pending_restore())
with closing(sqlite3.connect(self.database)) as restored:
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
def test_managed_restore_preserves_destination_application_origin(self):
db.set_setting("magent_application_url", "https://source.example.test")
content = self.export()
db.set_setting("magent_application_url", "https://destination.example.test")
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(backups.apply_pending_restore())
self.assertEqual(db.get_setting("magent_application_url"), "https://destination.example.test")
def test_manual_restore_retains_legacy_application_url_behavior(self):
db.set_setting("magent_application_url", "https://source.example.test")
content = self.export()
db.set_setting("magent_application_url", "https://destination.example.test")
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": ""}):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(backups.apply_pending_restore())
self.assertEqual(db.get_setting("magent_application_url"), "https://source.example.test")
def test_managed_restore_without_destination_origin_does_not_stage(self):
content = self.export()
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}), \
patch("backend.app.services.public_urls.magent_public_url", return_value=""):
with self.assertRaisesRegex(backups.BackupError, "destination application address"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertIsNone(backups.backup_status()["pending_restore"])
def test_process_interruption_is_recovered_on_next_startup(self):
class ProcessStopped(BaseException):
pass
content = self.export()
db.set_setting("site_login_message", "Value before interrupted restart")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
with self.assertRaises(ProcessStopped):
backups.apply_pending_restore()
self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
self.assertFalse(backups.apply_pending_restore())
self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
self.assertIsNone(backups.backup_status()["pending_restore"])
def test_crash_after_rollback_does_not_reapply_pending_restore(self):
class ProcessStopped(BaseException):
pass
content = self.export()
db.set_setting("site_login_message", "Value to retain")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
replace_assets = backups._replace_assets
remove_tree = backups.shutil.rmtree
calls = 0
def fail_first_copy(source, target):
nonlocal calls
calls += 1
if calls == 1:
raise OSError("failed apply")
return replace_assets(source, target)
def interrupt_cleanup(path, *args, **kwargs):
if Path(path).name == "pending":
raise ProcessStopped()
return remove_tree(path, *args, **kwargs)
with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
with self.assertRaises(ProcessStopped):
backups.apply_pending_restore()
journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
self.assertEqual(journal["phase"], "rolled_back")
self.assertFalse(backups.apply_pending_restore())
self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
self.assertIsNone(backups.backup_status()["pending_restore"])
def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
directory = self.root / "schema-test"
directory.mkdir()
backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
source = directory / "database.sqlite3"
with closing(sqlite3.connect(source)) as conn, conn:
conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
backups._validate_database(source)
def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
content = self.export()
db.set_setting("site_login_message", "Current data")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
settings.settings_encryption_key = Fernet.generate_key().decode()
with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
backups.apply_pending_restore()
self.assertEqual(db.get_setting("site_login_message"), "Current data")
self.assertIsNotNone(backups.backup_status()["pending_restore"])
def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
self.assertIn("database.sqlite3", archive.namelist())
self.assertIn("files/branding/logo.png", archive.namelist())
self.assertFalse(any("artwork" in name for name in archive.namelist()))
def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
content = self.export()
for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
with self.subTest(password=password):
with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
backups.stage_restore(io.BytesIO(bad_content), password)
self.assertIsNone(backups.backup_status()["pending_restore"])
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
content = self.export()
for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
with self.subTest(name=name):
malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
with self.assertRaises(backups.BackupError):
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
with self.assertRaises(backups.BackupError):
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
self.assertFalse((self.root / "outside.txt").exists())
def test_size_limit_and_unsupported_schema_rejected(self):
content = self.export()
with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
with self.assertRaisesRegex(backups.BackupError, "upload limit"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with closing(sqlite3.connect(self.database)) as conn, conn:
conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
# Validate the original fixture to avoid executing the malicious trigger in export.
with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
backups._validate_database(self.database)
def test_unsupported_compression_is_rejected_before_expansion(self):
content = self.export()
rewritten = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
for entry in original.infolist():
target.writestr(entry.filename, original.read(entry))
with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
def test_cancel_is_idempotent_and_does_not_change_database(self):
content = self.export()
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with self.assertRaisesRegex(backups.BackupError, "already staged"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
backups.cancel_restore()
backups.cancel_restore()
self.assertIsNone(backups.backup_status()["pending_restore"])
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
content = self.export()
db.set_setting("site_login_message", "Keep this current value")
(self.assets / "branding" / "logo.png").write_bytes(b"current logo")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
original = backups._replace_assets
calls = 0
def fail_once(source, target):
nonlocal calls
calls += 1
if calls == 1:
raise OSError("simulated interrupted copy")
return original(source, target)
with patch.object(backups, "_replace_assets", side_effect=fail_once):
with self.assertRaisesRegex(OSError, "interrupted copy"):
backups.apply_pending_restore()
self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
self.assertFalse(backups.apply_pending_restore())
def test_api_requires_admin_and_restore_confirmation(self):
app = FastAPI()
app.include_router(backup_router.router)
with TestClient(app) as client:
self.assertEqual(client.get("/admin/backups").status_code, 401)
app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
self.assertEqual(client.get("/admin/backups").status_code, 403)
self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
status = client.get("/admin/backups")
self.assertEqual(status.status_code, 200)
self.assertEqual(status.headers["cache-control"], "no-store")
self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
self.assertEqual(rejected.status_code, 422)
restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
self.assertEqual(restored.status_code, 202)
self.assertTrue(restored.json()["restart_required"])
self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
if __name__ == "__main__":
unittest.main()
-519
View File
@@ -1,519 +0,0 @@
"""Managed installation regression tests; use only disposable local files."""
import base64
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing, redirect_stderr, redirect_stdout
import io
import json
import os
from pathlib import Path
import sqlite3
import stat
import tempfile
from threading import Barrier
import unittest
from unittest.mock import patch
from backend.app import container_bootstrap as bootstrap
class ContainerBootstrapTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.data = self.root / "data"
self.data.mkdir(mode=0o700)
self.state_path = self.data / bootstrap.STATE_FILENAME
self.database = self.data / "magent.db"
self.environment = {
"MAGENT_MANAGED_SECRETS": "true",
"MAGENT_APPLICATION_URL": "https://magent.example.test",
}
def prepare(self, **changes):
return bootstrap.prepare_environment({**self.environment, **changes}, self.data)
def state(self):
return json.loads(self.state_path.read_text(encoding="utf-8"))
def create_database(self, *, completed=0, admin=False):
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE installation_setup (id INTEGER PRIMARY KEY, completed INTEGER)")
connection.execute("INSERT INTO installation_setup VALUES (1, ?)", (completed,))
connection.execute("CREATE TABLE users (role TEXT)")
if admin:
connection.execute("INSERT INTO users VALUES ('ADMIN')")
def create_symlink(self, path, target, *, directory=False):
try:
path.symlink_to(target, target_is_directory=directory)
except (OSError, NotImplementedError) as exc:
self.skipTest(f"This platform cannot create test symlinks: {type(exc).__name__}")
def test_fresh_install_generates_independent_valid_random_secrets(self):
before = dict(self.environment)
prepared = self.prepare()
state = self.state()
self.assertEqual(self.environment, before)
self.assertEqual(set(state), {"version", *bootstrap.SECRET_NAMES})
self.assertEqual(state["version"], 1)
for name in ("JWT_SECRET", "SETUP_TOKEN"):
self.assertRegex(state[name], r"^[A-Za-z0-9_-]{64}$")
self.assertNotEqual(state["JWT_SECRET"], state["SETUP_TOKEN"])
self.assertEqual(len(base64.urlsafe_b64decode(state["SETTINGS_ENCRYPTION_KEY"])), 32)
for name in bootstrap.SECRET_NAMES:
self.assertEqual(prepared[name], state[name])
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
self.assertFalse(self.database.exists())
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
@unittest.skipUnless(os.name == "posix", "POSIX filesystem ownership/permissions")
def test_state_has_private_permissions_and_runtime_ownership(self):
self.prepare()
metadata = self.state_path.stat()
self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600)
self.assertEqual(metadata.st_uid, os.geteuid())
def test_separate_installations_get_different_secrets(self):
first = self.prepare()
other = self.root / "other"
other.mkdir(mode=0o700)
second = bootstrap.prepare_environment(self.environment, other)
for name in bootstrap.SECRET_NAMES:
self.assertNotEqual(first[name], second[name])
def test_restart_and_existing_database_reuse_exact_file_and_values(self):
first = self.prepare()
original = self.state_path.read_bytes()
original_modified = self.state_path.stat().st_mtime_ns
self.create_database(admin=True)
with patch.object(bootstrap.secrets, "token_bytes", side_effect=AssertionError("Must not regenerate")), \
patch.object(bootstrap.secrets, "token_urlsafe", side_effect=AssertionError("Must not regenerate")):
second = self.prepare()
self.assertEqual(first, second)
self.assertEqual(self.state_path.read_bytes(), original)
self.assertEqual(self.state_path.stat().st_mtime_ns, original_modified)
def test_disabled_mode_is_an_unchanged_copy_without_filesystem_access(self):
for value in (None, "false", "0", "no", "", " FALSE "):
with self.subTest(mode=value):
environment = {"JWT_SECRET": "legacy-key", "MAGENT_APPLICATION_URL": "invalid"}
if value is not None:
environment["MAGENT_MANAGED_SECRETS"] = value
result = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
self.assertEqual(result, environment)
self.assertIsNot(result, environment)
self.assertFalse(self.state_path.exists())
def test_invalid_managed_mode_fails_before_writing(self):
with self.assertRaises(bootstrap.BootstrapError):
self.prepare(MAGENT_MANAGED_SECRETS="perhaps")
self.assertFalse(self.state_path.exists())
def test_auto_mode_generates_fresh_install_keys_without_explicit_jwt(self):
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
self.assertTrue(self.state_path.exists())
self.assertEqual(prepared["MAGENT_MANAGED_SECRETS"], "true")
self.assertEqual(prepared["MAGENT_RUNTIME_MANAGED"], "1")
for name in bootstrap.SECRET_NAMES:
self.assertEqual(prepared[name], self.state()[name])
def test_auto_mode_preserves_explicit_jwt_manual_install_without_filesystem_access(self):
environment = {
"MAGENT_MANAGED_SECRETS": "auto",
"JWT_SECRET": "legacy-explicit-signing-key",
"SQLITE_PATH": "/existing/custom-database.db",
"API_DOCS_ENABLED": "true",
"MAGENT_APPLICATION_URL": "https://legacy.example.test",
"CORS_ALLOW_ORIGIN": "https://legacy.example.test",
}
prepared = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
self.assertEqual(prepared, environment)
self.assertIsNot(prepared, environment)
self.assertNotIn("SETTINGS_ENCRYPTION_KEY", prepared)
self.assertNotIn("MAGENT_RUNTIME_MANAGED", prepared)
self.assertFalse(self.state_path.exists())
def test_auto_mode_whitespace_jwt_is_treated_as_unset(self):
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto", "JWT_SECRET": " "}, self.data)
self.assertEqual(prepared["JWT_SECRET"], self.state()["JWT_SECRET"])
def test_absent_application_url_uses_fixed_defaults_without_claiming_an_origin(self):
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
self.assertFalse(prepared.get("MAGENT_APPLICATION_URL"))
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
self.assertEqual(prepared["API_DOCS_ENABLED"], "false")
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
def test_empty_application_url_is_deferred_to_setup(self):
prepared = self.prepare(MAGENT_APPLICATION_URL="")
self.assertEqual(prepared["MAGENT_APPLICATION_URL"], "")
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
self.assertTrue(self.state_path.exists())
def test_managed_api_docs_cannot_be_enabled(self):
for value in ("true", "1", "yes", "on", "invalid"):
with self.subTest(value=value), self.assertRaisesRegex(bootstrap.BootstrapError, "API_DOCS_ENABLED"):
self.prepare(API_DOCS_ENABLED=value)
self.assertFalse(self.state_path.exists())
def test_saved_public_url_controls_restart_without_key_regeneration(self):
original = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
state_bytes = self.state_path.read_bytes()
self.create_database(admin=True)
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://saved.example.test')")
restarted = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "https://saved.example.test")
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "https://saved.example.test")
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "true")
self.assertEqual(self.state_path.read_bytes(), state_bytes)
for name in bootstrap.SECRET_NAMES:
self.assertEqual(restarted[name], original[name])
def test_saved_public_url_wins_over_stale_deployment_url_on_restart(self):
self.prepare()
self.create_database(admin=True)
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'http://magent.lan:3000')")
restarted = self.prepare(CORS_ALLOW_ORIGIN="https://magent.example.test")
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "http://magent.lan:3000")
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "http://magent.lan:3000")
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "false")
def test_invalid_saved_url_fails_closed_without_changing_keys(self):
self.prepare()
original = self.state_path.read_bytes()
self.create_database(admin=True)
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://user:secret@evil.test')")
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(self.state_path.read_bytes(), original)
def test_existing_database_or_recovery_sidecar_never_generates_replacement_keys(self):
for suffix in ("", "-wal", "-shm", "-journal"):
with self.subTest(suffix=suffix):
path = Path(str(self.database) + suffix)
path.write_bytes(b"existing installation data")
try:
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(path.read_bytes(), b"existing installation data")
self.assertFalse(self.state_path.exists())
finally:
path.unlink()
def test_lost_keys_after_initialization_are_not_recreated(self):
self.prepare()
self.create_database()
self.state_path.unlink()
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertFalse(self.state_path.exists())
def test_fresh_manual_secrets_conflict_without_writing_state(self):
for name in bootstrap.SECRET_NAMES:
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError):
self.prepare(**{name: "synthetic-manual-secret"})
self.assertFalse(self.state_path.exists())
def test_matching_environment_values_are_accepted_but_conflicts_never_replace_file(self):
first = self.prepare()
original = self.state_path.read_bytes()
keys = {name: first[name] for name in bootstrap.SECRET_NAMES}
self.assertEqual(self.prepare(**keys), first)
for name in bootstrap.SECRET_NAMES:
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError) as raised:
self.prepare(**{name: "conflicting-private-value"})
self.assertNotIn("conflicting-private-value", str(raised.exception))
self.assertEqual(self.state_path.read_bytes(), original)
def test_custom_database_location_is_rejected_without_touching_it(self):
custom = self.root / "other.db"
with self.assertRaises(bootstrap.BootstrapError):
self.prepare(SQLITE_PATH=str(custom))
self.assertFalse(custom.exists())
self.assertFalse(self.state_path.exists())
def test_missing_or_symlink_data_directory_is_rejected(self):
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.prepare_environment(self.environment, self.root / "missing")
linked = self.root / "linked-data"
self.create_symlink(linked, self.data, directory=True)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.prepare_environment(self.environment, linked)
self.assertFalse(self.state_path.exists())
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
def test_shared_writable_data_directory_is_rejected(self):
self.data.chmod(0o777)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertFalse(self.state_path.exists())
def test_malformed_json_oversized_and_invalid_schema_never_get_replaced(self):
self.prepare()
valid = self.state()
invalid_states = [
b"not-json", b"\xff", b"x" * (bootstrap.MAX_STATE_BYTES + 1), b"[]", b"{}",
json.dumps({**valid, "version": True}).encode(),
json.dumps({**valid, "version": 2}).encode(),
json.dumps({**valid, "unexpected": "value"}).encode(),
json.dumps({**valid, "JWT_SECRET": None}).encode(),
json.dumps({**valid, "JWT_SECRET": "a" * 64}).encode(),
json.dumps({**valid, "JWT_SECRET": "short"}).encode(),
json.dumps({**valid, "SETUP_TOKEN": valid["JWT_SECRET"]}).encode(),
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": "invalid-key"}).encode(),
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(b"short").decode()}).encode(),
]
for index, payload in enumerate(invalid_states):
with self.subTest(case=index):
self.state_path.write_bytes(payload)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(self.state_path.read_bytes(), payload)
def test_state_directory_is_not_replaced(self):
self.state_path.mkdir()
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertTrue(self.state_path.is_dir())
def test_state_symlink_is_not_followed_or_replaced(self):
self.prepare()
target = self.root / "original-secrets.json"
self.state_path.rename(target)
original = target.read_bytes()
self.create_symlink(self.state_path, target)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(target.read_bytes(), original)
self.assertTrue(self.state_path.is_symlink())
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
def test_publicly_readable_secrets_are_rejected_without_fixing_or_overwriting_them(self):
self.prepare()
original = self.state_path.read_bytes()
self.state_path.chmod(0o644)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o644)
self.assertEqual(self.state_path.read_bytes(), original)
@unittest.skipUnless(hasattr(os, "mkfifo"), "POSIX named pipes")
def test_named_pipe_state_is_rejected_without_blocking(self):
os.mkfifo(self.state_path, 0o600)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertTrue(stat.S_ISFIFO(self.state_path.stat().st_mode))
def test_https_sets_matching_cors_and_secure_cookies(self):
prepared = self.prepare()
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], self.environment["MAGENT_APPLICATION_URL"])
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "true")
def test_explicit_http_lan_origin_disables_secure_cookie_flag_only(self):
prepared = self.prepare(MAGENT_APPLICATION_URL="http://192.0.2.10:3000")
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://192.0.2.10:3000")
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
def test_invalid_origin_fails_without_creating_keys(self):
origins = (
"not-a-url", "https://magent.example.test/", "https://magent.example.test/path",
"//magent.example.test", "ftp://magent.example.test", "http:/magent.example.test",
"https://user:password@magent.example.test", "https://@magent.example.test",
"https://magent.example.test?", "https://magent.example.test#",
"https://magent.example.test:0", "https://magent.example.test:65536",
"https://*.example.test", "https://magent.\ttest", "https://magent.example.test\\path",
" https://magent.example.test", "https://magent.example.test\x00",
)
for origin in origins:
with self.subTest(origin=repr(origin)), self.assertRaises(bootstrap.BootstrapError):
self.prepare(MAGENT_APPLICATION_URL=origin)
self.assertFalse(self.state_path.exists())
def test_cors_mismatch_or_cookie_scheme_conflict_fails_without_keys(self):
cases = (
{"CORS_ALLOW_ORIGIN": "https://elsewhere.example.test"},
{"AUTH_COOKIE_SECURE": "false"},
{"AUTH_COOKIE_SECURE": "0"},
{"AUTH_COOKIE_SECURE": "maybe"},
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "true"},
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "1"},
)
for changes in cases:
with self.subTest(changes=changes), self.assertRaises(bootstrap.BootstrapError):
self.prepare(**changes)
self.assertFalse(self.state_path.exists())
def test_racing_initializers_publish_and_return_one_complete_state(self):
barrier = Barrier(8)
def initialize(_):
barrier.wait(timeout=10)
return self.prepare()
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(initialize, range(8)))
for result in results:
self.assertEqual(result, results[0])
state = self.state()
for name in bootstrap.SECRET_NAMES:
self.assertEqual(state[name], results[0][name])
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
def test_token_command_requires_managed_mode_and_does_not_create_state(self):
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token({}, self.data)
self.assertFalse(self.state_path.exists())
with self.assertRaises((bootstrap.BootstrapError, FileNotFoundError)):
bootstrap.setup_token(self.environment, self.data)
self.assertFalse(self.state_path.exists())
self.assertFalse(self.database.exists())
def test_token_command_does_not_create_an_uninitialized_database(self):
self.prepare()
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertFalse(self.database.exists())
def test_token_command_returns_only_initial_token_using_readonly_closed_connection(self):
prepared = self.prepare()
self.create_database()
before = {path.name: path.read_bytes() for path in self.data.iterdir()}
connections = []
real_connect = sqlite3.connect
def connect(*args, **kwargs):
self.assertTrue(kwargs.get("uri"))
self.assertTrue(args[0].endswith("?mode=ro"))
connection = real_connect(*args, **kwargs)
with self.assertRaises(sqlite3.OperationalError):
connection.execute("INSERT INTO users VALUES ('admin')")
connections.append(connection)
return connection
with patch.object(bootstrap.sqlite3, "connect", side_effect=connect):
token = bootstrap.setup_token(self.environment, self.data)
self.assertEqual(token, prepared["SETUP_TOKEN"])
self.assertEqual({path.name: path.read_bytes() for path in self.data.iterdir()}, before)
for connection in connections:
with self.assertRaises(sqlite3.ProgrammingError):
connection.execute("SELECT 1")
def test_token_command_refuses_once_any_admin_exists_even_before_setup_completion(self):
self.prepare()
self.create_database(admin=True)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
def test_token_command_refuses_completed_setup_even_without_admin(self):
self.prepare()
self.create_database(completed=1)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
def test_token_command_refuses_unknown_or_invalid_database_state(self):
self.prepare()
for payload in (b"", b"not a SQLite database"):
with self.subTest(payload=payload):
self.database.write_bytes(payload)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertEqual(self.database.read_bytes(), payload)
self.database.unlink()
self.create_database()
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("DELETE FROM installation_setup")
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
def test_existing_database_symlink_is_rejected_even_with_valid_state(self):
self.prepare()
self.create_database()
target = self.root / "other.db"
self.database.rename(target)
original = target.read_bytes()
self.create_symlink(self.database, target)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertEqual(target.read_bytes(), original)
def test_existing_database_directory_is_rejected_even_with_valid_state(self):
self.prepare()
self.database.mkdir()
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertTrue(self.database.is_dir())
def test_startup_passes_keys_to_runtime_without_printing_them(self):
prepared = self.prepare()
stdout, stderr = io.StringIO(), io.StringIO()
with patch.dict(os.environ, self.environment, clear=True), \
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord", "-c", "config"]), \
patch.object(bootstrap, "prepare_environment", return_value=prepared), \
patch.object(bootstrap.os, "execvpe") as execute, \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 0)
execute.assert_called_once_with("supervisord", ["supervisord", "-c", "config"], prepared)
self.assertIn("setup-token", stdout.getvalue())
self.assertEqual(stderr.getvalue(), "")
for name in bootstrap.SECRET_NAMES:
self.assertNotIn(prepared[name], stdout.getvalue() + stderr.getvalue())
def test_disabled_startup_does_not_print_managed_install_instructions(self):
stdout, stderr = io.StringIO(), io.StringIO()
environment = {"JWT_SECRET": "manual-test-value"}
with patch.dict(os.environ, environment, clear=True), \
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
patch.object(bootstrap.os, "execvpe") as execute, \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 0)
execute.assert_called_once_with("supervisord", ["supervisord"], environment)
self.assertEqual(stdout.getvalue() + stderr.getvalue(), "")
def test_cli_explicit_token_command_prints_only_token_not_other_keys(self):
prepared = self.prepare()
self.create_database()
retrieve = bootstrap.setup_token
stdout, stderr = io.StringIO(), io.StringIO()
with patch.dict(os.environ, self.environment, clear=True), \
patch.object(bootstrap.sys, "argv", ["bootstrap", "setup-token"]), \
patch.object(bootstrap, "setup_token", side_effect=lambda env: retrieve(env, self.data)), \
patch.object(bootstrap.os, "execvpe") as execute, \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 0)
execute.assert_not_called()
self.assertEqual(stdout.getvalue(), prepared["SETUP_TOKEN"] + "\n")
self.assertEqual(stderr.getvalue(), "")
self.assertNotIn(prepared["JWT_SECRET"], stdout.getvalue())
self.assertNotIn(prepared["SETTINGS_ENCRYPTION_KEY"], stdout.getvalue())
def test_cli_unexpected_io_failure_never_logs_sensitive_exception_details(self):
stdout, stderr = io.StringIO(), io.StringIO()
with patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
patch.object(bootstrap, "prepare_environment", side_effect=OSError("private-secret-material")), \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 1)
self.assertEqual(stdout.getvalue(), "")
self.assertNotIn("private-secret-material", stderr.getvalue())
self.assertIn("Check volume permissions", stderr.getvalue())
if __name__ == "__main__":
unittest.main()
-122
View File
@@ -1,122 +0,0 @@
"""Unit checks for the release smoke harness; no Docker or network required."""
from email.message import Message
from email.parser import BytesParser
from email.policy import default
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
HELPER_PATH = Path(__file__).resolve().parents[2] / "scripts" / "container_smoke.py"
SPEC = importlib.util.spec_from_file_location("magent_container_smoke", HELPER_PATH)
smoke = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(smoke)
def response_headers(**changes):
headers = Message()
for key, value in {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-test-nonce' 'strict-dynamic'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
**changes,
}.items():
headers[key] = value
return headers
class ContainerPackagingHarnessTests(unittest.TestCase):
def test_backup_multipart_preserves_binary_content_and_required_fields(self):
content = b"MAGENT-BACKUP\x00\x01\xff\r\n\x00encrypted"
body, content_type = smoke.backup_restore_upload(content, "synthetic backup passphrase")
parsed = BytesParser(policy=default).parsebytes(
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + body,
)
fields = {part.get_param("name", header="content-disposition"): part
for part in parsed.iter_parts()}
self.assertEqual(set(fields), {"passphrase", "confirmation", "file"})
self.assertEqual(fields["passphrase"].get_payload(decode=True), b"synthetic backup passphrase")
self.assertEqual(fields["confirmation"].get_payload(decode=True), b"RESTORE")
self.assertEqual(fields["file"].get_payload(decode=True), content)
self.assertEqual(fields["file"].get_filename(), "smoke.magent-backup")
def test_http_rejects_conflicting_body_encodings_without_network(self):
with patch.object(smoke.request, "urlopen") as urlopen:
with self.assertRaisesRegex(AssertionError, "only one encoding"):
smoke.http("/test", payload={}, raw=b"binary")
urlopen.assert_not_called()
def page(self, *, nonce="test-nonce", source="/_next/static/app.js", extra=""):
return (
f'<script nonce="{nonce}" src="{source}"></script>'
f'<script nonce="{nonce}">self.__next_f.push([])</script>'
'<link rel="stylesheet" href="/_next/static/app.css">'
f"{extra}"
).encode()
def test_static_assets_and_every_bootstrap_script_are_validated(self):
seen = []
def fake_http(path):
seen.append(path)
if path == "/login":
return self.page(), response_headers()
return b"static content", response_headers(**{"Content-Type": "application/javascript"})
with patch.object(smoke, "http", side_effect=fake_http):
assets = set()
self.assertEqual(smoke.check_page("/login", assets), "test-nonce")
self.assertEqual(assets, {"/_next/static/app.js", "/_next/static/app.css"})
self.assertEqual(seen, ["/login", "/_next/static/app.css", "/_next/static/app.js"])
smoke.check_page("/login", assets)
self.assertEqual(seen[-1], "/login")
self.assertEqual(len(seen), 4)
def test_nonce_mismatch_fails_before_fetching_assets(self):
with patch.object(smoke, "http", return_value=(self.page(nonce="wrong"), response_headers())):
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
smoke.check_page("/login", set())
def test_missing_nonce_policy_is_rejected(self):
headers = response_headers(**{"Content-Security-Policy": "script-src 'self'"})
with patch.object(smoke, "http", return_value=(self.page(), headers)):
with self.assertRaisesRegex(AssertionError, "missing script nonce policy"):
smoke.check_page("/login", set())
def test_development_eval_policy_is_rejected(self):
headers = response_headers(**{
"Content-Security-Policy": "script-src 'nonce-test-nonce' 'strict-dynamic' 'unsafe-eval'",
})
with patch.object(smoke, "http", return_value=(self.page(), headers)):
with self.assertRaisesRegex(AssertionError, "development eval"):
smoke.check_page("/login", set())
def test_html_fallback_for_static_asset_is_rejected(self):
with patch.object(smoke, "http", return_value=(self.page(), response_headers())):
with self.assertRaisesRegex(AssertionError, "Asset returned HTML"):
smoke.check_page("/login", set())
def test_missing_executable_script_nonce_is_rejected(self):
page = self.page(extra='<script src="/_next/static/missing-nonce.js"></script>')
with patch.object(smoke, "http", return_value=(page, response_headers())):
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
smoke.check_page("/login", set())
def test_inert_json_scripts_do_not_require_executable_nonce(self):
page = self.page(extra='<script type="application/ld+json">{"name":"Magent"}</script>')
with patch.object(smoke, "http", return_value=(page, response_headers())):
cache = {"/_next/static/app.js", "/_next/static/app.css"}
self.assertEqual(smoke.check_page("/login", cache), "test-nonce")
def test_external_scripts_are_not_followed_by_smoke_harness(self):
page = self.page(extra='<script nonce="test-nonce" src="https://external.invalid/app.js"></script>')
with patch.object(smoke, "http", return_value=(page, response_headers())):
with self.assertRaisesRegex(AssertionError, "Unexpected external executable asset"):
smoke.check_page("/login", {"/_next/static/app.js", "/_next/static/app.css"})
if __name__ == "__main__":
unittest.main()
-65
View File
@@ -1,65 +0,0 @@
import unittest
from unittest.mock import patch
from scripts.check_environment_docs import (
Setting,
check_documentation,
python_environment_names,
settings_inventory,
)
class EnvironmentDocumentationTests(unittest.TestCase):
def test_reference_covers_repository_variables_and_defaults(self):
errors, count = check_documentation()
self.assertGreater(count, 100)
self.assertEqual(errors, [], "\n".join(errors))
def test_settings_parser_preserves_implicit_names_alias_order_and_defaults(self):
source = '''
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Example"
service_url: str = Field(default=None, validation_alias=AliasChoices("SERVICE_URL", "OLD_URL"))
enabled: bool = Field(default=False, validation_alias="ENABLED")
interval: int = Field(default=60)
build_number: str = Field(default=BUILD_NUMBER)
'''
self.assertEqual(settings_inventory(source), [
Setting(("APP_NAME",), '"Example"'),
Setting(("SERVICE_URL", "OLD_URL"), "null"),
Setting(("ENABLED",), "false"),
Setting(("INTERVAL",), "60"),
Setting(("BUILD_NUMBER",), "@BUILD_NUMBER"),
])
def test_python_scanner_handles_reads_writes_and_bootstrap_mapping(self):
source = '''
os.getenv("METRICS_ENABLED", "false")
os.environ.get("WORKERS_ENABLED", "true")
environment.get("MANAGED_SECRETS", "auto")
prepared["GENERATED_KEY"] = "not-a-real-key"
other.get("NOT_AN_ENVIRONMENT_VARIABLE")
environment.get("lowercase-internal-key")
'''
self.assertEqual(python_environment_names(source), {
"METRICS_ENABLED", "WORKERS_ENABLED", "MANAGED_SECRETS", "GENERATED_KEY",
})
def test_scanning_never_executes_source_or_imports_settings(self):
source = '\ufeffraise RuntimeError("must not execute")\nos.getenv("SAFE_TO_SCAN")\n'
self.assertEqual(python_environment_names(source), {"SAFE_TO_SCAN"})
def test_reference_guard_reports_missing_variables_and_stale_defaults(self):
document = '| `RETRY_SECONDS` | `30` | Retry interval |'
source = 'class Settings(BaseSettings):\n retry_seconds: int = 60\n'
with patch("scripts.check_environment_docs.Path.read_text", side_effect=[document, source]), \
patch("scripts.check_environment_docs.runtime_environment_names", return_value={"NEW_FLAG"}):
errors, count = check_documentation()
self.assertEqual(count, 2)
self.assertIn("Undocumented environment variable: NEW_FLAG", errors)
self.assertTrue(any("Stale source default for RETRY_SECONDS" in error for error in errors))
if __name__ == "__main__":
unittest.main()
-175
View File
@@ -1,175 +0,0 @@
"""Real application HTTP checks for installation, cookies, and backup controls.
All persistence and artwork paths are isolated in temporary directories; workers,
logging file handlers, and the metrics listener are disabled for these tests.
"""
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from backend.app import db, main
from backend.app.config import settings
from backend.app.services import backups
OPERATOR_TOKEN = "installation-http-operator-token-test-123456789"
OWNER_PASSWORD = "installation-http-owner-password-123456789"
BACKUP_PASSPHRASE = "installation-http-backup-passphrase-123456789"
class InstallationHttpTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
for key, value in {
"sqlite_path": str(self.root / "magent.db"),
"sqlite_journal_mode": "DELETE",
"jwt_secret": "installation-http-test-jwt-secret-1234567890",
"settings_encryption_key": None,
"admin_username": "unused-environment-admin",
"admin_password": "",
"setup_token": OPERATOR_TOKEN,
"auth_cookie_secure": True,
"auth_cookie_domain": None,
"auth_cookie_samesite": "strict",
}.items():
context = patch.object(settings, key, value)
context.start()
self.addCleanup(context.stop)
for context in (
patch.object(main, "configure_logging"),
patch.object(main, "start_metrics"),
patch.object(main, "_background_tasks", []),
patch.object(main, "_background_started", False),
patch.object(backups, "_assets_root", return_value=self.root / "assets"),
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}),
):
context.start()
self.addCleanup(context.stop)
self.origin = str(settings.cors_allow_origin).rstrip("/")
self.client = self.enterContext(TestClient(main.app, base_url="https://magent.test"))
self.client.headers["Origin"] = self.origin
def create_owner(self):
response = self.client.post("/setup/bootstrap", json={
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
})
self.assertEqual(response.status_code, 201, response.text)
return response
def sign_in(self):
response = self.client.post("/auth/login", data={"username": "owner", "password": OWNER_PASSWORD})
self.assertEqual(response.status_code, 200, response.text)
self.assertIn(settings.auth_cookie_name, self.client.cookies)
auth_cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "="))
self.assertIn("HttpOnly", auth_cookie)
self.assertIn("Secure", auth_cookie)
self.assertIn("SameSite=strict", auth_cookie)
self.assertNotIn("Authorization", self.client.headers)
def test_fresh_setup_cookie_settings_completion_and_backup_round_trip(self):
status = self.client.get("/setup/status")
self.assertEqual(status.json(), {"setup_required": True, "needs_admin": True})
self.assertEqual(status.headers["cache-control"], "no-store")
self.assertIn("default-src 'none'", status.headers["content-security-policy"])
self.assertEqual(self.client.get("/setup/state").status_code, 401)
self.assertEqual(self.client.get("/admin/backups").status_code, 401)
self.create_owner()
self.sign_in()
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
response = self.client.put("/admin/settings", json={
"jellyfin_base_url": "http://jellyfin.test:8096",
"jellyfin_api_key": "test-integration-key-for-setup",
"site_login_message": "Welcome to this installation",
})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["updated"], 3)
values = {row["key"]: row for row in self.client.get("/admin/settings").json()["settings"]}
self.assertEqual(values["jellyfin_base_url"]["value"], "http://jellyfin.test:8096")
self.assertIsNone(values["jellyfin_api_key"]["value"])
self.assertTrue(values["jellyfin_api_key"]["isSet"])
response = self.client.put("/setup/state", json={"step": "review"})
self.assertEqual(response.status_code, 200, response.text)
completed = self.client.post("/setup/complete")
self.assertEqual(completed.status_code, 200, completed.text)
self.assertTrue(completed.json()["completed"])
self.assertEqual(self.client.get("/setup/status").json(), {"setup_required": False, "needs_admin": False})
self.assertEqual(main._background_tasks, [])
exported = self.client.post("/admin/backups/export", json={
"passphrase": BACKUP_PASSPHRASE, "include_cache": False,
})
self.assertEqual(exported.status_code, 200, exported.text[:100])
self.assertTrue(exported.content.startswith(backups.MAGIC))
self.assertEqual(exported.headers["cache-control"], "no-store")
self.assertNotIn(b"test-integration-key-for-setup", exported.content)
restored = self.client.post("/admin/backups/restore", files={
"file": ("restore.magent-backup", io.BytesIO(exported.content), "application/octet-stream"),
}, data={"passphrase": BACKUP_PASSPHRASE, "confirmation": "RESTORE"})
self.assertEqual(restored.status_code, 202, restored.text)
self.assertTrue(restored.json()["restart_required"])
self.assertEqual(db.get_setting("site_login_message"), "Welcome to this installation")
self.assertIsNotNone(self.client.get("/admin/backups").json()["pending_restore"])
cancelled = self.client.delete("/admin/backups/restore")
self.assertEqual(cancelled.status_code, 200, cancelled.text)
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
def test_cross_origin_bootstrap_and_authenticated_changes_are_rejected(self):
response = self.client.post("/setup/bootstrap", headers={"Origin": "https://unrelated.invalid"}, json={
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
})
self.assertEqual(response.status_code, 403)
self.assertFalse(db.has_admin_user())
self.create_owner()
self.sign_in()
response = self.client.put("/setup/state", headers={"Origin": "https://unrelated.invalid"}, json={"step": "review"})
self.assertEqual(response.status_code, 403)
response = self.client.post("/admin/backups/export", headers={"Origin": "https://unrelated.invalid"}, json={"passphrase": BACKUP_PASSPHRASE})
self.assertEqual(response.status_code, 403)
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
def test_setup_validation_errors_do_not_echo_password_or_token(self):
secret_password = "private-password-marker-" + "p" * 1024
secret_token = "private-token-marker-" + "t" * 1024
for payload, secret in (
({"setup_token": OPERATOR_TOKEN, "username": "owner", "password": secret_password}, secret_password),
({"setup_token": secret_token, "username": "owner", "password": OWNER_PASSWORD}, secret_token),
({"setup_token": OPERATOR_TOKEN, "password": OWNER_PASSWORD}, OWNER_PASSWORD),
):
with self.subTest(secret=secret[:22]):
response = self.client.post("/setup/bootstrap", json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertNotIn(secret, response.text)
self.assertNotIn(OPERATOR_TOKEN, response.text)
for error in response.json()["detail"]:
self.assertNotIn("input", error)
def test_backup_validation_errors_do_not_echo_passphrases(self):
self.create_owner()
self.sign_in()
passphrase = "private-backup-passphrase-marker-" + "p" * 1024
response = self.client.post("/admin/backups/export", json={"passphrase": passphrase})
self.assertEqual(response.status_code, 422)
self.assertNotIn(passphrase, response.text)
response = self.client.post("/admin/backups/restore", files={"file": ("archive", b"data")}, data={
"passphrase": passphrase, "confirmation": "RESTORE",
})
self.assertEqual(response.status_code, 422)
self.assertNotIn(passphrase, response.text)
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
def test_real_middleware_rejects_oversized_bootstrap_before_creation(self):
response = self.client.post("/setup/bootstrap", content=b"x" * (17 * 1024), headers={"Content-Type": "application/json"})
self.assertEqual(response.status_code, 413, response.text)
self.assertFalse(db.has_admin_user())
if __name__ == "__main__":
unittest.main()
@@ -1,162 +0,0 @@
import asyncio
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import httpx
from fastapi import FastAPI, File, Request, UploadFile
from backend.app import db, main
from backend.app.config import settings
from backend.app.request_limits import InstallationBodyLimitMiddleware
from backend.app.services import setup
class InstallationLifecycleTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temporary.cleanup)
patches = [
patch.object(settings, "sqlite_path", str(Path(temporary.name) / "magent.db")),
patch.object(settings, "jwt_secret", "installation-lifecycle-secret-1234567890"),
patch.object(settings, "settings_encryption_key", None),
patch.object(settings, "admin_password", ""),
patch.object(settings, "setup_token", "operator-setup-token-at-least-32-characters"),
patch.object(main, "_background_started", False),
patch.object(main, "_background_tasks", []),
patch.object(main, "start_metrics"),
patch.object(main, "configure_logging"),
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "true"}),
]
for item in patches:
item.start()
self.addCleanup(item.stop)
async def test_fresh_start_waits_for_admin_and_completion_then_starts_workers_once(self):
with patch.object(main, "_launch_background_task") as launch:
await main.startup()
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": True})
launch.assert_not_called()
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
await main._start_background_tasks()
launch.assert_not_called()
setup.complete_setup()
await main.app.state.on_setup_complete()
await main.app.state.on_setup_complete()
self.assertEqual(launch.call_count, 9)
async def test_upgraded_install_starts_normally_without_setup_token(self):
db.init_db()
db.create_user("owner", "existing-password-12345", role="admin")
settings.setup_token = ""
with patch.object(main, "_launch_background_task") as launch:
await main.startup()
self.assertFalse(setup.is_setup_required())
self.assertEqual(launch.call_count, 9)
async def test_disabled_workers_stay_disabled_after_setup(self):
setup.initialize_setup_state()
db.init_db()
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
setup.complete_setup()
with patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), patch.object(main, "_launch_background_task") as launch:
await main._start_background_tasks()
launch.assert_not_called()
async def test_bad_secret_stops_before_restore_or_database_initialization(self):
settings.jwt_secret = "short"
with patch.object(main, "apply_pending_restore") as restore, patch.object(main, "init_db") as initialize:
with self.assertRaisesRegex(RuntimeError, "JWT_SECRET"):
await main.startup()
restore.assert_not_called()
initialize.assert_not_called()
async def test_restore_failure_stops_before_initialization_and_workers(self):
with patch.object(main, "apply_pending_restore", side_effect=RuntimeError("restore failed")), patch.object(main, "init_db") as initialize, patch.object(main, "_launch_background_task") as launch:
with self.assertRaisesRegex(RuntimeError, "restore failed"):
await main.startup()
initialize.assert_not_called()
launch.assert_not_called()
async def test_startup_order_is_restore_then_setup_marker_then_schema(self):
calls = Mock()
calls.attach_mock(Mock(wraps=main.apply_pending_restore), "restore")
calls.attach_mock(Mock(wraps=main.initialize_setup_state), "setup")
calls.attach_mock(Mock(wraps=main.init_db), "schema")
with patch.object(main, "apply_pending_restore", calls.restore), patch.object(main, "initialize_setup_state", calls.setup), patch.object(main, "init_db", calls.schema):
await main.startup()
self.assertEqual([call[0] for call in calls.mock_calls], ["restore", "setup", "schema"])
def test_missing_token_does_not_allow_fresh_bootstrap(self):
setup.initialize_setup_state()
db.init_db()
settings.setup_token = ""
with self.assertRaisesRegex(RuntimeError, "SETUP_TOKEN"):
main._enforce_secure_startup_configuration()
def test_destination_environment_does_not_add_an_admin_to_restored_accounts(self):
db.init_db()
db.create_user("restored-owner", "existing-password-12345", role="admin")
with patch.object(settings, "admin_username", "host-bootstrap"), patch.object(settings, "admin_password", "new-host-password-12345"):
db.init_db()
self.assertIsNone(db.get_user_by_username("host-bootstrap"))
async def test_shutdown_cancels_workers_and_allows_next_start(self):
task = asyncio.create_task(asyncio.Event().wait())
main._background_tasks.append(task)
main._background_started = True
await main.shutdown()
self.assertTrue(task.cancelled())
self.assertEqual(main._background_tasks, [])
self.assertFalse(main._background_started)
class InstallationRequestLimitsTests(unittest.IsolatedAsyncioTestCase):
async def test_rejects_oversized_declared_body_before_parser(self):
app = FastAPI()
app.add_middleware(InstallationBodyLimitMiddleware)
@app.post("/setup/bootstrap")
async def bootstrap(request: Request):
self.fail("Body must be rejected before the endpoint")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/setup/bootstrap", content=b"{}", headers={"Content-Length": "999999"})
self.assertEqual(response.status_code, 413)
async def test_counts_chunks_with_missing_or_forged_content_length(self):
app = FastAPI()
app.add_middleware(InstallationBodyLimitMiddleware)
@app.post("/setup/bootstrap")
async def bootstrap(request: Request):
return await request.json()
async def chunks():
yield b'{"token":"'
yield b"a" * 17000
yield b'"}'
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
for headers in ({}, {"Content-Length": "1"}):
response = await client.post("/setup/bootstrap", content=chunks(), headers=headers)
self.assertEqual(response.status_code, 413)
async def test_multipart_stream_limit_is_413_not_parser_500(self):
app = FastAPI()
app.add_middleware(InstallationBodyLimitMiddleware)
@app.post("/admin/backups/restore")
async def restore(file: UploadFile = File(...)):
return {"size": file.size}
async def chunks():
yield b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="backup"\r\n\r\n'
yield b"a" * 2048
yield b"\r\n--boundary--\r\n"
with patch("backend.app.request_limits.RESTORE_BODY_LIMIT", 1024):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/admin/backups/restore", content=chunks(), headers={"Content-Type": "multipart/form-data; boundary=boundary"})
self.assertEqual(response.status_code, 413)
-3
View File
@@ -39,9 +39,6 @@ class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
content = email.await_args.kwargs
self.assertEqual(content["subject"], f"Ready to try again? Magent issue #{item['id']}")
self.assertIn("affected content in Jellyfin", content["body_text"])
self.assertNotIn("grizzlyflix", content["body_html"].lower())
self.assertIn("YES — it works", content["body_html"])
self.assertIn("NO — still broken", content["body_html"])
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
-379
View File
@@ -1,379 +0,0 @@
"""Managed first-install origin claims use a token, not request routing headers."""
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
import sqlite3
import tempfile
from threading import Barrier
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from backend.app import auth, db, main
from backend.app.config import settings
from backend.app.installation_origin import normalize_application_origin
from backend.app.security import create_access_token
from backend.app.services import setup
from backend.app.services.public_urls import magent_public_url
from backend.app.services.request_origins import can_claim_initial_origin, is_allowed_request_origin
PUBLIC_ORIGIN = "https://watch.example.test"
LOCAL_ORIGIN = "http://localhost:3000"
SETUP_TOKEN = "managed-origin-operator-token-for-tests-only-1234567890"
ADMIN_PASSWORD = "Managed-origin-password-for-tests!123"
class ApplicationOriginNormalizationTests(unittest.TestCase):
def test_exact_origins_are_canonicalized(self):
for value, expected in (
(PUBLIC_ORIGIN, PUBLIC_ORIGIN),
("HTTPS://WATCH.EXAMPLE.TEST:443/", PUBLIC_ORIGIN),
("http://magent.lan:80/", "http://magent.lan"),
("http://192.0.2.10:3000", "http://192.0.2.10:3000"),
("http://[fd00::10]:3000/", "http://[fd00::10]:3000"),
):
with self.subTest(value=value):
self.assertEqual(normalize_application_origin(value), expected)
def test_non_origins_and_ambiguous_values_are_rejected(self):
for value in (
"", "null", "*", "magent.lan", "//magent.lan", "https:/magent.lan",
"ftp://magent.lan", "https://user@magent.lan", "https://user:secret@magent.lan",
"https://magent.lan/path", "https://magent.lan/../", "https://magent.lan?",
"https://magent.lan#", "https://magent.lan?token=1", "https://magent.lan#fragment",
"https://*.magent.lan", "https://magent.lan:0", "https://magent.lan:65536",
"https://magent.lan:", "https://magent.lan\\path", " https://magent.lan",
"https://magent.\tlan", "https://magent.lan\x00",
):
with self.subTest(value=repr(value)), self.assertRaises(ValueError):
normalize_application_origin(value)
class ManagedSetupOriginTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temporary.cleanup)
self.enterContext(patch.dict(os.environ, {"MAGENT_RUNTIME_MANAGED": "1"}))
for name, value in {
"sqlite_path": str(Path(temporary.name) / "managed-setup.db"),
"sqlite_journal_mode": "DELETE",
"jwt_secret": "managed-origin-test-signing-key-at-least-32-characters",
"settings_encryption_key": None,
"setup_token": SETUP_TOKEN,
"admin_username": "unused-environment-admin",
"admin_password": "",
"cors_allow_origin": LOCAL_ORIGIN,
"magent_application_url": None,
"magent_proxy_enabled": False,
"magent_proxy_base_url": None,
"auth_cookie_domain": None,
"auth_cookie_secure": False,
}.items():
self.enterContext(patch.object(settings, name, value))
setup.initialize_setup_state()
db.init_db()
# No production startup: no external workers, listeners or real data.
self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN)
self.addCleanup(self.client.close)
def bootstrap(self, *, origin=PUBLIC_ORIGIN, headers=None, **changes):
request_headers = {} if origin is None else {"Origin": origin}
request_headers.update(headers or {})
return self.client.post("/setup/bootstrap", headers=request_headers, json={
"setup_token": SETUP_TOKEN,
"username": "first-admin",
"password": ADMIN_PASSWORD,
"application_url": PUBLIC_ORIGIN,
**changes,
})
def assert_unclaimed(self):
self.assertFalse(db.has_admin_user())
self.assertIsNone(db.get_setting("magent_application_url"))
self.assertEqual(setup.get_setup_state()["step"], "administrator")
def preflight(self, origin):
return self.client.options("/auth/login", headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type",
})
def admin_headers(self, origin=PUBLIC_ORIGIN):
return {
"Origin": origin,
"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}",
}
def test_fresh_managed_install_does_not_trust_any_origin_before_claim(self):
self.assertTrue(can_claim_initial_origin())
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
with self.subTest(origin=origin):
self.assertFalse(is_allowed_request_origin(origin))
self.assert_unclaimed()
def test_valid_operator_claim_creates_admin_and_persists_url_atomically(self):
response = self.bootstrap(application_url="HTTPS://WATCH.EXAMPLE.TEST:443/")
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
self.assertIsNotNone(db.verify_user_password("first-admin", ADMIN_PASSWORD))
self.assertEqual(setup.get_setup_state()["step"], "apps")
self.assertFalse(can_claim_initial_origin())
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN))
def test_missing_or_null_application_url_does_not_claim(self):
response = self.bootstrap(application_url=None)
self.assertEqual(response.status_code, 400, response.text)
response = self.client.post("/setup/bootstrap", headers={"Origin": PUBLIC_ORIGIN}, json={
"setup_token": SETUP_TOKEN, "username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 400, response.text)
self.assert_unclaimed()
def test_missing_browser_origin_does_not_claim(self):
response = self.bootstrap(origin=None)
self.assertEqual(response.status_code, 403, response.text)
self.assert_unclaimed()
def test_wrong_token_cannot_claim_even_when_url_matches_evil_origin(self):
for origin in (PUBLIC_ORIGIN, "https://evil.example.test"):
with self.subTest(origin=origin):
response = self.bootstrap(origin=origin, application_url=origin, setup_token="wrong-token")
self.assertEqual(response.status_code, 403, response.text)
self.assertNotIn(SETUP_TOKEN, response.text)
self.assert_unclaimed()
def test_different_origin_and_spoofed_routing_headers_cannot_claim(self):
response = self.bootstrap(origin="https://evil.example.test", headers={
"Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test",
"X-Forwarded-Proto": "https", "Sec-Fetch-Site": "same-origin",
})
self.assertEqual(response.status_code, 403, response.text)
self.assert_unclaimed()
def test_invalid_application_urls_never_claim_or_echo_secrets(self):
with patch.object(setup, "consume_bootstrap_attempt", return_value=None):
for value in ("", "https://user:secret@watch.example.test", PUBLIC_ORIGIN + "/path",
PUBLIC_ORIGIN + "?", "javascript:alert(1)", "//watch.example.test"):
with self.subTest(value=value):
response = self.bootstrap(application_url=value)
self.assertEqual(response.status_code, 400, response.text)
self.assertNotIn(SETUP_TOKEN, response.text)
self.assertNotIn(ADMIN_PASSWORD, response.text)
self.assert_unclaimed()
def test_other_state_changing_endpoints_do_not_inherit_bootstrap_origin_exception(self):
for path, method in (("/auth/login", "post"), ("/auth/jellyfin/login", "post"),
("/setup/complete", "post"), ("/setup/state", "put"),
("/setup/bootstrap/", "post"), ("/admin/settings", "put")):
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
with self.subTest(path=path, origin=origin):
response = getattr(self.client, method)(path, json={}, headers={"Origin": origin}, follow_redirects=False)
self.assertEqual(response.status_code, 403, response.text)
self.assertEqual(response.json()["detail"], "Cross-origin state change rejected")
self.assert_unclaimed()
def test_existing_admin_prevents_reclaim_and_url_replacement(self):
self.assertEqual(self.bootstrap().status_code, 201)
response = self.bootstrap(username="second-admin")
self.assertEqual(response.status_code, 409, response.text)
response = self.bootstrap(origin="https://evil.example.test", application_url="https://evil.example.test")
self.assertEqual(response.status_code, 403, response.text)
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
self.assertEqual(len(db.get_all_users()), 1)
def test_completed_install_cannot_reopen_origin_claim_after_admin_removal(self):
self.assertEqual(self.bootstrap().status_code, 201)
setup.complete_setup()
with db._connect() as connection:
connection.execute("DELETE FROM users")
connection.execute("DELETE FROM settings WHERE key='magent_application_url'")
self.assertFalse(can_claim_initial_origin())
self.assertEqual(self.bootstrap().status_code, 403)
self.assertFalse(db.has_admin_user())
def test_setting_write_failure_rolls_back_admin_and_setup_progress(self):
with db._connect() as connection:
connection.execute("""CREATE TRIGGER reject_origin BEFORE INSERT ON settings
WHEN NEW.key = 'magent_application_url'
BEGIN SELECT RAISE(ABORT, 'synthetic origin storage failure'); END""")
with self.assertRaises(sqlite3.IntegrityError):
setup.bootstrap_administrator(SETUP_TOKEN, "first-admin", ADMIN_PASSWORD, application_url=PUBLIC_ORIGIN)
self.assert_unclaimed()
def test_concurrent_claims_keep_the_winning_admin_and_origin_together(self):
barrier = Barrier(4)
def synchronized_hash(_):
barrier.wait(timeout=10)
return "test-only-precomputed-hash"
def claim(number):
try:
setup.bootstrap_administrator(
SETUP_TOKEN, f"owner-{number}", ADMIN_PASSWORD,
application_url=f"https://owner-{number}.example.test",
)
return number
except setup.SetupUnavailableError:
return None
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
with ThreadPoolExecutor(max_workers=4) as executor:
winners = [number for number in executor.map(claim, range(4)) if number is not None]
self.assertEqual(len(winners), 1)
self.assertEqual([user["username"] for user in db.get_all_users()], [f"owner-{winners[0]}"])
self.assertEqual(db.get_setting("magent_application_url"), f"https://owner-{winners[0]}.example.test")
def test_first_https_login_uses_secure_cookie_without_restart(self):
self.assertEqual(self.bootstrap().status_code, 201)
self.assertFalse(settings.auth_cookie_secure)
response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 200, response.text)
cookie = next(value for value in response.headers.get_list("set-cookie")
if value.startswith(settings.auth_cookie_name + "="))
self.assertIn("Secure", cookie)
self.assertIn("HttpOnly", cookie)
self.assertEqual(self.client.get("/auth/me").status_code, 200)
def test_http_lan_claim_uses_non_secure_cookie_despite_static_secure_default(self):
origin = "http://magent.lan:3000"
self.assertEqual(self.bootstrap(origin=origin, application_url=origin).status_code, 201)
with patch.object(settings, "auth_cookie_secure", True):
self.assertFalse(auth._cookie_settings()["secure"])
self.assertTrue(auth._cookie_settings()["httponly"])
as_client = TestClient(main.app, base_url=origin)
try:
response = as_client.post("/auth/login", headers={"Origin": origin}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(as_client.get("/auth/me").status_code, 200)
finally:
as_client.close()
self.assertEqual(response.status_code, 200, response.text)
cookie = next(value for value in response.headers.get_list("set-cookie")
if value.startswith(settings.auth_cookie_name + "="))
self.assertNotIn("Secure", cookie)
self.assertIn("HttpOnly", cookie)
def test_login_origin_policy_and_saved_url_survive_setup_reinitialization(self):
self.assertEqual(self.bootstrap().status_code, 201)
setup.initialize_setup_state()
db.init_db()
self.assertFalse(can_claim_initial_origin())
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
for origin in (LOCAL_ORIGIN, "https://evil.example.test"):
response = self.client.post("/auth/login", headers={"Origin": origin}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 403, response.text)
response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 200, response.text)
def test_managed_origin_policy_changes_with_saved_settings(self):
self.assertEqual(self.bootstrap().status_code, 201)
db.set_setting("magent_application_url", "http://magent.lan:3000")
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN))
self.assertTrue(is_allowed_request_origin("http://magent.lan:3000"))
self.assertFalse(auth._cookie_settings()["secure"])
def test_unclaimed_install_does_not_grant_cors_to_any_browser_origin(self):
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
with self.subTest(origin=origin):
response = self.preflight(origin)
self.assertEqual(response.status_code, 400, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
response = self.client.get("/setup/status", headers={"Origin": origin})
self.assertEqual(response.status_code, 200, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
self.assert_unclaimed()
def test_claim_immediately_updates_cors_preflights_and_response_headers(self):
self.assertEqual(self.bootstrap().status_code, 201)
response = self.preflight(PUBLIC_ORIGIN)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN)
self.assertEqual(response.headers["access-control-allow-credentials"], "true")
self.assertIn("Origin", response.headers["vary"])
response = self.client.get("/setup/status", headers={"Origin": PUBLIC_ORIGIN})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN)
self.assertEqual(response.headers["access-control-allow-credentials"], "true")
def test_claimed_install_denies_localhost_and_foreign_cors_preflights_and_reads(self):
self.assertEqual(self.bootstrap().status_code, 201)
for origin in (LOCAL_ORIGIN, "https://evil.example.test", PUBLIC_ORIGIN + "/", "null"):
with self.subTest(origin=origin):
response = self.preflight(origin)
self.assertEqual(response.status_code, 400, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
response = self.client.get("/setup/status", headers={"Origin": origin})
self.assertEqual(response.status_code, 200, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
def test_admin_cannot_blank_or_malform_managed_url_or_partially_save_other_settings(self):
self.assertEqual(self.bootstrap().status_code, 201)
for value in (None, "", " ", False, 123, [], {}, "javascript:alert(1)", "//watch.example.test",
PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?", "https://user:secret@watch.example.test"):
with self.subTest(value=value):
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={
"site_login_message": "must-not-be-written",
"magent_application_url": value,
})
self.assertEqual(response.status_code, 400, response.text)
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
self.assertIsNone(db.get_setting("site_login_message"))
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(can_claim_initial_origin())
def test_admin_url_update_is_canonical_and_immediately_replaces_cors_origin(self):
self.assertEqual(self.bootstrap().status_code, 201)
next_origin = "https://new.example.test"
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={
"magent_application_url": "HTTPS://NEW.EXAMPLE.TEST:443/",
})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(db.get_setting("magent_application_url"), next_origin)
self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 400)
response = self.preflight(next_origin)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["access-control-allow-origin"], next_origin)
response = self.client.get("/setup/status", headers={"Origin": next_origin})
self.assertEqual(response.headers["access-control-allow-origin"], next_origin)
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={"site_login_message": "stale"})
self.assertEqual(response.status_code, 403, response.text)
response = self.client.put("/admin/settings", headers=self.admin_headers(next_origin), json={"site_login_message": "new"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(db.get_setting("site_login_message"), "new")
def test_proxy_settings_cannot_replace_managed_origin_or_lower_cookie_security(self):
self.assertEqual(self.bootstrap().status_code, 201)
db.set_setting("magent_proxy_enabled", "true")
db.set_setting("magent_proxy_base_url", "http://proxy.example.test")
self.assertEqual(magent_public_url(), PUBLIC_ORIGIN)
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin("http://proxy.example.test"))
self.assertTrue(auth._cookie_settings()["secure"])
self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 200)
self.assertEqual(self.preflight("http://proxy.example.test").status_code, 400)
def test_unclaimed_managed_url_ignores_legacy_proxy_and_link_fallback(self):
db.set_setting("magent_proxy_enabled", "true")
db.set_setting("magent_proxy_base_url", "https://proxy.example.test")
self.assertEqual(magent_public_url("https://legacy.example.test"), "")
self.assertTrue(can_claim_initial_origin())
self.assertFalse(is_allowed_request_origin("https://proxy.example.test"))
self.assertEqual(self.preflight("https://proxy.example.test").status_code, 400)
if __name__ == "__main__":
unittest.main()
-28
View File
@@ -91,9 +91,6 @@ class NewsletterConsentTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase
result = await service.subscribe(self.user)
self.assertEqual(result['state'], 'pending')
rendered = sender.call_args.args[1]
self.assertEqual(rendered['subject'], 'Confirm your Magent newsletter subscription')
self.assertIn('NEW IN YOUR LIBRARY', rendered['body_html'])
self.assertNotIn('grizzlyflix', rendered['body_html'].lower())
self.assertNotIn('Arrival', rendered['body_html'])
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
self.assertEqual(urlsplit(url).path, '/newsletter-subscription')
@@ -242,7 +239,6 @@ class NewsletterEditionTests(NewsletterFixture, unittest.TestCase):
store.complete_weekly(claimed, content(), END+timedelta(hours=1))
store.enqueue_due((END+timedelta(hours=1)).timestamp())
self.assertEqual(len(store.overview()['editions']), 1)
self.assertEqual(store.overview()['editions'][0]['subject'], 'Whats new in your library · 11 Sep 2026')
self.assertEqual(store.overview()['total'], 0)
self.assertEqual(store.settings()['next_send_at'], (END+timedelta(days=7)).timestamp())
self.assertTrue(config['enabled'])
@@ -384,30 +380,6 @@ class NewsletterCatalogTests(unittest.IsolatedAsyncioTestCase):
class NewsletterDeliveryTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
async def test_new_draft_has_generic_subject_and_preserves_custom_intro(self):
custom_intro = 'News from our own media community.'
store.save_settings({**self.config, 'intro': custom_intro}, datetime.now(timezone.utc))
with patch.object(service, 'collect', new=AsyncMock(return_value=content())):
draft = await service.create_draft(self.user, 7)
self.assertTrue(draft['subject'].startswith('Whats new in your library · '))
self.assertEqual(draft['intro'], custom_intro)
def test_generic_email_template_preserves_custom_subject_and_intro(self):
custom_subject = 'Grizzlyflix weekend discoveries'
custom_intro = 'Welcome to our own <media> community.'
rendered = template.render(
{**content(), 'subject': custom_subject, 'intro': custom_intro}, {},
self.config['public_url'], self.runtime.jellyfin_public_url,
'https://beta.example.test/profile#newsletters',
)
self.assertEqual(rendered['subject'], custom_subject)
self.assertIn(custom_subject, rendered['body_text'])
self.assertIn(custom_intro, rendered['body_text'])
self.assertIn('Welcome to our own &lt;media&gt; community.', rendered['body_html'])
self.assertIn('Watch on Jellyfin', rendered['body_html'])
self.assertIn('Explore Jellyfin', rendered['body_html'])
self.assertNotIn('grizzlyflix', rendered['body_html'].lower())
async def test_weekly_worker_collects_once_and_delivers_to_confirmed_subscriber(self):
now = datetime.now(timezone.utc)
self.subscribe((now-timedelta(days=14)).timestamp())
-267
View File
@@ -1,267 +0,0 @@
from concurrent.futures import ThreadPoolExecutor
import os
import tempfile
from threading import Barrier
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.config import settings
from backend.app.routers import setup as setup_router
from backend.app.security import create_access_token
from backend.app.services import setup
SETUP_TOKEN = "operator-setup-token-for-tests-only-1234567890"
ADMIN_PASSWORD = "A-long-admin-password!123"
class SetupTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(self.temp.cleanup)
for field, value in {
"sqlite_path": os.path.join(self.temp.name, "test.db"),
"sqlite_journal_mode": "DELETE",
"admin_username": "environment-admin",
"admin_password": "",
"jwt_secret": "setup-test-jwt-secret-only-1234567890",
"settings_encryption_key": "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=",
}.items():
context = patch.object(settings, field, value)
context.start()
self.addCleanup(context.stop)
context = patch.object(setup, "settings", SimpleNamespace(setup_token=SETUP_TOKEN))
context.start()
self.addCleanup(context.stop)
setup.initialize_setup_state()
db.init_db()
self.app = FastAPI()
self.app.include_router(setup_router.router)
self.client = TestClient(self.app)
self.addCleanup(self.client.close)
def bootstrap(self, **changes):
return self.client.post("/setup/bootstrap", json={
"setup_token": SETUP_TOKEN,
"username": "first-admin",
"password": ADMIN_PASSWORD,
**changes,
})
def admin_headers(self):
return {"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}"}
def test_fresh_install_requires_setup_and_exposes_no_configuration(self):
response = self.client.get("/setup/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"setup_required": True, "needs_admin": True})
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertEqual(self.client.get("/setup/state").status_code, 401)
def test_existing_install_migrates_as_completed_without_reopening_bootstrap(self):
with db._connect() as conn:
conn.execute("DROP TABLE installation_setup")
setup.initialize_setup_state()
self.assertEqual(setup.get_public_setup_status(), {"setup_required": False, "needs_admin": False})
self.assertIsNotNone(setup.get_setup_state()["completed_at"])
self.assertEqual(self.bootstrap().status_code, 409)
def test_missing_marker_fails_closed(self):
with db._connect() as conn:
conn.execute("DROP TABLE installation_setup")
self.assertFalse(setup.is_setup_required())
self.assertEqual(self.bootstrap().status_code, 409)
def test_marker_survives_restart_before_schema_initialization(self):
new_path = os.path.join(self.temp.name, "interrupted.db")
with patch.object(settings, "sqlite_path", new_path):
setup.initialize_setup_state()
setup.initialize_setup_state()
db.init_db()
self.assertTrue(setup.is_setup_required())
def test_empty_precreated_database_is_a_fresh_install(self):
new_path = os.path.join(self.temp.name, "empty.db")
with open(new_path, "wb"):
pass
with patch.object(settings, "sqlite_path", new_path):
setup.initialize_setup_state()
db.init_db()
self.assertTrue(setup.is_setup_required())
def test_environment_admin_uses_wizard_without_public_bootstrap(self):
with patch.object(settings, "admin_password", ADMIN_PASSWORD):
db.ensure_admin_user()
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": False})
self.assertEqual(self.bootstrap().status_code, 409)
def test_valid_token_creates_local_admin_once_and_uses_password_hash(self):
response = self.bootstrap()
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(response.json(), {"status": "created", "username": "first-admin"})
user = db.verify_user_password("first-admin", ADMIN_PASSWORD)
self.assertIsNotNone(user)
self.assertEqual(user["role"], "admin")
self.assertEqual(user["auth_provider"], "local")
self.assertNotEqual(user["password_hash"], ADMIN_PASSWORD)
self.assertEqual(setup.get_setup_state()["step"], "apps")
self.assertEqual(self.bootstrap(username="second-admin").status_code, 409)
self.assertEqual(len(db.get_all_users()), 1)
def test_invalid_and_missing_operator_tokens_never_create_admin(self):
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
with patch.object(setup.settings, "setup_token", ""):
self.assertEqual(self.bootstrap().status_code, 403)
with patch.object(setup.settings, "setup_token", "too-short"):
self.assertEqual(self.bootstrap(setup_token="too-short").status_code, 403)
self.assertFalse(db.has_admin_user())
def test_non_ascii_token_fails_cleanly(self):
self.assertEqual(self.bootstrap(setup_token="invalid-\N{SNOWMAN}").status_code, 403)
self.assertFalse(db.has_admin_user())
def test_example_and_repeated_character_setup_tokens_are_rejected(self):
for token in (
"replace-with-a-separate-random-setup-token",
"CHANGE_ME_before_starting_this_installation",
"your-setup-token-goes-here-at-least-32-characters",
"a" * 64,
"0" * 64,
" " * 64,
):
with self.subTest(token=token), patch.object(setup.settings, "setup_token", token):
self.assertFalse(setup.setup_token_configured())
with self.assertRaises(setup.InvalidSetupTokenError):
setup.bootstrap_administrator(token, "owner", ADMIN_PASSWORD)
self.assertFalse(db.has_admin_user())
self.assertTrue(setup.setup_token_configured())
def test_password_policy_and_username_validation(self):
for username in (" ", "admin user", "admin\x7f", "admin\nname"):
with self.subTest(username=repr(username)):
self.assertEqual(self.bootstrap(username=username).status_code, 400)
self.assertEqual(self.bootstrap(password="short").status_code, 400)
self.assertFalse(db.has_admin_user())
def test_oversized_fields_and_unexpected_privileges_are_rejected(self):
self.assertEqual(self.bootstrap(password="x" * 1025).status_code, 422)
self.assertEqual(self.bootstrap(username="x" * 101).status_code, 422)
self.assertEqual(self.bootstrap(role="admin").status_code, 422)
self.assertFalse(db.has_admin_user())
def test_existing_normalized_username_is_not_replaced(self):
db.create_user("Taken", ADMIN_PASSWORD)
self.assertEqual(self.bootstrap(username="taken").status_code, 409)
self.assertFalse(db.has_admin_user())
self.assertEqual(len(db.get_all_users()), 1)
def test_bootstrap_attempts_are_persistently_limited(self):
for _ in range(setup.BOOTSTRAP_IP_ATTEMPTS):
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
setup.initialize_setup_state()
response = self.bootstrap()
self.assertEqual(response.status_code, 429)
self.assertGreater(int(response.headers["retry-after"]), 0)
self.assertFalse(db.has_admin_user())
with db._connect() as conn:
keys = [row[0] for row in conn.execute("SELECT key_hash FROM installation_setup_attempts")]
self.assertNotIn("testclient", keys)
def test_rate_limit_global_cap_and_expiry(self):
with patch.object(setup, "time", return_value=1000):
for number in range(setup.BOOTSTRAP_GLOBAL_ATTEMPTS):
self.assertIsNone(setup.consume_bootstrap_attempt(f"192.0.2.{number}"))
self.assertEqual(setup.consume_bootstrap_attempt("198.51.100.1"), 900)
with patch.object(setup, "time", return_value=1901):
self.assertIsNone(setup.consume_bootstrap_attempt("198.51.100.1"))
def test_concurrent_attempts_cannot_bypass_rate_limit(self):
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(lambda _: setup.consume_bootstrap_attempt("192.0.2.1"), range(12)))
self.assertEqual(results.count(None), setup.BOOTSTRAP_IP_ATTEMPTS)
def test_concurrent_bootstraps_create_only_one_admin(self):
barrier = Barrier(4)
def synchronized_hash(_):
barrier.wait(timeout=10)
return "test-only-precomputed-hash"
def create(number):
try:
setup.bootstrap_administrator(SETUP_TOKEN, f"admin-{number}", ADMIN_PASSWORD)
return True
except setup.SetupUnavailableError:
return False
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(create, range(4)))
self.assertEqual(results.count(True), 1)
self.assertEqual(len(db.get_all_users()), 1)
def test_state_mutations_require_admin_and_progress_resumes(self):
self.assertEqual(self.bootstrap().status_code, 201)
db.create_user("viewer", ADMIN_PASSWORD)
user_headers = {"Authorization": f"Bearer {create_access_token('viewer', 'user')}"}
for path, method, kwargs in (
("/setup/state", "get", {}),
("/setup/state", "put", {"json": {"step": "review"}}),
("/setup/complete", "post", {}),
):
with self.subTest(path=path, method=method):
call = getattr(self.client, method)
self.assertEqual(call(path, **kwargs).status_code, 401)
self.assertEqual(call(path, headers=user_headers, **kwargs).status_code, 403)
response = self.client.put("/setup/state", json={"step": "preferences"}, headers=self.admin_headers())
self.assertEqual(response.status_code, 200)
setup.initialize_setup_state()
db.init_db()
self.assertEqual(setup.get_setup_state()["step"], "preferences")
self.assertTrue(setup.is_setup_required())
self.assertEqual(self.client.put(
"/setup/state", json={"step": "invalid"}, headers=self.admin_headers()
).status_code, 422)
def test_completion_invokes_worker_callback_and_cannot_reopen_bootstrap(self):
self.assertEqual(self.bootstrap().status_code, 201)
callback = AsyncMock()
self.app.state.on_setup_complete = callback
response = self.client.post("/setup/complete", headers=self.admin_headers())
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(response.json()["completed"])
self.assertIsNotNone(response.json()["completed_at"])
callback.assert_awaited_once()
self.assertFalse(setup.is_setup_required())
# A retry can restart an idempotent callback if the first response was
# interrupted, while keeping the original completion timestamp.
retry = self.client.post("/setup/complete", headers=self.admin_headers())
self.assertEqual(retry.json(), response.json())
self.assertEqual(callback.await_count, 2)
self.client.put("/setup/state", json={"step": "administrator"}, headers=self.admin_headers())
with db._connect() as conn:
conn.execute("DELETE FROM users")
self.assertEqual(self.bootstrap().status_code, 409)
self.assertEqual(setup.get_setup_state()["step"], "review")
def test_completion_requires_an_administrator(self):
with self.assertRaises(setup.SetupUnavailableError):
setup.complete_setup()
self.assertTrue(setup.is_setup_required())
def test_sync_callback_is_supported(self):
self.assertEqual(self.bootstrap().status_code, 201)
called = []
self.app.state.on_setup_complete = lambda: called.append(True)
response = self.client.post("/setup/complete", headers=self.admin_headers())
self.assertEqual(response.status_code, 200)
self.assertEqual(called, [True])
if __name__ == "__main__":
unittest.main()
-6
View File
@@ -1,6 +0,0 @@
# Optional source build; retain compose.yml's storage and security defaults.
# docker compose -f compose.yml -f compose.build.yml up -d --build
services:
magent:
image: magent:local
build: .
-27
View File
@@ -1,27 +0,0 @@
# Fresh installs: paste this file into a Portainer Docker Standalone stack.
# Configure the site address and connected apps in Magent's setup wizard.
# No Dockerfile, source checkout, .env file or shared default password is needed.
# Existing installations must keep their original data mount and keys.
services:
magent:
image: rephl3xnz/magent:latest
ports:
# LAN access by default. Restrict with a firewall; use HTTPS for public use.
# Only the frontend is published; it also serves /api.
- "3000:3000"
volumes:
# Contains the database, settings, cache and private generated keys.
- magent-data:/app/data
restart: unless-stopped
stop_grace_period: 30s
# For security, leave these settings unchanged unless you understand the risks.
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
volumes:
magent-data:
+37
View File
@@ -0,0 +1,37 @@
name: magent-beta
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_NAME: Magent Beta
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true"
SITE_BANNER_MESSAGE: "Beta environment"
SITE_BANNER_TONE: warning
ports:
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
- "127.0.0.1:8100:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+4 -16
View File
@@ -1,22 +1,13 @@
services:
magent:
# Select a published immutable release tag or digest in .env.
image: ${MAGENT_IMAGE:?Set MAGENT_IMAGE to a published release tag or digest}
image: rephl3xnz/magent:latest
env_file:
- ./.env
environment:
JWT_SECRET: ${JWT_SECRET:?Generate an independent JWT_SECRET before starting}
SETTINGS_ENCRYPTION_KEY: ${SETTINGS_ENCRYPTION_KEY:?Set the original or newly generated Fernet key}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:?Set the exact browser-facing origin}
MAGENT_APPLICATION_URL: ${MAGENT_APPLICATION_URL:?Set the browser-facing application URL}
ports:
# Keep the API internal; the frontend serves /api on this same port.
- "${MAGENT_BIND_ADDRESS:-127.0.0.1}:${MAGENT_HTTP_PORT:-3000}:3000"
- "3000:3000"
- "127.0.0.1:8000:8000"
volumes:
# Fresh installs only: existing installs must retain their original mount.
- magent-data:/app/data
restart: unless-stopped
stop_grace_period: 30s
- ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
@@ -24,6 +15,3 @@ services:
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
volumes:
magent-data:
+23
View File
@@ -0,0 +1,23 @@
name: magent-production
services:
magent:
build: .
env_file:
- ./.env
environment:
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
ports:
- "10.30.1.32:3200:3000"
- "127.0.0.1:8200:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+19
View File
@@ -0,0 +1,19 @@
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
ports:
- "3000:3000"
- "127.0.0.1:8000:8000"
volumes:
- ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101012"><title>Coming soon | Magent — Grizzlyflix</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#101012;color:#f4f0ff;font-family:Arial,Helvetica,sans-serif}main{min-height:100svh;padding:60px 24px 28px;text-align:center;display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at 50% 20%,#282139,transparent 60%)}.brand{color:#c7bdff;letter-spacing:.3em;font-size:14px;font-weight:700;margin-bottom:36px}.badge{color:#8be7f1;border:1px solid #6eddec66;border-radius:30px;padding:10px 22px;font-size:12px;letter-spacing:.15em}h1{font-size:clamp(40px,7vw,88px);line-height:1.08;letter-spacing:-.045em;margin:28px 0 22px}h1 span{color:#c7bdff}p{max-width:560px;color:#bcb8c9;line-height:1.65;font-size:18px;margin:0}.steps{display:grid;grid-template-columns:repeat(3,1fr);width:min(580px,100%);margin:40px 0 24px;border:1px solid #ffffff20;border-radius:16px;background:#ffffff04}.steps div{padding:22px 12px;display:grid;gap:8px}.steps div+div{border-left:1px solid #ffffff15}.steps small{color:#8be7f1}.note{font-size:14px;color:#a9a4b5}footer{margin-top:60px;color:#a9a4b5;font-size:12px;display:flex;flex-wrap:wrap;justify-content:center;gap:14px}a{color:#c7bdff;text-underline-offset:3px}a:focus-visible{outline:2px solid #8be7f1;outline-offset:5px}
</style></head><body><main><div class="brand">GRIZZLYFLIX</div><div class="badge">COMING SOON</div><h1>Your next watch.<br><span>Made simpler.</span></h1><p>The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p><div class="steps" aria-label="Request journey"><div><small>01</small><strong>Request</strong></div><div><small>02</small><strong>Track</strong></div><div><small>03</small><strong>Watch</strong></div></div><p class="note">Were getting everything ready. Check back soon.</p><footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer></main></body></html>
-3
View File
@@ -1,3 +0,0 @@
# Process supervision shares the application's Python runtime; do not install
# the OS supervisor package and a second system Python into the release image.
supervisor==4.3.0
+2 -6
View File
@@ -14,13 +14,11 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
stopasgroup=true
killasgroup=true
[program:frontend]
directory=/app/frontend
command=/usr/local/bin/node /app/frontend/server.js
environment=HOSTNAME="0.0.0.0",PORT="3000",NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production",NEXT_TELEMETRY_DISABLED="1"
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
@@ -28,5 +26,3 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20
stopasgroup=true
killasgroup=true
-266
View File
@@ -1,266 +0,0 @@
# Environment variable reference
The public [Portainer stack](../compose.yml) needs **no environment variables**.
Its image supplies the runtime defaults; use the first-run setup wizard to set
the application URL, connect services and configure notifications. The normal
stack does not need a Dockerfile, source checkout or `.env` file.
This reference also covers advanced/manual deployments, compatibility aliases,
image-build inputs and repository-only tooling. A variable being listed here
does **not** mean that it belongs in the public Compose file.
## Managed installation defaults and precedence
- The image defaults `MAGENT_MANAGED_SECRETS` to `auto`. With no manually supplied
`JWT_SECRET`, startup generates independent signing, encryption and setup keys
once, then reloads them from `/app/data/bootstrap-secrets.json`. An existing
explicit signing key selects the legacy/manual path. Do not switch an existing
installation's key management, volume or keys just to match a fresh template.
- Managed installations fix `SQLITE_PATH` to `/app/data/magent.db` and
`API_DOCS_ENABLED` to `false`; these are not setup choices. Preserve the entire
`/app/data` volume, including the private keys. Startup fails rather than
silently replacing missing keys beside an existing database.
- Set the browser-facing application URL in setup. Managed CORS accepts the
configured same origin; do not configure `*` or invent an external API origin.
Before a URL is saved, only the token-authorized first-administrator setup can
claim the initial origin; arbitrary public requests do not establish trust.
HTTPS is required for public hosting. A private LAN can use explicit HTTP.
- Source defaults below describe `backend/app/config.py` before managed bootstrap
or saved configuration is applied. Saved, supported application settings take
precedence over their environment fallback. Security/bootstrap settings are
deployment controls, not ordinary editable settings. Container listener ports
are fixed by its process supervisor, not by application settings.
- For manual deployments, environment variables are read at process startup.
Restart/recreate after changing them. A `.env` file is loaded by the relevant
Compose template's `env_file`, not automatically discovered by the application.
Keep manual credentials stable across upgrades and offline restores.
Defaults use JSON notation: `null` means unset, `""` means an empty string,
`true`/`false` are booleans, and `@BUILD_NUMBER`/`@CHANGELOG` are bundled build
metadata. Do not literally enter `null` or the `@...` labels into Portainer.
Aliases in one row refer to the same setting; if multiple aliases are present,
the first listed alias wins. Use only one. Never place secrets into browser-
visible `NEXT_PUBLIC_*` variables, URLs, screenshots or public support reports.
## Core, authentication and storage
| Variable / aliases | Source default | Purpose and managed-install behaviour |
| --- | --- | --- |
| `APP_NAME` | `"Magent"` | Backend application name. |
| `CORS_ALLOW_ORIGIN` | `"http://localhost:3000"` | Legacy exact allowed browser origin. Managed installs use the URL confirmed in setup automatically; no Compose override needed. |
| `SQLITE_PATH` | `"data/magent.db"` | SQLite database file. Managed container path is fixed to `/app/data/magent.db`; manual source deployments retain their existing path. |
| `SQLITE_JOURNAL_MODE` | `"DELETE"` | SQLite journal mode; retain the default unless deliberately configuring storage behaviour. |
| `JWT_SECRET` | `""` | Secret signing key. Generated/persisted in managed mode; manual mode requires a strong non-default value of at least 32 characters. |
| `JWT_EXP_MINUTES` | `120` | Authentication token lifetime in minutes. |
| `JWT_ISSUER` | `"magent"` | Expected JWT issuer. Changing it invalidates existing tokens. |
| `JWT_AUDIENCE` | `"magent-web"` | Expected JWT audience. Changing it invalidates existing tokens. |
| `SETTINGS_ENCRYPTION_KEY` | `null` | Secret Fernet key for stored integration credentials. Generated/persisted in managed mode. Preserve an existing manual key (or the existing legacy signing-key-derived configuration). |
| `API_DOCS_ENABLED` | `false` | OpenAPI/interactive API documentation. Forced off in managed mode; leave off for public deployment. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Login rate-limit window in seconds. |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP` | `15` | Login attempts allowed per client IP per window. |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER` | `5` | Login attempts allowed per user identifier per window. |
| `PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS` | `300` | Password-reset rate-limit window in seconds. |
| `PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP` | `6` | Password-reset attempts allowed per IP per window. |
| `PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER` | `3` | Password-reset attempts allowed per account identifier per window. |
| `ADMIN_USERNAME` | `"admin"` | Legacy bootstrap username when supplying an initial administrator password. Wizard-created administrators choose their own name. |
| `ADMIN_PASSWORD` | `""` | Optional manual first-administrator password, minimum 12 characters. Leave empty for token-protected setup; never use a shared default password. |
| `SETUP_TOKEN` | `""` | Secret one-time administrator-creation credential. Generated in managed mode; retrieve using `python -m app.container_bootstrap setup-token` in the container console. Not shown once the first administrator exists/setup is complete. |
| `AUTH_COOKIE_NAME` | `"magent_auth"` | HttpOnly session-cookie name. |
| `AUTH_COOKIE_SECURE` | `false` | Legacy cookie Secure flag; public HTTPS deployments require `true`. Managed mode derives the correct behaviour from its confirmed application URL. |
| `AUTH_COOKIE_SAMESITE` | `"strict"` | Authentication cookie SameSite policy; retain `strict` unless deliberately evaluating a different deployment model. |
| `AUTH_COOKIE_DOMAIN` | `null` | Cookie domain override; unset creates safer host-only cookies. |
| `AUTH_STATE_COOKIE_NAME` | `"magent_logged_in"` | Non-secret UI login-state marker. The frontend expects its normal default. |
## Logs, request cache and issue follow-up
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `LOG_LEVEL` | `"INFO"` | Application log verbosity. |
| `LOG_FORMAT` | `"text"` | `text` for readable logs or `json` for structured collection. |
| `LOG_FILE` | `"data/magent.log"` | Rotating application log; resolves to `/app/data/magent.log` in the combined image. |
| `LOG_FILE_MAX_BYTES` | `20000000` | Maximum active log size before rotation, in bytes. |
| `LOG_FILE_BACKUP_COUNT` | `10` | Number of rotated log files to keep. |
| `LOG_HTTP_CLIENT_LEVEL` | `"INFO"` | Outbound integration HTTP logging verbosity. |
| `LOG_BACKGROUND_SYNC_LEVEL` | `"INFO"` | Scheduled background-sync log verbosity. |
| `REQUESTS_SYNC_TTL_MINUTES` | `1440` | Request-cache freshness period in minutes. |
| `REQUESTS_STAGE_REFRESH_MINUTES` | `15` | Background request-stage refresh interval, from 1 to 1440 minutes. Short intervals increase integration traffic. |
| `REQUESTS_POLL_INTERVAL_SECONDS` | `300` | Interval for checking whether a full request sync is due. |
| `REQUESTS_DELTA_SYNC_INTERVAL_MINUTES` | `5` | Incremental new/changed request polling interval. |
| `REQUESTS_FULL_SYNC_TIME` | `"00:00"` | Daily full request-cache rebuild time, `HH:MM`. |
| `REQUESTS_CLEANUP_TIME` | `"02:00"` | Daily request-history cleanup time, `HH:MM`. |
| `REQUESTS_CLEANUP_DAYS` | `90` | Request-history retention period in days. |
| `REQUESTS_DATA_SOURCE` | `"prefer_cache"` | Request data-source strategy; prefer cached results by default. |
| `ISSUE_CONFIRMATION_CONTACT_ATTEMPTS` | `2` | Confirmation emails after an issue is fixed; `0` closes without sending these emails. |
| `ISSUE_CONFIRMATION_INTERVAL_VALUE` | `3` | Time between confirmation attempts and final closure wait, in the unit below. |
| `ISSUE_CONFIRMATION_INTERVAL_UNIT` | `"days"` | Follow-up interval unit: `days`, `weeks` or `months`. |
| `ARTWORK_CACHE_MODE` | `"remote"` | Artwork delivery mode; remote sources by default, local caching when configured. |
## Site appearance and login
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `SITE_BUILD_NUMBER` | `@BUILD_NUMBER` | Bundled release build number; normally leave unchanged. |
| `SITE_CHANGELOG` | `@CHANGELOG` | Bundled release changelog; normally leave unchanged. |
| `SITE_BANNER_ENABLED` | `false` | Enable the signed-in sitewide announcement banner. |
| `SITE_BANNER_MESSAGE` | `null` | Banner message. |
| `SITE_BANNER_TONE` | `"info"` | Banner preset tone when custom colours are unset. |
| `SITE_BANNER_BACKGROUND_COLOR` | `null` | Optional six-digit hexadecimal banner background colour, e.g. `#123456`. |
| `SITE_BANNER_BORDER_COLOR` | `null` | Optional six-digit hexadecimal banner border colour. |
| `SITE_LOGIN_MESSAGE` | `null` | Separate message on the logged-out login page. |
| `SITE_LOGIN_SHOW_JELLYFIN_LOGIN` | `true` | Show the Jellyfin login option. |
| `SITE_LOGIN_SHOW_LOCAL_LOGIN` | `true` | Show local Magent login. |
| `SITE_LOGIN_SHOW_FORGOT_PASSWORD` | `true` | Show password recovery. |
| `SITE_LOGIN_SHOW_SIGNUP_LINK` | `true` | Show invite signup. |
| `SITE_NAV_SHOW_REQUESTS` | `true` | Show requests navigation. |
## Application URLs, proxy and TLS
Configure these supported settings in setup/admin rather than adding environment
entries to the public stack. Do not assume these settings reconfigure Docker
port mappings or the bundled supervisor: it listens on frontend `3000` and
backend `8000`, with only `3000` published. Terminate public HTTPS at a reverse
proxy. Only explicitly trusted proxy addresses may supply forwarded headers.
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `MAGENT_APPLICATION_URL` | `null` | Canonical browser-facing origin used by links and request-origin protection. Confirm it in setup; no initial environment value is required in managed mode. |
| `MAGENT_APPLICATION_PORT` | `3000` | Preferred application port metadata for direct/local deployments; does not change the image listener or host port mapping. |
| `MAGENT_API_URL` | `null` | Optional canonical API URL metadata. Normal users access same-origin `/api`; do not expose backend port 8000 publicly. |
| `MAGENT_API_PORT` | `8000` | Preferred backend port metadata; does not change the image listener. |
| `MAGENT_BIND_HOST` | `"0.0.0.0"` | Direct/local-hosting bind configuration; container supervisor retains its configured listeners. |
| `MAGENT_PROXY_ENABLED` | `false` | Enable configured proxy-aware URL handling. |
| `MAGENT_PROXY_BASE_URL` | `null` | Optional configured reverse-proxy public base URL. |
| `MAGENT_PROXY_TRUST_FORWARDED_HEADERS` | `true` | Permit forwarding metadata only from trusted proxies. |
| `MAGENT_PROXY_TRUSTED_PROXIES` | `"127.0.0.1,::1"` | Comma-separated trusted proxy addresses/networks; loopback by default. Do not broadly trust arbitrary clients. |
| `MAGENT_PROXY_FORWARDED_PREFIX` | `null` | Optional reverse-proxy path prefix metadata. This is not a promise that every root-based frontend asset supports arbitrary subpaths. |
| `MAGENT_SSL_BIND_ENABLED` | `false` | Direct-hosting TLS configuration; does not replace the bundled image's external HTTPS proxy. |
| `MAGENT_SSL_CERTIFICATE_PATH` | `null` | Direct-hosting TLS certificate PEM path. |
| `MAGENT_SSL_PRIVATE_KEY_PATH` | `null` | Secret direct-hosting TLS private-key PEM path. |
| `MAGENT_SSL_CERTIFICATE_PEM` | `null` | Direct-hosting certificate PEM content. |
| `MAGENT_SSL_PRIVATE_KEY_PEM` | `null` | Secret direct-hosting private-key PEM content. |
## Notification services
Provider credentials are secrets. Configure them in the authenticated setup/admin
UI, where sensitive saved values are encrypted; do not share webhook URLs or bot
tokens in support logs. The master switch and each provider switch both apply.
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `MAGENT_NOTIFY_ENABLED` | `false` | Master notification switch. |
| `MAGENT_NOTIFY_EMAIL_ENABLED` | `false` | Enable SMTP email notifications. |
| `MAGENT_NOTIFY_EMAIL_SMTP_HOST` | `null` | SMTP hostname/address. |
| `MAGENT_NOTIFY_EMAIL_SMTP_PORT` | `587` | SMTP port, commonly 587 with STARTTLS or 465 with implicit TLS. |
| `MAGENT_NOTIFY_EMAIL_SMTP_USERNAME` | `null` | SMTP login username. |
| `MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD` | `null` | Secret SMTP password/app password. |
| `MAGENT_NOTIFY_EMAIL_FROM_ADDRESS` | `null` | Sender email address. |
| `MAGENT_NOTIFY_EMAIL_FROM_NAME` | `null` | Sender display name. |
| `MAGENT_NOTIFY_EMAIL_USE_TLS` | `true` | Use SMTP STARTTLS. |
| `MAGENT_NOTIFY_EMAIL_USE_SSL` | `false` | Use implicit SMTP TLS instead of STARTTLS. |
| `MAGENT_NOTIFY_DISCORD_ENABLED` | `false` | Enable Discord notifications. |
| `MAGENT_NOTIFY_DISCORD_WEBHOOK_URL` | `null` | Secret Discord webhook URL, also usable for feedback routing. |
| `MAGENT_NOTIFY_TELEGRAM_ENABLED` | `false` | Enable Telegram notifications. |
| `MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN` | `null` | Secret Telegram bot token. |
| `MAGENT_NOTIFY_TELEGRAM_CHAT_ID` | `null` | Telegram destination chat/group/user ID. |
| `MAGENT_NOTIFY_PUSH_ENABLED` | `false` | Enable push-provider notifications. |
| `MAGENT_NOTIFY_PUSH_PROVIDER` | `"ntfy"` | Provider selector such as `ntfy`, `gotify`, `pushover` or `webhook`. |
| `MAGENT_NOTIFY_PUSH_BASE_URL` | `null` | Push-service base URL. |
| `MAGENT_NOTIFY_PUSH_TOPIC` | `null` | Push topic/channel name. |
| `MAGENT_NOTIFY_PUSH_TOKEN` | `null` | Secret push-service token/API key. |
| `MAGENT_NOTIFY_PUSH_USER_KEY` | `null` | Provider recipient key, such as a Pushover user key. |
| `MAGENT_NOTIFY_PUSH_DEVICE` | `null` | Optional target device selector. |
| `MAGENT_NOTIFY_WEBHOOK_ENABLED` | `false` | Enable generic webhook notifications. |
| `MAGENT_NOTIFY_WEBHOOK_URL` | `null` | Generic notification webhook URL; may contain secret credentials. |
| `MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS` | `false` | Explicitly allow private-network notification destinations. Keep disabled unless deliberately trusting an internal endpoint. |
| `DISCORD_WEBHOOK_URL` | `null` | Legacy Discord feedback webhook fallback. Prefer the notification provider setting above. |
## Media integrations
URLs must be reachable from the Magent container. `localhost` names the Magent
container itself, not another application or the Docker host. Configure these
through setup/admin; API keys and passwords are secret. Aliases are retained for
existing installations.
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `JELLYSEERR_URL`, `JELLYSEERR_BASE_URL` | `null` | Seerr/Jellyseerr request-service base URL. |
| `JELLYSEERR_API_KEY`, `JELLYSEERR_KEY` | `null` | Seerr API key. |
| `JELLYSTAT_URL`, `JELLYSTAT_BASE_URL` | `null` | Jellystat statistics-service base URL, including its base path if used. |
| `JELLYSTAT_API_KEY` | `null` | Jellystat API key. |
| `JELLYFIN_URL`, `JELLYFIN_BASE_URL` | `null` | Jellyfin server URL for authentication, user sync and library lookups. |
| `JELLYFIN_API_KEY`, `JELLYFIN_KEY` | `null` | Jellyfin administrative API key. |
| `JELLYFIN_PUBLIC_URL` | `null` | Browser-facing Jellyfin URL for watch/open buttons. |
| `JELLYFIN_SYNC_TO_ARR` | `true` | Automatically add existing Jellyfin items to Sonarr/Radarr tracking where supported. |
| `SONARR_URL`, `SONARR_BASE_URL` | `null` | Sonarr TV-service base URL. |
| `SONARR_API_KEY`, `SONARR_KEY` | `null` | Sonarr API key. |
| `SONARR_QUALITY_PROFILE_ID` | `null` | Default Sonarr quality profile; requests use Seerr's default if Magent has none configured. |
| `SONARR_ROOT_FOLDER` | `null` | Sonarr TV root folder. |
| `SONARR_QBITTORRENT_CATEGORY` | `"sonarr"` | Legacy qBittorrent category setting, retained for compatibility and hidden from the ordinary settings UI. |
| `RADARR_URL`, `RADARR_BASE_URL` | `null` | Radarr movie-service base URL. |
| `RADARR_API_KEY`, `RADARR_KEY` | `null` | Radarr API key. |
| `RADARR_QUALITY_PROFILE_ID` | `null` | Default Radarr quality profile; requests use Seerr's default if Magent has none configured. |
| `RADARR_ROOT_FOLDER` | `null` | Radarr movie root folder. |
| `RADARR_QBITTORRENT_CATEGORY` | `"radarr"` | Legacy qBittorrent category setting, retained for compatibility and hidden from the ordinary settings UI. |
| `BAZARR_URL`, `BAZARR_BASE_URL` | `null` | Bazarr subtitle-service base URL. |
| `BAZARR_API_KEY`, `BAZARR_KEY` | `null` | Bazarr API key. |
| `BAZARR_DEFAULT_LANGUAGE` | `"en"` | Default subtitle search language code. |
| `PROWLARR_URL`, `PROWLARR_BASE_URL` | `null` | Prowlarr indexer-service base URL. |
| `PROWLARR_API_KEY`, `PROWLARR_KEY` | `null` | Prowlarr API key. |
| `QBIT_URL`, `QBITTORRENT_URL`, `QBITTORRENT_BASE_URL` | `null` | qBittorrent base URL for download status. |
| `QBIT_USERNAME`, `QBITTORRENT_USERNAME` | `null` | qBittorrent login username. |
| `QBIT_PASSWORD`, `QBITTORRENT_PASSWORD` | `null` | Secret qBittorrent password. |
## Additional runtime and image controls
These variables are read outside `Settings`. Defaults below identify their actual
consumer; builder values and supervisor values are not user-editable app settings.
| Variable | Default / scope | Purpose |
| --- | --- | --- |
| `MAGENT_MANAGED_SECRETS` | Image: `auto` | Automatically use persistent generated secrets when no manual signing key is supplied. `true` explicitly opts in; `false` retains manual management. Existing keys must be preserved. |
| `MAGENT_RUNTIME_MANAGED` | Internal bootstrap output: `1` for managed mode | Internal marker set by bootstrap for backend/frontend origin and cookie handling. It is not a supported user override; do not set it in Compose. |
| `BACKGROUND_TASKS_ENABLED` | `true` | Run background schedulers/workers. `false` is useful for isolated tests, not normal service operation. |
| `BRANDING_SOURCE` | `bundled` | Use bundled logo/favicon; `data` prefers custom assets under persistent data. |
| `MAGENT_METRICS_ENABLED` | Disabled (empty) | `true` enables the separate Prometheus metrics listener. Do not expose it publicly. |
| `MAGENT_METRICS_PORT` | `9108` | Metrics listener port when enabled. |
| `MAGENT_METRICS_BIND` | `127.0.0.1` | Metrics listener bind address when enabled. |
| `MAGENT_COMING_SOON` | Disabled (unset) | Frontend root-route holding page enabled only by the exact value `true`. |
| `BACKEND_INTERNAL_URL` | Build/supervisor: `http://127.0.0.1:8000`; source fallback: `http://backend:8000` | Next.js internal API/branding rewrite destination, baked when building its configuration. Runtime overrides do not rebuild a published image's rewrites. |
| `NEXT_PUBLIC_API_BASE` | `/api` | Browser API prefix. Public frontend values are baked at build time; never place credentials here. |
| `NODE_ENV` | Image: `production` | Node/Next runtime mode. Development allows development-only CSP eval; do not override in public production. |
| `NEXT_TELEMETRY_DISABLED` | Image/builder: `1` | Disable Next.js telemetry. |
| `HOSTNAME` | Supervisor: `0.0.0.0` | Bundled standalone frontend listen address, explicitly set by supervisor. |
| `PORT` | Supervisor: `3000` | Bundled standalone frontend port, explicitly set by supervisor. |
| `PYTHONDONTWRITEBYTECODE` | Image: `1` | Do not write Python bytecode into the read-only image. |
| `PYTHONUNBUFFERED` | Image: `1` | Flush Python stdout/stderr without buffering. |
| `MAGENT_UID` | Build argument: `1000` | Image runtime user's UID. Not a runtime environment switch; rebuilding with a different UID also requires matching volume/tmpfs ownership. |
| `MAGENT_GID` | Build argument: `1000` | Image runtime user's GID; same ownership caveat as UID. |
## Manual Compose and test tooling
These optional variables belong to the advanced manual template or isolated
verification tools, not the zero-input public Compose install.
| Variable | Default / scope | Purpose |
| --- | --- | --- |
| `MAGENT_IMAGE` | Required by `docker-compose.hub.yml` | Explicit published image tag/digest for manual-secret installations. |
| `MAGENT_BIND_ADDRESS` | `127.0.0.1` | Manual template frontend bind address. |
| `MAGENT_HTTP_PORT` | `3000` | Manual template frontend host port. |
| `PYTHON_BIN` | `python3` | Interpreter used by the backend quality gate. |
| `MAGENT_IMAGE_MAX_MB` | `350` | Container smoke-test unpacked image size budget in MiB. |
| `MAGENT_SMOKE_MANAGED` | `false` | Test generated managed secrets when true, or synthetic manual keys when false. |
| `GITHUB_RUN_ID` | `local` | Optional CI identifier for disposable smoke-test resources. |
## Keeping this reference complete
Run `python scripts/check_environment_docs.py`. The dependency-free check parses
the Settings AST (including every alias and source default), scans explicit
runtime environment reads, Docker build/runtime declarations, Compose variables
and repository tooling. It never imports application settings, reads `.env`,
contacts a service or prints secret values. Backend unit tests run the same check
so newly declared variables and changed Settings defaults require documentation.
This is the inventory of variables explicitly consumed/declared by Magent's
source, not an enumeration of all knobs understood by Python, Node, Docker,
OpenSSL or third-party libraries. Unsupported third-party/internal flags should
not be used to bypass the packaged deployment defaults.
-179
View File
@@ -1,179 +0,0 @@
# Install with Portainer
For a **fresh installation**, paste [compose.yml](../compose.yml) into a new
Portainer stack and deploy with **no environment variables**. The stack names
`image: rephl3xnz/magent:latest` directly. Portainer pulls that prebuilt Docker Hub image; Magent
creates its private keys, database and persistent storage, then guides you
through administrator and app setup. Users do not need a Dockerfile, source
checkout, Python installation, `.env` file or another database container.
**Release availability:** the image with zero-input managed bootstrap and
wizard-configured origins is published on Docker Hub. `latest` is mutable, not
an immutable release identifier; record the deployed digest and review release
notes before updating. If an existing stack still uses an older image, updating
the Compose text alone does not pull the new image: explicitly re-pull it when
redeploying, while retaining the same persistent volume.
## Requirements
- A Portainer-managed **Docker Standalone** environment running Linux containers.
This template is for one Magent instance, not Docker Swarm or multiple replicas
sharing SQLite.
- A compatible published image for your CPU architecture. Only `linux/amd64`
has been validated; do not assume ARM64 support.
- A stable address you will actually use in your browser. Use HTTPS before
exposing Magent publicly or inviting internet users.
- Existing media services, if you want to connect them. This stack installs
Magent, not Jellyfin, Seerr or the Arr applications.
## Deploy and finish setup
1. In your Docker environment, open **Stacks**, choose **Add stack**, name it
`magent`, and select **Web editor**. Paste the complete root `compose.yml`.
Uploading that file is an alternative. See
[Portainer's stack instructions](https://docs.portainer.io/user/docker/stacks/add).
2. Leave the stack's **Environment variables** section empty. Keep the
runtime-security block unchanged. No signing keys, database path, API-docs
flag, application URL or CORS value needs entering into the stack.
3. Choose **Deploy the stack** and wait for the Magent container to become
healthy. The image's non-root user owns a fresh named volume automatically.
4. Open that container's **Console**, choose command `/bin/ash` and user
**`magent`** (UID `1000`), connect, and run:
```sh
python -m app.container_bootstrap setup-token
```
This deliberately displays a private first-install token only in your
administrative console, not the normal container logs. Keep the output
private: anyone with that token and access to an unclaimed installation can
create its first administrator. The command refuses to reveal it once an
administrator exists.
The setup page's **Get setup token** button also shows these instructions and
offers **Copy command**. On browsers without clipboard access, select and
copy the displayed command manually. This help dialog does not generate or
reveal a token over the public web interface.
5. Open Magent at the browser address you intend to use, such as
`http://192.168.1.50:3000` on a trusted LAN or `https://magent.example.com`
through your configured HTTPS proxy. Use the **Docker host's reachable
address**, not Portainer's address if Portainer runs elsewhere. The fresh
installation opens `/setup`. Confirm the application URL shown there,
enter the token and create your administrator with a unique password. The
confirmed URL must match the browser origin, including any non-default port,
with no path, trailing slash, credentials or query string. To choose another
address, open setup at that address first. Token-authorized creation saves
the URL and administrator together; arbitrary visitors cannot claim a trusted
origin merely by loading a page. Then connect/test the apps you use, select
preferences and finish. Optional apps can be skipped. There is no shared
default administrator password.
6. Make an encrypted backup from **Settings → Advanced tools → Backup & restore**
and save its passphrase separately. Test recovery before relying on the
installation.
There is no need to manually generate keys or remove an environment setup token
in this mode. First-admin creation stops accepting the token after an account
has been created. Restarting or recreating the container retains the same
database and keys; it does not reopen first-admin signup.
Managed CORS and cookie security follow the saved URL automatically. SQLite is
fixed at `/app/data/magent.db`, and API documentation is disabled; neither is a
setup choice. All environment options, including advanced legacy overrides, are
listed in the [complete environment reference](ENVIRONMENT.md).
App connection URLs must be reachable **from Magent's container**. `localhost`
refers to Magent itself, not the Docker host or another application. Use LAN/DNS
addresses or explicitly attach applications to an appropriate shared Docker
network. Never mount the Docker socket into Magent.
## Ports, HTTPS and security defaults
The default publishes host port `3000` on all interfaces so a browser on the LAN
can reach a typical Portainer deployment. Restrict that port with your host and
network firewall. Do not port-forward this plain-HTTP endpoint to the internet.
Only port 3000 is published; browser API requests use `/api` on the same origin.
The default port mapping is the literal `3000:3000`; this template has no variable
substitutions. An operator needing another host port can deliberately edit only
the left-hand port, then use/confirm that address in setup. For a reverse proxy
running directly on the Docker host, `127.0.0.1:3000:3000` restricts the listener
to host loopback. A containerized proxy instead needs an explicitly shared Docker
network or reachable host interface; its own loopback is not the Docker host.
For public service, configure DNS and an HTTPS reverse proxy, set
the application URL to the external `https://` origin in setup/admin.
Managed installations derive matching CORS and Secure cookies from that saved
URL, while an explicitly confirmed private HTTP origin permits HTTP cookies.
They do not install a certificate or reverse proxy. See the
[reverse-proxy guide](PUBLIC_RELEASE.md#https-public-urls-and-reverse-proxies).
Keep the configured address consistent: visiting an IP when the configured
origin is a domain can make sign-in fail the origin check.
The following Docker runtime controls stay enabled in the Compose file:
- `read_only: true` protects the image filesystem; only the data volume and
designated temporary areas are writable.
- `cap_drop: ["ALL"]` and `security_opt: ["no-new-privileges:true"]` restrict
process privileges.
- `init: true` handles child-process reaping and signal forwarding.
- `tmpfs` supplies restricted temporary writable areas for process state and
frontend cache without making the image writable.
**Leave this security block unchanged.** These are Docker engine settings, not
application environment variables; deleting them does not make them implicit
image defaults. No privileged container or Docker socket access is required.
Advanced/manual environment installs remain supported separately; see
[ENVIRONMENT.md](ENVIRONMENT.md). Adding an unreferenced variable to Portainer's
variable list alone does not inject it into this no-variable template. Do not
add manual secrets or attempt to override fixed managed defaults in a working
managed installation.
HTTP is intended for trusted-LAN setup/testing, not a fully secure deployment.
Some browser features (such as clipboard access and report/newsletter operations
using secure-context APIs) require HTTPS. Configure HTTPS for ongoing use.
## Persistence, backups and upgrades
Keep the stack name stable. Docker creates a project-scoped `magent-data` named
volume mounted at `/app/data`. It holds the database, cached artwork, branding
and `/app/data/bootstrap-secrets.json`, which contains the generated signing key,
settings-encryption key and setup token. Its restrictive permissions do not
protect it from the Docker host administrator: restrict Portainer/host access
and use encrypted host storage. Treat the complete volume as sensitive.
Never delete that file to fix a startup or login problem. Without the original
encryption key, a raw database copy's encrypted credentials cannot be recovered.
Keep a secure, consistent offline backup of the **whole volume** by stopping
only Magent while taking the copy. Preserve the generated secrets with that
copy. A portable `.magent-backup` export is different: it excludes deployment
keys and re-encrypts settings for the destination's keys during restore. See
[backup and recovery](installation-and-recovery.md).
Managed application-backup restore also retains the destination's confirmed
application URL, not the backup source's URL. Its origin/cookie policy therefore
continues to match the destination site. An offline full-volume restore is a
different procedure and must preserve that volume's original keys.
Before updating, save the current image tag/digest, stack definition and backup.
Update the existing stack with a fresh pull of `rephl3xnz/magent:latest`, retaining
its name and volume. A plain container restart does not pull an updated image.
For a controlled release or rollback, edit only the `image:` line to a compatible
published immutable tag or digest; this is optional, not an installation input.
Verify health, login,
app connections and invites afterwards. Rollback may require the matching data
backup if a migration is not backward compatible.
Do not remove volumes when deleting/recreating a stack unless you intend to
erase the installation. Changing the stack name or mounting an empty volume
does not migrate your data.
## Existing installations
This is a **fresh-install template**, not an automatic migration from manual
secrets. Existing installations must retain their own data mount, database path,
`JWT_SECRET`, `SETTINGS_ENCRYPTION_KEY` and deployment settings. Continue using
your saved stack or [docker-compose.hub.yml](../docker-compose.hub.yml). Do not
switch an existing bind mount to a fresh named volume or enable managed secrets
to replace established keys. Follow an explicitly reviewed migration if you
choose to change secret management later.
The project retains its Dockerfile for maintainers to build release images.
End users deploying this stack do not download or run it.
-263
View File
@@ -1,263 +0,0 @@
# Public installation and release guide
Magent runs as one non-root Linux container containing the Python API and the
Next.js frontend. Connect your own media services in the setup wizard; no
pre-existing Magent account or database is required. Optional
integrations may be skipped. Media files remain in your existing media services.
The lightweight Dockerfile builds the frontend separately and copies only its
standalone runtime, static assets and public files into the final image. Build
tools, the full development dependency tree and package-manager caches are not
runtime requirements. The image still needs both Python and Node to serve the
API and frontend; it is not a static website.
Both build and runtime stages use Alpine Linux, so native dependencies are built
for its musl runtime rather than copied from an incompatible glibc image.
The non-root UID/GID and `/app/data` layout are unchanged. Existing installations
do not need new databases, replacement keys or new data volumes for this change.
Magent's own source code is licensed under the [MIT License](../LICENSE).
Bundled dependencies retain their own licenses; Magent's license does not
relicense third-party software or grant rights to third-party branding.
## Fresh installation
For the simplest **Portainer** installation, use the image-only root
[compose.yml](../compose.yml) and follow the [Portainer guide](PORTAINER.md).
That fresh-install path pulls `rephl3xnz/magent:latest` from Docker Hub with no
environment inputs, automatically persists its private keys and requires no
Dockerfile or `.env`. Confirm the browser-facing application URL in the
token-authorized first-administrator wizard; matching CORS and cookie security
follow the saved URL automatically. Managed SQLite stays at
`/app/data/magent.db`, and API documentation stays disabled.
The managed-install image is published on Docker Hub. Repository changes alone
do not update an existing deployment: explicitly pull the image when upgrading,
retaining the same persistent volume. `latest` is mutable, so record the resolved
digest before an upgrade; older cached images may lack this setup workflow.
The Compose runtime-security block stays enabled and must be left unchanged.
See the [complete environment reference](ENVIRONMENT.md) for all supported
variables and the difference between managed, manual and tooling controls.
### Advanced/manual secrets
The procedure below remains supported for operators who manage their own
deployment keys. It uses `docker-compose.hub.yml`, not the zero-input root
`compose.yml`; its required environment inputs are specific to this manual path.
Prerequisites: Docker Engine or Docker Desktop running Linux containers, Docker
Compose v2, and an image release published for your host's architecture. Use the
Compose and environment examples from the **same release** as the image.
1. Put `docker-compose.hub.yml` and a copy of `.env.example` named `.env` in a
private deployment directory. Keep the directory and Compose project name
stable: the project owns the persistent named volume. On Linux restrict `.env`
to the deployment account, for example `chmod 600 .env`; on Windows restrict
its file permissions. Do not commit it, paste it into support reports or
expose it through a web server.
2. Set `MAGENT_IMAGE` to the release's published immutable tag, for example
`rephl3xnz/magent:prod-<actual-commit>`, or its published
`rephl3xnz/magent@sha256:<actual-digest>`. Replace the angle-bracket placeholders.
There is deliberately no implicit `latest` fallback. Confirm the selected
release contains the setup/backup features before following this guide.
3. Generate three **independent** values. Run these on a trusted machine; the
commands print secrets, so do not share their output or put it in CI logs:
```bash
# JWT_SECRET
python -c "import secrets; print(secrets.token_urlsafe(48))"
# SETUP_TOKEN (not the JWT secret)
python -c "import secrets; print(secrets.token_urlsafe(48))"
# SETTINGS_ENCRYPTION_KEY (Fernet-compatible, using only Python's standard library)
python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
```
Put each value in its matching `.env` field. Leave `ADMIN_PASSWORD` blank to
create the administrator through the wizard. There is no shared default
administrator password. Save the signing and encryption keys in a separate
secure backup; generating replacement keys is **not** an upgrade step.
4. For a local trial, keep `MAGENT_BIND_ADDRESS=127.0.0.1`,
`MAGENT_HTTP_PORT=3000`, the localhost URLs and `AUTH_COOKIE_SECURE=false`.
For internet access, configure HTTPS as described below **before** inviting
users. If changing the local port, update both browser-facing URLs too.
5. Validate without printing expanded secrets, pull and start:
```bash
docker compose -p magent -f docker-compose.hub.yml config --quiet
docker compose -p magent -f docker-compose.hub.yml pull magent
docker compose -p magent -f docker-compose.hub.yml up -d --no-build magent
docker compose -p magent -f docker-compose.hub.yml ps
```
6. Open `http://localhost:3000` on the Docker host (or your configured HTTPS
address). The fresh database opens `/setup`. Enter the setup token, create
your administrator, connect the apps you use, review preferences and finish.
App URLs must be reachable **from the container**; `localhost` means Magent
itself, not another container or the Docker host. Use your internal DNS,
reachable LAN addresses or service names on an explicitly shared Docker
network.
7. Remove `SETUP_TOKEN` from `.env` and recreate only Magent using the same
`up -d --no-build magent` command. Setup progress and accounts remain in the
volume. Make and test a backup before relying on the installation.
The template creates a project-scoped named volume at `/app/data`. The image's
data directory is prepared for UID/GID `1000:1000`, avoiding the fresh root-owned
bind-directory problem. The container runs with a read-only root filesystem,
dropped capabilities and private temporary writable areas. The backend's port
8000 is not published: browser API calls use `/api` on the frontend port.
Do not run `docker compose down --volumes` unless you intend to erase the data.
## HTTPS, public URLs and reverse proxies
For the **managed Portainer path**, visit the intended HTTPS address and confirm
it in first-administrator setup. Later application URL changes belong in admin
configuration. CORS and Secure cookies follow the saved URL; no environment
values are required. Setup does not provision DNS, certificates or a proxy.
For the **advanced/manual path**, set these deployment values to **your own**
origin, without a path or trailing slash:
```dotenv
CORS_ALLOW_ORIGIN=https://magent.example.com
MAGENT_APPLICATION_URL=https://magent.example.com
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
```
The supplied image already routes browser `/api/*` calls to its internal API.
Do not expose port 8000, set a public API hostname or override the internal
backend URL for this combined-image deployment. Runtime environment changes do
not rebuild the frontend's compiled routing configuration.
With a reverse proxy running directly on the **same host**, use a loopback
bind and route the whole hostname, including `/api`, to `127.0.0.1:3000` (or your
chosen host port). The manual template defaults to loopback; in the root
Portainer template deliberately change its port mapping to `127.0.0.1:3000:3000`
for this topology. For example, a host-running Caddy instance can use:
```caddyfile
magent.example.com {
reverse_proxy 127.0.0.1:3000
}
```
For a containerized proxy, loopback inside that proxy is not the Docker host.
Attach the proxy and Magent to an intentionally shared Docker network and proxy
to `magent:3000`, or configure a reachable host address explicitly. If the proxy
is on another machine, bind the frontend host port to the Docker host's private
interface address and restrict access with a firewall to the trusted proxy. In
the root Portainer template edit the port mapping directly; the manual Hub
template instead supports `MAGENT_BIND_ADDRESS`.
Avoid exposing all interfaces merely to work around routing.
Provide valid TLS, suitable DNS and upload limits of at least 34 MiB for backup
restore. Do not cache login, setup, authenticated pages or API responses at the
proxy/CDN. Keep the exact public origin configured: the request-origin guard
does not trust arbitrary `Host` or forwarded headers. Keep `API_DOCS_ENABLED=false`
for public service. TLS terminates at your proxy, not inside this image.
## Existing installations and upgrades
**Do not replace an existing deployment with the fresh named-volume template.**
Keep its existing project name, mount, database path, environment, signing key
and encryption key. Switching from `./data:/app/data` to a new named volume makes
an existing installation look empty; it does not migrate data. Never complete
fresh setup to fix a missing mount.
For existing Linux bind mounts, confirm the exact intended data directory and
its files are writable by UID/GID `1000:1000`. Back up first and correct only that
application directory if needed; do not recursively change an entire shared
stack, host directory or filesystem. Do not work around permissions by running
Magent as root. Custom UID/GID images require matching ownership.
Before upgrading:
1. Save the old image tag **and digest**, Compose definition and protected
environment/key backup. Create a consistent data backup and verify recovery.
2. Read the target release's migration notes and select its immutable image.
Pull it before interrupting service. Validate the actual saved deployment
definition, including a Portainer stack's saved environment if used; editing
a separate host Compose file does not update Portainer's copy.
3. Recreate **only Magent**, retaining the original data mount and secrets. For
this template use `docker compose -p magent -f docker-compose.hub.yml up -d
--no-deps --no-build magent`. Use your actual project/file for other stacks.
4. Verify health, real local-account login, any enabled Jellyfin login, requests,
app connections and invite behaviour. Do not trigger imports, notifications
or destructive repair actions merely as a smoke test.
The zero-input Portainer template deliberately uses `latest`. On that path,
review the new release and pull its updated image when updating the existing
stack; restarting alone leaves the current image in use. Save the previous
digest because `latest` can move. Operators preferring controlled releases may
replace its literal `image:` value with a published immutable tag/digest without
changing the volume or generated keys.
For rollback, select the recorded image and recreate only Magent. Database
migrations may prevent older versions from reading newer data: follow the
release's compatibility notes and restore the matching backup if required.
Restoring old data discards changes since that backup. Never restore a whole
shared stack file over unrelated service changes.
## Backups and recovery
For managed Portainer installations, retain `bootstrap-secrets.json` alongside
the database in consistent offline volume backups; the generated encryption key
is essential to recovering a raw database copy. It is deliberately excluded
from portable encrypted application exports. See [Portainer persistence](PORTAINER.md#persistence-backups-and-upgrades).
See [installation and recovery](installation-and-recovery.md) for the encrypted
admin backup/export flow and its size limits. It supports configuration,
database and optional artwork cache. The backup passphrase cannot be recovered.
Keep it separately and test a restore to a disposable instance of the same
version.
For an offline volume backup, stop **only Magent** and snapshot/copy its complete
data volume, including SQLite journal/WAL sidecars. Start it again after the
consistent copy completes. Keep `.env` and the original encryption/signing keys
separately protected: encrypted settings in a raw database copy cannot be
recovered without the original encryption key (or original signing key for
older installations that derived their encryption key from it). The portable
encrypted application backup re-encrypts settings for its destination instead;
these are different recovery procedures.
Managed portable restore keeps the destination's confirmed application URL as
well as its signing/encryption keys. It does not replace the destination's
trusted origin with the source backup's URL; review integration addresses and
sign in using the destination URL after recovery.
Never rotate keys, replace the database or delete volumes as a response to an
unhealthy container. Inspect health/log errors without posting credentials.
## Public release gates
The release branch is a source snapshot, not a published image. Build and
verify the exact commit before publishing Docker Hub tags.
This guide and the lightweight Dockerfile do not by themselves certify a
release. Before publishing a new immutable tag or moving `latest`:
- Build from the reviewed release commit with the root Dockerfile and its pinned
base digests. Do not send `.env`, live data, local caches or development
credentials in the build context. Record source revision, final image digest,
compressed download size and unpacked image size separately.
- Run backend and frontend checks, the production build and a **container** smoke
test using the read-only Compose settings, a fresh named volume and non-root
user. Confirm setup survives recreation, login works and the UI hydrates
without CSP errors. Test backup/restore and an upgrade against a disposable
copy, preserving its keys; never use live accounts/data for destructive tests.
- Check same-origin POST protection: empty-form requests to both
`/api/auth/login` and `/api/auth/jellyfin/login` with the configured origin must
reach validation (422); an unrelated origin must be rejected (403). These are
necessary checks, not substitutes for a successful authenticated login.
- Inspect the final image for development packages, build caches, unexpected
credentials, unnecessary privileged execution and known dependency/base-image
vulnerabilities. Review dependency notices and licensing before redistribution.
- Declare only architectures actually built **and smoke-tested**. A Linux/amd64
test is not an ARM64 test; an amd64 image running under emulation is not native
ARM64 validation. Do not advertise a multi-architecture release until its
manifest and each advertised platform have been verified. Docker Desktop runs
the Linux image; this is not a native Windows-container or macOS build.
- Publish migration/rollback notes, supported image tags/digests and a support
and private security-reporting route. Include the MIT license and required
dependency notices, and confirm rights to any bundled branding assets.
Publish only with the release owner's approval; building locally does not
publish or deploy an image.
@@ -0,0 +1,68 @@
# Historical production cutover notes — superseded
These notes describe the temporary AMS-DEV01 setup, not the current production
deployment. Do not run these cutover or rollback instructions against the live
service. See [current production instructions](../../PRODUCTION.md).
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
overwritten. Main runs CI verification; production activation is deliberately
manual during the initial cutover.
Only API connection URLs/credentials and SMTP configuration are exported by
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
uses an explicit allowlist, refuses existing output directories, and creates
private files. It never copies a database, users, invite codes, issues, history,
tokens, sessions, branding or notification templates. A new bootstrap admin and
JWT secret are generated. Retrieve the bootstrap credentials from the protected
`bootstrap-admin.json` on the server; never commit them.
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
imports and repair emails. The cover is not an authentication/security boundary;
normal API authentication remains in force. Administrators can use `/login`.
Run `docker compose -f docker-compose.production.yml up -d --build` from the
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
Next forwards `/api` internally. The backend health port is localhost-only at
8200. Do not alter beta's route or other Caddy sites.
Before public activation, validate Caddy config, save its existing configuration,
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
Do not send SMTP tests without approval. Keep the old upstream for rollback.
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
then recreate the container. External service records can then be imported through
normal synchronization; no beta client data is migrated. Review quality profiles,
root folders, invite policy and notification rules in admin settings before use.
## Initial cutover — 7 September 2026
- Public HTTPS cover and `/api/health` verified after cutover.
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
`10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged.
- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`.
Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then
`sudo systemctl reload caddy`. Review subsequent edits before restoring the
whole file; the old application was not stopped or deleted.
- Initial database: one newly generated bootstrap admin; zero invites, issues,
cached requests, actions or snapshots. Login smoke-testing subsequently creates
normal admin login activity only.
- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on
AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active.
- No SMTP message was sent as part of validation. Background jobs remain paused.
## Cover resilience update
The application host subsequently became unreachable over TCP from Caddy (both
3100 and 3200 timed out, despite responding to ping). The cover is now served
directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
It needs no application server, JavaScript, API or external assets.
Other paths retain the production reverse proxy. Full launch now also requires
removing the `@landing`/static `handle` block from the production Caddy site once
upstream connectivity is stable; the environment switch alone is insufficient.
Pre-static configuration backup:
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
+24
View File
@@ -0,0 +1,24 @@
# Duplicate account repair
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a shared-ID conflict. An individual user's management overlay also links to this view with their username prefilled.
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
Eligibility requires a single current Jellyfin ID, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin or Seerr sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
The transaction:
- Archives the account records, settings, subscriptions and identity links in `user_duplicate_repairs`, recording the administrator and timestamp. This internal archive includes credential fields and is never returned through the preview API.
- Keeps the selected Magent ID, email and profile, and uses the current verified Jellyfin username.
- Preserves the most restrictive feature permissions, automatic-search setting, invitation access, any block and the earliest expiry.
- Consolidates username references for requests, issues, comments, invitations and login activity. Seerr request IDs and Seerr author IDs remain unchanged.
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no unattended bulk merge or self-service undo. An explicitly authorized operator can use `scripts/reconcile_verified_accounts.py --apply --output <new-private-directory>`; it takes a SQLite backup and archives each repair. Without `--apply` it previews only. The archive supports administrative investigation; conflicting identities require separate review.
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
Seerr sync/resync now reconciles against Jellyfin IDs without deleting the directory. Daily imports and verified login reuse linked accounts; account creation also guards against duplicate Seerr IDs.
+22
View File
@@ -0,0 +1,22 @@
# Personal report emails
## On-demand personal reports
Users can email the month shown on **My stats > Monthly report**, including the
current month to date or any available previous month. Delivery uses the confirmed
profile email and the existing private report generator. The API does not accept
another user, recipient, or delivery kind. The request UUID is idempotent, and
manual/test report requests share a five-minute per-user cooldown.
**Profile > Your reports, your choice** offers on-demand-only delivery or on-demand
plus automatic monthly emails. New confirmations default to on-demand-only;
existing confirmed subscribers retain their previous automatic monthly preference.
Turning automatic delivery off cancels pending scheduled emails, but preserves
verified-email consent and explicitly requested emails. A full unsubscribe, email
change, identity change or blocked account prevents pending personal delivery.
On-demand requests work while the monthly schedule is paused, provided SMTP,
Jellystat and the delivery worker are available. Delivery status is available to
the requesting user and in the admin recap history. New-arrivals newsletters are
unchanged and keep their independent subscriptions and schedule.
-79
View File
@@ -1,79 +0,0 @@
# Installation, backup and recovery
## Fresh installation
For a new Portainer installation without a Dockerfile or `.env`, use the
[single-file stack guide](PORTAINER.md). It generates persistent deployment
secrets and provides a console-only setup-token command. The root stack pulls
`rephl3xnz/magent:latest` with no environment inputs. Confirm the application URL
alongside the token when creating your first administrator; managed CORS and
cookie security follow that URL automatically. SQLite is fixed at
`/app/data/magent.db`, and API documentation remains disabled. Leave the Compose
runtime-security defaults unchanged. The updated image still needs publishing
before `latest` provides this behaviour; repository changes alone do not deploy
or publish it. Record deployed digests because `latest` is mutable.
The **manual-secret and source-build instructions below** remain supported for
other deployments. All environment options and defaults are documented in
[ENVIRONMENT.md](ENVIRONMENT.md); they are not required inputs to the managed
Portainer template.
Start with `.env.example`. Generate independent random values for `JWT_SECRET` and `SETUP_TOKEN` (at least 32 characters each), plus a Fernet `SETTINGS_ENCRYPTION_KEY`. Never deploy the example placeholders. Keep the environment file private.
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
The first two commands produce the JWT secret and setup token respectively. The third requires the backend dependencies. Alternatively generate the Fernet key using Python's standard library: `python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"`.
For a manual-secret installation, set the correct browser-facing `CORS_ALLOW_ORIGIN`, `MAGENT_APPLICATION_URL`, cookie HTTPS settings and host paths before starting. Changing the application URL in its wizard does not replace its explicit environment CORS policy. Managed Portainer installations instead confirm their origin during token-authorized first-admin setup, and automatically follow that saved origin for CORS and cookie security. Neither mode changes reverse-proxy configuration or creates certificates.
After `docker compose -f compose.yml -f compose.build.yml up -d --build` for a source build, visit the frontend. A new database redirects to `/setup`:
1. Enter `SETUP_TOKEN` and create a local administrator with a unique password of at least 12 characters. Alternatively, set `ADMIN_USERNAME` and `ADMIN_PASSWORD` in the environment before the first start, then sign in with that account.
2. Expand each app you use: Jellyfin, Seerr/Jellyseerr, Sonarr, Radarr, Prowlarr, qBittorrent, Bazarr and Jellystat. Enter its internal address and credentials, then **Save & test**. For Sonarr/Radarr, a successful check loads quality profiles and root folders.
3. Set site access, request refresh/retention and optional SMTP preferences. Invite signup remains invite-only.
4. Review and finish. Magent starts its configured background jobs, unless `BACKGROUND_TASKS_ENABLED=false`.
Use server-reachable addresses: `localhost` in a container refers to that container. Optional apps can be skipped. Each successful save persists; closing the tab leaves setup resumable. Unsaved form fields are not retained. Remove `SETUP_TOKEN` after finishing. Bootstrap is permanently disabled after setup, and cannot replace an existing administrator. Administrators can revisit the wizard from Settings without resetting the installation.
Upgrades with an existing users table are marked configured automatically. Setup status reveals only whether setup is needed and whether the first administrator is missing. Configuration and wizard progress require administrator authentication. First-admin creation uses a constant-time token comparison, persistent rate limits and a database transaction to prevent concurrent claims.
## Create a backup
Open **Settings → Advanced tools → Backup & restore** (`/admin/backups`). Choose a unique backup passphrase of 121024 characters, confirm it, optionally include the filesystem artwork cache, and download the `.magent-backup` file.
Every backup includes:
- A consistent SQLite snapshot: users, password hashes, invite records, requests, issues, settings, saved statistics, subscriptions and database-backed caches.
- Portable runtime configuration, including environment-provided app credentials. Secrets are decrypted only inside the private export staging area and encrypted archive; they are re-encrypted with the destination installation key when restoring.
- Custom branding (`data/branding/logo.png` and `favicon.ico`).
The optional cache adds supported TMDB artwork from `data/artwork/tmdb`. In-memory caches are rebuilt, not backed up. Media files, the connected apps' databases, log files, `.env`, TLS private keys, host paths, signing/encryption keys and deployment/network controls are not included. Keep a separate secure record of the deployment configuration and backup passphrase.
Backups use authenticated AES-256-GCM encryption with a per-backup salt and scrypt-derived key. The passphrase is never stored by Magent and cannot be recovered. Keep backups and their passphrases separately, off the Magent host. Treat backups as sensitive even though encrypted.
Current limits: **32 MiB encrypted archive**, **128 MiB expanded data**, and **20,000 entries**. These bound memory and disk use; including a large artwork cache can exceed them. Retry without artwork if necessary. For larger installations, use a separate operator-managed offline volume/database backup; this UI does not silently omit oversized data. Automatic scheduled backups and media-server backups are not part of this feature.
The frontend and backend accept up to 34 MiB for the whole multipart request, including the 32 MiB file. Configure any external reverse proxy's upload limit accordingly (for example `client_max_body_size 34m` in nginx); otherwise it may reject valid files before they reach Magent.
## Restore safely
1. Make a fresh backup of the destination. Stop external writes/other backend processes sharing its SQLite file. The supplied deployment uses one backend worker; do not run restore against a multi-worker/shared-database deployment.
2. Sign in as an administrator, select a `.magent-backup`, enter its passphrase and type `RESTORE`. A fresh replacement installation must first create its temporary administrator through `/setup`; then use the **Restore it here** link before connecting apps.
3. Upload and stage the restore. Magent checks authentication, encrypted integrity, archive paths and sizes, checksums, SQLite integrity, schema compatibility and an active restored administrator. Live data is unchanged at this point. A pending restore can be cancelled from the same page.
4. Restart the application using your normal deployment process, for example `docker compose restart magent` for a source build, or restart the container in Portainer. The UI never restarts a server automatically.
5. On startup, before schema initialization or workers, Magent creates a private rollback copy, replaces the database/selected assets and records the result. Failed or interrupted replacement is rolled back using a durable journal. Review the backend logs if startup stops.
6. Sign in with an account from the restored backup, verify Settings/service checks, requests, issues and invite policy, then create a new backup. Old sessions and password-reset tokens are invalidated. Existing invite records and links are retained, with their original expiry and usage state.
Restore **replaces** the destination database; it does not merge changes made after the backup. After staging, pause normal usage until the restart so new writes are not mistaken for restored data. Do not change the destination encryption key between staging and restart. Restoring earlier invite state can also restore its remaining uses: review active invitations after recovery.
Use the same Magent version for restore, then upgrade normally. Portable settings follow the backup, but destination host identity, JWT/encryption keys, local paths, TLS/cookie/proxy controls and ports remain destination-owned. For managed installations, restore also preserves the destination's confirmed application URL rather than adopting the backup source's URL; its CORS/cookie policy therefore stays aligned with the destination site. Configure and verify that destination URL before staging a restore. Review service addresses when moving hosts. Without the optional artwork cache, database artwork flags are reset and missing artwork can be fetched again; the existing destination artwork directory is left in place.
## Recovery files
The `backups/` directory beside the configured SQLite database contains private staging, lock/journal/status files and `rollback-<id>/` copies. It is not a library of exported encrypted downloads. Rollback copies contain the old database and assets; protect the data volume with host encryption and restrictive access. Magent does not automatically delete rollback copies after success. After validating the restored installation and saving a separate backup, an operator may archive or remove the specific old rollback directories during maintenance. Never remove an active `pending/` directory or `restore-journal.json` during a restore.
Insufficient disk space or invalid input stops the operation rather than partially accepting a backup. Allow room for the upload, extracted staging database/assets, live data and a rollback copy. The supplied Docker image's unprivileged user must have write access to the persistent data volume. Do not delete the data volume or replace `.env` to retry setup or recovery.
+93
View File
@@ -0,0 +1,93 @@
# Jellystat in Magent Beta
Magent's **My Stats** page (`/insights`) reads personal viewing history from an existing Jellystat instance. Jellystat owns playback collection, history and retention. Magent does not install Jellystat, collect sessions or keep a second playback database.
## Setup
1. Run Jellystat and connect it to the same Jellyfin server Magent uses. Let its initial sync finish.
2. Create an API key in Jellystat's settings.
3. In Magent, open **Configuration → Jellystat**, enter its internal URL and API key, save, and test the connection. Include any reverse-proxy base path in the URL.
4. Sign in using Jellyfin. Existing Jellyfin accounts can also be linked by **Configuration → Jellyfin → Import Jellyfin users**. First use of My Stats resolves an existing Jellyfin account against Jellyfin's user directory using its exact username.
Alternatively, set these backend environment variables:
```dotenv
JELLYSTAT_URL=http://jellystat:3000
JELLYSTAT_API_KEY=your-jellystat-api-key
```
`JELLYSTAT_BASE_URL` is also accepted. Docker deployments already load the backend environment through `.env`. These are server settings; no `NEXT_PUBLIC_` variables or browser credentials are needed. Saved Configuration values override environment values.
## What users see
- Past 7, 30, 90 or 365 days of watch time, distinct movies and episodes played, and total plays.
- Watch-time chart, current/longest streak within the chosen period, active days, favourite titles, players and streaming methods.
- Latest 20 plays in the chosen period and personal request totals from Magent's Seerr cache.
- Clear setup, account-link, no-history and temporary-unavailability states.
The page is personal for admins as well as ordinary users. There is no arbitrary user-ID parameter or server-wide history endpoint in this version. Monthly reports are available from My Stats; admin reporting and newsletters can build on this integration later.
## Data semantics and boundaries
History comes from Jellystat's `POST /api/getUserHistory`, with the backend's linked Jellyfin ID in `userid`, and a fixed date filter. `GET /api/getLibraries` supplies movie-library classification and the connection test. Authentication uses the `x-api-token` header. The adapter follows the [upstream API routes](https://github.com/CyferShepard/Jellystat/blob/main/backend/routes/api.js) and [playback model](https://github.com/CyferShepard/Jellystat/blob/main/backend/models/jf_playback_activity.js); the installed instance exposes its API at `/swagger`.
- Playback duration is in seconds and displayed as minutes. Positive-duration history entries count as plays, including unfinished watches. Repeat plays add time without inflating distinct movie/episode counts.
- Episodes are identified by `EpisodeId`. Movies are identified by their movie library. Mixed libraries or deleted library metadata may leave an item classified as other media; that time still contributes to totals.
- Ranges cover a rolling number of days. Charts and streaks use UTC and Jellystat's `ActivityDateInserted`, so the first/last chart days can be partial. A streak day requires at least one minute. Streaks are bounded by the selected period. Long charts group days for readability.
- Requests use their creation date and the authenticated account's canonical Seerr ID. Exact usernames are only used for legacy requests without an owner ID; conflicting IDs never fall back to a name.
- Pages are fetched at 200 rows per request, up to 50 pages, with a 30-second total timeout. Excess history asks the user to choose a shorter period; it is never presented as a complete partial total.
- A normalized, per-identity response is cached in memory for up to 60 seconds, with a 128-entry bound. The cache is separated by Jellystat URL/key, Jellyfin URL, user ID and period. HTTP responses are marked `no-store`.
- Browser output excludes raw Jellystat responses, usernames from playback data, user/device IDs, IP addresses, tokens and media stream details. Unexpected account IDs in upstream history are rejected.
- The only new database table is the stable Magent-to-Jellyfin identity mapping. It is scoped to the configured Jellyfin URL and does not automatically transfer ownership after account replacement. A changed Jellyfin URL needs identity resolution again.
## Validation
Backend coverage is in `backend/tests/test_insights.py` and `backend/tests/test_insights_media.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification, artwork authorization, transcoding attribution and empty/error states.
After building the frontend, `scripts/review_insights_ui.cjs` checks the page and configuration using fixture-only requests. Set `REVIEW_BASE`, `REVIEW_PLAYWRIGHT`, and optionally `REVIEW_DIR` to save screenshots outside the repository. Live Jellystat verification requires configuring the actual instance.
## Monthly reports
Open **My Stats → Monthly reports** (`/insights/reports`). The default is the most recent complete calendar month. The month picker covers the current month and the previous 23 months. Reports include viewing and request totals, changes against the preceding month, daily viewing, active days, longest streak, favourite titles, players, transcoding and the latest 20 plays in that month. The chart and streaming cards are shared with the Stats overview.
- Completed months compare full UTC calendar months, even when their lengths differ. The current month compares the same elapsed time in the previous month, capped at that month's end when it is shorter. The page identifies reports that are still in progress.
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata.
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
## Personal monthly email recaps
New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription.
**Settings → Monthly email recaps** (`/admin/recaps`) controls the monthly schedule, personal preview, test emails and delivery history. Email links inherit the application URL from Hosting & proxy (or the proxy base URL when enabled); this address is shown read-only on the recap page. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token.
Users choose **Email me my monthly recap** in Profile and confirm ownership of their profile email through a link that expires in 24 hours. The confirmation email contains no viewing data. Opening a confirmation or unsubscribe link only checks it; the user must press the action button. Unsubscribe works without signing in and is also available in Profile. Link tokens travel in URL fragments, then in a redacted JSON `token` field. Confirmation tokens are stored as hashes and consumed on use. Unsubscribe tokens are random, scoped to the current subscription and rotated on a new opt-in.
Subscriptions are bound to the Magent account, confirmed email and stored Jellyfin source/user ID. The background worker does not infer links from emails or playback names. Email changes (even if later changed back), blocked accounts, deleted/replaced identity links and changed Jellyfin sources invalidate consent. Expired and deleted accounts are excluded. The worker checks the current binding again immediately before handing a message to SMTP; unsubscribing cancels queued/preparing messages. Email already handed to the mail server cannot be recalled.
The schedule uses a selected day from 128 and an hour in **UTC**, defaulting to day 2 at 09:00. Starting, resuming or changing a schedule begins at its next future occurrence; it does not immediately email an old report. Each occurrence covers the preceding complete UTC calendar month and includes subscribers confirmed by that scheduled time. After an outage, only the latest due occurrence is caught up. Earlier missed months and late subscribers are not backfilled. Pausing cancels queued scheduled deliveries. `BACKGROUND_TASKS_ENABLED=false` also disables recap automation. The worker checks the durable queue every 30 seconds.
SQLite stores the schedule, consent and delivery metadata in `email_recap_settings`, `email_recap_subscriptions` and `email_recap_deliveries`. Report bodies are generated at delivery and are not stored in the queue. Keep the existing Magent database persistent across deployments and back it up with the application's other data. The migration is additive; no existing user is opted in and no identity is merged.
- A unique account/month key prevents duplicate scheduled recaps across workers, refreshes and restarts. Test requests carry an idempotency key and have a five-minute account cooldown. Confirmation requests also have a five-minute account cooldown.
- Queue claims are transactional. Report preparation has a three-minute timeout. Known temporary SMTP rejections and temporary history failures retry after five minutes, then thirty minutes, with at most three attempts. Permanent failures and history limits stop without sending a partial report.
- The worker records SMTP acceptance separately from connection teardown. A failed QUIT after acceptance does not cause a retry. A disconnect while submitting DATA, or an interrupted worker that had begun sending, is marked **Needs review** and is not automatically resent. Inspect the mail server for the stable `magent-recap-<delivery ID>` Message-ID before deciding whether any follow-up is needed. A stable Message-ID helps investigation; SMTP does not promise deduplication. See [RFC 5321 §4.5.3.2.6](https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.2.6) and the [Python SMTP exception definitions](https://docs.python.org/3/library/smtplib.html).
- Delivery history contains recipient, month, type, attempts, timestamps and a sanitized outcome. It reports mail-server acceptance, not inbox placement or read receipts. No automatic retry button is offered for uncertain deliveries.
The APIs are `/profile/email-recaps`, `/admin/email-recaps`, `/admin/email-recaps/preview`, `/admin/email-recaps/test`, and the public token-only `/email-recaps/check` and `/email-recaps/confirm` actions. Admin APIs enforce the administrator role; personal APIs use the signed-in account. Payloads reject recipient/user overrides.
`backend/tests/test_email_recaps.py` covers consent, identity changes, scheduling boundaries, concurrent claims, duplicate suppression, retries, interruption recovery, SMTP acceptance, access control and escaping. Its SMTP capture listens only on localhost and never delivers external mail. `scripts/review_email_recaps_ui.cjs` intercepts every API request and checks desktop/mobile layouts, preview isolation, preferences, scheduling, delivery history and public links. Set `REVIEW_EMAIL_FIXTURE` to a JSON file returned by `recap_email.render_recap`, with `month` and `email` added, plus the usual `REVIEW_BASE`, `REVIEW_PLAYWRIGHT` and optional `REVIEW_DIR`.
## Artwork and transcoding
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
How you streamed includes audio transcoding minutes and hardware-assisted video transcoding minutes. These are playback durations attributed to the recorded transcode flags, not GPU busy time or encoder runtime. Audio and video durations can overlap. Video must actually be transcoded for hardware-assisted minutes to count; a hardware label on an audio-only conversion does not count as GPU video work. Direct-play records ignore residual transcoding metadata, and missing details remain unknown.
Jellyfin exposes separate [video/audio passthrough flags and hardware type](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Session/TranscodingInfo.cs), with [hardware type names](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Entities/HardwareAccelerationType.cs). The existing Jellystat history does not contain GPU utilization or GPU busy-time samples, so Magent does not calculate those figures.
+13
View File
@@ -0,0 +1,13 @@
# Manual release selection
Manual TV searches query Sonarr by each missing monitored episode ID, with three concurrent searches and batches of 20. Season packs returned by those searches remain visible. Larger requests offer the next batch. Movie searches use the Radarr movie ID. Both manual and automatic searches retain the assigned quality profile; admin defaults apply when creating requests.
Results include rejection reasons instead of silently filtering everything out. Approved releases can be selected normally. The **Ignore profile limits** permission defaults off for non-admin users and is available in User management > Manage users and Manage this user > Feature access. Administrators retain access.
Permitted users enable the override in the release picker and explicitly confirm each out-of-profile download. Quality, size, language and custom-format/profile rejections can be overridden. Other rejection reasons remain blocked. Downloads go through Sonarr/Radarr's native manual release endpoint without modifying quality profiles or bypassing the collector.
Selections carry a ten-minute signed receipt bound to the user, request, collector, media item and release. The backend rechecks the current permission and explicit override consent on download. Expired collector caches require another search; arbitrary client-provided download URLs are not pushed upstream.
Validation covers per-episode batching, profile preservation, default-off and bulk/individual permissions, permission revocation with an existing login, forged selections, rejection classification, desktop/mobile confirmation and blocked results.
Upstream reference: [Sonarr ReleaseController](https://github.com/Sonarr/Sonarr/blob/develop/src/Sonarr.Api.V3/Indexers/ReleaseController.cs) exposes episode-specific interactive search and the collector's manual grab operation.
+34
View File
@@ -0,0 +1,34 @@
# Grizzlyflix newsletters
Open **Config → Newsletters** (`/admin/newsletters`) to create an edition from the last 7, 14 or 30 days of Jellyfin additions. Select up to 24 titles, feature up to three picks, edit the subject and add a plain-text announcement. Episodes are grouped by show. The editor offers the newest 60 titles in the chosen period and displays the total found.
Save and preview the edition, then send a test to your own confirmed newsletter email. Choose **Send now** or an explicit UTC date and time within the next 90 days. Scheduled editions retain their saved content. To change a scheduled edition, cancel it and create another draft. Cancellation stops pending delivery; messages already accepted by SMTP cannot be recalled.
The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects the 12 newest titles from the previous seven days. The default announcement applies to future weekly editions and new drafts. Starting or changing settings moves the schedule to its next future occurrence. Empty weeks are skipped. After downtime, only the latest due week is generated; Magent does not backfill every missed week. Pausing cancels pending automatic editions; custom schedules continue independently.
## Setup and subscriptions
- Configure Jellyfin and its **public** address for Watch links, plus the existing SMTP email settings. Background automation must be enabled. Jellystat is not required for newsletters.
- Email links inherit the application URL from Hosting & proxy, or the proxy base URL when reverse proxy mode is enabled. Weekly schedule displays the effective address read-only. Existing email-specific addresses remain a fallback only when hosting has not been configured. Watch links use Jellyfin's public playback URL. No schedule or subscriptions are enabled by migration.
- Users opt in at **Profile → New on Grizzlyflix**. This consent is separate from monthly viewing recaps. A current email already confirmed for monthly recaps can be reused after the user explicitly subscribes to newsletters. Otherwise a confirmation email is sent, with a 24-hour expiry and a five-minute resend limit.
- Each subscriber needs a stored Jellyfin account link. Email or identity changes, blocking and account removal invalidate consent. Confirmation and unsubscribe tokens are specific to newsletters. Opening a public link checks it; changing the preference requires pressing its confirmation button.
## Content and artwork
Arrivals use Jellyfin's `DateCreated`, not premiere dates. How Jellyfin assigns this timestamp depends on the server's library configuration and imported metadata. Magent scans descending pages, validates the date range and rejects incomplete or changing results. A 5,000-item bound prevents an unbounded library scan; shorten the period if the editor reports this limit.
Immediately before preparation, Magent checks the stored Jellyfin server and user identities. Each item lookup supplies both the recipient's `UserId` and a `ParentId` from that user's permitted views. This matters because Jellyfin 10.11 skips its default library filter when `Ids` is supplied. Restricted or removed titles are excluded and episode counts reflect only permitted episodes. Administrators preview the full selection; their test email uses their own library access. Announcements are shared text and should be written for the whole audience.
This behaviour was checked against Jellyfin 10.11.11's [item queries](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Jellyfin.Api/Controllers/ItemsController.cs), [library query scoping](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Emby.Server.Implementations/Library/LibraryManager.cs) and [user views](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Jellyfin.Api/Controllers/UserViewsController.cs).
Posters are fetched on the server, decoded and resized to bounded JPEGs. Emails embed them as CID attachments; recipients do not need a Magent session to load them. Admin previews embed data images. Missing artwork uses a placeholder. API keys never appear in email or artwork URLs. Watch links open the movie or show in the configured public Jellyfin web client and require its normal sign-in.
## Delivery behaviour
Subscriptions, editions, immutable versions and delivery history use independent `newsletter_*` SQLite tables. Both newsletter and monthly recap queues share atomic claim/lease handling and the existing SMTP transport.
An edition queues once per eligible subscriber, with durable deduplication. Subscribers must have confirmed before the edition's send time. Tests require a request UUID, retain the requested saved version and have a five-minute cooldown. Account, subscription and cancellation checks run again immediately before SMTP DATA.
Temporary preparation or SMTP failures retry after five and thirty minutes, up to three attempts. If acceptance becomes uncertain after DATA begins, history shows **Needs review** and automatic retries stop. **Accepted by mail server** records SMTP acceptance, not inbox placement. An edition marked **Finished** has no pending deliveries; check individual history rows for sent, skipped or failed outcomes.
No test recipient overrides or bulk subscription actions are exposed. UI review scripts intercept all API requests. Backend tests use disposable SQLite databases and captured mail, never the live SMTP service.
+17
View File
@@ -0,0 +1,17 @@
# Original-language requests
New Requests displays a language notice when Seerr reports a known, non-English original language. This is title metadata, not proof that a particular release lacks an English audio track or includes subtitles. Unknown and English original languages do not produce the notice.
Users can leave the normal request settings or explicitly accept original-language audio. The choice resets when changing titles and is verified against fresh Seerr metadata during submission; browser-supplied profile IDs remain ignored.
For movies, consent creates or reuses a `Magent Original …` Radarr quality profile. It copies the current default's quality ordering, allowed qualities, cutoff, upgrade rules and custom-format scores, changing only the language to Original. The existing default is never edited. The copy is selected for this new Seerr request only. Magent's subsequent Search and auto-download action preserves a verified copy instead of resetting it to English. Copies are content-addressed so later default changes do not silently change earlier requests.
TV requests show the same notice and retain their configured Sonarr profile. The inspected Sonarr configuration has no language custom formats. This feature does not bypass custom-format rejection, indexer restrictions, availability or permissions; it does not guarantee that a download is available. Existing requests show a prominent audio panel above the pipeline. **Use <language> audio & search** explicitly updates and reads back the existing Radarr movie profile before searching. Seerr only permits editing pending requests, so an approved request retains its historical Seerr profile field; the live Radarr profile is authoritative for collection.
The first opted-in movie request creates a profile in Radarr. Failed request submission can leave an unused copy, which is reused on retry. Do not rename/edit managed copies if they should retain Magent's recognition during subsequent searches.
Radarr's API represents Original as language ID -2: [language source](https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Languages/Language.cs). Profile fields are defined in its [quality profile resource](https://github.com/Radarr/Radarr/blob/develop/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs).
Validation: backend consent/profile isolation tests and `scripts/review_request_language_ui.cjs` with intercepted APIs; no live requests or downloads are created by these tests.
Manual actions automatically open their progress dialog. The final response distinguishes a queued download, a completed search with no observed download, a failed search and a search still running. Interactive searches expose rejection reasons. Radarr queue reads use the supported `movieIds` filter before pagination, preventing unrelated first-page records from hiding the actual download.
+27
View File
@@ -0,0 +1,27 @@
# User feature access
In **Configuration → User management → Manage users**, the Feature access checkboxes apply to all existing non-admin accounts. A mixed checkbox means some accounts have access. Only changed checkboxes are saved; search filters do not restrict the bulk operation. New accounts retain the default access described below.
Open a user and choose **Manage this user** to change individual permissions, contact email, role, automatic search/download, profile defaults or expiry. Request statistics remain on the main profile page. Administrators always have all features.
| Feature | Access controlled |
| --- | --- |
| My Stats | Viewing statistics, report exports, report email preferences and delivery |
| My Requests | Existing requests, progress, request actions and live request streams |
| New Requests | Media request options and submission |
| Issues | Issue lists, reporting, comments, resolution responses and issue repair actions |
| Invites | Creating, viewing and managing personal invitations; existing limits still apply |
Media search is shared by New Requests and the issue picker. Either permission allows search; only New Requests permits submission. The existing automatic search/download permission still applies in addition to feature access.
Navigation and direct-page access use the authenticated account's permissions. APIs enforce them independently on each request. Open request streams recheck access, and report emails recheck Stats access before sending. Removing a permission does not erase existing records or unsend emails. The switches apply inside Magent and do not change Jellyfin or Seerr permissions.
Existing users retain Stats, My Requests, New Requests and Issues access when upgrading. Invite access uses the existing `users.invite_management_enabled` column. Other overrides are stored by Magent user ID in `user_feature_permissions`; deletion of the account removes its overrides. The old site-wide navigation visibility setting is no longer used by the menus.
The red account section distinguishes:
- **Block Magent access:** prevent Magent sign-in and keep the account.
- **Disable Magent and Jellyfin access:** block Magent, attempt to disable the same-name Jellyfin account, disable issued invitations and attempt a notification email. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.
- **Delete Magent, Jellyfin and Seerr accounts:** remove Magent and local activity, attempt deletion of the same-name Jellyfin account and linked Seerr account, disable invitations and attempt notification. Media files and Jellystat history are retained. External actions can partially fail.
Validation: backend permission tests use temporary databases and real signed tokens. `scripts/review_feature_access_ui.cjs` checks desktop/mobile profiles, dialogs, bulk scope and denied routes with intercepted API fixtures. Set `PLAYWRIGHT_PACKAGE` when Playwright is installed outside the project, and optionally `REVIEW_BASE` to target a deployed frontend.
+59
View File
@@ -0,0 +1,59 @@
# Confirming user identities
Open **Users → Confirm user IDs**, or **Settings → User identities**. Administrator access is required.
1. Choose **Check all user IDs** to read the live Jellyfin and Seerr directories and check Jellystat user metadata.
2. Search by account name or ID, or filter by status. The results include raw Magent rows that the ordinary user directory may hide as duplicates.
3. Select accounts marked **Ready to review**. Check the Magent account, Jellyfin ID and Seerr ID in **Review selected links**.
4. Choose **Confirm and save links**. Magent checks the live mappings again before saving. If accounts, service settings or mappings changed, run a fresh check.
The canonical external identity is the Jellyfin server ID plus Jellyfin user ID. Seerr is matched through its explicit `jellyfinUserId`; Jellystat must return the same user ID. Existing Magent Jellyfin or Seerr links take precedence. For existing Jellyfin sign-in accounts without a stored ID, a unique normalized Jellyfin username provides a **suggestion requiring administrator review**. Emails and email prefixes never establish an identity.
Confirmation saves the Jellyfin link, Seerr user ID, Jellyfin server ID, timestamp and confirming administrator. Normal name-based sync cannot replace confirmed links. My Stats uses the saved Jellyfin ID for playback and the Seerr ID for requests. Changes to the authentication token format are outside this feature.
Conflicts, duplicate accounts, ambiguous case/whitespace names, absent IDs and unavailable services cannot be confirmed. This workflow does not merge, delete or create user accounts in any platform. It does not rewrite playback or requests. Conflicting mappings need investigation before reconciliation.
Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links. They do not enumerate historical Jellystat-only users or playback records. Each run supports up to 3,000 identities, fetches complete Seerr pages, limits concurrent Jellystat requests to six, and stops checking Jellystat after 25 seconds. Unfinished checks remain unavailable, never verified. Results are not HTTP-cached and contain no credentials or raw playback history.
All selected accounts are saved in one transaction. The server derives the destination IDs from a fresh check and verifies the database snapshot before writing; the browser only supplies the reviewed revision and selected Magent row IDs.
### Resolve a missing link
Open **Users > Manage users > Review account links**, then **Check all user IDs**.
For an account marked **Missing link**, choose **Resolve missing link**. Select the
correct Jellyfin account by name and ID, then **Check selected account**. The
preview checks Seerr's explicit Jellyfin ID, Jellystat's matching ID, and every
Magent account (including hidden duplicates) for ownership conflicts.
Review the IDs and choose **Confirm and save link**. Magent rechecks live services
and the local directory before atomically saving both links and the administrator
audit record. A changed preview must be checked again. Existing confirmed or
conflicting stored identities cannot be replaced using this flow. Missing upstream
records must be corrected in their service before confirmation is available.
No accounts are created, merged or deleted; emails are not used to infer identity.
### User Management and repairs
Identity checks now live at **Config > User management > Account links & repairs**.
The old `/admin/identities` link redirects there. Choose **Review repair** on a
missing or conflicting account, select the authoritative Jellyfin identity, and
preview the current and proposed Magent links. Saving rechecks live service IDs,
all local owners, the server identity and concurrent changes. Repairs retain an
atomic before/after audit in `user_identity_repairs`. Changing a Jellyfin identity
revokes identity-bound email subscriptions; users must opt in again.
If a person has never had a Seerr account, explicitly choose the single-account
import option and preview again. Confirmation imports only that Jellyfin ID via
Seerr's supported API and rechecks its resulting Seerr ID before saving Magent.
Seerr and Magent cannot share a transaction: if an import succeeds but the local
save fails, the imported account is retained and the administrator must recheck.
No automatic deletion or rollback of upstream accounts is attempted.
For an existing Seerr account with a different Jellyfin ID, inspect its ID in the
preview and reconnect that existing account in Seerr using the account owner's
Jellyfin sign-in. Magent cannot rewrite Seerr's Jellyfin ID through the normal
admin user-update endpoint. Do not import another account to bypass a mismatch.
Duplicate Magent owners remain blocked until the ownership conflict is resolved;
this workflow does not merge users, permissions, requests or playback history.
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.next/
.env
+28
View File
@@ -0,0 +1,28 @@
# Shared workspace layout
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
## Browser checks
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
- `review_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
+7 -9
View File
@@ -1,16 +1,16 @@
"use client";
import PageHeading from "./ui/PageHeading";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth";
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
import {
normalizeRecentResults,
normalizeSearchResults,
type RecentRequest,
type RequestSearchResult,
} from "./lib/request-results";
import { useEffectiveRole } from "./lib/viewMode";
import PageHeading from "./ui/PageHeading";
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
export default function HomePage() {
@@ -22,8 +22,6 @@ export default function HomePage() {
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const effectiveRole = useEffectiveRole(role);
const isAdmin = effectiveRole === "admin";
const [recentDays, setRecentDays] = useState(90);
const [recentStage, setRecentStage] = useState<RequestStage>("all");
const [authReady, setAuthReady] = useState(false);
@@ -64,7 +62,7 @@ export default function HomePage() {
const userRole = me?.role ?? null;
setRole(userRole);
setAuthReady(true);
const take = isAdmin ? 50 : 6;
const take = userRole === "admin" ? 50 : 6;
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
@@ -98,7 +96,7 @@ export default function HomePage() {
return () => {
cancelled = true;
};
}, [isAdmin, recentDays, recentStage, router]);
}, [recentDays, recentStage, router]);
useEffect(() => {
if (!authReady) {
@@ -307,7 +305,7 @@ export default function HomePage() {
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{isAdmin ? "Recent requests" : "My recent requests"}</h2>
<h2>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
@@ -339,7 +337,7 @@ export default function HomePage() {
<span>Try a wider period or a different stage.</span>
</div>
) : (
(isAdmin ? recent : recent.slice(0, 6)).map((item) => (
recent.map((item) => (
<button
key={item.id}
type="button"
+8 -8
View File
@@ -1102,18 +1102,18 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_telegram_chat_id: "-1001234567890",
magent_notify_push_base_url: "https://ntfy.example.com or https://gotify.example.com",
magent_notify_push_topic: "magent-alerts",
magent_notify_push_device: "my-phone",
magent_notify_push_device: "iphone-zak",
magent_notify_webhook_url: "https://automation.example.com/webhooks/magent",
jellyseerr_base_url: "https://requests.example.com or http://seerr:5055",
jellyfin_base_url: "https://jelly.example.com or http://jellyfin:8096",
jellyseerr_base_url: "https://requests.example.com or 10.30.1.81:5055",
jellyfin_base_url: "https://jelly.example.com or 10.40.0.80:8096",
jellystat_base_url: "http://jellystat:3000",
jellyfin_public_url: "https://jelly.example.com",
sonarr_base_url: "https://sonarr.example.com or http://sonarr:8989",
bazarr_base_url: "https://bazarr.example.com or http://bazarr:6767",
sonarr_base_url: "https://sonarr.example.com or 10.30.1.81:8989",
bazarr_base_url: "https://bazarr.example.com or 10.30.1.81:6767",
bazarr_default_language: "en",
radarr_base_url: "https://radarr.example.com or http://radarr:7878",
prowlarr_base_url: "https://prowlarr.example.com or http://prowlarr:9696",
qbittorrent_base_url: "https://qb.example.com or http://qbittorrent:8080",
radarr_base_url: "https://radarr.example.com or 10.30.1.81:7878",
prowlarr_base_url: "https://prowlarr.example.com or 10.30.1.81:9696",
qbittorrent_base_url: "https://qb.example.com or 10.30.1.81:8080",
site_login_message: "Sign-in information, an outage notice, or help for users…",
};
@@ -1,133 +0,0 @@
.page {
display: grid;
gap: 24px;
min-width: 0;
}
.page h2 {
margin: 0;
font-size: 1.15rem;
}
.page p {
margin: 0;
line-height: 1.65;
}
.summary,
.pending,
.panel {
display: grid;
align-content: start;
gap: 16px;
min-width: 0;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
}
.summary p,
.panel > p,
.muted,
.help {
color: var(--ink-muted);
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
align-items: start;
}
.pending {
border-color: var(--accent);
border-inline-start-width: 4px;
}
.notice {
padding: 16px;
border: 1px solid var(--accent);
border-radius: 8px;
}
.fields {
display: grid;
gap: 16px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-bottom: 16px;
color: var(--ink-muted);
font-size: 0.85rem;
}
.field {
display: grid;
gap: 8px;
font-weight: 600;
}
.field input {
width: 100%;
min-width: 0;
min-height: 44px;
box-sizing: border-box;
}
.field input[type="file"] {
padding: 10px;
font-size: 0.85rem;
}
.checkbox {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
}
.checkbox input {
flex: 0 0 auto;
width: 20px;
height: 20px;
margin: 0;
}
.help {
font-size: 0.85rem;
}
.page button {
justify-self: start;
min-height: 44px;
white-space: normal;
}
.page input:focus-visible,
.page button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
@media (max-width: 1000px) {
.columns {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 600px) {
.summary,
.pending,
.panel {
padding: 18px;
}
.page button {
width: 100%;
}
}
-395
View File
@@ -1,395 +0,0 @@
"use client";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { apiUrl, requestJson } from "../../lib/api-client";
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
import AdminShell from "../../ui/AdminShell";
import styles from "./backups.module.css";
type BackupDetails = {
created_at: string;
build: string;
include_cache: boolean;
};
type BackupStatus = {
format_version: number;
max_upload_bytes: number;
max_expanded_bytes: number;
include_cache_default: boolean;
pending_restore: (BackupDetails & { staged_at: string }) | null;
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
};
type RestoreResult = {
status: "staged";
restart_required: true;
backup: BackupDetails;
message: string;
};
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
export default function BackupsPage() {
const router = useRouter();
const [data, setData] = useState<BackupStatus | null>(null);
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
const [includeCache, setIncludeCache] = useState(false);
const [exportPassphrase, setExportPassphrase] = useState("");
const [confirmPassphrase, setConfirmPassphrase] = useState("");
const [restorePassphrase, setRestorePassphrase] = useState("");
const [confirmation, setConfirmation] = useState("");
const [file, setFile] = useState<File | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const handleError = useCallback(
(cause: unknown, fallback: string) => {
if (cause instanceof UnauthorizedError) {
router.replace("/login?next=%2Fadmin%2Fbackups");
return;
}
if (cause instanceof ForbiddenError) {
router.replace("/");
return;
}
setError(cause instanceof Error ? cause.message : fallback);
},
[router],
);
useEffect(() => {
void revision;
const abort = new AbortController();
setLoading(true);
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
.then((result) => {
if (abort.signal.aborted) return;
setData(result);
setIncludeCache(result.include_cache_default);
})
.catch((cause: unknown) => {
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
})
.finally(() => {
if (!abort.signal.aborted) setLoading(false);
});
return () => abort.abort();
}, [revision, handleError]);
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (busy) return;
setError("");
setNotice("");
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
setError("Choose a backup passphrase between 12 and 1,024 characters.");
return;
}
if (exportPassphrase !== confirmPassphrase) {
setError("The backup passphrases do not match.");
return;
}
setBusy("export");
try {
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
});
if (!response.ok) {
const payload: unknown = await response.json().catch(() => null);
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
link.href = downloadUrl;
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
link.hidden = true;
document.body.appendChild(link);
try {
link.click();
} finally {
link.remove();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
}
setExportPassphrase("");
setConfirmPassphrase("");
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
} catch (cause) {
handleError(cause, "Could not create the backup.");
} finally {
setBusy(null);
}
};
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (busy || !data || data.pending_restore) return;
setError("");
setNotice("");
if (!file || file.size === 0) {
setError("Choose a Magent backup file to restore.");
return;
}
if (file.size > data.max_upload_bytes) {
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
return;
}
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
return;
}
if (confirmation !== "RESTORE") {
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
return;
}
setBusy("restore");
try {
const form = new FormData();
form.append("file", file);
form.append("passphrase", restorePassphrase);
form.append("confirmation", confirmation);
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
setData((current) =>
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
);
setRestorePassphrase("");
setConfirmation("");
setFile(null);
if (fileInput.current) fileInput.current.value = "";
setNotice(
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
);
} catch (cause) {
handleError(cause, "Could not prepare the restore.");
} finally {
setBusy(null);
}
};
const cancelRestore = async () => {
if (busy) return;
setBusy("cancel");
setError("");
setNotice("");
try {
await requestJson("/admin/backups/restore", { method: "DELETE" });
setData((current) => (current ? { ...current, pending_restore: null } : current));
setNotice("Pending restore cancelled. Your current data is unchanged.");
} catch (cause) {
handleError(cause, "Could not cancel the restore.");
} finally {
setBusy(null);
}
};
return (
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
<div className={styles.page}>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
{loading && <p role="status">Loading backup settings...</p>}
{!loading && !data && (
<button
type="button"
className="ghost-button"
onClick={() => {
setError("");
setRevision((current) => current + 1);
}}
>
Try again
</button>
)}
{data && (
<>
<section className={styles.summary} aria-labelledby="backup-contents">
<h2 id="backup-contents">What is saved</h2>
<p>
Every backup includes Magent settings, app connection credentials, branding, and the complete database
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
</p>
<p>
Connected apps and media files need their own backups. App credentials configured through the
environment are included, but the deployment environment file, host paths, and signing or encryption
keys are not.
</p>
{data.last_restore && (
<p className={styles.muted}>
{data.last_restore.status === "rolled_back"
? "Last restore was rolled back"
: "Last restore completed"}
: {dateLabel(data.last_restore.restored_at)}.
{data.last_restore.status === "rolled_back" &&
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
</p>
)}
</section>
{data.pending_restore && (
<section className={styles.pending} aria-labelledby="pending-restore-title">
<h2 id="pending-restore-title">Restore ready restart required</h2>
<p>
Backup from {dateLabel(data.pending_restore.created_at)}
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
{data.pending_restore.include_cache ? "included" : "not included"}.
</p>
<p>
Restart the Magent container or service to apply this backup. Changes made since the backup was
created will be replaced. Afterwards, sign in again with an administrator account from the restored
backup.
</p>
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
</button>
</section>
)}
<div className={styles.columns}>
<section className={styles.panel} aria-labelledby="create-backup-title">
<h2 id="create-backup-title">Create a backup</h2>
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
<p>
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
artwork caches unchecked.
</p>
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
<fieldset className={styles.fields} disabled={!!busy}>
<legend className={styles.legend}>Backup options</legend>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={includeCache}
onChange={(event) => setIncludeCache(event.target.checked)}
aria-describedby="cache-help"
/>
Include artwork caches
</label>
<p id="cache-help" className={styles.help}>
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
again. Database caches are always included.
</p>
<label className={styles.field}>
Backup passphrase
<input
type="password"
autoComplete="new-password"
value={exportPassphrase}
onChange={(event) => setExportPassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
aria-describedby="backup-passphrase-help"
/>
</label>
<p id="backup-passphrase-help" className={styles.help}>
Use at least 12 characters. This passphrase is separate from your login password. A lost
passphrase cannot be recovered.
</p>
<label className={styles.field}>
Confirm backup passphrase
<input
type="password"
autoComplete="new-password"
value={confirmPassphrase}
onChange={(event) => setConfirmPassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
/>
</label>
<button type="submit">
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
</button>
</fieldset>
</form>
</section>
<section className={styles.panel} aria-labelledby="restore-backup-title">
<h2 id="restore-backup-title">Restore a backup</h2>
<p>
Restoring replaces Magent&apos;s settings and database, including users and invites. Download a
current backup first if you want to keep these changes.
</p>
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
<legend className={styles.legend}>Choose and confirm a backup</legend>
<label className={styles.field}>
Backup file
<input
ref={fileInput}
type="file"
accept=".magent-backup,application/octet-stream"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
required
aria-describedby="backup-file-help"
/>
</label>
<p id="backup-file-help" className={styles.help}>
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
</p>
<label className={styles.field}>
Backup passphrase
<input
type="password"
autoComplete="off"
value={restorePassphrase}
onChange={(event) => setRestorePassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
/>
</label>
<label className={styles.field}>
Type RESTORE to confirm replacement
<input
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
pattern="RESTORE"
required
aria-describedby="restore-restart-help"
/>
</label>
<p id="restore-restart-help" className={styles.help}>
The backup is checked before being queued. It only takes effect when you restart Magent; you can
cancel before then. You will need to sign in using an account from the backup.
</p>
<button type="submit" className="danger-button">
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
</button>
</fieldset>
</form>
</section>
</div>
</>
)}
</div>
</AdminShell>
);
}
-6
View File
@@ -82,12 +82,6 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
advanced: true,
items: [
{ href: "/admin/general", label: "Hosting & proxy", description: "Public addresses and deployment options" },
{ href: "/setup", label: "Setup wizard", description: "Guided app connections and installation preferences" },
{
href: "/admin/backups",
label: "Backup & restore",
description: "Encrypted settings, database and cache backups",
},
{ href: "/admin/diagnostics", label: "System health", description: "Service checks and diagnostics" },
{ href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" },
{ href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" },
+1 -1
View File
@@ -1837,7 +1837,7 @@ export default function AdminInviteManagementPage() {
onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Welcome! Use this link to create your account."
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
/>
</label>
{inviteFlowStep === 2 && (
+1 -1
View File
@@ -368,7 +368,7 @@ export default function NewslettersAdminPage() {
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">A fresh edition</span>
<h2>Whats new in your library</h2>
<h2>Whats new on Grizzlyflix</h2>
<p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p>
</div>
<div className="newsletter-create">
+3 -3
View File
@@ -1,11 +1,11 @@
import "./style.css";
export const metadata = { title: "Coming soon | Magent" };
export const metadata = { title: "Coming soon | Magent — Grizzlyflix" };
export default function ComingSoonPage() {
return (
<main className="launch-cover">
<div className="launch-brand">MAGENT</div>
<div className="launch-brand">GRIZZLYFLIX</div>
<span className="launch-badge">COMING SOON</span>
<h1>
Your next watch.
@@ -26,7 +26,7 @@ export default function ComingSoonPage() {
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer>
<strong>Magent</strong>
<span>Your media member portal</span>
<span>Grizzlyflix member portal</span>
<a href="/login">Admin sign in</a>
</footer>
</main>
+2 -2
View File
@@ -6,7 +6,7 @@ export default function HowItWorksPage() {
<main className="friendly-guide">
<PageHeading
title="A little help getting started."
description="Magent looks after your requests. Jellyfin is where you watch them."
description="Magent looks after your requests. GrizzlyFlix is where you watch them."
/>
<nav aria-label="Quick links">
<a href="/welcome">Welcome page</a>
@@ -62,7 +62,7 @@ export default function HowItWorksPage() {
season pack.
</li>
<li>
<strong>Available to watch:</strong> Jellyfin has added the content. Use the watch button to open it.
<strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.
</li>
</ol>
<p>
+2 -4
View File
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../lib/auth";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import {
type Stats,
@@ -21,7 +20,6 @@ export default function InsightsPage() {
const router = useRouter();
const [days, setDays] = useState(30);
const [data, setData] = useState<Stats | null>(null);
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
@@ -128,11 +126,11 @@ export default function InsightsPage() {
</span>
<h2>Your viewing story starts here</h2>
<p>
{isAdmin
{data.is_admin
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
: "Viewing stats will appear here once your administrator connects Jellystat."}
</p>
{isAdmin && (
{data.is_admin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
+3 -5
View File
@@ -5,7 +5,6 @@ import EmailReportControl from "./EmailReportControl";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../../lib/auth";
import { useEffectiveRole } from "../../lib/viewMode";
import PageHeading from "../../ui/PageHeading";
import {
type Stats,
@@ -68,7 +67,6 @@ export default function MonthlyReportsPage() {
const [monthReady, setMonthReady] = useState(false);
const [months, setMonths] = useState<string[]>([]);
const [data, setData] = useState<MonthlyReport | null>(null);
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
@@ -269,11 +267,11 @@ export default function MonthlyReportsPage() {
<section className="stats-state">
<h2>Your monthly story starts here</h2>
<p>
{isAdmin
{data.is_admin
? "Connect Jellystat to bring your monthly viewing reports into Magent."
: "Monthly reports will appear once your administrator connects Jellystat."}
</p>
{isAdmin && (
{data.is_admin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
@@ -287,7 +285,7 @@ export default function MonthlyReportsPage() {
Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
your user identities.
</p>
{isAdmin && (
{data.is_admin && (
<a className="stats-action" href="/admin/identities">
Review user identities
</a>
-6
View File
@@ -9,8 +9,6 @@ import type { ReactNode } from "react";
import BrandingFavicon from "./ui/BrandingFavicon";
import FeatureGate from "./ui/FeatureGate";
import ApplicationChrome from "./ui/ApplicationChrome";
import SetupGate from "./ui/SetupGate";
import AdminViewGate from "./ui/AdminViewGate";
export const metadata = {
title: "Magent",
@@ -28,12 +26,8 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body>
<BrandingFavicon />
<div className="page">
<SetupGate>
<ApplicationChrome />
<AdminViewGate>
<FeatureGate>{children}</FeatureGate>
</AdminViewGate>
</SetupGate>
</div>
</body>
</html>
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { getEffectiveRole, isAdminPage } from "./user-view-policy";
describe("user view preview policy", () => {
it("downgrades only the displayed administrator role during preview", () => {
expect(getEffectiveRole("admin", true)).toBe("user");
expect(getEffectiveRole("admin", false)).toBe("admin");
for (const role of ["user", null, undefined]) {
expect(getEffectiveRole(role, true)).toBe(role);
expect(getEffectiveRole(role, false)).toBe(role);
}
});
it("covers configuration, nested admin pages, user management and setup", () => {
for (const path of [
"/admin",
"/admin/",
"/admin/backups",
"/admin/recaps",
"/users",
"/users/42",
"/setup",
"/admin?section=site",
"/%61dmin/diagnostics",
]) {
expect(isAdminPage(path), path).toBe(true);
}
});
it("does not restrict normal member pages or similarly named paths", () => {
for (const path of [
"/",
"/profile",
"/profile/invites",
"/portal/issues",
"/requests/3580",
"/insights",
"/administrator",
"/users-guide",
]) {
expect(isAdminPage(path), path).toBe(false);
}
});
it("keeps public first-install setup separate from admin authentication", () => {
expect(isAdminPage("/setup", false)).toBe(false);
expect(isAdminPage("/admin/backups", false)).toBe(true);
});
});
-16
View File
@@ -1,16 +0,0 @@
// Preview never promotes a user or changes server-side account permissions.
export function getEffectiveRole(role: string | null | undefined, preview: boolean) {
return preview && role === "admin" ? "user" : role;
}
export function isAdminPage(pathname: string, includeSetup = true): boolean {
let path = pathname.split(/[?#]/, 1)[0];
try {
path = decodeURIComponent(path);
} catch {
// Let the router handle malformed URLs; never infer a more privileged role.
}
path = path.replace(/\/{2,}/g, "/");
const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"];
return roots.some((root) => path === root || path.startsWith(`${root}/`));
}
+16 -36
View File
@@ -1,19 +1,13 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { getEffectiveRole } from "./user-view-policy";
import { useEffect, useState } from "react";
const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
const USER_VIEW_EVENT = "magent:user-view-change";
let fallbackPreview = false;
const readUserViewPreview = () => {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
} catch {
return fallbackPreview;
}
};
const applyDocumentMode = (enabled: boolean) => {
@@ -23,46 +17,32 @@ const applyDocumentMode = (enabled: boolean) => {
export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === "undefined") return;
fallbackPreview = enabled;
try {
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
}
} catch {
// Preview still works for this document when browser storage is unavailable.
}
applyDocumentMode(enabled);
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
};
const subscribe = (notify: () => void) => {
window.addEventListener(USER_VIEW_EVENT, notify);
window.addEventListener("storage", notify);
return () => {
window.removeEventListener(USER_VIEW_EVENT, notify);
window.removeEventListener("storage", notify);
};
};
// Unknown during server rendering/initial hydration: admin pages must not mount
// and fetch privileged data before the saved per-tab preview mode is known.
const serverSnapshot = (): boolean | null => null;
export const useUserViewState = () => {
const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot);
export const useUserViewPreview = () => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
if (value !== null) applyDocumentMode(value);
}, [value]);
return { enabled: value === true, ready: value !== null };
const sync = () => {
const nextValue = readUserViewPreview();
applyDocumentMode(nextValue);
setEnabled(nextValue);
};
sync();
window.addEventListener(USER_VIEW_EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(USER_VIEW_EVENT, sync);
window.removeEventListener("storage", sync);
};
}, []);
export const useUserViewPreview = () => useUserViewState().enabled;
export const useEffectiveRole = (role?: string | null) => {
const { enabled, ready } = useUserViewState();
return getEffectiveRole(role, !ready || enabled);
return enabled;
};
+2 -4
View File
@@ -93,8 +93,6 @@ export default function LoginPage() {
"/profile#newsletters",
"/admin/recaps",
"/admin/newsletters",
"/setup",
"/admin/backups",
].includes(next) ||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
/^\/issues\/confirm\/\d+$/.test(next);
@@ -138,7 +136,7 @@ export default function LoginPage() {
setError("");
}}
>
Jellyfin
Grizzlyflix
</button>
<button
type="button"
@@ -164,7 +162,7 @@ export default function LoginPage() {
) : (
<form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">
{selectedMode === "jellyfin" ? "Use your Jellyfin account." : "Use your Magent account."}
{selectedMode === "jellyfin" ? "Use your Grizzlyflix / Jellyfin account." : "Use your Magent account."}
</p>
<label htmlFor="login-username">Username</label>
<input
@@ -699,7 +699,7 @@ export default function NewRequestClient() {
<div className="request-submit-bar">
<div>
<span>Delivery route</span>
<strong>Seerr {options.destination.collector} Jellyfin</strong>
<strong>Seerr {options.destination.collector} Grizzlyflix</strong>
<small>Your request uses the default quality set by your administrator.</small>
</div>
<button
@@ -106,7 +106,7 @@ export default function NewsletterLinkPage() {
<span>Magent</span>
</a>
<section className="account-panel">
<span className="recap-eyebrow">Magent newsletters</span>
<span className="recap-eyebrow">Grizzlyflix newsletters</span>
<h1>
{state === "enabled"
? "Youre on the list."
+30 -50
View File
@@ -1,12 +1,12 @@
"use client";
import ResolutionChoice from "../ui/ResolutionChoice";
import PageHeading from "../ui/PageHeading";
import IssueFlowStep from "./IssueFlowStep";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import ResolutionChoice from "../ui/ResolutionChoice";
import IssueFlowStep from "./IssueFlowStep";
type PortalPermissions = {
can_edit?: boolean;
@@ -218,7 +218,7 @@ const ISSUE_CATEGORIES: Array<{
id: "missing_content",
marker: "MISSING",
label: "Movie or episode is missing",
description: "A title, season, episode, or expected part is not available in Jellyfin.",
description: "A title, season, episode, or expected part is not available in Grizzlyflix.",
outcome: "The selected missing content will be sent back to Sonarr or Radarr.",
issueType: "missing_content",
titlePrefix: "Missing content",
@@ -254,7 +254,7 @@ const ISSUE_CATEGORIES: Array<{
id: "service_unavailable",
marker: "SERVER",
label: "Nothing will play",
description: "Jellyfin will not open or every title fails across the device or household.",
description: "Grizzlyflix will not open or every title fails across the device or household.",
outcome: "Magent will check Jellyfin and attach the result to the issue.",
issueType: "service_unavailable",
titlePrefix: "Media server unavailable",
@@ -285,7 +285,7 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
"Only fails on one device",
],
service_unavailable: [
"Jellyfin will not open",
"Grizzlyflix will not open",
"Every title fails",
"Login works but playback does not",
"Server error is shown",
@@ -536,16 +536,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
const effectiveRole = useEffectiveRole(me?.role);
const isAdmin = effectiveRole === "admin";
const isOwner = (item: PortalItem) => me?.username === item.created_by_username;
const canConfirmResolution = (item: PortalItem) =>
Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item)));
const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem)));
const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate);
const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete);
const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal);
const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added");
const isAdmin = me?.role === "admin";
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
const workspaceLabel = workspace === "request" ? "request" : "issue";
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
@@ -619,16 +610,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
});
const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
useEffect(() => {
if (isAdmin) return;
setDeleteConfirming(false);
if (commentInternal) {
// Do not turn an unfinished internal note into a public comment when preview changes.
setCommentText("");
setCommentInternal(false);
}
}, [isAdmin, commentInternal]);
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
@@ -1354,7 +1335,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const saveItem = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem || !canEditSelected) return;
if (!selectedItem) return;
setSaving(true);
setError(null);
setStatus(null);
@@ -1366,7 +1347,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
year: editYear.trim() ? toPositiveInt(editYear) : null,
external_ref: editExternalRef || null,
};
if (canModerateSelected) {
if (selectedItem.permissions?.can_moderate) {
if (selectedItem.kind === "request") {
payload.request_status = editRequestStatus;
payload.media_status = editMediaStatus;
@@ -1409,7 +1390,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const postComment = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
if (commentInternal && !isAdmin) return;
if (!commentText.trim()) {
setError("Comment message is required.");
return;
@@ -1424,7 +1404,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: commentText,
is_internal: isAdmin && commentInternal,
is_internal: commentInternal,
}),
});
if (!response.ok) {
@@ -1449,7 +1429,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
};
const respondToResolution = async (resolved: boolean) => {
if (!selectedItem || !canConfirmResolution(selectedItem)) return;
if (!selectedItem) return;
setRespondingResolution(true);
setError(null);
setStatus(null);
@@ -1485,7 +1465,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
};
const deleteIssue = async () => {
if (selectedItem?.kind !== "issue" || !canDeleteSelected) return;
if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return;
setDeleting(true);
setError(null);
setStatus(null);
@@ -1570,7 +1550,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
.filter(
(item) =>
item.status === "awaiting_confirmation" &&
canConfirmResolution(item) &&
item.permissions?.can_confirm_resolution &&
item.created_by_username === me?.username,
)
.map((item) => (
@@ -1743,7 +1723,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setSelectedEpisodeIds([]);
}
}}
placeholder="Search the media catalogue"
placeholder="Search the Grizzlyflix catalogue"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -2436,7 +2416,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</strong>
</div>
<div className="issue-modal-toolbar-actions">
{canDeleteSelected ? (
{selectedItem?.permissions?.can_delete ? (
<button
type="button"
className="danger-button"
@@ -2462,7 +2442,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<>
{selectedItem.kind === "issue" &&
selectedItem.status === "awaiting_confirmation" &&
canConfirmResolution(selectedItem) && (
selectedItem.permissions?.can_confirm_resolution && (
<ResolutionChoice
title={selectedItem.title}
busy={respondingResolution}
@@ -2498,7 +2478,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</div>
{selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
{selectedItem.kind === "issue" && deleteConfirming ? (
<section className="issue-delete-confirmation" aria-live="polite">
<div>
<span className="section-kicker">Permanent deletion</span>
@@ -2549,7 +2529,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
disabled={!canEditSelected}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
<label className="portal-field-span-2">
@@ -2558,7 +2538,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
rows={4}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
disabled={!canEditSelected}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
{selectedItem.kind === "request" ? (
@@ -2568,7 +2548,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<select
value={editMediaType}
onChange={(event) => setEditMediaType(event.target.value)}
disabled={!canEditSelected}
disabled={!selectedItem.permissions?.can_edit}
>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || "none"} value={option.value}>
@@ -2583,7 +2563,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
value={editYear}
onChange={(event) => setEditYear(event.target.value)}
inputMode="numeric"
disabled={!canEditSelected}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
</>
@@ -2593,10 +2573,10 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<input
value={editExternalRef}
onChange={(event) => setEditExternalRef(event.target.value)}
disabled={!canEditSelected}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
{canModerateSelected && (
{selectedItem.permissions?.can_moderate && (
<>
{selectedItem.kind === "request" ? (
<>
@@ -2672,7 +2652,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</>
)}
<div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={saving || !canEditSelected}>
<button type="submit" disabled={saving || !selectedItem.permissions?.can_edit}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
@@ -2685,13 +2665,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<span className="section-kicker">Recorded work</span>
<h3>Issue activity</h3>
</div>
<span className="small-pill">{visibleActivity.length} events</span>
<span className="small-pill">{activity.length} events</span>
</div>
{visibleActivity.length === 0 ? (
{activity.length === 0 ? (
<div className="status-banner">No issue activity has been recorded yet.</div>
) : (
<ol className="issue-activity-list">
{visibleActivity.map((entry) => (
{activity.map((entry) => (
<li key={entry.id}>
<i aria-hidden="true" />
<div>
@@ -2710,11 +2690,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<div className="portal-comments-block">
<h3>Comments</h3>
{visibleComments.length === 0 ? (
{comments.length === 0 ? (
<div className="status-banner">No comments yet.</div>
) : (
<div className="portal-comment-list">
{visibleComments.map((comment) => (
{comments.map((comment) => (
<article key={comment.id} className="portal-comment-card">
<header>
<strong>{comment.author_username}</strong>
@@ -94,7 +94,7 @@ export default function NewsletterPreference() {
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Your next watch</span>
<h2 id="newsletter-preference-title">New in your library.</h2>
<h2 id="newsletter-preference-title">New on Grizzlyflix.</h2>
</div>
{data && (
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
+7 -12
View File
@@ -1,11 +1,12 @@
"use client";
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import PageHeading from "../../ui/PageHeading";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import { useEffectiveRole } from "../../lib/viewMode";
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import PageHeading from "../../ui/PageHeading";
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
type OwnedInvite = {
@@ -76,10 +77,6 @@ export default function ProfileInvitesPage() {
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
const effectiveRole = useEffectiveRole(profile?.role);
const canManageInvites =
effectiveRole === "admin" ||
(profile?.role === "admin" ? Boolean(profile.invite_management_enabled) : inviteAccessEnabled);
const signupBaseUrl = useMemo(() => {
if (typeof window === "undefined") return "/signup";
@@ -161,7 +158,6 @@ export default function ProfileInvitesPage() {
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault();
if (!canManageInvites) return;
const inviteName = inviteForm.label.trim();
const recipientEmail = inviteForm.recipient_email.trim();
if (!inviteName) {
@@ -229,7 +225,6 @@ export default function ProfileInvitesPage() {
};
const deleteInvite = async (invite: OwnedInvite) => {
if (!canManageInvites) return;
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
setError(null);
try {
@@ -245,7 +240,6 @@ export default function ProfileInvitesPage() {
};
const copyInviteLink = async (invite: OwnedInvite) => {
if (!canManageInvites) return;
try {
let usableInvite = invite;
if (!invite.code_available) {
@@ -269,13 +263,14 @@ export default function ProfileInvitesPage() {
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
const canManageInvites = profile?.role === "admin" || inviteAccessEnabled;
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
if (loading) return <main className="card">Loading invite workspace</main>;
return (
<main className="card invites-page">
<PageHeading title="Invites" description="Invite someone to your media library and manage the links you share." />
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
@@ -418,7 +413,7 @@ export default function ProfileInvitesPage() {
onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Welcome! Use this link to create your account."
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
/>
</label>
{flowStep === 2 && (
+9 -12
View File
@@ -1,14 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
import { canAccess, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import MonthlyRecapPreference from "./MonthlyRecapPreference";
import NewsletterPreference from "./NewsletterPreference";
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type ProfileInfo = {
features?: FeatureAccess;
username: string;
@@ -200,7 +200,7 @@ export default function ProfilePage() {
tone: "status",
message:
result.provider === "jellyfin"
? "Password updated for Jellyfin and Magent. Seerr uses the same password."
? "Password updated for Grizzlyflix and Magent. Seerr uses the same password."
: "Password updated.",
});
} catch (error) {
@@ -214,7 +214,6 @@ export default function ProfilePage() {
};
const user = data?.user;
const effectiveRole = useEffectiveRole(user?.role);
const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
const canChangePassword =
user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
@@ -240,7 +239,7 @@ export default function ProfilePage() {
</span>
<div>
<strong>{user.username}</strong>
<span>{effectiveRole === "admin" ? "Administrator" : "Member"}</span>
<span>{user.role === "admin" ? "Administrator" : "Member"}</span>
</div>
</div>
)
@@ -335,15 +334,13 @@ export default function ProfilePage() {
<span className="account-connection-dot" aria-hidden="true" />
<span>
{user.auth_provider === "jellyfin"
? "Connected with your Jellyfin account"
? "Connected with your Grizzlyflix account"
: user.auth_provider === "local"
? "Signed in with a Magent account"
: "Signed in with your media account"}
</span>
</div>
{canAccess({ ...user, role: effectiveRole ?? undefined }, "stats") && (
<MonthlyRecapPreference key={user.email || "no-email"} />
)}
{canAccess(user, "stats") && <MonthlyRecapPreference key={user.email || "no-email"} />}
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
</section>
@@ -358,7 +355,7 @@ export default function ProfilePage() {
<h2>Change password</h2>
<p>
{passwordProvider === "jellyfin"
? "One password for Jellyfin, Seerr and Magent."
? "One password for Grizzlyflix, Seerr and Magent."
: "Keep your Magent account secure."}
</p>
</div>
+30 -45
View File
@@ -4,9 +4,8 @@ import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import { canAccess, type FeatureAccess } from "../../lib/features";
import { canAccess } from "../../lib/features";
import { lockBodyScroll } from "../../lib/scrollLock";
import { useEffectiveRole } from "../../lib/viewMode";
import PageHeading from "../../ui/PageHeading";
import LatestActivity from "./LatestActivity";
import RequestLanguage from "./RequestLanguage";
@@ -289,14 +288,14 @@ const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
{ id: "download", label: "Download", state: "waiting", summary: "No download attempt yet" },
{
id: "available",
label: complete ? "Available to watch" : indexing ? "Adding to Jellyfin" : "Media server",
label: complete ? "Available to watch" : indexing ? "Adding to Grizzlyflix" : "Media server",
state: complete ? "complete" : indexing ? "active" : "waiting",
stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting",
summary: complete
? "This title is ready to watch in Jellyfin."
? "This title is ready to watch in Grizzlyflix."
: indexing
? "The download is complete. Jellyfin is indexing this title now."
: "This title has not reached Jellyfin yet.",
? "The download is complete. Grizzlyflix is indexing this title now."
: "This title has not reached Grizzlyflix yet.",
link: snapshot.raw?.jellyfin?.link,
},
];
@@ -330,10 +329,8 @@ export default function RequestTimelinePage() {
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]);
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]);
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
const [viewer, setViewer] = useState<{ role?: string; features?: Partial<FeatureAccess> } | null>(null);
const effectiveRole = useEffectiveRole(viewer?.role);
const isAdmin = effectiveRole === "admin";
const canReportIssues = canAccess(viewer ? { ...viewer, role: effectiveRole ?? undefined } : null, "issues");
const [isAdmin, setIsAdmin] = useState(false);
const [canReportIssues, setCanReportIssues] = useState(false);
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([]);
const awaitingMediaIndex = Boolean(
snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"),
@@ -392,13 +389,32 @@ export default function RequestTimelinePage() {
throw new Error("Unable to verify your request access.");
}
const me = await meResponse.json();
setViewer(me);
const viewerIsAdmin = me?.role === "admin";
setIsAdmin(viewerIsAdmin);
setCanReportIssues(canAccess(me, "issues"));
if (!snapshotResponse.ok) {
throw new Error(await readApiError(snapshotResponse, "Unable to load this request."));
}
const snapshotData = await snapshotResponse.json();
if (!isSnapshotPayload(snapshotData)) throw new Error("Unable to load this request.");
setSnapshot(snapshotData);
if (viewerIsAdmin) {
const [historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
]);
if (historyResponse.ok) {
const historyData = await historyResponse.json();
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots);
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json();
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions);
}
} else {
setHistorySnapshots([]);
setHistoryActions([]);
}
} catch (error) {
console.error(error);
setLoadError(error instanceof Error ? error.message : "Unable to load this request.");
@@ -409,37 +425,6 @@ export default function RequestTimelinePage() {
void load();
}, [requestId, router]);
useEffect(() => {
if (!isAdmin || !requestId) {
setShowDetails(false);
setHistorySnapshots([]);
setHistoryActions([]);
return;
}
const controller = new AbortController();
const loadHistory = async () => {
try {
const baseUrl = getApiBase();
const [historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`, { signal: controller.signal }),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`, { signal: controller.signal }),
]);
if (historyResponse.ok) {
const data = await historyResponse.json();
if (!controller.signal.aborted && Array.isArray(data.snapshots)) setHistorySnapshots(data.snapshots);
}
if (actionsResponse.ok) {
const data = await actionsResponse.json();
if (!controller.signal.aborted && Array.isArray(data.actions)) setHistoryActions(data.actions);
}
} catch (error) {
if (!controller.signal.aborted) console.error(error);
}
};
void loadHistory();
return () => controller.abort();
}, [isAdmin, requestId]);
useEffect(() => {
if (!getToken() || !requestId) return;
let stopped = false;
@@ -1031,13 +1016,13 @@ export default function RequestTimelinePage() {
<section>
<span className="request-overview-label">Ready to watch</span>
<strong>Watch this now!</strong>
<p>Open {snapshot.title} directly in Jellyfin.</p>
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
{mediaServerLink ? (
<a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">
Watch on Jellyfin <span aria-hidden="true">&rarr;</span>
Watch on Grizzlyflix <span aria-hidden="true">&rarr;</span>
</a>
) : (
<span className="request-ready-unavailable">The Jellyfin watch link is not configured.</span>
<span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>
)}
</section>
<section>
-91
View File
@@ -1,91 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { copySetupTokenCommand, SETUP_TOKEN_COMMAND } from "./setup-token-help";
import styles from "./setup.module.css";
export default function SetupTokenHelp({ disabled = false }: { disabled?: boolean }) {
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const commandField = useRef<HTMLTextAreaElement>(null);
const [copyStatus, setCopyStatus] = useState("");
const copyCommand = async () => {
const result = await copySetupTokenCommand(navigator.clipboard, commandField.current);
setCopyStatus(
result === "copied"
? "Command copied. Paste it into the Magent container console."
: result === "selected"
? "Automatic copying is unavailable. The command is selected; press Ctrl+C (Command+C on Mac), or touch and hold to copy."
: "Automatic copying is unavailable. Select and copy the command above.",
);
};
return (
<div className={styles.tokenHelp}>
<button
ref={trigger}
type="button"
className="ghost-button"
disabled={disabled}
aria-haspopup="dialog"
aria-controls="setup-token-help"
onClick={() => {
setCopyStatus("");
dialog.current?.showModal();
}}
>
Get setup token
</button>
<dialog
ref={dialog}
id="setup-token-help"
className={styles.tokenHelpDialog}
aria-labelledby="setup-token-help-title"
aria-describedby="setup-token-help-description"
onClose={() => trigger.current?.focus()}
>
<header className={styles.tokenHelpHeading}>
<h2 id="setup-token-help-title">Get your setup token</h2>
<button type="button" className="ghost-button" onClick={() => dialog.current?.close()}>
Close
</button>
</header>
<p id="setup-token-help-description">
Magent generates a private setup token when a managed installation starts. Retrieve it from your server
console to create the first administrator.
</p>
<ol className={styles.tokenHelpSteps}>
<li>In Portainer, open Containers and select the healthy Magent container.</li>
<li>
Open Console, choose command <code>/bin/ash</code> and user <code>magent</code>, then connect.
</li>
<li>Run the command below, then copy its output into the Setup token field on this page.</li>
</ol>
<label htmlFor="setup-token-command">Container console command</label>
<textarea
ref={commandField}
id="setup-token-command"
className={styles.tokenCommand}
readOnly
rows={3}
spellCheck={false}
value={SETUP_TOKEN_COMMAND}
/>
<button type="button" onClick={() => void copyCommand()}>
Copy command
</button>
<p role="status" aria-live="polite" className={styles.copyStatus}>
{copyStatus}
</p>
<p>
Keep the token private. Anyone with it and access to this installation can create the first administrator. The
command stops returning it once an administrator exists.
</p>
<p>
For a manual deployment, use the <code>SETUP_TOKEN</code> from your deployment environment.
</p>
</dialog>
</div>
);
}
-620
View File
@@ -1,620 +0,0 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { apiUrl, requestJson } from "../lib/api-client";
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
import MagentMark from "../ui/MagentMark";
import SetupTokenHelp from "./SetupTokenHelp";
import { serviceStatusLabel } from "../admin/configNavigation";
import {
ALL_FIELDS,
APPS,
PREFERENCES,
bootstrapApplicationUrl,
configuredApp,
settingsPayload,
settingsValues,
type AppDefinition,
type Field,
type Setting,
type SetupState,
type SetupStatus,
type SetupStep,
type Values,
} from "./setup-model";
import styles from "./setup.module.css";
type Check = { status: string; message?: string };
type CollectorOptions = { rootFolders: { path: string }[]; qualityProfiles: { id: number; name: string }[] };
const steps: { id: SetupStep; label: string }[] = [
{ id: "administrator", label: "Administrator" },
{ id: "apps", label: "Apps" },
{ id: "preferences", label: "Preferences" },
{ id: "review", label: "Review" },
];
const json = (body: unknown, method = "POST"): RequestInit => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const message = (error: unknown) =>
error instanceof Error ? error.message : "Something went wrong. Please try again.";
export default function SetupPage() {
const [status, setStatus] = useState<SetupStatus | null>(null);
const [state, setState] = useState<SetupState | null>(null);
const [step, setStep] = useState<SetupStep>("administrator");
const [settings, setSettings] = useState<Setting[]>([]);
const [draft, setDraft] = useState<Values>({});
const [ready, setReady] = useState(false);
const [admin, setAdmin] = useState(false);
const [forbidden, setForbidden] = useState(false);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [setupToken, setSetupToken] = useState("");
const [applicationUrl, setApplicationUrl] = useState("");
const [checks, setChecks] = useState<Record<string, Check>>({});
const [options, setOptions] = useState<Record<string, CollectorOptions>>({});
const [accepted, setAccepted] = useState(false);
const values = { ...settingsValues(settings), ...draft };
useEffect(() => {
setApplicationUrl(window.location.origin);
const controller = new AbortController();
const load = async () => {
try {
const current = await requestJson<SetupStatus>(
"/setup/status",
{ signal: controller.signal, cache: "no-store" },
authFetch,
);
setStatus(current);
if (current.needs_admin) return;
const response = await authFetch(apiUrl("/auth/me"), { signal: controller.signal });
if (!response.ok) return;
const user = await response.json();
if (user.role !== "admin") {
setForbidden(true);
return;
}
const [progress, config] = await Promise.all([
requestJson<SetupState>("/setup/state", { signal: controller.signal }),
requestJson<{ settings: Setting[] }>("/admin/settings", { signal: controller.signal }),
]);
setAdmin(true);
setState(progress);
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
setSettings(config.settings);
} catch (failure) {
if (!controller.signal.aborted) setError(message(failure));
} finally {
if (!controller.signal.aborted) setReady(true);
}
};
void load();
return () => controller.abort();
}, []);
useEffect(() => {
if (!Object.keys(draft).length) return;
const warn = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [draft]);
const run = async (name: string, action: () => Promise<void>) => {
if (busy) return;
setBusy(name);
setError("");
setNotice("");
try {
await action();
} catch (failure) {
if (failure instanceof UnauthorizedError) {
setAdmin(false);
setForbidden(false);
setAccepted(false);
setPassword("");
setError("Your session expired. Sign in to continue; your unsaved changes are still here.");
} else if (failure instanceof ForbiddenError) {
setAdmin(false);
setForbidden(true);
setAccepted(false);
} else setError(message(failure));
} finally {
setBusy("");
}
};
const signIn = async (loginPassword = password) => {
const result = await requestJson<{ authenticated: boolean; user?: { role: string } }>(
"/auth/login",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: username.trim(), password: loginPassword }),
},
authFetch,
);
if (!result.authenticated) throw new Error("Could not sign in. Try your administrator credentials again.");
setToken("cookie");
setPassword("");
setConfirmation("");
const user = await requestJson<{ role: string }>("/auth/me");
if (user.role !== "admin") {
setForbidden(true);
return;
}
const [progress, config] = await Promise.all([
requestJson<SetupState>("/setup/state"),
requestJson<{ settings: Setting[] }>("/admin/settings"),
]);
setAdmin(true);
setForbidden(false);
setNotice("");
setState(progress);
setSettings(config.settings);
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
};
const authenticate = (event: FormEvent) => {
event.preventDefault();
void run("account", async () => {
let loginPassword = password;
if (status?.needs_admin) {
const confirmedApplicationUrl = bootstrapApplicationUrl(applicationUrl, window.location.origin);
if (password !== confirmation) throw new Error("The passwords do not match.");
loginPassword = password.trim();
if (loginPassword.length < 12)
throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces.");
await requestJson(
"/setup/bootstrap",
json({
setup_token: setupToken,
username: username.trim(),
password,
application_url: confirmedApplicationUrl,
}),
authFetch,
);
setPassword(loginPassword);
setSetupToken("");
setStatus({ setup_required: true, needs_admin: false });
setNotice("Administrator created. Signing in...");
}
await signIn(loginPassword);
});
};
const switchAccount = () =>
void run("switch-account", async () => {
await logout();
setAdmin(false);
setForbidden(false);
setAccepted(false);
setUsername("");
setPassword("");
setConfirmation("");
setDraft({});
setSettings([]);
setChecks({});
setOptions({});
setNotice("Sign in with a Magent administrator account to continue setup.");
});
const save = async (fields: Field[] = ALL_FIELDS) => {
const payload = settingsPayload(draft, fields);
if (!Object.keys(payload).length) return;
if (values.site_login_show_local_login === false && values.site_login_show_jellyfin_login === false) {
throw new Error("Keep at least one sign-in method enabled.");
}
if (values.magent_notify_email_use_tls === true && values.magent_notify_email_use_ssl === true) {
throw new Error("Choose STARTTLS or implicit TLS, not both.");
}
await requestJson("/admin/settings", json(payload, "PUT"));
const config = await requestJson<{ settings: Setting[] }>("/admin/settings");
setSettings(config.settings);
setDraft((previous) =>
Object.fromEntries(Object.entries(previous).filter(([key]) => !fields.some((field) => field.key === key))),
);
};
const go = (next: SetupStep) =>
void run("save", async () => {
await save();
if (!state?.completed) setState(await requestJson<SetupState>("/setup/state", json({ step: next }, "PUT")));
setStep(next);
setNotice("Settings saved. You can return to finish setup later.");
});
const test = (app: AppDefinition) =>
void run(app.id, async () => {
await save(app.fields);
const check = await requestJson<Check>(`/status/services/${app.id}/test`, { method: "POST" });
setChecks((previous) => ({ ...previous, [app.id]: check }));
setNotice(`${app.name}: ${serviceStatusLabel(check.status)}${check.message ? `${check.message}` : ""}`);
if ((app.id === "sonarr" || app.id === "radarr") && check.status === "up") {
const choices = await requestJson<CollectorOptions>(`/admin/${app.id}/options`);
setOptions((previous) => ({ ...previous, [app.id]: choices }));
}
});
const update = (field: Field, value: string | boolean) => {
setDraft((previous) => ({ ...previous, [field.key]: value }));
setAccepted(false);
setNotice("");
const app = APPS.find((candidate) => candidate.fields.some((item) => item.key === field.key));
if (app) setChecks((previous) => ({ ...previous, [app.id]: { status: "unchecked" } }));
};
const fieldControl = (field: Field) => {
const saved = settings.some((setting) => setting.key === field.key && setting.isSet);
const collectorId = field.key.startsWith("sonarr_") ? "sonarr" : "radarr";
const choices = options[collectorId];
const profile = field.key.endsWith("_quality_profile_id") && choices?.qualityProfiles.length;
const folders = field.key.endsWith("_root_folder") && choices?.rootFolders.length;
return (
<div key={field.key} className={`${styles.field} ${field.type === "checkbox" ? styles.toggle : ""}`}>
<label htmlFor={`setup-${field.key}`}>
{field.label}
{field.type === "password" && saved && <small>Saved securely</small>}
</label>
{field.type === "checkbox" ? (
<input
id={`setup-${field.key}`}
type="checkbox"
checked={values[field.key] === true}
onChange={(event) => update(field, event.target.checked)}
disabled={!!busy}
/>
) : field.type === "textarea" ? (
<textarea
id={`setup-${field.key}`}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
rows={3}
/>
) : profile ? (
<select
id={`setup-${field.key}`}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
>
<option value="">Choose a profile</option>
{choices.qualityProfiles.map((choice) => (
<option key={choice.id} value={choice.id}>
{choice.name}
</option>
))}
</select>
) : (
<input
id={`setup-${field.key}`}
type={field.type || "text"}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
autoComplete={field.type === "password" ? "new-password" : "off"}
min={field.min}
max={field.max}
placeholder={
field.type === "password" && saved ? "Leave blank to keep saved credential" : field.placeholder
}
list={folders ? `options-${field.key}` : undefined}
/>
)}
{folders ? (
<datalist id={`options-${field.key}`}>
{choices.rootFolders.map((folder) => (
<option key={folder.path} value={folder.path} />
))}
</datalist>
) : null}
{field.hint && <p>{field.hint}</p>}
</div>
);
};
return (
<main className={styles.setup}>
<header className={styles.heading}>
<div className={styles.brand}>
<MagentMark />
<span>Magent / Installation</span>
</div>
<h1>Set up Magent</h1>
<p>Connect your media apps, choose your settings and make yourself at home.</p>
</header>
{!ready ? (
<p role="status">Checking installation...</p>
) : (
<>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{notice && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
{!status ? (
<button type="button" onClick={() => window.location.reload()}>
Retry
</button>
) : forbidden ? (
<section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}>
{busy === "switch-account" ? "Signing out..." : "Sign in with an administrator account"}
</button>
</section>
) : !admin ? (
<section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
<p>
{status.needs_admin
? "Use your private setup token to create the first administrator account."
: "Use your local Magent administrator account. Settings are never available to unauthenticated visitors."}
</p>
<form onSubmit={authenticate} className={styles.account}>
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-application-url">Public Magent URL</label>
<input
id="setup-application-url"
type="url"
autoComplete="url"
required
maxLength={2048}
value={applicationUrl}
onChange={(event) => setApplicationUrl(event.target.value)}
aria-describedby="setup-application-url-hint"
disabled={!!busy}
/>
<p id="setup-application-url-hint">
Confirm the address your users will open. Use HTTPS for internet-facing installs. This must match
the address currently open in your browser; if you plan to use another domain, open Magent there
before creating your administrator. Managed installs use this address for links and sign-in
security.
</p>
</div>
)}
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-token">Setup token</label>
<input
id="setup-token"
type="password"
autoComplete="off"
required
minLength={32}
maxLength={1024}
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
aria-describedby="setup-token-hint"
disabled={!!busy}
/>
<p id="setup-token-hint">Retrieve this token from your server console.</p>
<SetupTokenHelp disabled={!!busy} />
</div>
)}
<div className={styles.field}>
<label htmlFor="setup-username">Username</label>
<input
id="setup-username"
autoComplete="username"
required
maxLength={100}
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={!!busy}
/>
</div>
<div className={styles.field}>
<label htmlFor="setup-password">Password</label>
<input
id="setup-password"
type="password"
autoComplete={status.needs_admin ? "new-password" : "current-password"}
required
minLength={status.needs_admin ? 12 : undefined}
maxLength={1024}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={!!busy}
/>
</div>
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-confirm">Confirm password</label>
<input
id="setup-confirm"
type="password"
autoComplete="new-password"
required
minLength={12}
maxLength={1024}
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
disabled={!!busy}
/>
<p>Use at least 12 characters and a unique password.</p>
</div>
)}
<button type="submit" disabled={!!busy}>
{busy ? "Working..." : status.needs_admin ? "Create administrator" : "Sign in to continue"}
</button>
</form>
{!status.setup_required && <a href="/login?next=/setup">Use Jellyfin sign-in instead</a>}
</section>
) : (
<>
{state?.completed ? (
<p className={styles.notice}>
This installation is already set up. You can use this guide to update its connections.{" "}
<a href="/admin">Back to settings</a>
</p>
) : (
<p className={styles.notice}>
Your administrator is ready. Background imports and automation are paused until you finish. Already
have a backup? <a href="/admin/backups">Restore it here</a>.
</p>
)}
<nav aria-label="Setup steps" className={styles.steps}>
{steps.map((item, index) => (
<button
key={item.id}
type="button"
aria-current={step === item.id ? "step" : undefined}
disabled={!!busy || item.id === "administrator"}
onClick={() => go(item.id)}
>
<span>{index + 1}</span>
{item.id === "administrator" ? "Administrator ready" : item.label}
</button>
))}
</nav>
<form
onSubmit={(event) => {
event.preventDefault();
go(step === "apps" ? "preferences" : "review");
}}
>
{step === "apps" && (
<section aria-labelledby="apps-title">
<h2 id="apps-title">Connect your apps</h2>
<p>
Each app is optional. Expand the apps you use, save and test their connections, then continue. In
Docker, localhost means the Magent container itself.
</p>
<div className={styles.apps}>
{APPS.map((app) => (
<details key={app.id} className={styles.panel}>
<summary>
<span>
<strong>{app.name}</strong>
<small>{app.description}</small>
</span>
<span className={styles.badge}>
{checks[app.id]
? serviceStatusLabel(checks[app.id].status)
: configuredApp(app, settings)
? "Configured"
: "Optional / not set up"}
</span>
</summary>
<div className={styles.fields}>{app.fields.map(fieldControl)}</div>
<button type="button" disabled={!!busy} onClick={() => test(app)}>
{busy === app.id ? "Testing..." : `Save & test ${app.name}`}
</button>
{checks[app.id]?.message && <p role="status">{checks[app.id].message}</p>}
</details>
))}
</div>
</section>
)}
{step === "preferences" && (
<section aria-labelledby="preferences-title">
<h2 id="preferences-title">Choose your preferences</h2>
<p>
Defaults are loaded from your installation. Advanced notification channels, branding and invite
policies are available in Settings afterwards.
</p>
{PREFERENCES.map((group) => (
<section key={group.title} className={styles.panel}>
<h3>{group.title}</h3>
<div className={styles.fields}>{group.fields.map(fieldControl)}</div>
</section>
))}
</section>
)}
{step === "review" && (
<section className={styles.panel} aria-labelledby="review-title">
<h2 id="review-title">Ready to finish?</h2>
<p>Unconfigured apps remain disconnected. You can change every connection later in Settings.</p>
<ul className={styles.review}>
{APPS.map((app) => (
<li key={app.id}>
<span>{app.name}</span>
<span>
{checks[app.id]
? serviceStatusLabel(checks[app.id].status)
: configuredApp(app, settings)
? "Configured (not tested this session)"
: "Not configured"}
</span>
</li>
))}
</ul>
<p>
Finishing starts the configured background imports and automation, unless disabled in your
deployment. Save an encrypted backup once you have checked the installation.
</p>
<label className={styles.confirm}>
<input
type="checkbox"
checked={accepted}
onChange={(event) => setAccepted(event.target.checked)}
disabled={!!busy}
/>
I have reviewed the connections and want to finish setup.
</label>
<p className={styles.hint}>
You may remove a manually configured SETUP_TOKEN from your environment after completion. Managed
installs keep their generated keys in the data volume; do not remove that file or volume. Existing
users and invites are preserved.
</p>
</section>
)}
<div className={styles.actions}>
{step !== "apps" && (
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => go(step === "review" ? "preferences" : "apps")}
>
Back
</button>
)}
<span>{Object.keys(draft).length ? "Unsaved changes" : "Progress is saved"}</span>
{step !== "review" ? (
<button type="submit" disabled={!!busy}>
{busy === "save" ? "Saving..." : "Save & continue"}
</button>
) : (
<button
type="button"
disabled={!!busy || !accepted}
onClick={() =>
void run("finish", async () => {
await save();
await requestJson("/setup/complete", { method: "POST" });
window.location.assign("/admin");
})
}
>
{busy === "finish" ? "Finishing..." : "Finish setup"}
</button>
)}
</div>
</form>
</>
)}
</>
)}
</main>
);
}
-125
View File
@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest";
import { APPS, bootstrapApplicationUrl, configuredApp, settingsPayload, settingsValues } from "./setup-model";
describe("first administrator application URL confirmation", () => {
it.each([
["https://magent.example.com", "https://magent.example.com"],
[" https://MAGENT.example.com:443/ ", "https://magent.example.com"],
["http://192.0.2.10:3000", "http://192.0.2.10:3000"],
["http://[fd00::10]:3000/", "http://[fd00::10]:3000"],
])("confirms a canonical same-origin address %s", (value, browserOrigin) => {
expect(bootstrapApplicationUrl(value, browserOrigin)).toBe(browserOrigin);
});
it.each([
"",
"magent.example.com",
"//magent.example.com",
"https:/magent.example.com",
"ftp://magent.example.com",
"javascript:alert(1)",
"https://user:password@magent.example.com",
"https://magent.example.com/setup",
"https://magent.example.com/../",
"https://magent.example.com?query=1",
"https://magent.example.com?",
"https://magent.example.com#fragment",
"https://magent.example.com#",
"https://magent.example.com\\path",
"https://magent.\texample.com",
])("rejects a non-origin or unsafe URL %j", (value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow("Public Magent URL must");
});
it.each(["https://other.example.com", "http://magent.example.com", "https://magent.example.com:8443"])(
"requires the intended browser origin before claiming %s",
(value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow(
"Open Magent at your intended address",
);
},
);
});
describe("installation settings", () => {
it("offers every supported media integration", () => {
expect(APPS.map((app) => app.id).sort()).toEqual([
"bazarr",
"jellyfin",
"jellystat",
"prowlarr",
"qbittorrent",
"radarr",
"seerr",
"sonarr",
]);
});
it("never copies saved secrets into the form or overwrites them with a blank", () => {
expect(settingsValues([{ key: "sonarr_api_key", value: "secret", sensitive: true, isSet: true }])).toEqual({
sonarr_api_key: "",
});
expect(settingsPayload({ sonarr_api_key: "", sonarr_base_url: "http://sonarr:8989" })).toEqual({
sonarr_base_url: "http://sonarr:8989",
});
});
it("sends only editable fields and validates numeric settings", () => {
expect(
settingsPayload({ jwt_secret: "no", requests_cleanup_days: "90", site_login_show_signup_link: false }),
).toEqual({ requests_cleanup_days: 90, site_login_show_signup_link: false });
expect(() => settingsPayload({ requests_cleanup_days: "-1" })).toThrow("whole number");
expect(() => settingsPayload({ sonarr_quality_profile_id: "1.5" })).toThrow("whole number");
});
it("can save just one app without accidentally saving another draft", () => {
expect(
settingsPayload(
{ sonarr_base_url: "http://sonarr:8989", radarr_api_key: "draft-secret" },
APPS.find((app) => app.id === "sonarr")?.fields,
),
).toEqual({ sonarr_base_url: "http://sonarr:8989" });
});
it("validates URL drafts even when app testing bypasses browser form validation", () => {
for (const value of [
"sonarr:8989",
"/sonarr",
"ftp://sonarr:8989",
"javascript:alert(1)",
"http://sonarr/my library",
]) {
expect(() => settingsPayload({ sonarr_base_url: value })).toThrow("HTTP or HTTPS URL");
}
expect(() => settingsPayload({ sonarr_base_url: "https://user:secret@sonarr.test" })).toThrow("credential fields");
expect(
settingsPayload({
sonarr_base_url: " http://sonarr:8989 ",
magent_application_url: "https://magent.example.test",
}),
).toEqual({ sonarr_base_url: "http://sonarr:8989", magent_application_url: "https://magent.example.test" });
expect(settingsPayload({ sonarr_base_url: "" })).toEqual({ sonarr_base_url: "" });
});
it("validates sender email and sync time before step navigation saves", () => {
for (const value of ["not-an-email", "two@@example.test", "name@example test", "Name <name@example.test>"]) {
expect(() => settingsPayload({ magent_notify_email_from_address: value })).toThrow("valid email address");
}
for (const value of ["24:00", "12:60", "2:30", "02:30:00"]) {
expect(() => settingsPayload({ requests_full_sync_time: value })).toThrow("HH:MM");
}
expect(
settingsPayload({
magent_notify_email_from_address: " alerts+admin@example.test ",
requests_full_sync_time: "23:59",
}),
).toEqual({ magent_notify_email_from_address: "alerts+admin@example.test", requests_full_sync_time: "23:59" });
expect(settingsPayload({ magent_notify_email_from_address: "", requests_full_sync_time: "" })).toEqual({
magent_notify_email_from_address: "",
requests_full_sync_time: "",
});
});
it("does not call a URL-only app configured", () => {
const app = APPS[0];
const url = { key: "jellyfin_base_url", value: "http://jellyfin:8096", sensitive: false, isSet: true };
expect(configuredApp(app, [url])).toBe(false);
expect(configuredApp(app, [url, { key: "jellyfin_api_key", value: null, sensitive: true, isSet: true }])).toBe(
true,
);
});
});
-294
View File
@@ -1,294 +0,0 @@
export type SetupStep = "administrator" | "apps" | "preferences" | "review";
export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null };
export type SetupStatus = { setup_required: boolean; needs_admin: boolean };
export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean };
export type Values = Record<string, string | boolean>;
export type Field = {
key: string;
label: string;
type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea";
hint?: string;
placeholder?: string;
min?: number;
max?: number;
};
export type AppDefinition = { id: string; name: string; description: string; fields: Field[] };
const connection = (prefix: string, placeholder: string): Field[] => [
{
key: `${prefix}_base_url`,
label: "Server URL",
type: "url",
placeholder,
hint: "Use an address reachable from the Magent server, not your browser.",
},
{ key: `${prefix}_api_key`, label: "API key", type: "password" },
];
const collector = (prefix: string): Field[] => [
{
key: `${prefix}_quality_profile_id`,
label: "Quality profile ID",
type: "number",
min: 1,
hint: "Save and test the connection to load available profiles.",
},
{
key: `${prefix}_root_folder`,
label: "Root folder",
hint: "The library path as seen by this app, for example /tv or /movies.",
},
{
key: `${prefix}_qbittorrent_category`,
label: "Download category",
hint: "Match the category configured in the app's download client.",
},
];
export const APPS: AppDefinition[] = [
{
id: "jellyfin",
name: "Jellyfin",
description: "Playback, library availability and Jellyfin sign-in.",
fields: [
...connection("jellyfin", "http://jellyfin:8096"),
{
key: "jellyfin_public_url",
label: "Public playback URL",
type: "url",
hint: "The address your users open to watch media.",
},
{
key: "jellyfin_sync_to_arr",
label: "Sync Jellyfin library into Sonarr / Radarr",
type: "checkbox",
hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.",
},
],
},
{
id: "seerr",
name: "Seerr",
description: "Requests, approvals and request history (including Jellyseerr).",
fields: connection("jellyseerr", "http://seerr:5055"),
},
{
id: "sonarr",
name: "Sonarr",
description: "TV requests, seasons and collection progress.",
fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")],
},
{
id: "radarr",
name: "Radarr",
description: "Movie requests and collection progress.",
fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")],
},
{
id: "prowlarr",
name: "Prowlarr",
description: "Indexer searches and release discovery.",
fields: connection("prowlarr", "http://prowlarr:9696"),
},
{
id: "qbittorrent",
name: "qBittorrent",
description: "Download progress and recovery actions.",
fields: [
{ key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" },
{ key: "qbittorrent_username", label: "Username" },
{ key: "qbittorrent_password", label: "Password", type: "password" },
],
},
{
id: "bazarr",
name: "Bazarr",
description: "Optional subtitle searches and repairs.",
fields: [
...connection("bazarr", "http://bazarr:6767"),
{ key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" },
],
},
{
id: "jellystat",
name: "Jellystat",
description: "Optional personal viewing statistics.",
fields: connection("jellystat", "http://jellystat:3000"),
},
];
export const PREFERENCES: { title: string; fields: Field[] }[] = [
{
title: "Site & access",
fields: [
{
key: "magent_application_url",
label: "Public Magent URL",
type: "url",
hint: "Used in invite and notification links. Managed installs also use this address for CORS and sign-in; changing it changes the allowed browser origin. Manual installs keep their environment-configured CORS policy.",
},
{ key: "site_login_message", label: "Login page message", type: "textarea" },
{
key: "site_login_show_local_login",
label: "Show Magent account sign-in",
type: "checkbox",
hint: "Keep this enabled for local administrator access.",
},
{ key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" },
{
key: "site_login_show_signup_link",
label: "Show invite signup link",
type: "checkbox",
hint: "Account creation still requires a valid invite. This does not open public registration.",
},
],
},
{
title: "Request updates",
fields: [
{ key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 },
{
key: "requests_delta_sync_interval_minutes",
label: "Incremental sync interval (minutes)",
type: "number",
min: 1,
},
{ key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" },
{ key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 },
],
},
{
title: "Email (optional)",
fields: [
{ key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" },
{
key: "magent_notify_email_enabled",
label: "Enable email delivery",
type: "checkbox",
hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.",
},
{ key: "magent_notify_email_smtp_host", label: "SMTP hostname" },
{ key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 },
{ key: "magent_notify_email_smtp_username", label: "SMTP username" },
{ key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" },
{ key: "magent_notify_email_from_address", label: "Sender email", type: "email" },
{ key: "magent_notify_email_from_name", label: "Sender name" },
{ key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" },
{
key: "magent_notify_email_use_ssl",
label: "Use implicit TLS (usually port 465)",
type: "checkbox",
hint: "Choose either STARTTLS or implicit TLS, not both.",
},
],
},
];
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
export function bootstrapApplicationUrl(value: string, browserOrigin: string): string {
const configured = value.trim();
let url: URL;
try {
url = new URL(configured);
} catch {
throw new Error("Public Magent URL must be a full HTTP or HTTPS origin, for example https://magent.example.com.");
}
if (
!/^https?:\/\/[^/?#]+\/?$/i.test(configured) ||
/[\s\\]/.test(configured) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error(
"Public Magent URL must be an HTTP or HTTPS origin without credentials, a path, query or fragment.",
);
}
if (url.origin !== browserOrigin) {
throw new Error(
"Public Magent URL must match the address open in this browser. Open Magent at your intended address, then confirm it and create the administrator there.",
);
}
return url.origin;
}
export function settingsValues(settings: Setting[]): Values {
const values: Values = {};
for (const field of ALL_FIELDS) {
const setting = settings.find((candidate) => candidate.key === field.key);
if (!setting) continue;
values[field.key] =
field.type === "password" || setting.sensitive
? ""
: field.type === "checkbox"
? setting.value === true || setting.value === "true" || setting.value === "1"
: String(setting.value ?? "");
}
return values;
}
// Only explicitly edited fields are sent. A blank password never clears a saved
// secret (masked values from the settings endpoint are not actual credentials).
export function settingsPayload(
draft: Values,
fields: Field[] = ALL_FIELDS,
): Record<string, string | boolean | number> {
const payload: Record<string, string | boolean | number> = {};
for (const field of fields) {
const value = draft[field.key];
if (value === undefined || (field.type === "password" && !String(value).trim())) continue;
if (field.type === "url" || field.type === "email" || field.type === "time") {
const text = String(value).trim();
if (text && field.type === "url") {
let url: URL;
try {
url = new URL(text);
} catch {
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
}
if (
!/^https?:\/\//i.test(text) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
/\s/.test(text)
) {
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
}
if (url.username || url.password)
throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`);
}
if (
text &&
field.type === "email" &&
!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
text,
)
) {
throw new Error(`${field.label} must be a valid email address.`);
}
if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
throw new Error(`${field.label} must be a valid time in HH:MM format.`);
}
payload[field.key] = text;
} else if (field.type === "number" && value !== "") {
const number = Number(value);
if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) {
throw new Error(
`${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`,
);
}
payload[field.key] = number;
} else payload[field.key] = value;
}
return payload;
}
export function configuredApp(app: AppDefinition, settings: Setting[]): boolean {
return app.fields
.filter((field) => field.key.endsWith("_base_url") || field.type === "password")
.every((field) => settings.some((setting) => setting.key === field.key && setting.isSet));
}
@@ -1,60 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import SetupTokenHelp from "./SetupTokenHelp";
import { copySetupTokenCommand, SETUP_TOKEN_COMMAND } from "./setup-token-help";
describe("setup token console help", () => {
it("copies only the retrieval command when clipboard access succeeds", async () => {
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(clipboard, commandField)).toBe("copied");
expect(clipboard.writeText).toHaveBeenCalledExactlyOnceWith("python -m app.container_bootstrap setup-token");
expect(commandField.select).not.toHaveBeenCalled();
});
it("selects the command for manual copying when LAN HTTP has no clipboard API", async () => {
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(undefined, commandField)).toBe("selected");
expect(commandField.focus).toHaveBeenCalledOnce();
expect(commandField.select).toHaveBeenCalledOnce();
});
it("offers manual copying instead of reporting success when clipboard permission is denied", async () => {
const clipboard = { writeText: vi.fn().mockRejectedValue(new Error("Clipboard permission denied")) };
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(clipboard, commandField)).toBe("selected");
expect(commandField.focus).toHaveBeenCalledOnce();
expect(commandField.select).toHaveBeenCalledOnce();
});
it("does not claim a selection or successful copy if the command field is unavailable", async () => {
expect(await copySetupTokenCommand(undefined, null)).toBe("manual");
});
it("provides labelled native dialog controls and the managed and manual retrieval instructions", () => {
const html = renderToStaticMarkup(<SetupTokenHelp />);
expect(html).toContain('aria-haspopup="dialog"');
expect(html).toContain('aria-controls="setup-token-help"');
expect(html).toContain('<dialog id="setup-token-help"');
expect(html).toContain('aria-labelledby="setup-token-help-title"');
expect(html).toContain('aria-describedby="setup-token-help-description"');
expect(html).toContain('for="setup-token-command"');
expect(html).toMatch(/readonly=""/i);
expect(html).toContain('role="status"');
expect(html).toContain("Portainer");
expect(html).toContain("/bin/ash");
expect(html).toContain("<code>magent</code>");
expect(html).toContain(SETUP_TOKEN_COMMAND);
expect(html).toContain("<code>SETUP_TOKEN</code>");
expect(html).not.toContain('type="submit"');
expect(html).not.toContain("Command copied.");
});
it("can disable the help trigger while account creation is busy", () => {
expect(renderToStaticMarkup(<SetupTokenHelp disabled />)).toMatch(/<button[^>]*disabled=""[^>]*aria-haspopup/);
});
});
-24
View File
@@ -1,24 +0,0 @@
export const SETUP_TOKEN_COMMAND = "python -m app.container_bootstrap setup-token";
type ClipboardWriter = Pick<Clipboard, "writeText">;
type CommandField = Pick<HTMLTextAreaElement, "focus" | "select">;
export async function copySetupTokenCommand(
clipboard: ClipboardWriter | undefined,
commandField: CommandField | null,
): Promise<"copied" | "selected" | "manual"> {
try {
if (clipboard?.writeText) {
await clipboard.writeText(SETUP_TOKEN_COMMAND);
return "copied";
}
} catch {
// Clipboard access may be unavailable on LAN HTTP or denied by the browser.
}
if (commandField) {
commandField.focus();
commandField.select();
return "selected";
}
return "manual";
}
-62
View File
@@ -1,62 +0,0 @@
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
.heading { margin-bottom: 30px; text-align: center; }
.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
.setup p { color: var(--ops-muted); line-height: 1.6; }
.brand { display: flex; align-items: center; justify-content: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
.brand svg { width: 38px; height: 38px; }
.panel { padding: 24px; margin: 16px 0; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); min-width: 0; }
.accountPanel { width: 100%; max-width: 560px; box-sizing: border-box; margin-left: auto; margin-right: auto; }
.panel h2, .panel h3 { margin-top: 0; }
.panel summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; list-style: none; }
.panel summary::after { content: "+"; color: var(--ops-primary-2); }
.panel[open] summary::after { content: ""; }
.panel summary > span:first-child { flex: 1; }
.panel summary strong { display: block; font-size: 17px; }
.panel summary small { display: block; margin-top: 6px; color: var(--ops-muted); line-height: 1.5; }
.panel[open] summary { margin-bottom: 24px; }
.badge { font-size: 12px; color: var(--ops-primary-2); }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
.field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.field label { color: var(--ops-text); font-size: 13px; }
.field label small { margin-left: 8px; color: var(--ops-green); }
.field p, .hint { font-size: 12px; margin: 0; }
.field input:not([type=checkbox]), .field textarea, .field select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); border-radius: 8px; font: inherit; font-size: 14px; }
.field textarea { resize: vertical; }
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
.toggle p { grid-column: 1 / -1; }
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
.account { display: grid; gap: 20px; margin: 24px 0; }
.tokenHelp { min-width: 0; }
.tokenHelpDialog { width: min(560px, calc(100% - 32px)); max-height: calc(100dvh - 48px); box-sizing: border-box; margin: auto; padding: 24px; overflow-y: auto; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); color: var(--ops-text); text-align: left; }
.tokenHelpDialog::backdrop { background: rgb(0 0 0 / 65%); }
.tokenHelpDialog p { margin: 14px 0; font-size: 14px; }
.tokenHelpHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.tokenHelpHeading h2 { margin: 0; font-size: 22px; }
.tokenHelpHeading button { flex-shrink: 0; }
.tokenHelpSteps { padding-left: 22px; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
.tokenHelpSteps li + li { margin-top: 10px; }
.field .tokenCommand { display: block; margin: 8px 0 12px; resize: none; font-family: monospace; }
.tokenHelpDialog .copyStatus { min-height: 1.6em; color: var(--ops-primary-2); }
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 30px; }
.steps button { flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px; background: var(--ops-panel); color: var(--ops-muted); border: 1px solid var(--ops-line); box-shadow: none; }
.steps button[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
.steps button span { font-size: 12px; }
.actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--ops-line); }
.actions > span { flex: 1; color: var(--ops-muted); font-size: 12px; }
.error, .notice { padding: 16px 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-bg-2); overflow-wrap: anywhere; }
.setup .error { border-color: var(--ops-red); color: var(--ops-red); }
.review { list-style: none; padding: 0; margin: 24px 0; }
.review li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--ops-line); }
.review li span:last-child { font-size: 13px; color: var(--ops-muted); text-align: right; }
.confirm { display: flex; align-items: center; gap: 12px; margin: 24px 0; }
.setup :is(button, input, textarea, select, a, summary):focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 3px; }
.setup button:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 640px) {
.setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; }
.tokenHelpDialog { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; }
}
+2 -2
View File
@@ -143,7 +143,7 @@ function SignupPageContent() {
};
return (
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<form onSubmit={submit} className="account-form login-form auth-flow-form">
<label>
Invite code
@@ -254,7 +254,7 @@ export default function SignupPage() {
return (
<Suspense
fallback={
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<p role="status">Loading sign-up</p>
</AuthLayout>
}

Some files were not shown because too many files have changed in this diff Show More