Compare commits

..
126 Commits
Author SHA1 Message Date
Assclaw 153ac86a5a fix(auth): accept configured public origin for sign-in
Magent CI/CD / verify (push) Successful in 4m51s
Magent CI/CD / deploy-beta (push) Successful in 12s
2026-09-19 12:25:43 +12:00
Assclaw fd6671cf7e feat: add backup recovery, setup wizard and user-view guards
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s
2026-09-18 17:23:03 +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
137 changed files with 5826 additions and 3692 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. .git
# Local configuration, databases, backups, Git metadata and tool caches must .env
# never be sent to the builder, even if new directories are added to the repo. .env.*
** .venv/
!Dockerfile **/.pytest_cache/
!.dockerignore stitch_magent_media_operations_redesign/
!LICENSE *.tar
!backend/ *.tar.gz
!backend/requirements.txt *.zip
!backend/app/ bootstrap-admin.json
!backend/app/** release.tar
!frontend/ *.log
!frontend/package.json data/*
!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/
!data/branding/ !data/branding/
!data/branding/** !data/branding/**
frontend/node_modules/
# Defense in depth for accidental private/generated files under allowed paths. frontend/.next/
**/.env backend/__pycache__/
**/.env.* **/__pycache__/
**/__pycache__
**/*.pyc **/*.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
+3 -17
View File
@@ -1,26 +1,13 @@
# Copy to .env for a fresh install; never replace an existing deployment's keys. # Copy to .env for local development. Never reuse these example values in a deployed environment.
# See docs/PUBLIC_RELEASE.md. The localhost settings below are for local HTTP only.
# Never deploy the example secret placeholders.
APP_NAME=Magent 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 CORS_ALLOW_ORIGIN=http://localhost:3000
MAGENT_APPLICATION_URL=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://localhost:8000
MAGENT_API_URL=http://127.0.0.1:8000
SQLITE_PATH=/app/data/magent.db SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log LOG_FILE=/app/data/magent.log
LOG_FORMAT=text LOG_FORMAT=text
# Generate independent values as documented in docs/PUBLIC_RELEASE.md. # Generate independent values as documented in README.md.
# Keep both unchanged when upgrading or restoring an offline data-volume backup.
JWT_SECRET=replace-with-at-least-32-random-characters JWT_SECRET=replace-with-at-least-32-random-characters
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
@@ -31,7 +18,6 @@ SETUP_TOKEN=replace-with-a-separate-random-setup-token
# Leave blank to create the account using the setup wizard and SETUP_TOKEN. # Leave blank to create the account using the setup wizard and SETUP_TOKEN.
ADMIN_PASSWORD= ADMIN_PASSWORD=
# false is ONLY for local HTTP; public HTTPS deployments must use true.
AUTH_COOKIE_SECURE=false AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false 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.* bootstrap-admin.json
!.env.example
.venv/ .venv/
.security-test-venv*/
data/
!data/branding/
!data/branding/**
backend/__pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
**/.pytest_cache/ backend/.pytest_cache/
.coverage .coverage
coverage.xml coverage.xml
htmlcov/ htmlcov/
@@ -12,15 +16,10 @@ frontend/node_modules/
frontend/.next/ frontend/.next/
*.tsbuildinfo *.tsbuildinfo
*.log *.log
*.db **/.pytest_cache/
*.db-* .env.*
*.sqlite* !.env.example
*.magent-backup !.env.*.example
bootstrap-admin.json
bootstrap-secrets.json
.magent-secrets-*
data/*
!data/branding/
*.tar *.tar
*.tar.gz *.tar.gz
*.zip *.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 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 \ ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \ BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api 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/proxy.ts ./proxy.ts
COPY frontend/tsconfig.json ./tsconfig.json COPY frontend/tsconfig.json ./tsconfig.json
# Keep dependency notices outside the traced bundle: file tracing deliberately RUN npm run build
# 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 {} +
FROM python:3.14-alpine@sha256:016508ba505da24f7139765bc4bb669df4e88eb2f12eeadd571bf2f88d7533df AS runtime FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
WORKDIR /app WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
MAGENT_MANAGED_SECRETS=auto \ NODE_ENV=production
SQLITE_PATH=/app/data/magent.db \
API_DOCS_ENABLED=false \
NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
# Keep curl for existing deployments that override the image healthcheck. RUN apt-get update \
# Copy only Node's runtime binary: npm, headers and the NodeSource installer && apt-get install -y --no-install-recommends curl gnupg supervisor \
# are build tools, not dependencies of the standalone frontend server. && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
RUN apk upgrade --no-cache \ && apt-get install -y --no-install-recommends nodejs \
&& apk add --no-cache curl libstdc++ && apt-get clean \
&& rm -rf /var/lib/apt/lists/*
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
ARG MAGENT_UID=1000 ARG MAGENT_UID=1000
ARG MAGENT_GID=1000 ARG MAGENT_GID=1000
RUN addgroup -g ${MAGENT_GID} magent \ RUN groupadd --gid ${MAGENT_GID} magent \
&& adduser -D -u ${MAGENT_UID} -G magent -s /sbin/nologin magent \ && useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/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
COPY backend/requirements.txt docker/requirements-runtime.txt /tmp/requirements/ COPY backend/requirements.txt .
RUN pip install --no-cache-dir --no-compile \ RUN pip install --no-cache-dir -r requirements.txt
-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 --chown=magent:magent backend/app ./app COPY --chown=magent:magent backend/app ./app
COPY --chown=magent:magent data/branding /app/data/branding 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 /app/frontend/.next
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/public /app/frontend/public 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 --chown=magent:magent 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"
RUN chown -R magent:magent /app
USER magent:magent USER magent:magent
EXPOSE 3000 8000 EXPOSE 3000 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ 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 \ CMD curl --fail --silent --show-error 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 \ && curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|| exit 1 || exit 1
ENTRYPOINT ["python", "-m", "app.container_bootstrap"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
CMD ["/usr/local/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).
+203 -70
View File
@@ -1,104 +1,237 @@
# Magent # Magent
Self-hosted media requests, viewing stats and issue management for Jellyfin, 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.
Seerr, Sonarr, Radarr and related services. Magent combines a Python/FastAPI API,
a Next.js frontend and SQLite in one non-root container.
## Install ## How it works
Paste [compose.yml](compose.yml) into a Portainer **Docker Standalone** stack. 1) Requests are pulled from Seerr and stored locally.
It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no 2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
environment variables or Dockerfile on the user's machine. 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. ## Core features
Only Linux/amd64 has been validated. `latest` is mutable; record the resolved
image digest before updating, or pin an immutable release tag.
1. Deploy the stack and wait for the container to become healthy. - Request search by title/year or request ID.
2. In its console, select `/bin/ash` and user `magent`, then run: - 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.
- Guided, resumable first-install setup with app connection tests.
- Encrypted backups of configuration, database and optional artwork cache, with restart-only restore.
```sh ## Quick start (Docker - primary)
python -m app.container_bootstrap setup-token
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 Then open:
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.
Keep the Compose security block unchanged. Database storage is fixed at - Frontend: http://localhost:3000
`/app/data/magent.db` and API docs are disabled in managed installs. CORS and - Backend: http://localhost:8000
cookie security follow the confirmed URL. Use HTTPS before public access.
See [Portainer setup](docs/PORTAINER.md), ### Docker setup steps
[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.
## Build and test 1) Copy `.env.example` to `.env`. Generate independent `JWT_SECRET`, `SETTINGS_ENCRYPTION_KEY` and `SETUP_TOKEN` values as described below. Do not use the example placeholders.
2) Set `CORS_ALLOW_ORIGIN` and `MAGENT_APPLICATION_URL` to your browser-facing origin. For public deployments, use HTTPS and `AUTH_COOKIE_SECURE=true`.
3) Run `docker compose up --build`.
4) Open http://localhost:3000. A fresh database opens the setup wizard automatically. Use your `SETUP_TOKEN` to create a local administrator, then connect and test each app you use.
5) Choose site, sign-in, request-sync and email preferences, review the connections, and finish setup. Remove `SETUP_TOKEN` from the deployment environment afterwards.
The source tree contains everything needed to build the application: Apps may be skipped and configured later. Progress is saved in SQLite. Background imports and automation remain paused until setup is complete; `BACKGROUND_TASKS_ENABLED=false` still takes precedence. Existing installations are automatically treated as configured and are not forced through the wizard. Administrators can reopen it at **Settings → Advanced tools → Setup wizard**.
```sh If you prefer to seed an administrator through deployment configuration, set a unique `ADMIN_USERNAME` and `ADMIN_PASSWORD` instead of `SETUP_TOKEN`. The wizard then asks you to sign in with that account. Environment credentials create only the first administrator; they do not add another account to a restored installation. Service URLs and API keys can still be supplied through the environment, and the wizard preloads these settings without exposing saved secrets.
docker compose -f compose.yml -f compose.build.yml up -d --build
See [installation and recovery](docs/installation-and-recovery.md) for migration, backup limits and restore instructions.
### Docker environment variables (sample)
```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 Add screenshots here once available:
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
```
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 python -m venv .venv
. .venv/bin/activate .\.venv\Scripts\Activate.ps1
pip install -r backend/requirements-dev.txt pip install -r requirements.txt
python -m unittest discover -s backend/tests -p 'test_*.py' uvicorn app.main:app --reload --port 8000
python scripts/check_environment_docs.py ```
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 cd frontend
npm ci npm ci
npm test
npm run lint npm run lint
npm run format:check npm run format:check
npm run typecheck npm run typecheck
npm test
npm run build
``` ```
On Windows, activate `.venv\Scripts\Activate.ps1` instead. Do not point tests ## Public Hosting Notes
at live services or use production credentials.
## How it is organised The frontend proxies `/api/*` to the backend container. Set:
- `backend/app/routers/`: authenticated API endpoints and administration. - `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
- `backend/app/clients/`: media-service clients; `services/`: request states, - `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
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.
Requests are cached from Seerr, joined to collector/download/library evidence, 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.
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.
This `release` branch intentionally excludes internal deployment scripts, ## Gitea CI/CD
environment files, runtime data, development reports and prior Git history.
It contains no workflow that automatically deploys or publishes an image.
## 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 - Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
commit tokens, database exports, backups or real user information. - Push to `main` or `prod`: runs the same verification without automatically changing production.
See [SECURITY.md](SECURITY.md) for reporting guidance and deployment precautions. - 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.
- `SETUP_TOKEN` is a separate random value of at least 32 characters, generated using the first command above a second time. It only authorizes first-admin creation on an unfinished, fresh installation. Never put it in a URL or share it with ordinary users. After completion the public bootstrap endpoint remains disabled even if the token is retained.
- `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.
- **View as user** is a per-tab interface preview: it hides configuration, user-management pages, diagnostics and moderation tools, including direct admin-page URLs. **Exit user view** restores the administrator interface. It does not impersonate another account or change backend permissions; the displayed data still belongs to the signed-in account. Test real permission boundaries with a separate non-admin account.
## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots
- `GET /requests/{id}/actions?limit=10` recent action logs
## Troubleshooting
### Login fails
- For a fresh installation, open `/setup` and use `SETUP_TOKEN`, or sign in with the environment-seeded administrator. Existing installations use the accounts already in the database; changing `ADMIN_PASSWORD` does not reset an existing account.
- 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 fastapi.security import OAuth2PasswordBearer
from .config import settings 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 .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .network_security import request_trusts_forwarded_headers from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password 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() samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}: if samesite not in {"lax", "strict", "none"}:
samesite = "lax" 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 { return {
"secure": secure, "secure": bool(settings.auth_cookie_secure),
"httponly": True, "httponly": True,
"samesite": samesite, "samesite": samesite,
"domain": settings.auth_cookie_domain or None, "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) or (isinstance(items, list) and len(items) > 0)
) )
return ( 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 if available
else "Jellyfin did not find this title in its library search." else "Grizzlyflix did not find this title in its library search."
) )
-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())
-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
+4 -7
View File
@@ -8,6 +8,7 @@ from typing import Awaitable, Callable
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .config import settings from .config import settings
@@ -59,7 +60,7 @@ from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics from .metrics import record_api, start_metrics
from .request_limits import InstallationBodyLimitMiddleware from .request_limits import InstallationBodyLimitMiddleware
from .secret_storage import validate_secret_storage_configuration 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__) logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = [] _background_tasks: list[asyncio.Task[None]] = []
@@ -73,7 +74,7 @@ app = FastAPI(
) )
app.add_middleware( app.add_middleware(
ConfiguredOriginCORSMiddleware, CORSMiddleware,
allow_origins=[settings.cors_allow_origin], allow_origins=[settings.cors_allow_origin],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
@@ -113,11 +114,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
request.state.request_id = request_id request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}: if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "") origin = str(request.headers.get("origin") or "")
initial_origin_claim = ( if origin and not is_allowed_request_origin(origin):
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:
record_api(request, 403, 0.0) record_api(request, 403, 0.0)
if operation_id and operation_token is not None: if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403) finish_operation(operation_id, success=False, status_code=403)
-6
View File
@@ -680,12 +680,6 @@ async def list_settings() -> Dict[str, Any]:
@router.put("/settings") @router.put("/settings")
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: 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 updates = 0
touched_logging = False touched_logging = False
changed_keys: List[str] = [] changed_keys: List[str] = []
+1 -13
View File
@@ -8,8 +8,6 @@ from pydantic import Field, SecretStr
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
from ..auth import _extract_client_ip, require_admin from ..auth import _extract_client_ip, require_admin
from ..services import setup as setup_service 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) router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
@@ -19,7 +17,6 @@ class BootstrapRequest(StrictRequest):
setup_token: SecretStr = Field(min_length=1, max_length=1024) setup_token: SecretStr = Field(min_length=1, max_length=1024)
username: str = Field(min_length=1, max_length=100) username: str = Field(min_length=1, max_length=100)
password: SecretStr = Field(min_length=1, max_length=1024) password: SecretStr = Field(min_length=1, max_length=1024)
application_url: str | None = Field(default=None, max_length=2048)
class SetupProgress(StrictRequest): class SetupProgress(StrictRequest):
@@ -45,17 +42,8 @@ def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
headers={"Retry-After": str(retry_after)}, headers={"Retry-After": str(retry_after)},
) )
try: 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( setup_service.bootstrap_administrator(
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value(), payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value()
application_url=application_url,
) )
except setup_service.InvalidSetupTokenError as exc: except setup_service.InvalidSetupTokenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc raise HTTPException(status_code=403, detail=str(exc)) from exc
-16
View File
@@ -34,7 +34,6 @@ from pydantic import TypeAdapter
from ..config import Settings, settings from ..config import Settings, settings
from ..db import _db_path from ..db import _db_path
from ..installation_origin import managed_runtime, normalize_application_origin
from ..schema_migrations import MIGRATIONS from ..schema_migrations import MIGRATIONS
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
@@ -466,13 +465,6 @@ def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
if pending.exists(): if pending.exists():
raise BackupError("A restore is already staged; cancel it before uploading another") raise BackupError("A restore is already staged; cancel it before uploading another")
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase) 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: with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
stage = Path(temporary) stage = Path(temporary)
stage.chmod(0o700) stage.chmod(0o700)
@@ -484,14 +476,6 @@ def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
if value and str(value).startswith("enc:v1:"): if value and str(value).startswith("enc:v1:"):
raise BackupError("Backup settings are not portable") raise BackupError("Backup settings are not portable")
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key)) 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. # Do not revive reset links or existing browser sessions. Invites remain intact.
conn.execute("DELETE FROM password_reset_tokens") conn.execute("DELETE FROM password_reset_tokens")
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,)) conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
+3 -3
View File
@@ -213,11 +213,11 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
sent = False sent = False
delivery_error: Optional[str] = None delivery_error: Optional[str] = None
if recipient: if recipient:
subject = f"Ready to try again? Magent issue #{item['id']}" subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
body_text = ( body_text = (
"Your repair looks ready to test.\n\n" "Your repair looks ready to test.\n\n"
f"{item.get('title') or 'Your reported issue'}\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"YES — it works: {issue_url}#yes\n"
f"NO — still broken: {issue_url}#no\n\n" f"NO — still broken: {issue_url}#no\n\n"
"Confirm your answer in Magent. You may need to sign in first.\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 = ( body_html = (
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">' '<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;">' '<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>' '<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>' '<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>' 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): def render_confirmation(username, url):
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from your media library." intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from Grizzlyflix."
return {'subject': 'Confirm your Magent newsletter subscription', 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_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, '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>', 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.')} 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> 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"> <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} <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, ''] lines += [entry['title'], details, watch, '']
if not titles: 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" 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'] 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'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
f'Email preferences: {public_url}/profile#newsletters']), 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.', 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} '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 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) (id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''', 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')) '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()) row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
if not empty: if not empty:
+1 -1
View File
@@ -133,7 +133,7 @@ async def create_draft(user, days):
end = datetime.now(timezone.utc) end = datetime.now(timezone.utc)
config = store.settings() config = store.settings()
content = await collect(end - timedelta(days=days), end, config['limit_titles']) 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): def require_edition(identity, revision=None):
-3
View File
@@ -1,7 +1,6 @@
"""Configured public email links, independent of request Host/forwarded headers.""" """Configured public email links, independent of request Host/forwarded headers."""
from urllib.parse import urlsplit from urllib.parse import urlsplit
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..installation_origin import managed_runtime
def valid_public_url(value): def valid_public_url(value):
@@ -22,8 +21,6 @@ def magent_public_url(legacy_url=''):
runtime = get_runtime_settings() runtime = get_runtime_settings()
proxy = getattr(runtime, 'magent_proxy_base_url', None) proxy = getattr(runtime, 'magent_proxy_base_url', None)
application = getattr(runtime, 'magent_application_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(): if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
return valid_public_url(proxy) return valid_public_url(proxy)
if str(application or '').strip(): 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 urllib.parse import urlsplit
from starlette.middleware.cors import CORSMiddleware
from ..config import settings from ..config import settings
from ..installation_origin import managed_runtime
from .public_urls import magent_public_url, valid_public_url 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) candidate = _origin(origin)
if candidate is None: if candidate is None:
return False 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("/")): if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
return True return True
return candidate == _origin(magent_public_url(), configured_url=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)
+1 -10
View File
@@ -14,7 +14,6 @@ from typing import Literal
from .. import db from .. import db
from ..config import settings from ..config import settings
from ..security import hash_password, validate_password_policy from ..security import hash_password, validate_password_policy
from ..installation_origin import normalize_application_origin
SetupStep = Literal["administrator", "apps", "preferences", "review"] SetupStep = Literal["administrator", "apps", "preferences", "review"]
@@ -136,7 +135,7 @@ def consume_bootstrap_attempt(client_ip: str) -> int | None:
return None return None
def bootstrap_administrator(setup_token: str, username: str, password: str, *, application_url: str | None = None) -> None: def bootstrap_administrator(setup_token: str, username: str, password: str) -> None:
"""Claim fresh setup exactly once using the deployment's setup token.""" """Claim fresh setup exactly once using the deployment's setup token."""
expected = str(getattr(settings, "setup_token", "") or "") expected = str(getattr(settings, "setup_token", "") or "")
if not setup_token_configured() or not hmac.compare_digest( if not setup_token_configured() or not hmac.compare_digest(
@@ -151,8 +150,6 @@ def bootstrap_administrator(setup_token: str, username: str, password: str, *, a
if len(password) > 1024: if len(password) > 1024:
raise ValueError("Password must contain no more than 1024 characters.") raise ValueError("Password must contain no more than 1024 characters.")
password = validate_password_policy(password) 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(): if not is_setup_required() or db.has_admin_user():
raise SetupUnavailableError("Initial administrator setup is no longer available.") raise SetupUnavailableError("Initial administrator setup is no longer available.")
@@ -171,12 +168,6 @@ def bootstrap_administrator(setup_token: str, username: str, password: str, *, a
(username, password_hash, datetime.now(timezone.utc).isoformat()), (username, password_hash, datetime.now(timezone.utc).isoformat()),
) )
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1") 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: def update_setup_step(step: SetupStep) -> dict:
+13 -13
View File
@@ -582,9 +582,9 @@ def _build_repair_activity(
message = ( message = (
f"{collector} now reports the replacement file as collected. " f"{collector} now reports the replacement file as collected. "
+ ( + (
"It is also available in Jellyfin." "It is also available in Grizzlyflix."
if jellyfin_found 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" state = "complete" if jellyfin_found else "indexing"
@@ -658,7 +658,7 @@ def _build_repair_activity(
"label": "Updated media available", "label": "Updated media available",
"state": available_step_state, "state": available_step_state,
"detail": ( "detail": (
"The repaired title is available in Jellyfin." "The repaired title is available in Grizzlyflix."
if jellyfin_found and collection_complete if jellyfin_found and collection_complete
else ( else (
"The media server is indexing the replacement." "The media server is indexing the replacement."
@@ -922,22 +922,22 @@ def _build_presentation(
available_label = "Partially available" available_label = "Partially available"
available_state = "partial" available_state = "partial"
available_state_label = "Partly ready" 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: elif jellyfin_found:
available_label = "Available to watch" available_label = "Available to watch"
available_state = "complete" available_state = "complete"
available_state_label = "Ready" 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": elif arr_state == "available":
available_label = "Adding to Jellyfin" available_label = "Adding to Grizzlyflix"
available_state = "active" available_state = "active"
available_state_label = "Indexing" 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: else:
available_label = "Media server" available_label = "Media server"
available_state = "waiting" available_state = "waiting"
available_state_label = "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) display_download = dict(download)
if fully_available: if fully_available:
@@ -1026,13 +1026,13 @@ def _apply_repair_presentation(
search = (arr_details.get("search") or {}).get("state") search = (arr_details.get("search") or {}).get("state")
pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]} pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]}
if imported: if imported:
label = "Replacement collected — updating Jellyfin" label = "Replacement collected — updating Grizzlyflix"
meaning = "The replacement has been imported. Waiting for Jellyfin to index the updated file." meaning = "The replacement has been imported. Waiting for Grizzlyflix to index the updated file."
snapshot.state = NormalizedState.importing snapshot.state = NormalizedState.importing
pipeline["download"].update(state="complete", summary="The replacement has been imported.", torrents=[], visible=False) 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"] = { 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: elif unavailable:
label = "Repair status temporarily 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"): 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 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", 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) snapshot.raw["jellyfin"].update(partial=True, link=link)
+4 -4
View File
@@ -448,7 +448,7 @@ class OperationMessageTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}), _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: 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["state"], "complete")
self.assertEqual(available_stage["stateLabel"], "Ready") self.assertEqual(available_stage["stateLabel"], "Ready")
self.assertEqual(available_stage["label"], "Available to watch") 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") self.assertEqual(available_stage["link"], "https://media.test/title/3909")
def test_partially_available_content_keeps_missing_download_attention(self) -> None: 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(download_stage["state"], "complete")
self.assertEqual(available_stage["state"], "active") self.assertEqual(available_stage["state"], "active")
self.assertEqual(available_stage["stateLabel"], "Indexing") self.assertEqual(available_stage["stateLabel"], "Indexing")
self.assertEqual(available_stage["label"], "Adding to Jellyfin") self.assertEqual(available_stage["label"], "Adding to Grizzlyflix")
self.assertEqual( self.assertEqual(
available_stage["summary"], available_stage["summary"],
"The download is complete. Jellyfin is indexing this title now.", "The download is complete. Grizzlyflix is indexing this title now.",
) )
-26
View File
@@ -126,32 +126,6 @@ class BackupTests(unittest.TestCase):
with closing(sqlite3.connect(self.database)) as restored: with closing(sqlite3.connect(self.database)) as restored:
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL") 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): def test_process_interruption_is_recovered_on_next_startup(self):
class ProcessStopped(BaseException): class ProcessStopped(BaseException):
pass pass
-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()
-3
View File
@@ -39,9 +39,6 @@ class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
self.assertEqual(email.await_count, 1) self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation") self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
content = email.await_args.kwargs 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("YES — it works", content["body_html"])
self.assertIn("NO — still broken", content["body_html"]) self.assertIn("NO — still broken", content["body_html"])
self.assertIn(f"/issues/confirm/{item['id']}#yes", 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) result = await service.subscribe(self.user)
self.assertEqual(result['state'], 'pending') self.assertEqual(result['state'], 'pending')
rendered = sender.call_args.args[1] 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']) self.assertNotIn('Arrival', rendered['body_html'])
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0) url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
self.assertEqual(urlsplit(url).path, '/newsletter-subscription') 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.complete_weekly(claimed, content(), END+timedelta(hours=1))
store.enqueue_due((END+timedelta(hours=1)).timestamp()) store.enqueue_due((END+timedelta(hours=1)).timestamp())
self.assertEqual(len(store.overview()['editions']), 1) 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.overview()['total'], 0)
self.assertEqual(store.settings()['next_send_at'], (END+timedelta(days=7)).timestamp()) self.assertEqual(store.settings()['next_send_at'], (END+timedelta(days=7)).timestamp())
self.assertTrue(config['enabled']) self.assertTrue(config['enabled'])
@@ -384,30 +380,6 @@ class NewsletterCatalogTests(unittest.IsolatedAsyncioTestCase):
class NewsletterDeliveryTests(NewsletterFixture, 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): async def test_weekly_worker_collects_once_and_delivers_to_confirmed_subscriber(self):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
self.subscribe((now-timedelta(days=14)).timestamp()) self.subscribe((now-timedelta(days=14)).timestamp())
-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: services:
magent: magent:
# Select a published immutable release tag or digest in .env. image: rephl3xnz/magent:latest
image: ${MAGENT_IMAGE:?Set MAGENT_IMAGE to a published release tag or digest}
env_file: env_file:
- ./.env - ./.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: ports:
# Keep the API internal; the frontend serves /api on this same port. - "3000:3000"
- "${MAGENT_BIND_ADDRESS:-127.0.0.1}:${MAGENT_HTTP_PORT:-3000}:3000" - "127.0.0.1:8000:8000"
volumes: volumes:
# Fresh installs only: existing installs must retain their original mount. - ./data:/app/data
- magent-data:/app/data
restart: unless-stopped
stop_grace_period: 30s
read_only: true read_only: true
cap_drop: ["ALL"] cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"] security_opt: ["no-new-privileges:true"]
@@ -24,6 +15,3 @@ services:
tmpfs: tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 - /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,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=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
priority=10 priority=10
stopasgroup=true
killasgroup=true
[program:frontend] [program:frontend]
directory=/app/frontend directory=/app/frontend
command=/usr/local/bin/node /app/frontend/server.js command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
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" environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
autostart=true autostart=true
autorestart=true autorestart=true
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
@@ -28,5 +26,3 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
priority=20 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.
+4 -20
View File
@@ -2,22 +2,6 @@
## Fresh installation ## 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. 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 ```bash
@@ -28,9 +12,9 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d
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())"`. 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. Set the correct browser-facing `CORS_ALLOW_ORIGIN`, `MAGENT_APPLICATION_URL`, cookie HTTPS settings and host paths before starting. These deployment settings are deliberately not editable through public setup. Changing the public application URL in the wizard does not change CORS or reverse-proxy configuration.
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`: After `docker compose up -d --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. 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. 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.
@@ -64,13 +48,13 @@ The frontend and backend accept up to 34 MiB for the whole multipart request, in
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. 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. 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. 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. 4. Restart the application using your normal deployment process, for example `docker compose restart magent`. Beta: `docker compose -p magent-beta -f docker-compose.beta.yml restart magent`. 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. 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. 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. 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. 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. Review public URLs and 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 ## Recovery files
+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.
+8 -8
View File
@@ -1102,18 +1102,18 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_telegram_chat_id: "-1001234567890", magent_notify_telegram_chat_id: "-1001234567890",
magent_notify_push_base_url: "https://ntfy.example.com or https://gotify.example.com", magent_notify_push_base_url: "https://ntfy.example.com or https://gotify.example.com",
magent_notify_push_topic: "magent-alerts", 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", magent_notify_webhook_url: "https://automation.example.com/webhooks/magent",
jellyseerr_base_url: "https://requests.example.com or http://seerr:5055", jellyseerr_base_url: "https://requests.example.com or 10.30.1.81:5055",
jellyfin_base_url: "https://jelly.example.com or http://jellyfin:8096", jellyfin_base_url: "https://jelly.example.com or 10.40.0.80:8096",
jellystat_base_url: "http://jellystat:3000", jellystat_base_url: "http://jellystat:3000",
jellyfin_public_url: "https://jelly.example.com", jellyfin_public_url: "https://jelly.example.com",
sonarr_base_url: "https://sonarr.example.com or http://sonarr:8989", sonarr_base_url: "https://sonarr.example.com or 10.30.1.81:8989",
bazarr_base_url: "https://bazarr.example.com or http://bazarr:6767", bazarr_base_url: "https://bazarr.example.com or 10.30.1.81:6767",
bazarr_default_language: "en", bazarr_default_language: "en",
radarr_base_url: "https://radarr.example.com or http://radarr:7878", radarr_base_url: "https://radarr.example.com or 10.30.1.81:7878",
prowlarr_base_url: "https://prowlarr.example.com or http://prowlarr:9696", prowlarr_base_url: "https://prowlarr.example.com or 10.30.1.81:9696",
qbittorrent_base_url: "https://qb.example.com or http://qbittorrent:8080", 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…", site_login_message: "Sign-in information, an outage notice, or help for users…",
}; };
+1 -1
View File
@@ -1837,7 +1837,7 @@ export default function AdminInviteManagementPage() {
onChange={(event) => onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value })) 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> </label>
{inviteFlowStep === 2 && ( {inviteFlowStep === 2 && (
+1 -1
View File
@@ -368,7 +368,7 @@ export default function NewslettersAdminPage() {
<div className="recap-section-heading"> <div className="recap-section-heading">
<div> <div>
<span className="recap-eyebrow">A fresh edition</span> <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> <p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p>
</div> </div>
<div className="newsletter-create"> <div className="newsletter-create">
+3 -3
View File
@@ -1,11 +1,11 @@
import "./style.css"; import "./style.css";
export const metadata = { title: "Coming soon | Magent" }; export const metadata = { title: "Coming soon | Magent — Grizzlyflix" };
export default function ComingSoonPage() { export default function ComingSoonPage() {
return ( return (
<main className="launch-cover"> <main className="launch-cover">
<div className="launch-brand">MAGENT</div> <div className="launch-brand">GRIZZLYFLIX</div>
<span className="launch-badge">COMING SOON</span> <span className="launch-badge">COMING SOON</span>
<h1> <h1>
Your next watch. Your next watch.
@@ -26,7 +26,7 @@ export default function ComingSoonPage() {
<p className="launch-note">Were getting everything ready. Check back soon.</p> <p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer> <footer>
<strong>Magent</strong> <strong>Magent</strong>
<span>Your media member portal</span> <span>Grizzlyflix member portal</span>
<a href="/login">Admin sign in</a> <a href="/login">Admin sign in</a>
</footer> </footer>
</main> </main>
+2 -2
View File
@@ -6,7 +6,7 @@ export default function HowItWorksPage() {
<main className="friendly-guide"> <main className="friendly-guide">
<PageHeading <PageHeading
title="A little help getting started." 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"> <nav aria-label="Quick links">
<a href="/welcome">Welcome page</a> <a href="/welcome">Welcome page</a>
@@ -62,7 +62,7 @@ export default function HowItWorksPage() {
season pack. season pack.
</li> </li>
<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> </li>
</ol> </ol>
<p> <p>
+2 -2
View File
@@ -138,7 +138,7 @@ export default function LoginPage() {
setError(""); setError("");
}} }}
> >
Jellyfin Grizzlyflix
</button> </button>
<button <button
type="button" type="button"
@@ -164,7 +164,7 @@ export default function LoginPage() {
) : ( ) : (
<form className="account-form login-form" onSubmit={submit}> <form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help"> <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> </p>
<label htmlFor="login-username">Username</label> <label htmlFor="login-username">Username</label>
<input <input
@@ -699,7 +699,7 @@ export default function NewRequestClient() {
<div className="request-submit-bar"> <div className="request-submit-bar">
<div> <div>
<span>Delivery route</span> <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> <small>Your request uses the default quality set by your administrator.</small>
</div> </div>
<button <button
@@ -106,7 +106,7 @@ export default function NewsletterLinkPage() {
<span>Magent</span> <span>Magent</span>
</a> </a>
<section className="account-panel"> <section className="account-panel">
<span className="recap-eyebrow">Magent newsletters</span> <span className="recap-eyebrow">Grizzlyflix newsletters</span>
<h1> <h1>
{state === "enabled" {state === "enabled"
? "Youre on the list." ? "Youre on the list."
+4 -4
View File
@@ -218,7 +218,7 @@ const ISSUE_CATEGORIES: Array<{
id: "missing_content", id: "missing_content",
marker: "MISSING", marker: "MISSING",
label: "Movie or episode is 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.", outcome: "The selected missing content will be sent back to Sonarr or Radarr.",
issueType: "missing_content", issueType: "missing_content",
titlePrefix: "Missing content", titlePrefix: "Missing content",
@@ -254,7 +254,7 @@ const ISSUE_CATEGORIES: Array<{
id: "service_unavailable", id: "service_unavailable",
marker: "SERVER", marker: "SERVER",
label: "Nothing will play", 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.", outcome: "Magent will check Jellyfin and attach the result to the issue.",
issueType: "service_unavailable", issueType: "service_unavailable",
titlePrefix: "Media server unavailable", titlePrefix: "Media server unavailable",
@@ -285,7 +285,7 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
"Only fails on one device", "Only fails on one device",
], ],
service_unavailable: [ service_unavailable: [
"Jellyfin will not open", "Grizzlyflix will not open",
"Every title fails", "Every title fails",
"Login works but playback does not", "Login works but playback does not",
"Server error is shown", "Server error is shown",
@@ -1743,7 +1743,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setSelectedEpisodeIds([]); setSelectedEpisodeIds([]);
} }
}} }}
placeholder="Search the media catalogue" placeholder="Search the Grizzlyflix catalogue"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@@ -94,7 +94,7 @@ export default function NewsletterPreference() {
<div className="recap-section-heading"> <div className="recap-section-heading">
<div> <div>
<span className="recap-eyebrow">Your next watch</span> <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> </div>
{data && ( {data && (
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}> <span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
+2 -2
View File
@@ -275,7 +275,7 @@ export default function ProfileInvitesPage() {
return ( return (
<main className="card invites-page"> <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>} {error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>} {status && <div className="status-banner">{status}</div>}
@@ -418,7 +418,7 @@ export default function ProfileInvitesPage() {
onChange={(event) => onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value })) 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> </label>
{flowStep === 2 && ( {flowStep === 2 && (
+3 -3
View File
@@ -200,7 +200,7 @@ export default function ProfilePage() {
tone: "status", tone: "status",
message: message:
result.provider === "jellyfin" 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.", : "Password updated.",
}); });
} catch (error) { } catch (error) {
@@ -335,7 +335,7 @@ export default function ProfilePage() {
<span className="account-connection-dot" aria-hidden="true" /> <span className="account-connection-dot" aria-hidden="true" />
<span> <span>
{user.auth_provider === "jellyfin" {user.auth_provider === "jellyfin"
? "Connected with your Jellyfin account" ? "Connected with your Grizzlyflix account"
: user.auth_provider === "local" : user.auth_provider === "local"
? "Signed in with a Magent account" ? "Signed in with a Magent account"
: "Signed in with your media account"} : "Signed in with your media account"}
@@ -358,7 +358,7 @@ export default function ProfilePage() {
<h2>Change password</h2> <h2>Change password</h2>
<p> <p>
{passwordProvider === "jellyfin" {passwordProvider === "jellyfin"
? "One password for Jellyfin, Seerr and Magent." ? "One password for Grizzlyflix, Seerr and Magent."
: "Keep your Magent account secure."} : "Keep your Magent account secure."}
</p> </p>
</div> </div>
+7 -7
View File
@@ -289,14 +289,14 @@ const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
{ id: "download", label: "Download", state: "waiting", summary: "No download attempt yet" }, { id: "download", label: "Download", state: "waiting", summary: "No download attempt yet" },
{ {
id: "available", 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", state: complete ? "complete" : indexing ? "active" : "waiting",
stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting", stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting",
summary: complete summary: complete
? "This title is ready to watch in Jellyfin." ? "This title is ready to watch in Grizzlyflix."
: indexing : indexing
? "The download is complete. Jellyfin is indexing this title now." ? "The download is complete. Grizzlyflix is indexing this title now."
: "This title has not reached Jellyfin yet.", : "This title has not reached Grizzlyflix yet.",
link: snapshot.raw?.jellyfin?.link, link: snapshot.raw?.jellyfin?.link,
}, },
]; ];
@@ -1031,13 +1031,13 @@ export default function RequestTimelinePage() {
<section> <section>
<span className="request-overview-label">Ready to watch</span> <span className="request-overview-label">Ready to watch</span>
<strong>Watch this now!</strong> <strong>Watch this now!</strong>
<p>Open {snapshot.title} directly in Jellyfin.</p> <p>Open {snapshot.title} directly in Grizzlyflix.</p>
{mediaServerLink ? ( {mediaServerLink ? (
<a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer"> <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> </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>
<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>
);
}
+6 -42
View File
@@ -4,13 +4,11 @@ import { useEffect, useState, type FormEvent } from "react";
import { apiUrl, requestJson } from "../lib/api-client"; import { apiUrl, requestJson } from "../lib/api-client";
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth"; import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
import MagentMark from "../ui/MagentMark"; import MagentMark from "../ui/MagentMark";
import SetupTokenHelp from "./SetupTokenHelp";
import { serviceStatusLabel } from "../admin/configNavigation"; import { serviceStatusLabel } from "../admin/configNavigation";
import { import {
ALL_FIELDS, ALL_FIELDS,
APPS, APPS,
PREFERENCES, PREFERENCES,
bootstrapApplicationUrl,
configuredApp, configuredApp,
settingsPayload, settingsPayload,
settingsValues, settingsValues,
@@ -56,14 +54,12 @@ export default function SetupPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState(""); const [confirmation, setConfirmation] = useState("");
const [setupToken, setSetupToken] = useState(""); const [setupToken, setSetupToken] = useState("");
const [applicationUrl, setApplicationUrl] = useState("");
const [checks, setChecks] = useState<Record<string, Check>>({}); const [checks, setChecks] = useState<Record<string, Check>>({});
const [options, setOptions] = useState<Record<string, CollectorOptions>>({}); const [options, setOptions] = useState<Record<string, CollectorOptions>>({});
const [accepted, setAccepted] = useState(false); const [accepted, setAccepted] = useState(false);
const values = { ...settingsValues(settings), ...draft }; const values = { ...settingsValues(settings), ...draft };
useEffect(() => { useEffect(() => {
setApplicationUrl(window.location.origin);
const controller = new AbortController(); const controller = new AbortController();
const load = async () => { const load = async () => {
try { try {
@@ -169,19 +165,13 @@ export default function SetupPage() {
void run("account", async () => { void run("account", async () => {
let loginPassword = password; let loginPassword = password;
if (status?.needs_admin) { if (status?.needs_admin) {
const confirmedApplicationUrl = bootstrapApplicationUrl(applicationUrl, window.location.origin);
if (password !== confirmation) throw new Error("The passwords do not match."); if (password !== confirmation) throw new Error("The passwords do not match.");
loginPassword = password.trim(); loginPassword = password.trim();
if (loginPassword.length < 12) if (loginPassword.length < 12)
throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces."); throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces.");
await requestJson( await requestJson(
"/setup/bootstrap", "/setup/bootstrap",
json({ json({ setup_token: setupToken, username: username.trim(), password }),
setup_token: setupToken,
username: username.trim(),
password,
application_url: confirmedApplicationUrl,
}),
authFetch, authFetch,
); );
setPassword(loginPassword); setPassword(loginPassword);
@@ -353,7 +343,7 @@ export default function SetupPage() {
Retry Retry
</button> </button>
) : forbidden ? ( ) : forbidden ? (
<section className={`${styles.panel} ${styles.accountPanel}`}> <section className={styles.panel}>
<h2>Administrator access required</h2> <h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p> <p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}> <button type="button" disabled={!!busy} onClick={switchAccount}>
@@ -361,36 +351,14 @@ export default function SetupPage() {
</button> </button>
</section> </section>
) : !admin ? ( ) : !admin ? (
<section className={`${styles.panel} ${styles.accountPanel}`}> <section className={styles.panel}>
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2> <h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
<p> <p>
{status.needs_admin {status.needs_admin
? "Use your private setup token to create the first administrator account." ? "Enter the SETUP_TOKEN from your deployment environment. Only the server operator can create the first administrator."
: "Use your local Magent administrator account. Settings are never available to unauthenticated visitors."} : "Use your local Magent administrator account. Settings are never available to unauthenticated visitors."}
</p> </p>
<form onSubmit={authenticate} className={styles.account}> <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 && ( {status.needs_admin && (
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="setup-token">Setup token</label> <label htmlFor="setup-token">Setup token</label>
@@ -403,11 +371,8 @@ export default function SetupPage() {
maxLength={1024} maxLength={1024}
value={setupToken} value={setupToken}
onChange={(event) => setSetupToken(event.target.value)} onChange={(event) => setSetupToken(event.target.value)}
aria-describedby="setup-token-hint"
disabled={!!busy} disabled={!!busy}
/> />
<p id="setup-token-hint">Retrieve this token from your server console.</p>
<SetupTokenHelp disabled={!!busy} />
</div> </div>
)} )}
<div className={styles.field}> <div className={styles.field}>
@@ -572,9 +537,8 @@ export default function SetupPage() {
I have reviewed the connections and want to finish setup. I have reviewed the connections and want to finish setup.
</label> </label>
<p className={styles.hint}> <p className={styles.hint}>
You may remove a manually configured SETUP_TOKEN from your environment after completion. Managed You may remove SETUP_TOKEN from your environment after completion. Existing users and invites are
installs keep their generated keys in the data volume; do not remove that file or volume. Existing preserved.
users and invites are preserved.
</p> </p>
</section> </section>
)} )}
+1 -41
View File
@@ -1,45 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { APPS, bootstrapApplicationUrl, configuredApp, settingsPayload, settingsValues } from "./setup-model"; import { APPS, 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", () => { describe("installation settings", () => {
it("offers every supported media integration", () => { it("offers every supported media integration", () => {
+1 -32
View File
@@ -124,7 +124,7 @@ export const PREFERENCES: { title: string; fields: Field[] }[] = [
key: "magent_application_url", key: "magent_application_url",
label: "Public Magent URL", label: "Public Magent URL",
type: "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.", hint: "Used in invite and notification links. Set CORS_ALLOW_ORIGIN in your environment to the same origin; changing this field does not change CORS.",
}, },
{ key: "site_login_message", label: "Login page message", type: "textarea" }, { key: "site_login_message", label: "Login page message", type: "textarea" },
{ {
@@ -185,37 +185,6 @@ export const PREFERENCES: { title: string; fields: Field[] }[] = [
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)]; 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 { export function settingsValues(settings: Setting[]): Values {
const values: Values = {}; const values: Values = {};
for (const field of ALL_FIELDS) { for (const field of ALL_FIELDS) {
@@ -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";
}
+3 -16
View File
@@ -1,11 +1,10 @@
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); } .setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
.heading { margin-bottom: 30px; text-align: center; } .heading { margin-bottom: 30px; }
.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; } .heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
.setup p { color: var(--ops-muted); line-height: 1.6; } .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 { display: flex; align-items: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
.brand svg { width: 38px; height: 38px; } .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; } .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 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 { 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 summary::after { content: "+"; color: var(--ops-primary-2); }
@@ -25,18 +24,7 @@
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; } .toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
.toggle p { grid-column: 1 / -1; } .toggle p { grid-column: 1 / -1; }
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; } .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; } .account { display: grid; gap: 20px; max-width: 440px; 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 { 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 { 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[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
@@ -55,7 +43,6 @@
.setup { margin-top: 20px; padding: 0 4px; } .setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; } .fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; } .panel { padding: 18px; }
.tokenHelpDialog { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; } .steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; } .badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; } .panel summary { gap: 10px; }
+2 -2
View File
@@ -143,7 +143,7 @@ function SignupPageContent() {
}; };
return ( 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"> <form onSubmit={submit} className="account-form login-form auth-flow-form">
<label> <label>
Invite code Invite code
@@ -254,7 +254,7 @@ export default function SignupPage() {
return ( return (
<Suspense <Suspense
fallback={ 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> <p role="status">Loading sign-up</p>
</AuthLayout> </AuthLayout>
} }
+4 -1
View File
@@ -33,11 +33,14 @@ export default function ApplicationChrome() {
<BrandingLogo className="brand-logo brand-logo--header" /> <BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack"> <div className="brand-stack">
<div className="brand">Magent</div> <div className="brand">Magent</div>
<div className="tagline">Your media operations</div> <div className="tagline">GrizzlyFlix media operations</div>
</div> </div>
</a> </a>
</div> </div>
<div className="header-right"> <div className="header-right">
<span className="beta-chip" title="Beta environment">
Beta
</span>
<HeaderIdentity /> <HeaderIdentity />
</div> </div>
<div className="header-nav"> <div className="header-nav">
+2 -1
View File
@@ -20,6 +20,7 @@ export default function AuthLayout({
<MagentMark /> <MagentMark />
<span>Magent</span> <span>Magent</span>
</a> </a>
<span className="login-beta">Beta</span>
</div> </div>
<header> <header>
<h1 id="login-title">{title}</h1> <h1 id="login-title">{title}</h1>
@@ -28,7 +29,7 @@ export default function AuthLayout({
{children} {children}
{footer && <footer>{footer}</footer>} {footer && <footer>{footer}</footer>}
</section> </section>
<p className="login-credit">Magent · Request. Watch. Enjoy.</p> <p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main> </main>
); );
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export default function ResolutionChoice({
<span className="section-kicker">Your answer is needed</span> <span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2> <h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p> <p>{title}</p>
<p>Try the affected content in Jellyfin, then choose:</p> <p>Try the affected content in Grizzlyflix, then choose:</p>
<div className="resolution-choice-buttons"> <div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}> <button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}>
<strong>YES</strong> <strong>YES</strong>
-37
View File
@@ -1,37 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import ComingSoonPage from "../coming-soon/page";
import HowItWorksPage from "../how-it-works/page";
import AuthLayout from "./AuthLayout";
describe("portable default branding", () => {
it("uses Magent branding without replacing the supplied sign-in content", () => {
const html = renderToStaticMarkup(
<AuthLayout title="Welcome to our library" description="Use your account" footer="Local help">
<p>A custom message</p>
</AuthLayout>,
);
expect(html).toContain("Magent · Request. Watch. Enjoy.");
expect(html).toContain("Welcome to our library");
expect(html).toContain("A custom message");
expect(html).toContain("Local help");
expect(html).not.toMatch(/grizzlyflix/i);
expect(html).not.toContain(">Beta<");
});
it("shows a generic coming-soon page", () => {
const html = renderToStaticMarkup(<ComingSoonPage />);
expect(html).toContain("MAGENT");
expect(html).toContain("Your media member portal");
expect(html).not.toMatch(/grizzlyflix/i);
});
it("explains the Jellyfin integration without a deployment-specific service name", () => {
const html = renderToStaticMarkup(<HowItWorksPage />);
expect(html).toContain("Jellyfin is where you watch them.");
expect(html).not.toMatch(/grizzlyflix/i);
});
});
+3 -3
View File
@@ -33,7 +33,7 @@ export default function WelcomePage() {
return ( return (
<main className="welcome-page"> <main className="welcome-page">
<header> <header>
<span className="welcome-kicker">Your media + Magent</span> <span className="welcome-kicker">GrizzlyFlix + Magent</span>
<h1>Make yourself at home.</h1> <h1>Make yourself at home.</h1>
<p>Something to watch, or something to sort out?</p> <p>Something to watch, or something to sort out?</p>
</header> </header>
@@ -54,7 +54,7 @@ export default function WelcomePage() {
<span className="welcome-icon" aria-hidden="true"> <span className="welcome-icon" aria-hidden="true">
</span> </span>
<h2>Open Jellyfin</h2> <h2>Go to GrizzlyFlix</h2>
<p>Find your next favourite. Watch movies and TV shows.</p> <p>Find your next favourite. Watch movies and TV shows.</p>
<strong> <strong>
Lets watch <span aria-hidden="true"></span> Lets watch <span aria-hidden="true"></span>
@@ -65,7 +65,7 @@ export default function WelcomePage() {
<span className="welcome-icon" aria-hidden="true"> <span className="welcome-icon" aria-hidden="true">
</span> </span>
<h2>Open Jellyfin</h2> <h2>Go to GrizzlyFlix</h2>
<p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p> <p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p>
</section> </section>
)} )}
-1
View File
@@ -2,7 +2,6 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || "http://backend:8000";
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
poweredByHeader: false, poweredByHeader: false,
compress: true, compress: true,
// API rewrites clone bodies even when excluded from proxy.ts's matcher. // API rewrites clone bodies even when excluded from proxy.ts's matcher.
-101
View File
@@ -1,101 +0,0 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { proxy } from "./proxy";
function response(headers: Record<string, string> = {}) {
return proxy(new NextRequest("http://localhost:3000/setup", { headers }));
}
describe("deployment-aware content security policy", () => {
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("MAGENT_APPLICATION_URL", undefined);
vi.stubEnv("MAGENT_RUNTIME_MANAGED", undefined);
});
afterEach(() => vi.unstubAllEnvs());
it("keeps HTTPS upgrades enabled by default", () => {
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it("keeps HTTPS upgrades for an explicitly configured HTTPS site", () => {
vi.stubEnv("MAGENT_APPLICATION_URL", "https://magent.example.com");
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it("allows an unclaimed managed install to load its wizard on HTTP", () => {
vi.stubEnv("MAGENT_RUNTIME_MANAGED", "1");
expect(response().headers.get("Content-Security-Policy")).not.toContain("upgrade-insecure-requests");
});
it.each(["0", "true", "false", ""])('does not activate managed setup for flag "%s"', (flag) => {
vi.stubEnv("MAGENT_RUNTIME_MANAGED", flag);
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it.each(["https://magent.example.com", "not-a-url", "http://magent.lan/path"])(
"does not let managed mode bypass configured HTTPS or invalid origins: %s",
(origin) => {
vi.stubEnv("MAGENT_RUNTIME_MANAGED", "1");
vi.stubEnv("MAGENT_APPLICATION_URL", origin);
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
},
);
it("does not accept a caller-provided managed-mode header", () => {
expect(response({ MAGENT_RUNTIME_MANAGED: "1" }).headers.get("Content-Security-Policy")).toContain(
"upgrade-insecure-requests",
);
});
it.each(["http://192.0.2.10:3000", "http://magent.lan:3000/", "http://[fd00::10]:3000"])(
"supports the operator's explicit HTTP origin %s without upgrading its assets",
(origin) => {
vi.stubEnv("MAGENT_APPLICATION_URL", origin);
expect(response().headers.get("Content-Security-Policy")).not.toContain("upgrade-insecure-requests");
},
);
it.each([
"",
"not-a-url",
"http:/magent.lan",
"//magent.lan",
"ftp://magent.lan",
"http://user:password@magent.lan",
"http://magent.lan/path",
"http://magent.lan?query=1",
"http://magent.lan#fragment",
"http://magent.lan\\path",
"http://magent.\tlan",
])("does not relax HTTPS upgrades for invalid or non-origin configuration %j", (origin) => {
vi.stubEnv("MAGENT_APPLICATION_URL", origin);
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it("does not trust caller-controlled host or forwarding headers to disable upgrades", () => {
const policy = response({
Host: "magent.lan:3000",
"X-Forwarded-Host": "magent.lan:3000",
"X-Forwarded-Proto": "http",
Forwarded: "host=magent.lan:3000;proto=http",
}).headers.get("Content-Security-Policy");
expect(policy).toContain("upgrade-insecure-requests");
});
it("preserves nonce propagation and strict production script rules on HTTP", () => {
vi.stubEnv("MAGENT_APPLICATION_URL", "http://magent.lan:3000");
const first = response();
const policy = first.headers.get("Content-Security-Policy");
const nonce = first.headers.get("x-middleware-request-x-nonce");
expect(nonce).toBeTruthy();
expect(policy).toContain(`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`);
expect(policy).not.toContain("'unsafe-eval'");
expect(policy).toContain("frame-ancestors 'none'");
expect(policy).toContain("form-action 'self'");
expect(policy).toContain("connect-src 'self'");
expect(first.headers.get("x-middleware-request-content-security-policy")).toBe(policy);
expect(response().headers.get("x-middleware-request-x-nonce")).not.toBe(nonce);
});
});
+1 -18
View File
@@ -1,22 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
function hasExplicitHttpOrigin(): boolean {
// Allow initial managed setup on a private LAN before the operator claims its
// origin. The entrypoint owns this flag; it is never derived from the request.
// Otherwise only an operator-provided origin may opt into HTTP for a private LAN.
// Never derive this decision from caller-controlled Host/forwarded headers.
const configured = (process.env.MAGENT_APPLICATION_URL || '').trim()
if (!configured && process.env.MAGENT_RUNTIME_MANAGED === '1') return true
if (!/^http:\/\//i.test(configured) || /[\s\\]/.test(configured)) return false
try {
const url = new URL(configured)
return url.protocol === 'http:' && !!url.hostname && !url.username && !url.password
&& url.pathname === '/' && !url.search && !url.hash
} catch {
return false
}
}
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '' const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''
@@ -33,7 +16,7 @@ export function proxy(request: NextRequest) {
"connect-src 'self'", "connect-src 'self'",
"worker-src 'self' blob:", "worker-src 'self' blob:",
"manifest-src 'self'", "manifest-src 'self'",
...(hasExplicitHttpOrigin() ? [] : ['upgrade-insecure-requests']), "upgrade-insecure-requests",
].join('; ') ].join('; ')
const requestHeaders = new Headers(request.headers) const requestHeaders = new Headers(request.headers)
+21
View File
@@ -0,0 +1,21 @@
# Magent monitoring
Grafana dashboard: `grafana/magent-api-performance.json` (Prometheus UID `prometheus`).
Set `MAGENT_METRICS_ENABLED=true`, `MAGENT_METRICS_BIND=0.0.0.0` and
`MAGENT_METRICS_PORT=9108` inside the container. Publish port 9108 **only on a
private interface**; do not proxy it through the public website. By default the
listener is disabled and its bind address is loopback.
Production publishes `100.114.113.88:9108:9108` on GRZ-DKR01's Tailscale interface.
Prometheus on ANA-DKR01 scrapes it every 15 seconds with job name `magent`.
Grafana's existing file provider loads the dashboard from its Magent folder.
API labels contain method, matched route template and HTTP status, never raw
paths, query values, usernames or credentials. API latency measures time to
response headers, not long-lived event-stream duration. Service metrics cover
the shared ApiClient, including background calls; custom client paths and CSRF
subrequests are not separate calls. CPU/memory refer to the Python backend only.
Metrics start at deployment, with no historical backfill. Rate/percentile panels
need multiple scrapes; unused services have no series until called. Prometheus
retains history across Magent restarts, while process counters reset normally.
@@ -0,0 +1,474 @@
{
"uid": "magent-api-performance",
"title": "Magent — API & Performance",
"tags": [
"magent",
"production"
],
"schemaVersion": 40,
"version": 1,
"refresh": "15s",
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"editable": true,
"description": "Metrics begin when instrumentation is deployed. No historical backfill. API timings are time-to-headers. Outbound metrics cover shared ApiClient calls; CPU/memory cover the Python backend. No user IDs, usernames, tokens, search terms or raw URLs are labels.",
"panels": [
{
"id": 1,
"title": "Magent metrics reachable",
"description": "1 = scrape healthy; 0 = unavailable.",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "up{job=\"magent\"}",
"legendFormat": "Magent"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
},
{
"id": 2,
"title": "API calls / second",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "Calls / sec"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 3,
"title": "API response time — p95 by route",
"description": "Time to response headers; streaming session lifetime is excluded.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (le, route) (rate(magent_api_response_seconds_bucket{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{route}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 4,
"title": "API responses by status",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (status) (rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "HTTP {{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 5,
"title": "API server error percentage",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "100 * (sum(rate(magent_api_requests_total{job=\"magent\",status=~\"5..\"}[$__rate_interval])) or vector(0)) / clamp_min(sum(rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval])), 0.000001)",
"legendFormat": "5xx"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 6,
"title": "Busiest API routes",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "topk(10,sum by (route) (rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{route}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 7,
"title": "Connected service calls / second",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 24,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (service) (rate(magent_remote_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "{{service}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 8,
"title": "Connected services — p95 response time",
"description": "Instrumented shared API-client calls, including background work. Does not count every low-level HTTP exchange.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 24,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95,sum by (le,service) (rate(magent_remote_response_seconds_bucket{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{service}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 9,
"title": "Service redirects and errors",
"description": "error = connection/transport failure. Redirects are shown because they can prevent API operations.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 32,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (service,status) (rate(magent_remote_requests_total{job=\"magent\",status=~\"3..|4..|5..|error\"}[$__rate_interval]))",
"legendFormat": "{{service}} · {{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 10,
"title": "Backend memory",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 32,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "process_resident_memory_bytes{job=\"magent\"}",
"legendFormat": "Python backend"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 11,
"title": "Backend CPU — cores used",
"description": "Backend process only, not the frontend or whole host.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 40,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "rate(process_cpu_seconds_total{job=\"magent\"}[$__rate_interval])",
"legendFormat": "CPU cores"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 12,
"title": "Backend uptime",
"description": "",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 40,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "time() - process_start_time_seconds{job=\"magent\"}",
"legendFormat": "Uptime"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
}
]
}
-132
View File
@@ -1,132 +0,0 @@
"""Check the environment reference against source without importing application settings.
Only tracked-source locations are inspected. Deployment .env files, process
environment values and runtime data are never opened or evaluated.
"""
import ast
from dataclasses import dataclass
import json
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z")
@dataclass(frozen=True)
class Setting:
names: tuple[str, ...]
default: str
def settings_inventory(source: str) -> list[Setting]:
tree = ast.parse(source.lstrip("\ufeff"))
settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings")
result = []
for node in settings.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
name = node.target.id
names = (name.upper(),)
default = node.value
if isinstance(default, ast.Call):
arguments = {keyword.arg: keyword.value for keyword in default.keywords}
alias = arguments.get("validation_alias")
if isinstance(alias, ast.Constant):
names = (alias.value,)
elif isinstance(alias, ast.Call):
names = tuple(ast.literal_eval(argument) for argument in alias.args)
default = arguments.get("default")
if isinstance(default, ast.Name):
value = "@" + default.id
else:
value = json.dumps(ast.literal_eval(default), ensure_ascii=True)
result.append(Setting(names, value))
return result
def python_environment_names(source: str) -> set[str]:
names = set()
for node in ast.walk(ast.parse(source.lstrip("\ufeff"))):
argument = None
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args:
receiver = ast.unparse(node.func.value)
if (node.func.attr == "getenv" and receiver == "os") or (
node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"}
):
argument = node.args[0]
elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in {
"os.environ", "environ", "environment", "prepared"
}:
argument = node.slice
if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value):
names.add(argument.value)
return names
def runtime_environment_names(root: Path) -> set[str]:
names = set()
sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")]
for path in sources:
names.update(python_environment_names(path.read_text(encoding="utf-8")))
javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"),
*root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"]
for path in javascript:
if ".test." not in path.name:
names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"),
*root.glob(".gitea/workflows/*.yml")]
for path in deployment:
source = path.read_text(encoding="utf-8")
names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source))
names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source))
names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source))
# These are shell syntax/builtins, not Magent configuration options.
names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"})
dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
for line in dockerfile.splitlines():
if line.startswith(("ENV ", "ARG ")):
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8")
for line in supervisor.splitlines():
if line.startswith("environment="):
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
return names
def check_documentation(root: Path = ROOT) -> tuple[list[str], int]:
document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8")
documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document))
settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8"))
required = runtime_environment_names(root) | {name for setting in settings for name in setting.names}
errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)]
defaults = {}
for line in document.splitlines():
cells = line.split("|")
if len(cells) >= 4 and cells[1].strip().startswith("`"):
for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]):
defaults[name] = cells[2].strip().strip("`")
for setting in settings:
for name in setting.names:
if name in documented and defaults.get(name) != setting.default:
errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}")
return errors, len(required)
def main() -> int:
errors, count = check_documentation()
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -2
View File
@@ -14,10 +14,9 @@ echo "Running Python dependency integrity check"
echo "Auditing Python production dependencies" echo "Auditing Python production dependencies"
"$python_bin" -m pip_audit -r backend/requirements.txt --progress-spinner off "$python_bin" -m pip_audit -r backend/requirements.txt --progress-spinner off
"$python_bin" -m pip_audit -r docker/requirements-runtime.txt --progress-spinner off
echo "Linting backend application code" echo "Linting backend application code"
"$python_bin" -m ruff check backend/app scripts/container_smoke.py scripts/check_environment_docs.py backend/tests/test_container_packaging.py backend/tests/test_container_bootstrap.py backend/tests/test_managed_setup_origin.py backend/tests/test_environment_docs.py "$python_bin" -m ruff check backend/app
echo "Running backend unit tests with coverage" echo "Running backend unit tests with coverage"
"$python_bin" -m coverage erase "$python_bin" -m coverage erase
+43 -120
View File
@@ -1,141 +1,64 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# No argument preserves the CI build-and-test entry point. Pass an image tag to
# test an already-built release without building, pulling, or publishing it.
set -euo pipefail set -euo pipefail
if [ "$#" -gt 1 ]; then container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$"
echo "Usage: $0 [existing-image]" >&2
exit 2
fi
script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repository_directory="$(cd -- "$script_directory/.." && pwd)"
image="${1:-magent:ci}"
size_limit_mb="${MAGENT_IMAGE_MAX_MB:-350}"
managed_mode="${MAGENT_SMOKE_MANAGED:-false}"
if [[ "$managed_mode" != true && "$managed_mode" != false ]]; then
echo "MAGENT_SMOKE_MANAGED must be true or false." >&2
exit 2
fi
if ! [[ "$size_limit_mb" =~ ^[1-9][0-9]*$ ]]; then
echo "MAGENT_IMAGE_MAX_MB must be a positive integer (MiB)." >&2
exit 2
fi
container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$-${RANDOM}"
volume_name="${container_name}-data" volume_name="${container_name}-data"
network_name="${container_name}-isolated"
container_created=false
volume_created=false
network_created=false
cleanup() { cleanup() {
result=$?
trap - EXIT
if [ "$result" -ne 0 ] && [ "$container_created" = true ]; then
# Only synthetic credentials/data enter this test container.
docker logs --tail 100 "$container_name" >&2 || true
fi
if [ "$container_created" = true ]; then
docker rm -f "$container_name" >/dev/null 2>&1 || true docker rm -f "$container_name" >/dev/null 2>&1 || true
fi docker volume rm -f "$volume_name" >/dev/null 2>&1 || true
if [ "$volume_created" = true ]; then
docker volume rm "$volume_name" >/dev/null 2>&1 || true
fi
if [ "$network_created" = true ]; then
docker network rm "$network_name" >/dev/null 2>&1 || true
fi
exit "$result"
} }
trap cleanup EXIT trap cleanup EXIT
if [ "$#" -eq 0 ]; then docker build --tag magent:ci .
docker build --tag "$image" "$repository_directory"
fi
image_size="$(docker image inspect --format '{{.Size}}' "$image")"
image_id="$(docker image inspect --format '{{.Id}}' "$image")"
if [ "$image_size" -gt "$((size_limit_mb * 1024 * 1024))" ]; then
echo "Image exceeds ${size_limit_mb} MiB unpacked budget: ${image_size} bytes" >&2
exit 1
fi
echo "Image size: ${image_size} bytes (budget ${size_limit_mb} MiB unpacked)"
# Inspect the image's original filesystem before tmpfs or volume mounts could
# hide accidentally shipped build caches or private files.
docker run --rm --pull never --network none --read-only \
--cap-drop ALL --security-opt no-new-privileges:true \
--entrypoint python -i "$image_id" - packaging < "$script_directory/container_smoke.py"
# An internal network prevents accidental external integration calls. No host
# files, existing volumes, host credentials, or host ports are used.
docker network create --internal "$network_name" >/dev/null
network_created=true
docker volume create "$volume_name" >/dev/null docker volume create "$volume_name" >/dev/null
volume_created=true docker run --rm --user 0 \
--volume "$volume_name:/app/data" \
start_container() { --entrypoint chown \
local -a secret_environment magent:ci -R 1000:1000 /app/data
if [ "$managed_mode" = true ]; then docker run --detach --name "$container_name" \
# Exercise the image defaults: no keys, origin or managed-mode variables.
secret_environment=()
else
secret_environment=(
--env JWT_SECRET=ci-only-secret-with-at-least-32-characters
--env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
--env SETUP_TOKEN=ci-only-setup-token-with-at-least-32-characters
--env AUTH_COOKIE_SECURE=true
--env MAGENT_APPLICATION_URL=https://magent-ci.example.test
)
fi
docker run --detach --name "$container_name" --pull never \
--network "$network_name" \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \ --read-only --cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 256 --memory 1g --cpus 2 \
--tmpfs /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 \ --tmpfs /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 \
--tmpfs /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \ --tmpfs /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \
--volume "$volume_name:/app/data" \ --volume "$volume_name:/app/data" \
"${secret_environment[@]}" \ --env JWT_SECRET=ci-only-secret-with-at-least-32-characters \
--env ADMIN_PASSWORD= \ --env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \
--env AUTH_COOKIE_SAMESITE=strict \ --env ADMIN_PASSWORD=ci-only-bootstrap-password-123 \
--env BACKGROUND_TASKS_ENABLED=false \ --env MAGENT_APPLICATION_URL=https://magent-ci.example.test \
--env MAGENT_METRICS_ENABLED=false \ magent:ci >/dev/null
"$image_id" >/dev/null
container_created=true
}
wait_for_health() { deadline=$((SECONDS + 120))
local deadline=$((SECONDS + 150)) until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container_name")" = "healthy" ]; do
local status if [ "$SECONDS" -ge "$deadline" ]; then
while true; do docker logs "$container_name"
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_name")" echo "Container did not become healthy within 120 seconds" >&2
if [ "$status" = healthy ]; then exit 1
return
fi
if [ "$status" = missing ] || [ "$SECONDS" -ge "$deadline" ]; then
echo "Container did not become healthy within 150 seconds (status: $status)" >&2
return 1
fi
if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != true ]; then
echo "Container exited before becoming healthy" >&2
return 1
fi fi
sleep 2 sleep 2
done done
}
# Deliberately no root/chown helper: the image must initialize a fresh named docker exec "$container_name" curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null
# volume with correct ownership for its normal non-root runtime user. docker exec "$container_name" curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null
start_container
wait_for_health
docker exec -i "$container_name" python - fresh < "$script_directory/container_smoke.py"
docker restart --time 15 "$container_name" >/dev/null # Exercise browser-origin requests, not only GET health checks. A configured
wait_for_health # public address must work even when CORS_ALLOW_ORIGIN has its localhost default.
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py" docker exec -i "$container_name" python - <<'PY'
from urllib import error, request
# Recreation proves database/configuration are in the volume, not merely in the for path in ("/auth/login", "/auth/jellyfin/login"):
# container's writable layer. Both test instances use the same immutable image. for origin, expected in (
docker rm -f "$container_name" >/dev/null ("https://magent-ci.example.test", 422),
container_created=false ("https://untrusted.example.test", 403),
start_container ):
wait_for_health probe = request.Request(
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py" "http://127.0.0.1:8000" + path,
echo "Container smoke passed: fresh install, security headers, assets, login, backup/restore, restart, recreation." data=b"",
headers={"Origin": origin, "Content-Type": "application/x-www-form-urlencoded"},
)
try:
response = request.urlopen(probe, timeout=10)
except error.HTTPError as exc:
response = exc
with response:
assert response.status == expected, (path, origin, response.status, expected)
print(f"Browser-origin login smoke: {path} {origin} -> {expected}")
PY
-340
View File
@@ -1,340 +0,0 @@
"""Disposable-image checks, streamed into the container by ci_container_smoke.sh.
Uses only Python's standard library. All credentials and configuration below
are synthetic and the caller disables network egress and background workers.
This checks script/asset delivery and CSP compatibility, not browser execution.
"""
from html.parser import HTMLParser
import hashlib
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import sqlite3
import subprocess
import sys
from urllib import error, parse, request
ORIGIN = "https://magent-ci.example.test"
FRONTEND = "http://127.0.0.1:3000"
SETUP_TOKEN = "ci-only-setup-token-with-at-least-32-characters"
ADMIN_USERNAME = "container-smoke-admin"
ADMIN_PASSWORD = "Container-smoke-owner-password-123456789!"
INTEGRATION_SECRET = "synthetic-container-smoke-integration-key"
LOGIN_MESSAGE = "Welcome to an independent Magent installation"
BACKUP_PASSPHRASE = "Synthetic container backup passphrase only"
CACHE_FIXTURE = Path("/app/data/artwork/tmdb/w342/container-smoke.jpg")
CACHE_CONTENT = b"synthetic artwork cache fixture"
def managed_installation() -> bool:
return os.environ.get("MAGENT_MANAGED_SECRETS") in {"true", "auto"} and not os.environ.get("JWT_SECRET")
def check(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def http(
path: str,
*,
expected: int = 200,
method: str = "GET",
payload: dict | None = None,
form: dict | None = None,
raw: bytes | None = None,
headers: dict | None = None,
base: str = FRONTEND,
) -> tuple[bytes, object]:
outgoing_headers = {"Origin": ORIGIN, **(headers or {})}
check(sum(value is not None for value in (payload, form, raw)) <= 1,
"HTTP body must use only one encoding")
data = raw
if payload is not None:
data = json.dumps(payload).encode()
outgoing_headers["Content-Type"] = "application/json"
elif form is not None:
data = parse.urlencode(form).encode()
outgoing_headers["Content-Type"] = "application/x-www-form-urlencoded"
probe = request.Request(base + path, data=data, headers=outgoing_headers, method=method)
try:
response = request.urlopen(probe, timeout=30)
except error.HTTPError as exc:
response = exc
with response:
check(response.status == expected, f"{method} {path}: expected {expected}, got {response.status}")
return response.read(), response.headers
def api(path: str, **kwargs) -> dict:
body, _ = http("/api" + path, **kwargs)
return json.loads(body)
class PageAssets(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.scripts: list[dict] = []
self.assets: set[str] = set()
def handle_starttag(self, tag: str, attributes: list) -> None:
values = dict(attributes)
if tag == "script":
self.scripts.append(values)
if values.get("src"):
self.assets.add(values["src"])
if tag == "link" and values.get("href", "").startswith("/_next/static/"):
self.assets.add(values["href"])
def check_page(path: str, asset_cache: set[str]) -> str:
body, headers = http(path)
check("text/html" in headers.get("Content-Type", ""), f"{path} is not HTML")
policy = headers.get("Content-Security-Policy", "")
match = re.search(r"script-src [^;]*'nonce-([^']+)'", policy)
check(match is not None, f"{path} missing script nonce policy")
nonce = match.group(1)
check("'strict-dynamic'" in policy, f"{path} lost strict-dynamic")
check("'unsafe-eval'" not in policy, f"{path} enables development eval")
check(headers.get("X-Content-Type-Options") == "nosniff", "Missing nosniff header")
check(headers.get("X-Frame-Options") == "DENY", "Missing anti-framing header")
check(headers.get("X-Powered-By") is None, "Frontend exposes its framework")
parsed = PageAssets()
parsed.feed(body.decode())
executable_scripts = [
script for script in parsed.scripts
if script.get("type", "").lower() in ("", "module", "text/javascript", "application/javascript")
]
check(bool(executable_scripts), f"{path} contains no frontend bootstrap scripts")
for script in executable_scripts:
check(script.get("nonce") == nonce, f"{path} contains a script blocked by its CSP nonce")
check(any(asset.startswith("/_next/static/") and ".js" in asset for asset in parsed.assets),
f"{path} contains no static JavaScript assets")
for asset in sorted(parsed.assets - asset_cache):
check(asset.startswith("/_next/static/"), f"Unexpected external executable asset on {path}")
content, asset_headers = http(asset)
check(bool(content), f"Empty static asset: {asset}")
check("text/html" not in asset_headers.get("Content-Type", ""), f"Asset returned HTML: {asset}")
asset_cache.add(asset)
return nonce
def check_packaging() -> None:
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
check(Path("/app/frontend/server.js").is_file(), "Missing standalone frontend server")
check(shutil.which("node") == "/usr/local/bin/node", "Node is not the standalone runtime binary")
check(shutil.which("supervisord") == "/usr/local/bin/supervisord", "Missing Python supervisor")
check(shutil.which("curl") is not None, "curl compatibility for existing healthchecks was removed")
for executable in ("npm", "npx", "yarn", "pnpm", "pip", "pip3", "gcc", "g++", "make", "git", "gpg"):
check(shutil.which(executable) is None, f"Unnecessary runtime development tool: {executable}")
for forbidden in (
"/app/.git", "/app/tests", "/app/app/tests", "/app/backend/tests",
"/app/frontend/app", "/app/frontend/tsconfig.json", "/app/frontend/proxy.ts",
"/app/frontend/node_modules/typescript", "/app/frontend/node_modules/eslint",
"/app/frontend/node_modules/vitest", "/app/frontend/node_modules/@playwright",
"/app/frontend/node_modules/@biomejs",
"/app/frontend/node_modules/@next/swc-linux-x64-gnu",
"/app/frontend/node_modules/@next/swc-linux-arm64-gnu",
"/app/frontend/node_modules/@next/swc-linux-x64-musl",
"/app/frontend/node_modules/@next/swc-linux-arm64-musl",
"/root/.npm", "/root/.cache/pip", "/usr/local/lib/node_modules/npm",
):
check(not Path(forbidden).exists(), f"Unnecessary build/private artifact: {forbidden}")
for directory in (Path("/app"), Path("/app/frontend")):
check(not any(directory.glob(".env*")), f"Private environment file in {directory}")
check(not Path("/app/data/bootstrap-secrets.json").exists(), "Managed secrets baked into image")
check(not any(Path("/app/frontend/.next/cache").iterdir()), "Frontend build cache shipped in runtime")
check(Path("/usr/share/licenses/magent/LICENSE").is_file(), "Magent license is missing")
check(Path("/usr/local/share/doc/nodejs/LICENSE").is_file(), "Node distribution license is missing")
check(Path("/usr/share/licenses/magent/frontend/dependencies.json").is_file(),
"Frontend dependency inventory is missing")
print("Standalone packaging, non-root runtime and absent development tools/private files: PASS")
def check_runtime() -> None:
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
check(Path("/app/data").stat().st_uid == os.getuid(), "Fresh data volume is not owned by runtime user")
check(os.access("/app/data", os.W_OK), "Data volume is not writable")
for path in ("/api/health", "/api/setup/status"):
http(path)
http("/health", base="http://127.0.0.1:8000")
asset_cache: set[str] = set()
first_nonce = check_page("/login", asset_cache)
second_nonce = check_page("/login", asset_cache)
check(first_nonce != second_nonce, "CSP nonce is reused between requests")
check_page("/setup", asset_cache)
# Check the retained curl command because some deployed stacks override the
# image HEALTHCHECK with this exact runtime dependency.
subprocess.run(["curl", "--fail", "--silent", "--show-error", FRONTEND + "/api/health"],
check=True, stdout=subprocess.DEVNULL)
print(f"Runtime, API rewrite, CSP nonce consistency and {len(asset_cache)} static assets: PASS")
def check_origin_guards() -> None:
# MAGENT_APPLICATION_URL must permit the public origin even while CORS uses
# its localhost default. Test both direct backend and Next's API rewrite.
for base, prefix in ((FRONTEND, "/api"), ("http://127.0.0.1:8000", "")):
for endpoint in ("/auth/login", "/auth/jellyfin/login"):
for origin, expected in ((ORIGIN, 422), ("https://untrusted.example.test", 403)):
http(prefix + endpoint, base=base, method="POST", form={}, expected=expected,
headers={"Origin": origin})
print("Both login Origin guards, directly and via frontend: PASS")
def sign_in() -> dict:
_, headers = http("/api/auth/login", method="POST", form={
"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
})
cookies = SimpleCookie()
for raw_cookie in headers.get_all("Set-Cookie", []):
cookies.load(raw_cookie)
check("magent_auth" in cookies, "Local login did not issue an authentication cookie")
auth_cookie = cookies["magent_auth"]
check(bool(auth_cookie["httponly"]), "Authentication cookie missing HttpOnly")
check(bool(auth_cookie["secure"]), "Authentication cookie missing Secure")
check(auth_cookie["samesite"].lower() == "strict", "Authentication cookie missing SameSite=strict")
# These requests traverse HTTP loopback behind the simulated HTTPS public
# origin. Forward only our synthetic cookie explicitly; never print tokens.
authenticated_headers = {"Cookie": "magent_auth=" + auth_cookie.value}
identity = api("/auth/me", headers=authenticated_headers)
check(identity["username"] == ADMIN_USERNAME and identity["role"] == "admin",
"Local administrator identity did not survive login")
return authenticated_headers
def check_persisted_settings(headers: dict) -> None:
values = {item["key"]: item for item in api("/admin/settings", headers=headers)["settings"]}
check(values["site_login_message"]["value"] == LOGIN_MESSAGE, "Public configuration did not persist")
check(values["jellyfin_api_key"]["value"] is None and values["jellyfin_api_key"]["isSet"],
"Integration secret is missing or exposed by settings API")
with sqlite3.connect("file:/app/data/magent.db?mode=ro", uri=True) as connection:
check(connection.execute("PRAGMA quick_check").fetchone()[0] == "ok", "SQLite integrity failure")
check(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1,
"Fresh smoke instance has unexpected users")
stored = connection.execute("SELECT value FROM settings WHERE key = 'jellyfin_api_key'").fetchone()
check(stored is not None and INTEGRATION_SECRET not in str(stored[0]),
"Integration secret was stored without encryption")
def backup_restore_upload(content: bytes, passphrase: str) -> tuple[bytes, str]:
boundary = "magent-smoke-" + secrets.token_hex(24)
parts = []
for name, value in (("passphrase", passphrase), ("confirmation", "RESTORE")):
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n'
f'\r\n{value}\r\n').encode())
parts.extend([
(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="smoke.magent-backup"\r\n'
'Content-Type: application/octet-stream\r\n\r\n').encode(),
content,
f"\r\n--{boundary}--\r\n".encode(),
])
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def stage_backup_roundtrip(headers: dict) -> None:
# All paths and values belong to this disposable CI volume, never real data.
CACHE_FIXTURE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FIXTURE.write_bytes(CACHE_CONTENT)
content, response_headers = http("/api/admin/backups/export", method="POST", headers=headers,
payload={"passphrase": BACKUP_PASSPHRASE, "include_cache": True})
check(content.startswith(b"MAGENT-BACKUP\x00\x01"), "Backup is not the encrypted portable format")
check(INTEGRATION_SECRET.encode() not in content, "Backup exposed plaintext integration credentials")
check(response_headers.get("Cache-Control") == "no-store", "Backup download is cacheable")
api("/admin/settings", method="PUT", headers=headers,
payload={"site_login_message": "Changed after backup"})
CACHE_FIXTURE.write_bytes(b"changed after backup")
body, content_type = backup_restore_upload(content, BACKUP_PASSPHRASE)
restored = api("/admin/backups/restore", method="POST", expected=202, raw=body,
headers={**headers, "Content-Type": content_type})
check(restored["restart_required"], "Restore did not require a restart")
status = api("/admin/backups", headers=headers)
check(status["pending_restore"] is not None, "Backup was not staged")
check(CACHE_FIXTURE.read_bytes() == b"changed after backup", "Restore applied before restart")
print("Encrypted config/database/cache backup and authenticated restore staging: PASS")
def fresh_install() -> None:
check(api("/setup/status") == {"setup_required": True, "needs_admin": True},
"Fresh volume did not open authorized first-install setup")
api("/setup/state", expected=401)
api("/admin/settings", expected=401)
token = SETUP_TOKEN
if managed_installation():
# docker exec does not inherit the entrypoint's generated environment:
# the console command must read persistent state, not rely on getenv.
token = subprocess.check_output(
[sys.executable, "-m", "app.container_bootstrap", "setup-token"], text=True,
).strip()
check(len(token) == 64 and token != SETUP_TOKEN, "Managed setup token was not generated")
state_file = Path("/app/data/bootstrap-secrets.json")
check(state_file.stat().st_mode & 0o777 == 0o600, "Managed secrets file is not private")
Path("/app/data/.smoke-managed-digest").write_text(hashlib.sha256(state_file.read_bytes()).hexdigest())
bootstrap = {"setup_token": token, "username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
"application_url": ORIGIN}
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=403,
headers={"Origin": "https://untrusted.example.test"})
api("/setup/bootstrap", method="POST", payload={**bootstrap, "setup_token": "wrong-token"}, expected=403)
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=201)
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=409)
headers = sign_in()
check(api("/setup/state", headers=headers)["step"] == "apps", "Setup did not advance to apps")
updated = api("/admin/settings", method="PUT", headers=headers, payload={
"site_login_message": LOGIN_MESSAGE,
"jellyfin_api_key": INTEGRATION_SECRET,
})
check(updated["updated"] == 2, "Setup configuration was not saved")
api("/setup/state", method="PUT", payload={"step": "review"}, headers=headers)
check(api("/setup/complete", method="POST", headers=headers)["completed"], "Setup did not complete")
check(api("/setup/status") == {"setup_required": False, "needs_admin": False}, "Setup remained public")
check_persisted_settings(headers)
print("Token-authorized setup, local admin login, secure cookies and encrypted settings: PASS")
stage_backup_roundtrip(headers)
def persisted_install() -> None:
check(api("/setup/status") == {"setup_required": False, "needs_admin": False},
"Setup reopened after restart/recreation")
api("/setup/bootstrap", method="POST", payload={
"setup_token": SETUP_TOKEN, "username": "must-not-exist", "password": ADMIN_PASSWORD,
}, expected=409)
headers = sign_in()
check_persisted_settings(headers)
status = api("/admin/backups", headers=headers)
check(status["pending_restore"] is None, "Restore remained pending after restart")
check(status["last_restore"] and status["last_restore"]["status"] == "restored",
"Backup restore did not complete")
check(CACHE_FIXTURE.read_bytes() == CACHE_CONTENT, "Artwork cache was not restored")
if managed_installation():
state_file = Path("/app/data/bootstrap-secrets.json")
check(hashlib.sha256(state_file.read_bytes()).hexdigest()
== Path("/app/data/.smoke-managed-digest").read_text(), "Managed keys changed on restart/restore")
result = subprocess.run([sys.executable, "-m", "app.container_bootstrap", "setup-token"],
capture_output=True, text=True, check=False)
check(result.returncode != 0 and not result.stdout, "Setup token remains available after admin creation")
print("Generated keys persisted unchanged; initial setup token is no longer available: PASS")
print("Persistent setup state, administrator login, encrypted settings and database integrity: PASS")
print("Restored configuration, database and artwork cache: PASS")
def main() -> None:
check(len(sys.argv) == 2 and sys.argv[1] in ("packaging", "fresh", "persisted"),
"Expected packaging, fresh or persisted mode")
if sys.argv[1] == "packaging":
check_packaging()
return
check_runtime()
if sys.argv[1] == "fresh":
fresh_install()
else:
persisted_install()
check_origin_guards()
if __name__ == "__main__":
main()
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
umask 077
mkdir -p '${deploy_path}'
chmod 700 '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
chmod 700 \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
(umask 022; tar -xf - -C '${deploy_path}')
if [ -f '${deploy_path}/.env' ]; then
chmod 600 '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data'
chmod 700 '${deploy_path}/data'
docker compose build
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
settings_key=\"\$(docker compose run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
chmod 600 .env
fi
docker compose run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
docker compose run --rm --user 0 --cap-add CHOWN --cap-add DAC_OVERRIDE magent chown -R 1000:1000 /app/data
docker compose up -d
"
echo "Running remote smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8000/health', 200),
('http://127.0.0.1:3000/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Deployment completed successfully"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
umask 077
mkdir -p '${deploy_path}'
chmod 700 '${deploy_path}'
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
chmod 700 \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
(umask 022; tar -xf - -C '${deploy_path}')
if [ ! -f '${deploy_path}/.env' ]; then
echo 'Beta .env is missing. Provision independent beta secrets before deploying.' >&2
exit 1
fi
chmod 600 '${deploy_path}/.env'
mkdir -p '${deploy_path}/data'
chmod 700 '${deploy_path}/data'
cd '${deploy_path}'
docker compose -p magent-beta -f docker-compose.beta.yml build
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
settings_key=\"\$(docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
chmod 600 .env
fi
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --user 0 --cap-add CHOWN --cap-add DAC_OVERRIDE magent chown -R 1000:1000 /app/data
docker compose -p magent-beta -f docker-compose.beta.yml up -d
"
echo "Running remote beta smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
import time
from urllib import error, request
checks = [
('http://127.0.0.1:8100/health', 200),
('http://${beta_frontend_bind}:3100/login', 200),
]
# Compose returns before the app is ready. Allow the new processes to start
# instead of reporting a failed deployment on the first connection reset.
deadline = time.monotonic() + 90
for url, expected in checks:
while True:
try:
with request.urlopen(url, timeout=5) as response:
if response.status != expected:
raise OSError(f'HTTP {response.status}, expected {expected}')
print(url, response.status)
break
except (error.URLError, OSError, TimeoutError) as exc:
if time.monotonic() >= deadline:
raise SystemExit(f'Beta did not become ready: {url}: {exc}') from exc
print(f'Waiting for beta to start: {url}', flush=True)
time.sleep(2)
PY
"
echo "Beta deployment completed successfully"
+10
View File
@@ -0,0 +1,10 @@
function Set-EnvBuildNumber {
param(
[AllowEmptyString()][string]$Content,
[Parameter(Mandatory = $true)][string]$BuildNumber
)
if ($BuildNumber -notmatch '^\d+$') { throw 'Build number must contain digits only.' }
$newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" }
$remaining = [regex]::Replace($Content, '(?m)^[\t ]*(?:export[\t ]+)?BUILD_NUMBER[\t ]*=[^\r\n]*(?:\r?\n|$)', '')
return "BUILD_NUMBER=$BuildNumber$newline$remaining"
}
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
def _normalize_email(value: object) -> str | None:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as handle:
return [dict(row) for row in csv.DictReader(handle)]
def _ensure_email_column(conn: sqlite3.Connection) -> None:
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, username, email
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
rows = _load_rows(csv_path)
username_counts = Counter(
str(row.get("Username") or "").strip().lower()
for row in rows
if str(row.get("Username") or "").strip()
)
duplicate_usernames = {
username for username, count in username_counts.items() if username and count > 1
}
summary: dict[str, object] = {
"csv_path": str(csv_path),
"db_path": str(db_path),
"source_rows": len(rows),
"updated": 0,
"unchanged": 0,
"missing_email": [],
"missing_user": [],
"duplicate_source_username": [],
}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
_ensure_email_column(conn)
for row in rows:
username = str(row.get("Username") or "").strip()
if not username:
continue
username_key = username.lower()
if username_key in duplicate_usernames:
cast_list = summary["duplicate_source_username"]
assert isinstance(cast_list, list)
if username not in cast_list:
cast_list.append(username)
continue
email = _normalize_email(row.get("Email"))
if not email:
cast_list = summary["missing_email"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
matches = _lookup_user(conn, username)
if not matches:
cast_list = summary["missing_user"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
current_emails = {
normalized.lower()
for normalized in (_normalize_email(row["email"]) for row in matches)
if normalized
}
if current_emails == {email.lower()}:
summary["unchanged"] = int(summary["unchanged"]) + 1
continue
conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(email, username),
)
summary["updated"] = int(summary["updated"]) + 1
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
parser.add_argument(
"csv_path",
nargs="?",
default=str(DEFAULT_CSV_PATH),
help="CSV file containing Username and Email columns",
)
parser.add_argument(
"--db-path",
default=str(DEFAULT_DB_PATH),
help="Path to the Magent SQLite database",
)
args = parser.parse_args()
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

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