Compare commits

...

260 Commits

Author SHA1 Message Date
Michael Mayer
d9fc05806b Merge branch 'develop' into feature/batch-edit 2025-09-26 11:44:54 +02:00
Michael Mayer
a3dac7c707 Metadata: Update folder_test.go, photo_estimate_test.go, country_test.go
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 11:44:27 +02:00
Michael Mayer
4d91f5ffdf Metadata: Update TestCountryCode in pkg/txt/country_test.go
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 11:27:16 +02:00
Michael Mayer
1b48cb2a25 Metadata: Remove ambiguous location names from countries.go
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 11:25:56 +02:00
Michael Mayer
f7464772cb Merge branch 'develop' into feature/batch-edit 2025-09-26 11:02:08 +02:00
Michael Mayer
58180accee Config: Require secure cluster join tokens >= 24 chars #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 11:01:48 +02:00
Michael Mayer
da943defd1 Merge branch 'develop' into feature/batch-edit 2025-09-26 07:02:12 +02:00
Michael Mayer
52337eba27 Cluster: Renamed service/cluster/instance to cluster/node #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 07:01:09 +02:00
Michael Mayer
90f62a732e API: Add internal/api/cluster_metrics_test.go #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 06:59:05 +02:00
Michael Mayer
bc6c34cb2b API: Add GET /api/v1/cluster/metrics endpoint #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 06:36:23 +02:00
Michael Mayer
c12cec0e63 Merge branch 'develop' into feature/batch-edit 2025-09-26 06:18:03 +02:00
Michael Mayer
9f119a8cfa Auth: Return and persist ClusterCIDR when registering a node #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 06:17:31 +02:00
Michael Mayer
66e2027c10 Auth: Shorten code comments in pkg/clean/scope.go #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 05:55:50 +02:00
Michael Mayer
bd66110c18 Auth: Improve code comments in internal/auth/acl/scopes.go #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 05:53:28 +02:00
Michael Mayer
07658dac69 Docs: Recommend acl.Scope* functions for scope checks #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 05:49:23 +02:00
Michael Mayer
108b2c2df4 Auth: Recommend acl.ScopeAttrPermits / acl.ScopePermits #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 05:45:59 +02:00
Michael Mayer
48a965a7cc API: Refactor JWT-based request authorization #98 #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 05:32:30 +02:00
Michael Mayer
c2a4875b43 Merge branch 'develop' into feature/batch-edit 2025-09-26 02:40:40 +02:00
Michael Mayer
32c054da7a CLI: Added JWT issuance and diagnostics sub commands #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-26 02:38:49 +02:00
Michael Mayer
566eed05e0 Backend: Remove temporary SQLite files after running unit tests
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 23:21:48 +02:00
Michael Mayer
660c0a89db Backend: Introduce optimized test config helpers to improve performance
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 23:09:52 +02:00
Michael Mayer
ebb0410b20 Docs: Add reminder to keep "Last Updated" lines updated
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 20:28:08 +02:00
Michael Mayer
7e419f7419 Docs: Add "Last Updated" timestamps to AGENTS.md and CODEMAP.md files
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 20:08:45 +02:00
Michael Mayer
633d4222ab Auth: Improve JWKS Fetch Concurrency & Timeouts #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 18:46:24 +02:00
Michael Mayer
bae8ceb3a7 Auth: Support asymmetric JSON Web Tokens (JWT) and Key Sets (JWKS) #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 17:52:44 +02:00
Michael Mayer
4828c0423d Docs: Update Go package documentation requirements
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 14:22:23 +02:00
Michael Mayer
cb81f9be12 FFmpeg: Add descriptions to encoder packages in internal/ffmpeg/
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 14:20:35 +02:00
Ömer Duran
bbee14b1ac Batch Edit: Replace v-combobox with v-autocomplete for improved user experience in date and type selection 2025-09-25 11:55:17 +03:00
Ömer Duran
73d891ca65 Batch Edit: Improve validation and error handling for album and label actions #271 2025-09-25 06:41:49 +03:00
Ömer Duran
5903758063 Batch Edit: Fix the persistent isBatchDialog state that causes the edit control to remain hidden after batch preview. #271 2025-09-24 23:23:38 +03:00
Michael Mayer
87f206406a Merge branch 'develop' into feature/batch-edit 2025-09-24 08:30:12 +02:00
Ömer Duran
2c0f6d47cd Frontend: Enhance error handling in batch edit API calls #271 2025-09-22 15:27:18 +03:00
Michael Mayer
bd3de03c79 Merge branch 'develop' into feature/batch-edit 2025-09-21 13:53:34 +02:00
Ömer Duran
64182a9b8a Frontend: Refactor batch test mocks 2025-09-18 12:51:54 +03:00
Ömer Duran
1736701fe5 Merge branch 'feature/batch-edit' of https://github.com/photoprism/photoprism into feature/batch-edit 2025-09-18 12:20:08 +03:00
Ömer Duran
1003f649e2 Frontend: Clean up unused component mocks in batch tests #271 2025-09-18 12:19:12 +03:00
Michael Mayer
8fc0227b73 Merge branch 'develop' into feature/batch-edit 2025-09-18 09:53:40 +02:00
Ömer Duran
7664ee7556 Frontend: Remove original states tracking from chip-selector and update batch component logic 2025-09-17 15:48:43 +03:00
Ömer Duran
1342b1d1a2 Frontend: Improve code readability in chip-selector and batch component #271 2025-09-17 11:42:48 +03:00
graciousgrey
0277419f3b Merge branch 'develop' into feature/batch-edit 2025-09-16 10:08:22 +02:00
Ömer Duran
2d690afe21 Tests: Add tests for ChipSelector and BatchEdit components #271 2025-09-15 14:32:02 +03:00
Ömer Duran
5c75f6b035 Tests: Add unit tests for album and labels functionality #271 2025-09-15 08:58:02 +03:00
Ömer Duran
716e856110 Frontend: Refactor batch editing logic to improve selection handling and add unit tests #271 2025-09-15 07:11:42 +03:00
Michael Mayer
3283941ba0 Merge branch 'develop' into feature/batch-edit 2025-09-14 20:14:09 +02:00
Ömer Duran
7ca6b45217 Backend: Refactor photo conversion and date logic tests for clarity and structure 2025-09-12 10:46:22 +03:00
Ömer Duran
a920e10b7d Backend: Refactor date logic tests to ensure year value is preserved when month is unknown 2025-09-11 17:33:48 +03:00
Ömer Duran
46b8913ca4 Backend: Add date logic and conversion tests for photo metadata updates 2025-09-11 17:13:43 +03:00
Michael Mayer
7032a071e5 Merge branch 'develop' into feature/batch-edit 2025-09-09 13:08:14 +02:00
Michael Mayer
ffba22034a Merge branch 'develop' into feature/batch-edit 2025-09-09 11:34:14 +02:00
Michael Mayer
52beb0580c Merge branch 'develop' into feature/batch-edit 2025-09-09 09:56:21 +02:00
Ömer Duran
30609fbd17 Batch Edit: Ensured PhotoDay remains consistent with computed values when only Month and/or Year are updated. 2025-09-08 11:50:32 +03:00
Michael Mayer
2b850b42b7 Merge branch 'develop' into feature/batch-edit 2025-09-07 16:26:37 +02:00
Michael Mayer
74982c8d75 Merge branch 'develop' into feature/batch-edit 2025-09-07 16:05:29 +02:00
Ömer Duran
933606873a Tests: Adapt batch/photos_test 2025-09-06 10:41:14 +03:00
graciousgrey
db9e9e8624 Tests: Add tests #271 2025-09-04 15:39:48 +02:00
graciousgrey
ad925ded45 Tests: Improve unit tests #271 2025-09-04 10:16:34 +02:00
Michael Mayer
4f326faeea Merge branch 'develop' into feature/batch-edit 2025-09-03 16:38:05 +02:00
Michael Mayer
d932f6f49f Merge branch 'develop' into feature/batch-edit 2025-09-03 16:19:21 +02:00
Michael Mayer
07dd5a1b8b Merge branch 'develop' into feature/batch-edit 2025-09-03 11:48:17 +02:00
Michael Mayer
1a738c936a Merge branch 'develop' into feature/batch-edit 2025-09-03 11:44:40 +02:00
Ömer Duran
21e48a1876 Metadata: Clamp Day to valid month/year in details and batch editor and recompute TakenAtLocal with clamped day in backend batch conversion. 2025-09-02 17:25:17 +03:00
Michael Mayer
7a7a7353f0 Merge branch 'develop' into feature/batch-edit 2025-09-02 12:17:57 +02:00
Michael Mayer
f8f94ad814 Merge branch 'develop' into feature/batch-edit 2025-09-02 11:02:20 +02:00
Michael Mayer
b7c6ab1c90 Merge branch 'develop' into feature/batch-edit 2025-09-01 18:18:50 +02:00
Michael Mayer
b5fdbf7c5e Merge branch 'develop' into feature/batch-edit 2025-09-01 16:03:53 +02:00
Michael Mayer
44a2d902c2 Merge branch 'develop' into feature/batch-edit 2025-09-01 14:13:31 +02:00
Michael Mayer
1c8450d94c Merge branch 'develop' into feature/batch-edit 2025-09-01 13:07:45 +02:00
Michael Mayer
406ef8b645 Merge branch 'develop' into feature/batch-edit 2025-09-01 12:28:48 +02:00
Michael Mayer
14241c4d25 Merge branch 'develop' into feature/batch-edit 2025-08-28 19:36:21 +02:00
Michael Mayer
fc781b5647 Merge branch 'develop' into feature/batch-edit 2025-08-28 19:29:38 +02:00
Michael Mayer
01cd1c32c9 Merge branch 'develop' into feature/batch-edit 2025-08-28 19:13:23 +02:00
Michael Mayer
b096361d51 Merge branch 'develop' into feature/batch-edit 2025-08-27 10:32:13 +02:00
Ömer Duran
b0b03ef631 Batch Edit: Sync photo selection state between fullscreen viewer and batch edit sidebar 2025-08-26 23:08:36 +03:00
Michael Mayer
55fbb474bb Merge branch 'develop' into feature/batch-edit 2025-08-26 08:58:51 +02:00
Ömer Duran
9160bb2fea Batch Edit: Enhance save button with loading state and disable functionality during save operation 2025-08-25 21:12:50 +03:00
graciousgrey
39391e94a2 Tests: Add tests #271 2025-08-25 17:38:18 +02:00
graciousgrey
c05e892ad4 Tests: Add tests #271 2025-08-25 17:09:09 +02:00
Ömer Duran
72fa563edc Batch Edit: Change logic todelete or block labels based on source and uncertainty levels 2025-08-25 14:58:14 +03:00
Ömer Duran
e3e624b4b4 Batch Edit: Check for unsaved changes before closing. Refactor save method to handle async operations and notify users of success or failure. 2025-08-23 10:06:07 +03:00
Ömer Duran
53703c5eb2 Batch Edit: move batch logic to internal/photoprism/batch; refactor API 2025-08-23 09:12:52 +03:00
Michael Mayer
5e6c5d4b9f Merge branch 'develop' into feature/batch-edit 2025-08-22 15:28:49 +02:00
Michael Mayer
b0b74db258 Merge branch 'develop' into feature/batch-edit 2025-08-22 15:08:40 +02:00
graciousgrey
7380244f6d Tests: Add tests #271 2025-08-22 14:57:10 +02:00
graciousgrey
f04a84fd40 Tests: Skip test until related function is refactored #271 2025-08-22 12:23:01 +02:00
graciousgrey
0ad7ed4021 Tests: Adapt tests to updated fixtures #271 2025-08-22 12:21:51 +02:00
Michael Mayer
41ecea6360 Merge branch 'develop' into feature/batch-edit 2025-08-22 11:02:49 +02:00
Ömer Duran
f0667dab83 Merge branch 'feature/batch-edit' of https://github.com/photoprism/photoprism into feature/batch-edit 2025-08-21 20:18:31 +03:00
Michael Mayer
c9e7fc51b6 Merge branch 'develop' into feature/batch-edit 2025-08-21 11:58:27 +02:00
Ömer Duran
3c5eaeb1df Batch Edit: Refresh available label options 2025-08-21 11:45:12 +03:00
Ömer Duran
bd4f8a2582 Labels: Standardize label editing across dialogs for consistent source/probability handling and actions 2025-08-20 11:35:15 +03:00
Ömer Duran
c9e306274c Batch Edit: Refactor batch photo editing logic and introduce new batch form handling without import cycle 2025-08-19 17:40:00 +03:00
Michael Mayer
d1f9f681c8 Merge branch 'develop' into feature/batch-edit 2025-08-19 09:58:55 +02:00
Ömer Duran
4f86c04ebe Batch Edit: Update label removal logic 2025-08-18 09:46:21 +03:00
Michael Mayer
ecb9d6c9dc Merge branch 'develop' into feature/batch-edit 2025-08-16 16:47:40 +02:00
Michael Mayer
9e747aa72d Merge branch 'develop' into feature/batch-edit 2025-08-15 22:47:53 +02:00
graciousgrey
3056623934 Tests: Adapt acceptance tests to changes #271 2025-08-14 11:30:16 +02:00
Michael Mayer
0b74d21e58 Merge branch 'develop' into feature/batch-edit 2025-08-13 16:00:03 +02:00
graciousgrey
3ef0f1a646 Tests: Add fixtures and update tests #271 2025-08-12 15:13:28 +02:00
Michael Mayer
6eb85d9419 Merge branch 'develop' into feature/batch-edit 2025-08-11 18:11:53 +02:00
Michael Mayer
3996c165d1 Merge branch 'develop' into feature/batch-edit 2025-08-11 18:10:28 +02:00
Ömer Duran
b6d3af9c58 Backend: Update Location logic to support batch source in SavePhotoForm 2025-08-11 16:12:59 +03:00
Ömer Duran
0ea64c68ea Batch Edit: Refactor getData method to avoid duplicates 2025-08-11 14:30:11 +03:00
Ömer Duran
9fc72f3178 Backend: Photo date field updates to exclude batch source 2025-08-11 13:48:20 +03:00
Ömer Duran
5d5ed13cf5 Batch Edit: Updating label and album methods 2025-08-11 12:48:06 +03:00
Michael Mayer
40b5b938f5 Merge branch 'develop' into feature/batch-edit 2025-08-10 17:24:07 +02:00
Michael Mayer
28ed71988f Merge branch 'develop' into feature/batch-edit 2025-08-10 17:14:07 +02:00
Michael Mayer
fde47ebd3a Merge branch 'develop' into feature/batch-edit 2025-08-08 19:26:37 +02:00
Ömer Duran
6f6d985848 Batch Edit: refresh form after apply 2025-08-08 12:55:24 +03:00
Ömer Duran
0d10020eba Batch Edit: Implement fetching and updating of albums and labels in batch processing 2025-08-08 12:18:41 +03:00
Michael Mayer
12ed1ab507 Merge branch 'develop' into feature/batch-edit 2025-08-06 20:13:20 +02:00
Michael Mayer
e5cd2f1ceb Merge branch 'develop' into feature/batch-edit 2025-08-06 10:00:11 +02:00
Ömer Duran
95dc2e9a10 Batch Edit: Update source tracking for photo fields to use SrcBatch instead of SrcManual 2025-08-05 19:02:22 +03:00
graciousgrey
bbd7759d3b Tests: Add fixtures and update tests #271 2025-08-05 15:16:46 +02:00
Michael Mayer
51f877aef2 Merge branch 'develop' into feature/batch-edit 2025-08-05 12:47:23 +02:00
Michael Mayer
b8cd22d14b Merge branch 'develop' into feature/batch-edit 2025-08-05 10:10:04 +02:00
Michael Mayer
8e91f077b8 Merge branch 'develop' into feature/batch-edit 2025-08-04 13:25:07 +02:00
Ömer Duran
cdb13c9711 Backend: Implement batch photo proof of concept metadata updates 2025-08-04 07:14:27 +03:00
Michael Mayer
3a07e7049f Merge branch 'develop' into feature/batch-edit 2025-08-02 11:10:18 +02:00
Michael Mayer
8a82b10d23 Merge branch 'develop' into feature/batch-edit 2025-08-02 10:48:00 +02:00
Michael Mayer
3d030601ea Merge branch 'develop' into feature/batch-edit 2025-08-02 10:20:03 +02:00
Ömer Duran
423d232bf2 Frontend: Refactor location input component placement 2025-07-30 11:38:35 +03:00
Ömer Duran
939e912f2d Frontend: Add readonly state to country field and update country on location change 2025-07-30 11:20:59 +03:00
Ömer Duran
b83dbf1cb5 Frontend: Move mixed to the middle 2025-07-30 10:43:02 +03:00
Ömer Duran
31916ef7ae Frontend: Replace "<mixed>" with "mixed" in batch dialog options 2025-07-30 10:33:39 +03:00
Ömer Duran
583777f065 Frontend: Remove outlined variant from v-combobox and title prop from ChipSelector 2025-07-29 14:10:21 +03:00
Ömer Duran
44d2a6e771 Frontend: Clear input and restore placeholder after selection 2025-07-29 13:52:33 +03:00
Ömer Duran
4c16b8d9c5 Merge branch 'feature/batch-edit' of https://github.com/photoprism/photoprism into feature/batch-edit 2025-07-29 13:37:51 +03:00
Ömer Duran
8f417febb4 Frontend: Remove title prop from BatchChipSelector and add section headers for Albums and Labels 2025-07-29 13:37:38 +03:00
Michael Mayer
fca60eb816 Merge branch 'develop' into feature/batch-edit 2025-07-29 12:11:24 +02:00
Ömer Duran
0b5f9a783f Frontend: Update batch edit to use photo ID instead of UID for selected photos 2025-07-25 16:31:44 +03:00
Ömer Duran
f2b2697da0 Frontend: Remove unused variable 2025-07-25 14:34:40 +03:00
Ömer Duran
5813e8dbba Merge branch 'feature/batch-edit' of https://github.com/photoprism/photoprism into feature/batch-edit 2025-07-25 14:33:06 +03:00
Ömer Duran
00bf7ad680 Frontend: Fix batch edit save functionality to use currently selected photo UIDs 2025-07-25 14:33:04 +03:00
Ömer Duran
b9a1282f8e Frontend: Fix batch edit save functionality to use currently selected photo UIDs 2025-07-25 14:31:23 +03:00
Ömer Duran
6e8575840e Frontend: Fix action handling, delete field display, and undo functionality for position changes 2025-07-25 01:45:14 +03:00
Michael Mayer
18799b1481 Merge branch 'develop' into feature/batch-edit 2025-07-24 15:46:57 +02:00
Michael Mayer
bdec8e25ce Merge branch 'develop' into feature/batch-edit 2025-07-24 15:40:37 +02:00
Michael Mayer
2e229d8a98 Merge branch 'develop' into feature/batch-edit 2025-07-24 14:55:50 +02:00
Michael Mayer
9c24a30126 Merge branch 'develop' into feature/batch-edit 2025-07-24 13:07:30 +02:00
Ömer Duran
204c944804 Frontend: Remove options text in batch edit dialog 2025-07-24 01:02:41 +03:00
Ömer Duran
61f85604c6 Frontend: Refactor chip-selector component for improved item handling and placeholder logic 2025-07-24 00:59:44 +03:00
Ömer Duran
d23c3b10a1 Frontend: Enhance chip-selector component with tooltips and improved item removal logic 2025-07-23 20:36:06 +03:00
Ömer Duran
3656cefb23 Frontend: Update chip-selector component to use persistent placeholder and clear label 2025-07-23 20:13:04 +03:00
Michael Mayer
811d359343 Merge branch 'develop' into feature/batch-edit 2025-07-23 02:33:08 +02:00
Michael Mayer
7ba6171ff9 Merge branch 'develop' into feature/batch-edit 2025-07-22 22:09:39 +02:00
Michael Mayer
1809c12d9d Merge branch 'develop' into feature/batch-edit 2025-07-21 12:22:42 +02:00
Michael Mayer
db16a9d546 Merge branch 'develop' into feature/batch-edit 2025-07-21 10:51:25 +02:00
Ömer Duran
910aac9545 Frontend: Update batch edit logic to ensure actions are only updated when changes occur; update chip styles for better theming 2025-07-20 19:19:34 +03:00
Ömer Duran
822dc87d8e Frontend: Update computedInputLabel to return an empty string if no label is provided 2025-07-18 10:39:07 +03:00
Ömer Duran
58ce717004 Frontend: Remove input labels for album and label name in batch edit 2025-07-18 10:37:40 +03:00
Ömer Duran
718ed254b8 Frontend: Delete useless comments 2025-07-18 10:19:59 +03:00
Ömer Duran
f96b49d5a3 Frontend: Add reusable ChipSelector component and integrate into batch edit 2025-07-18 10:12:45 +03:00
Michael Mayer
9f11c2c773 Merge branch 'develop' into feature/batch-edit 2025-07-17 12:27:22 +02:00
Michael Mayer
96cf07dd94 Merge branch 'develop' into feature/batch-edit 2025-07-17 12:15:21 +02:00
Michael Mayer
c71c1db568 Merge branch 'develop' into feature/batch-edit 2025-07-17 12:03:55 +02:00
Michael Mayer
61c34713d8 Merge branch 'develop' into feature/batch-edit 2025-07-16 18:35:53 +02:00
Michael Mayer
ae0162c8d8 Merge branch 'develop' into feature/batch-edit 2025-07-16 18:29:52 +02:00
Michael Mayer
4fccb0ff6c Merge branch 'develop' into feature/batch-edit 2025-07-16 14:59:24 +02:00
Ömer Duran
3561f3fcc9 Frontend: Update form data handling for date and type fields in batch edit component 2025-07-16 09:51:59 +03:00
Ömer Duran
458ac57ce3 Frontend: Simplify checkbox event handling in batch edit component 2025-07-16 08:58:08 +03:00
Ömer Duran
49ab48a5a1 Frontend: Update label for title field in photo details 2025-07-16 01:43:07 +03:00
Michael Mayer
4341bb2fb4 Merge branch 'develop' into feature/batch-edit 2025-07-15 18:03:43 +02:00
Michael Mayer
cd3be44a3c Merge branch 'develop' into feature/batch-edit 2025-07-15 12:52:26 +02:00
Michael Mayer
f1b59fd01c Merge branch 'develop' into feature/batch-edit 2025-07-15 11:23:28 +02:00
Michael Mayer
889fc20fc1 Merge branch 'develop' into feature/batch-edit 2025-07-15 10:25:32 +02:00
Ömer Duran
45914d1eac Frontend: Improve location input component with undo/delete functionality 2025-07-15 07:44:56 +03:00
Ömer Duran
c26573eb5b Frontend: Adjust column layout for boolean flags in batch edit component 2025-07-15 06:35:11 +03:00
Ömer Duran
9a19a71551 Merge branch 'feature/batch-edit' of https://github.com/photoprism/photoprism into feature/batch-edit 2025-07-15 06:20:41 +03:00
Ömer Duran
d553508449 Frontend: Update click handling for photo selection in batch edit and enhance cursor style for thumbnails 2025-07-15 06:20:19 +03:00
Michael Mayer
3f2022f693 Merge branch 'develop' into feature/batch-edit 2025-07-15 05:13:55 +02:00
Ömer Duran
e98889b09e Frontend: Simplify label for photo title input in details form 2025-07-15 06:07:00 +03:00
Ömer Duran
04630f25f1 Frontend: Better select-field handling in batch edit, update Type field logic 2025-07-15 05:56:39 +03:00
Michael Mayer
26a80227b9 Merge branch 'develop' into feature/batch-edit 2025-07-14 23:36:05 +02:00
Michael Mayer
e75bb20027 Merge branch 'develop' into feature/batch-edit 2025-07-14 20:12:02 +02:00
Michael Mayer
b24631244c Merge branch 'develop' into feature/batch-edit 2025-07-14 19:32:59 +02:00
Michael Mayer
b6b260fa36 Merge branch 'develop' into feature/batch-edit 2025-07-14 19:30:46 +02:00
Michael Mayer
1269668edd Merge branch 'develop' into feature/batch-edit 2025-07-14 11:14:20 +02:00
Michael Mayer
829bc180a9 Merge branch 'develop' into feature/batch-edit 2025-07-14 10:42:47 +02:00
Michael Mayer
d14097f335 Merge branch 'develop' into feature/batch-edit 2025-07-14 09:33:45 +02:00
Michael Mayer
ba67081c56 Merge branch 'develop' into feature/batch-edit 2025-07-14 09:27:25 +02:00
Ömer Duran
b3d839de49 Frontend: Adjust column width in batch editing component 2025-07-14 08:57:16 +02:00
Ömer Duran
acdfde36c8 Frontend: Wrapping form controls in v-row 2025-07-14 08:55:29 +02:00
Ömer Duran
b0781b6451 Frontend: Improving toggle button layout 2025-07-14 08:50:24 +02:00
Ömer Duran
76eaaa812c Frontend: Refactor batch editing component to use v-data-table for better performance and usability in both desktop and mobile views 2025-07-14 08:30:21 +02:00
Ömer Duran
2996d64600 Frontend: Improve batch editing functionality by adding support for albums and labels selection, updated form data structure #271 2025-07-14 02:48:59 +02:00
Michael Mayer
b8469d6ced Merge branch 'develop' into feature/batch-edit 2025-07-11 16:31:17 +02:00
Michael Mayer
2b85d47d3b Merge branch 'develop' into feature/batch-edit 2025-07-11 12:20:18 +02:00
Michael Mayer
2b511e87ca Merge branch 'develop' into feature/batch-edit 2025-07-11 06:02:02 +02:00
Michael Mayer
de2e322a4e Merge branch 'develop' into feature/batch-edit 2025-07-11 03:34:13 +02:00
Ömer Duran
11d0cc3d26 Merge branch 'feature/batch-edit' of https://github.com/photoprism/photoprism into feature/batch-edit 2025-07-10 18:05:19 +02:00
Ömer Duran
d61dd9c16e Places: Added location input and dialog components for better location selection. 2025-07-10 18:05:11 +02:00
Michael Mayer
1f53458111 Merge branch 'develop' into feature/batch-edit 2025-07-10 16:50:08 +02:00
Michael Mayer
66ae99e486 Merge branch 'develop' into feature/batch-edit 2025-07-10 11:16:46 +02:00
Michael Mayer
1469b5ae78 Merge branch 'develop' into feature/batch-edit 2025-07-10 11:08:42 +02:00
Michael Mayer
0d6fba0f62 Merge branch 'develop' into feature/batch-edit 2025-07-10 10:29:00 +02:00
Michael Mayer
05113217e4 Merge branch 'develop' into feature/batch-edit 2025-07-10 10:18:13 +02:00
Michael Mayer
2531616b53 Merge branch 'develop' into feature/batch-edit 2025-07-10 09:39:07 +02:00
Michael Mayer
a2880be47d Merge branch 'develop' into feature/batch-edit 2025-07-10 09:06:23 +02:00
Michael Mayer
5bfa14702b Merge branch 'develop' into feature/batch-edit 2025-07-09 16:53:04 +02:00
Michael Mayer
ed005962bd Merge branch 'develop' into feature/batch-edit 2025-07-09 15:35:25 +02:00
Michael Mayer
940533c59e Merge branch 'develop' into feature/batch-edit 2025-07-09 13:31:02 +02:00
Michael Mayer
c6cdcb6b1f Merge branch 'develop' into feature/batch-edit 2025-07-08 10:41:28 +02:00
Anastasiia
2a044832be Frontend: unblock Apply btn and send request with error #271" 2025-07-08 09:51:22 +02:00
Michael Mayer
f06c65c217 Merge branch 'develop' into feature/batch-edit 2025-07-07 12:45:51 +02:00
Michael Mayer
ca9bac63e6 Merge branch 'develop' into feature/batch-edit 2025-07-07 11:15:30 +02:00
Michael Mayer
2a4627cc47 Merge branch 'develop' into feature/batch-edit 2025-07-06 14:07:02 +02:00
Michael Mayer
efa57f417c Merge branch 'develop' into feature/batch-edit 2025-07-06 11:33:47 +02:00
Michael Mayer
96356caaa6 Merge branch 'develop' into feature/batch-edit 2025-07-04 10:58:43 +02:00
Michael Mayer
568fd0d62a Merge branch 'develop' into feature/batch-edit 2025-07-03 20:45:47 +02:00
Michael Mayer
5b139b5c9e Merge branch 'develop' into feature/batch-edit 2025-07-03 19:47:05 +02:00
Michael Mayer
f57792511e Merge branch 'develop' into feature/batch-edit 2025-07-03 12:58:57 +02:00
Michael Mayer
5c57d23990 Merge branch 'develop' into feature/batch-edit 2025-07-02 20:45:52 +02:00
Michael Mayer
aba5faa870 CSS: Move embedded map styles to css/places.css #465 #5080 #5082
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-07-02 12:21:40 +02:00
Michael Mayer
915e9b4b6c Merge branch 'develop' into feature/batch-edit 2025-07-02 12:10:53 +02:00
Anastasiia
70665e6c24 Frontend: add new select-field Type #271" 2025-07-01 17:12:57 +02:00
Anastasiia
a62fb24d8d Frontend: add action logic for toggle fields #271" 2025-06-24 16:25:09 +02:00
Anastasiia
efcfafd5b1 Frontend: fix select-field actions #271" 2025-06-24 10:21:37 +02:00
Anastasiia
57f08d5b30 Frontend: add setting action values for text and input fields #271" 2025-06-23 20:18:20 +02:00
Anastasiia
2b77e5f929 Frontend: fix the name of image in the images list #271" 2025-06-17 12:26:10 +02:00
Anastasiia
402f674618 Frontend: fix mobile images list view #271" 2025-06-17 12:13:04 +02:00
Anastasiia
92440bcb75 Frontend: fix mobile images list view #271" 2025-06-17 12:06:26 +02:00
Anastasiia
e025e5a077 Frontend: fix white border for dark theme #271" 2025-06-16 17:58:42 +02:00
Anastasiia
011b3c0c77 Frontend: change logic for icons, fix batch edit model #271" 2025-06-16 17:32:03 +02:00
Anastasiia
0227d495e7 Frontend: add delete/undo button when input-field has value #271" 2025-05-28 16:30:19 +02:00
Anastasiia
7d74d58a98 Frontend: add toggle-fields functionality #271" 2025-05-27 23:10:59 +02:00
Anastasiia
a2e9eae388 Frontend: add delete/undo buttons functionality for input-fields #271" 2025-05-27 20:47:07 +02:00
Anastasiia
78143a08ee Frontend: add delete/undo buttons functionality for text-fields #271" 2025-05-27 14:54:03 +02:00
Anastasiia
15b6a3331d Frontend: add the placeholder/value checking for select-fields #271" 2025-05-19 21:02:27 +02:00
Michael Mayer
d691f59594 Merge branch 'develop' into feature/batch-edit 2025-05-15 16:37:46 +02:00
Anastasiia
d01823d130 Frontend: add the placeholder/value checking for input-fields #271" 2025-05-15 13:39:49 +02:00
Anastasiia
6240d2e31e Frontend: add the placeholder/value checking #271" 2025-05-14 22:19:16 +02:00
Anastasiia
91d65a9b07 Merge remote-tracking branch 'origin/feature/batch-edit' into feature/batch-edit 2025-05-13 19:38:47 +02:00
Anastasiia
9230b585c1 Frontend: fix gettext in details.vue #271" 2025-05-13 19:38:30 +02:00
Anastasiia
86bf05293b Frontend: add toggle functionality, fix title, add form logic #271" 2025-05-13 19:38:10 +02:00
Michael Mayer
f840efd90e Merge branch 'develop' into feature/batch-edit 2025-05-13 14:37:29 +02:00
Michael Mayer
e17bd03a14 Merge branch 'develop' into feature/batch-edit 2025-05-13 11:34:35 +02:00
Michael Mayer
1b87d57442 Merge branch 'develop' into feature/batch-edit 2025-05-13 10:25:19 +02:00
Anastasiia
ff8a5308d2 Frontend: add getting data from the request with images #271" 2025-05-09 23:32:53 +02:00
graciousgrey
832ae90652 Frontend: Open thumb fromPhotos on the batch edit dialog #271 2025-05-09 12:17:54 +02:00
Michael Mayer
bf2ece0023 Merge branch 'develop' into feature/batch-edit 2025-05-08 01:39:21 +02:00
Michael Mayer
9c16aa6227 Merge branch 'develop' into feature/batch-edit 2025-05-08 01:22:52 +02:00
Michael Mayer
e0034ec1b0 Merge branch 'develop' into feature/batch-edit 2025-05-06 16:24:20 +02:00
Michael Mayer
de9ee0a04d Merge branch 'develop' into feature/batch-edit 2025-05-05 09:26:22 +02:00
Michael Mayer
d44233d811 Merge branch 'develop' into feature/batch-edit 2025-05-05 09:08:29 +02:00
Michael Mayer
a9d87aa8c8 Merge branch 'develop' into feature/batch-edit 2025-05-04 16:00:33 +02:00
Michael Mayer
6462ddc628 Merge branch 'develop' into feature/batch-edit 2025-05-04 15:29:54 +02:00
Michael Mayer
576d6c1201 Merge branch 'develop' into feature/batch-edit 2025-05-04 14:34:37 +02:00
Michael Mayer
6e999eb2cc Merge branch 'develop' into feature/batch-edit 2025-05-04 14:09:48 +02:00
Michael Mayer
8264cd090f Merge branch 'develop' into feature/batch-edit 2025-05-03 12:44:10 +02:00
Anastasiia
275e6b62ea Frontend: add search request to load selected photos #271" 2025-04-29 21:57:26 +02:00
Anastasiia
1d9dbbef2f Frontend: add max height to images on mobile, optimize layout #271" 2025-04-28 18:47:11 +02:00
Anastasiia
b64202aefd Frontend: photo viewer hide edit button #271" 2025-04-25 18:11:53 +02:00
Anastasiia
4b8191a308 Frontend: Fix dialog width, icons position, mobile view, renamed label and add two scroll #271" 2025-04-25 16:45:07 +02:00
Anastasiia
1ec4adf44f Frontend: Center the checkbox of the selected photos in the column #271" 2025-04-25 16:41:04 +02:00
Anastasiia
9c0a5d4101 Merge develop into feature/batch-edit #271" 2025-04-24 16:37:44 +02:00
Anastasiia
97ad4710b4 Frontend: Add expansion panel for the photos list on mobile view #271" 2025-04-14 22:31:13 +02:00
Anastasiia
2672f23bbf Frontend: add opening photo viewer when click to photo #271" 2025-04-14 19:47:59 +02:00
Anastasiia
19cf3e4b38 Frontend: add left side form view with mocked data 2025-04-08 22:59:23 +02:00
Anastasiia
f4e94f4e9f Frontend: add right side form view 2025-04-07 23:03:56 +02:00
Anastasiia
159d097383 Frontend: add new edit batch dialog logic 2025-04-03 17:37:27 +02:00
211 changed files with 11011 additions and 1040 deletions

View File

@@ -1,5 +1,7 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** September 26, 2025
## Purpose
This file tells automated coding agents (and humans) where to find the single sources of truth for building, testing, and contributing to PhotoPrism.
@@ -157,9 +159,19 @@ Note: Across our public documentation, official images, and in production, the c
- Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets)
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
- Every Go package must contain a `<package>.go` file in its root (for example, `internal/auth/jwt/jwt.go`) with the standard license header and a short package description comment explaining its purpose.
- JS/Vue: use the lint/format scripts in `frontend/package.json` (ESLint + Prettier)
- All added code and tests **must** be formatted according to our standards.
> Remember to update the `**Last Updated:**` line at the top whenever you edit these guidelines or other files containing a timestamp.
## Safety & Data
- Never commit secrets, local configurations, or cache files. Use environment variables or a local `.env`.
- Ensure `.env` and `.local` are ignored in `.gitignore` and `.dockerignore`.
- Prefer using existing caches, workers, and batching strategies referenced in code and `Makefile`. Consider memory/CPU impact; suggest benchmarks or profiling only when justified.
- Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures when running acceptance tests.
### Filesystem Permissions & io/fs Aliasing (Go)
- Always use our shared permission variables from `pkg/fs` when creating files/directories:
@@ -173,13 +185,7 @@ Note: Across our public documentation, official images, and in production, the c
- Our package is `github.com/photoprism/photoprism/pkg/fs` and provides the only approved permission constants for `os.MkdirAll`, `os.WriteFile`, `os.OpenFile`, and `os.Chmod`.
- Prefer `filepath.Join` for filesystem paths; reserve `path.Join` for URL paths.
## Safety & Data
- Never commit secrets, local configurations, or cache files. Use environment variables or a local `.env`.
- Ensure `.env` and `.local` are ignored in `.gitignore` and `.dockerignore`.
- Prefer using existing caches, workers, and batching strategies referenced in code and `Makefile`. Consider memory/CPU impact; suggest benchmarks or profiling only when justified.
- Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures when running acceptance tests.
- ### File I/O — Overwrite Policy (force semantics)
### File I/O — Overwrite Policy (force semantics)
- Default is safety-first: callers must not overwrite non-empty destination files unless they opt-in with a `force` flag.
- Replacing empty destination files is allowed without `force=true` (useful for placeholder files).
@@ -191,7 +197,7 @@ Note: Across our public documentation, official images, and in production, the c
- Explicit “replace” actions or admin tools where the user confirmed overwrite.
- Not for import/index flows; Originals must not be clobbered.
- ### Archive Extraction — Security Checklist
### Archive Extraction — Security Checklist
- Always validate ZIP entry names with a safe join; reject:
- absolute paths (e.g., `/etc/passwd`).
@@ -225,11 +231,8 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
## Agent Quick Tips (Do This)
### NextSession Priorities
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
- Consider surfacing a short “uuid → db/user” mapping helper in the CLI (e.g., `nodes show --creds`) if operators request it.
### Testing & Fixtures
- Go tests live next to their sources (`path/to/pkg/<file>_test.go`); group related cases as `t.Run(...)` sub-tests to keep table-driven coverage readable.
- Prefer focused `go test` runs for speed (`go test ./internal/<pkg> -run <Name> -count=1`, `go test ./internal/commands -run <Name> -count=1`) and avoid `./...` unless you need the entire suite.
- Heavy packages such as `internal/entity` and `internal/photoprism` run migrations and fixtures; expect 30120s on first run and narrow with `-run` to keep iterations low.
@@ -237,12 +240,16 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- In `internal/photoprism` tests, rely on `photoprism.Config()` for runtime-accurate behavior; only build a new config if you replace it via `photoprism.SetConfig`.
- Generate identifiers with `rnd.GenerateUID(entity.ClientUID)` for OAuth client IDs and `rnd.UUIDv7()` for node UUIDs; treat `node.uuid` as required in responses.
- Shared fixtures live under `storage/testdata`; `NewTestConfig("<pkg>")` already calls `InitializeTestData()`, but call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) when you construct custom configs so originals/import/cache/temp exist. `InitializeTestData()` clears old data, downloads fixtures if needed, then calls `CreateDirectories()`.
- For slimmer tests that only need config objects, prefer the new helpers in `internal/config/test.go`: `NewMinimalTestConfig(t.TempDir())` when no database is needed, or `NewMinimalTestConfigWithDb("<pkg>", t.TempDir())` to spin up an isolated SQLite schema without seeding all fixtures.
- When you need illustrative credentials (join tokens, client IDs/secrets, etc.), reuse the shared `Example*` constants (see `internal/service/cluster/examples.go`) so tests, docs, and examples stay consistent.
### Roles & ACL
- Map roles via the shared tables: users through `acl.ParseRole(s)` / `acl.UserRoles[...]`, clients through `acl.ClientRoles[...]`.
- Treat `RoleAliasNone` ("none") and an empty string as `RoleNone`; no caller-specific overrides.
- Default unknown client roles to `RoleClient`; `acl.ParseRole` already handles `0/false/nil` as none for users.
- Build CLI role help from `Roles.CliUsageString()` (e.g., `acl.ClientRoles.CliUsageString()`); never hand-maintain role lists.
- When checking JWT/client scopes, use the shared helpers (`acl.ScopePermits` / `acl.ScopeAttrPermits`) instead of hand-written parsing.
### Import/Index
@@ -250,6 +257,7 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- Mixed roots: when testing related files, keep `ExamplesPath()/ImportPath()/OriginalsPath()` consistent so `RelatedFiles` and `AllowExt` behave as expected.
### CLI Usage & Assertions
- Wrap CLI tests in `RunWithTestContext(cmd, args)` so `urfave/cli` cannot exit the process; assert quoted `show` output with `assert.Contains`/regex for the trailing ", or <last>" rule.
- Prefer `--json` responses for automation. `photoprism show commands --json [--nested]` exposes the tree view (add `--all` for hidden entries).
- Use `internal/commands/catalog` to inspect commands/flags without running the binary; when validating large JSON docs, marshal DTOs via `catalog.BuildFlat/BuildNode` instead of parsing CLI stdout.
@@ -258,6 +266,9 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
### API & Config Changes
- Respect precedence: `options.yml` overrides CLI/env values, which override defaults. When adding a new option, update `internal/config/options.go` (yaml/flag tags), register it in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml` by setting `c.options.OptionsYaml` before persisting. Use `CliTestContext` in `internal/config/test.go` to exercise new flags.
- When touching configuration in Go code, use the public accessors on `*config.Config` (e.g. `Config.JWKSUrl()`, `Config.SetJWKSUrl()`, `Config.ClusterUUID()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures only.
- Logging: use the shared logger (`event.Log`) via the package-level `log` variable (see `internal/auth/jwt/logger.go`) instead of direct `fmt.Print*` or ad-hoc loggers.
- Cluster registry tests (`internal/service/cluster/registry`) currently rely on a full test config because they persist `entity.Client` rows. They run migrations and seed the SQLite DB, so they are intentionally slow. If you refactor them, consider sharing a single `config.TestConfig()` across subtests or building a lightweight schema harness; do not swap to the minimal config helper unless the tests stop touching the database.
- Favor explicit CLI flags: check `c.cliCtx.IsSet("<flag>")` before overriding user-supplied values, and follow the `ClusterUUID` pattern (`options.yml` → CLI/env → generated UUIDv4 persisted).
- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early.
- Handler conventions: reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s, lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers, compare secrets with constant time checks, set `Cache-Control: no-store` on sensitive responses, and register routes in `internal/server/routes.go`. For new list endpoints default `count=100` (max 1000) and `offset≥0`, document parameters explicitly, and set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed.
@@ -338,10 +349,11 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
### Cluster Operations
- Keep bootstrap code decoupled: avoid importing `internal/service/cluster/instance/*` from `internal/config` or the cluster root, let instances talk to the Portal over HTTP(S), and rely on constants from `internal/service/cluster/const.go`.
- Keep bootstrap code decoupled: avoid importing `internal/service/cluster/node/*` from `internal/config` or the cluster root, let nodes talk to the Portal over HTTP(S), and rely on constants from `internal/service/cluster/const.go`.
- Config init order: load `options.yml` (`c.initSettings()`), run `EarlyExt().InitEarly(c)`, connect/register the DB, then invoke `Ext().Init(c)`.
- Theme endpoint: `GET /api/v1/cluster/theme` streams a zip from `conf.ThemePath()`; only reinstall when `app.js` is missing and always use the header helpers in `pkg/service/http/header`.
- Registration flow: send `rotate=true` only for MySQL/MariaDB nodes without credentials, treat 401/403/404 as terminal, include `clientId` + `clientSecret` when renaming an existing node, and persist only newly generated secrets or DB settings.
- Registry & DTOs: use the client-backed registry (`NewClientRegistryWithConfig`)—the file-backed version is legacy—and treat migration as complete only after swapping callsites, building, and running focused API/CLI tests. Nodes are keyed by UUID v7 (`/api/v1/cluster/nodes/{uuid}`), the registry interface stays UUID-first (`Get`, `FindByNodeUUID`, `FindByClientID`, `RotateSecret`, `DeleteAllByUUID`), CLI lookups resolve `uuid → clientId → name`, and DTOs normalize `database.{name,user,driver,rotatedAt}` while exposing `clientSecret` only during creation/rotation. `nodes rm --all-ids` cleans duplicate client rows, admin responses may include `advertiseUrl`/`database`, client/user sessions stay redacted, registry files live under `conf.PortalConfigPath()/nodes/` (mode 0600), and `ClientData` no longer stores `NodeUUID`.
- Provisioner & DSN: database/user names use UUID-based HMACs (`photoprism_d<hmac11>`, `photoprism_u<hmac11>`); `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported.
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
- Testing: exercise Portal endpoints with `httptest`, guard extraction paths with `pkg/fs.Unzip` size caps, and expect admin-only fields to disappear when authenticated as a client/user session.

View File

@@ -1,5 +1,7 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** September 24, 2025
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
- Sources of truth: prefer Makefile targets and the Developer Guide linked in AGENTS.md.
@@ -78,7 +80,7 @@ Database & Migrations
AuthN/Z & Sessions
- Session model and cache: `internal/entity/auth_session*` and `internal/auth/session/*` (cleanup worker).
- ACL: `internal/auth/acl/*` roles, grants, scopes; use constants; avoid logging secrets, compare tokens constanttime.
- ACL: `internal/auth/acl/*` roles, grants, scopes; use constants; avoid logging secrets, compare tokens constanttime; for scope checks use `acl.ScopePermits` / `ScopeAttrPermits` instead of rolling your own parsing.
- OIDC: `internal/auth/oidc/*`.
Media Processing
@@ -92,7 +94,7 @@ Background Workers
Cluster / Portal
- Node types: `internal/service/cluster/const.go` (`cluster.RoleInstance`, `cluster.RolePortal`, `cluster.RoleService`).
- Instance bootstrap & registration: `internal/service/cluster/instance/*` (HTTP to Portal; do not import Portal internals).
- Node bootstrap & registration: `internal/service/cluster/node/*` (HTTP to Portal; do not import Portal internals).
- Registry/provisioner: `internal/service/cluster/registry/*`, `internal/service/cluster/provisioner/*`.
- Theme endpoint (server): GET `/api/v1/cluster/theme`; client/CLI installs theme only if missing or no `app.js`.
- See specs cheat sheet: `specs/portal/README.md`.

View File

@@ -72,15 +72,15 @@ watch: watch-js
build-all: build-go build-js
pull: docker-pull
test: test-js test-go
test-go: reset-sqlite run-test-go
test-pkg: reset-sqlite run-test-pkg
test-ai: reset-sqlite run-test-ai
test-api: reset-sqlite run-test-api
test-video: reset-sqlite run-test-video
test-entity: reset-sqlite run-test-entity
test-commands: reset-sqlite run-test-commands
test-photoprism: reset-sqlite run-test-photoprism
test-short: reset-sqlite run-test-short
test-go: run-test-go
test-pkg: run-test-pkg
test-ai: run-test-ai
test-api: run-test-api
test-video: run-test-video
test-entity: run-test-entity
test-commands: run-test-commands
test-photoprism: run-test-photoprism
test-short: run-test-short
test-mariadb: reset-acceptance run-test-mariadb
acceptance-run-chromium: storage/acceptance acceptance-auth-sqlite-restart wait acceptance-auth acceptance-auth-sqlite-stop acceptance-sqlite-restart wait-2 acceptance acceptance-sqlite-stop
acceptance-run-chromium-short: storage/acceptance acceptance-auth-sqlite-restart wait acceptance-auth-short acceptance-auth-sqlite-stop acceptance-sqlite-restart wait-2 acceptance-short acceptance-sqlite-stop

View File

@@ -118,6 +118,14 @@ export class Clipboard {
return result;
}
toggleAllIds(models) {
const result = models.forEach((model) => {
this.toggle(model);
});
return result;
}
add(model) {
if (!this.isModel(model)) {
return;

View File

@@ -6,8 +6,8 @@ export class Lightbox {
$event.publish("lightbox.open", options);
}
openModels(models, index, album) {
$event.publish("lightbox.open", { models, index, album });
openModels(models, index, album, isBatchDialog) {
$event.publish("lightbox.open", { models, index, album, isBatchDialog });
}
openView(view, index) {

View File

@@ -8,6 +8,11 @@
:tab="edit.tab"
@close="closeEditDialog"
></p-photo-edit-dialog>
<p-photo-edit-batch
:visible="editBatch.visible"
:selection="editBatch.selection"
@close="closeEditBatch"
></p-photo-edit-batch>
<p-upload-dialog
:visible="upload.visible"
:data="upload.data"
@@ -22,6 +27,7 @@
import Album from "model/album";
import PPhotoEditDialog from "component/photo/edit/dialog.vue";
import PPhotoEditBatch from "component/photo/edit/batch.vue";
import PUploadDialog from "component/upload/dialog.vue";
import PUpdate from "component/update.vue";
import PLightbox from "component/lightbox.vue";
@@ -30,6 +36,7 @@ export default {
name: "PDialogs",
components: {
PPhotoEditDialog,
PPhotoEditBatch,
PUploadDialog,
PUpdate,
PLightbox,
@@ -43,6 +50,10 @@ export default {
index: 0,
tab: "",
},
editBatch: {
visible: false,
selection: [],
},
upload: {
visible: false,
data: {},
@@ -57,13 +68,20 @@ export default {
};
},
created() {
// Opens the photo edit dialog.
// Opens the photo edit dialog (when 1 image is selected).
this.subscriptions.push(
this.$event.subscribe("dialog.edit", (ev, data) => {
this.onEdit(data);
})
);
// Opens the photo edit dialog (when more than 1 image are selected).
this.subscriptions.push(
this.$event.subscribe("dialog.editBatch", (ev, data) => {
this.onEditBatch(data);
})
);
// Opens the web upload dialog.
this.subscriptions.push(
this.$event.subscribe("dialog.upload", (ev, data) => {
@@ -101,11 +119,24 @@ export default {
this.edit.tab = data?.tab ? data.tab : "";
this.edit.visible = true;
},
onEditBatch(data) {
if (this.editBatch.visible || !this.hasAuth()) {
return;
}
this.editBatch.selection = data.selection;
this.editBatch.visible = true;
},
closeEditDialog() {
if (this.edit.visible) {
this.edit.visible = false;
}
},
closeEditBatch() {
if (this.editBatch.visible) {
this.editBatch.visible = false;
}
},
onUpload(data) {
if (this.upload.visible || !this.hasAuth() || this.isReadOnly() || !this.$config.feature("upload")) {
return;

View File

@@ -0,0 +1,279 @@
<template>
<div class="chip-selector">
<div v-if="shouldRenderChips" class="chip-selector__chips">
<v-tooltip
v-for="item in processedItems"
:key="item.value || item.title"
:text="getChipTooltip(item)"
location="top"
>
<template #activator="{ props }">
<div
v-bind="props"
:class="getChipClasses(item)"
:aria-pressed="item.selected"
:tabindex="0"
role="button"
@click="handleChipClick(item)"
@keydown.enter="handleChipClick(item)"
@keydown.space.prevent="handleChipClick(item)"
>
<div class="chip__content">
<v-icon v-if="getChipIcon(item)" class="chip__icon">
{{ getChipIcon(item) }}
</v-icon>
<span class="chip__text">{{ item.title }}</span>
</div>
</div>
</template>
</v-tooltip>
<div v-if="processedItems.length === 0 && !showInput" class="chip-selector__empty">
{{ emptyText }}
</div>
</div>
<div v-if="allowCreate" class="chip-selector__input-container">
<v-combobox
ref="inputField"
v-model="newItemTitle"
:placeholder="computedInputPlaceholder"
:persistent-placeholder="true"
:items="availableItems"
item-title="title"
item-value="value"
density="comfortable"
hide-details
hide-no-data
return-object
class="chip-selector__input"
@keydown.enter="addNewItem"
@update:model-value="onComboboxChange"
>
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ $gettext("Press enter to create new item") }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
</div>
</div>
</template>
<script>
export default {
name: "ChipSelector",
props: {
items: {
type: Array,
default: () => [],
},
availableItems: {
type: Array,
default: () => [],
},
allowCreate: {
type: Boolean,
default: true,
},
emptyText: {
type: String,
default: "",
},
inputPlaceholder: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:items"],
data() {
return {
newItemTitle: null,
};
},
computed: {
processedItems() {
return this.items.map((item) => ({
...item,
// Ensure action is always a string, never null/undefined
action: item.action || "none",
selected: item.action === "add" || item.action === "remove",
}));
},
computedInputPlaceholder() {
return this.inputPlaceholder || this.$gettext("Enter item name...");
},
showInput() {
return this.allowCreate;
},
shouldRenderChips() {
// Render chips container only when there are chips
return this.processedItems.length > 0 || !this.showInput;
},
},
methods: {
getChipClasses(item) {
const baseClass = "chip";
const classes = [baseClass];
if (this.loading || this.disabled) {
classes.push(`${baseClass}--loading`);
}
if (item.action === "add") {
classes.push(item.mixed ? `${baseClass}--green-light` : `${baseClass}--green`);
} else if (item.action === "remove") {
classes.push(item.mixed ? `${baseClass}--red-light` : `${baseClass}--red`);
} else if (item.mixed) {
classes.push(`${baseClass}--gray-light`);
} else {
classes.push(`${baseClass}--gray`);
}
return classes;
},
getChipIcon(item) {
if (item.action === "add") return "mdi-plus";
if (item.action === "remove") return "mdi-minus";
if (item.mixed) return "mdi-circle-half-full";
return null;
},
getChipTooltip(item) {
if (item.action === "add") {
return item.mixed ? this.$gettext("Add to all selected photos") : this.$gettext("Add to all");
} else if (item.action === "remove") {
return item.mixed ? this.$gettext("Remove from all selected photos") : this.$gettext("Remove from all");
} else if (item.mixed) {
return this.$gettext("Part of some selected photos");
}
return this.$gettext("Part of all selected photos");
},
handleChipClick(item) {
if (this.loading || this.disabled) return;
let newAction;
if (item.mixed) {
// Handle mixed state cycling
switch (item.action) {
case "none":
newAction = "add";
break;
case "add":
newAction = "remove";
break;
case "remove":
newAction = "none";
break;
}
} else {
// Handle normal state cycling
if (item.isNew) {
newAction = item.action === "add" ? "remove" : "add";
} else {
newAction = item.action === "remove" ? "none" : "remove";
}
}
this.updateItemAction(item, newAction);
},
updateItemAction(itemToUpdate, action) {
// Special case: remove new items completely
if (itemToUpdate.isNew && action === "remove") {
const updatedItems = this.items.filter(
(item) => (item.value || item.title) !== (itemToUpdate.value || itemToUpdate.title)
);
this.$emit("update:items", updatedItems);
return;
}
// Update action for existing item
const updatedItems = this.items.map((item) =>
(item.value || item.title) === (itemToUpdate.value || itemToUpdate.title) ? { ...item, action } : item
);
this.$emit("update:items", updatedItems);
},
onComboboxChange(value) {
if (value && typeof value === "object" && value.title) {
this.newItemTitle = value;
this.addNewItem();
// Immediately clear the input, remove focus and restore placeholder
this.$nextTick(() => {
this.newItemTitle = "";
if (this.$refs.inputField) {
this.$refs.inputField.blur();
// Force the combobox to reset completely
setTimeout(() => {
this.newItemTitle = null;
}, 10);
}
});
} else {
this.newItemTitle = value;
}
},
addNewItem() {
// Extract title and value from input
let title, value;
if (typeof this.newItemTitle === "string") {
title = this.newItemTitle.trim();
value = "";
} else if (this.newItemTitle && typeof this.newItemTitle === "object") {
title = this.newItemTitle.title;
value = this.newItemTitle.value;
} else {
return;
}
if (!title) return;
const existingItem = this.items.find(
(item) => item.title.toLowerCase() === title.toLowerCase() || (item.value && item.value === value)
);
if (existingItem) {
// Item already exists, skip adding
return;
}
const newItem = {
value: value || "",
title,
mixed: false,
action: "add",
isNew: true,
};
this.$emit("update:items", [...this.items, newItem]);
this.newItemTitle = null;
// Refocus input field
this.$nextTick(() => {
if (this.$refs.inputField) {
this.$refs.inputField.focus();
}
});
},
},
};
</script>
<style src="../../css/chip-selector.css"></style>

View File

@@ -169,6 +169,7 @@ export default {
model: new Thumb(), // Current slide.
models: [], // Slide models.
index: 0, // Current slide index in models.
isBatchDialog: false,
subscriptions: [], // Event subscriptions.
// Video properties for rendering the controls.
video: {
@@ -231,6 +232,8 @@ export default {
return;
}
this.isBatchDialog = !!data.isBatchDialog;
if (data.view) {
this.showView(data.view, data.index);
} else {
@@ -1242,7 +1245,7 @@ export default {
});
// Add edit button control if user has permission to use it.
if (this.canEdit) {
if (this.canEdit && !this.isBatchDialog) {
lightbox.pswp.ui.registerElement({
name: "edit-button",
className: "pswp__button--edit-button pswp__button--mdi hidden-shared-only", // Sets the icon style/size in lightbox.css.
@@ -1456,6 +1459,7 @@ export default {
onReset() {
this.resetControls();
this.resetModels();
this.isBatchDialog = false;
},
// Resets the state of the lightbox controls.
resetControls() {

View File

@@ -5,6 +5,7 @@
:hide-details="hideDetails"
:label="label"
:placeholder="placeholder"
:persistent-placeholder="persistentPlaceholder"
:density="density"
:validate-on="validateOn"
:rules="[() => !coordinateInput || isValidCoordinateInput]"
@@ -30,8 +31,16 @@
<v-icon v-else variant="plain" :icon="icon" class="text-disabled"> </v-icon>
</template>
<template #append-inner>
<v-icon v-if="isDeleted" variant="plain" icon="mdi-undo" class="action-undo" @click.stop="$emit('undo')"></v-icon>
<v-icon
v-if="showUndoButton"
v-else-if="isMixed"
variant="plain"
icon="mdi-delete"
class="action-delete"
@click.stop="$emit('delete')"
></v-icon>
<v-icon
v-else-if="showUndoButton"
variant="plain"
icon="mdi-undo"
class="action-undo"
@@ -40,8 +49,8 @@
<v-icon
v-else-if="coordinateInput"
variant="plain"
icon="mdi-close-circle"
class="action-clear"
icon="mdi-delete"
class="action-delete"
@click.stop="clearCoordinates"
></v-icon>
</template>
@@ -52,6 +61,14 @@
export default {
name: "PLocationInput",
props: {
isMixed: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
latlng: {
type: Array,
default: () => [null, null],
@@ -73,6 +90,10 @@ export default {
type: String,
default: "37.75267, -122.543",
},
persistentPlaceholder: {
type: Boolean,
default: false,
},
density: {
type: String,
default: "comfortable",
@@ -99,7 +120,7 @@ export default {
},
enableUndo: {
type: Boolean,
default: false,
default: true,
},
autoApply: {
type: Boolean,
@@ -110,7 +131,7 @@ export default {
default: 1000,
},
},
emits: ["update:latlng", "changed", "cleared", "open-map"],
emits: ["update:latlng", "changed", "cleared", "open-map", "delete", "undo"],
data() {
return {
coordinateInput: "",

View File

@@ -413,7 +413,11 @@ export default {
},
edit() {
// Open Edit Dialog
this.$event.PubSub.publish("dialog.edit", { selection: this.selection, album: this.album, index: 0 });
if (this.selection.length == 1) {
this.$event.PubSub.publish("dialog.edit", { selection: this.selection, album: this.album, index: 0 });
} else {
this.$event.PubSub.publish("dialog.editBatch", { selection: this.selection, album: this.album, index: 0 });
}
},
onShared() {
this.dialog.share = false;

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@
</v-row>
<v-row dense>
<v-col cols="4" lg="2">
<v-combobox
<v-autocomplete
:model-value="view.model.Day > 0 ? view.model.Day : null"
:disabled="disabled"
:error="invalidDate"
@@ -70,10 +70,10 @@
class="input-day"
@update:model-value="setDay"
>
</v-combobox>
</v-autocomplete>
</v-col>
<v-col cols="4" lg="2">
<v-combobox
<v-autocomplete
:model-value="view.model.Month > 0 ? view.model.Month : null"
:disabled="disabled"
:error="invalidDate"
@@ -91,10 +91,10 @@
class="input-month"
@update:model-value="setMonth"
>
</v-combobox>
</v-autocomplete>
</v-col>
<v-col cols="4" lg="2">
<v-combobox
<v-autocomplete
:model-value="view.model.Year > 0 ? view.model.Year : null"
:disabled="disabled"
:error="invalidDate"
@@ -112,7 +112,7 @@
class="input-year"
@update:model-value="setYear"
>
</v-combobox>
</v-autocomplete>
</v-col>
<v-col cols="6" lg="2">
<v-text-field
@@ -490,36 +490,78 @@ export default {
setDay(v) {
if (Number.isInteger(v?.value)) {
this.view.model.Day = v?.value;
this.clampDayToValidRange();
this.syncTime();
} else if (!v) {
// Day set to unknown -> set Year to unknown and update TakenAtLocal day to 01
this.view.model.Day = -1;
this.view.model.Year = -1;
this.updateModel();
} else if (this.rules.isNumberRange(v, 1, 31)) {
this.view.model.Day = Number(v);
this.clampDayToValidRange();
this.syncTime();
}
},
setMonth(v) {
if (Number.isInteger(v?.value)) {
this.view.model.Month = v?.value;
this.clampDayToValidRange();
this.syncTime();
} else if (!v) {
// Month set to unknown -> set Year to unknown
this.view.model.Month = -1;
this.view.model.Year = -1;
this.syncTime();
} else if (this.rules.isNumberRange(v, 1, 12)) {
this.view.model.Month = Number(v);
this.clampDayToValidRange();
this.syncTime();
}
},
setYear(v) {
if (Number.isInteger(v?.value)) {
this.view.model.Year = v?.value;
this.clampDayToValidRange();
this.syncTime();
} else if (!v) {
// Year set to unknown
this.view.model.Year = -1;
this.syncTime();
} else if (this.rules.isNumberRange(v, 1000, Number(new Date().getUTCFullYear()))) {
this.view.model.Year = Number(v);
this.clampDayToValidRange();
this.syncTime();
}
},
// Returns the effective year used for validation: explicit year or from TakenAtLocal if unknown
effectiveYear() {
if (this.view?.model?.Year && this.view.model.Year > 0) return this.view.model.Year;
const y = this.view?.model?.TakenAtLocal
? parseInt(this.view.model.TakenAtLocal.substring(0, 4))
: new Date().getUTCFullYear();
return isNaN(y) ? new Date().getUTCFullYear() : y;
},
// Returns the effective month used for validation: explicit month or from TakenAtLocal if unknown
effectiveMonth() {
if (this.view?.model?.Month && this.view.model.Month > 0) return this.view.model.Month;
const m = this.view?.model?.TakenAtLocal
? parseInt(this.view.model.TakenAtLocal.substring(5, 7))
: new Date().getUTCMonth() + 1;
return isNaN(m) ? new Date().getUTCMonth() + 1 : m;
},
// Clamp day to the maximum valid day of the current effective month/year
clampDayToValidRange() {
const day = this.view?.model?.Day || 0;
if (day <= 0) return; // Unknown day stays unknown
const y = this.effectiveYear();
const m = this.effectiveMonth();
// JS Date trick: day 0 of next month yields last day of current month
const maxDay = new Date(Date.UTC(y, m, 0)).getUTCDate();
if (day > maxDay) {
this.view.model.Day = maxDay;
}
},
setTime() {
if (this.rules.isTime(this.time)) {
this.updateModel();

View File

@@ -98,7 +98,10 @@
<v-icon color="surface-variant">mdi-magnify</v-icon>
</v-btn>
<v-btn
v-else-if="label.Uncertainty < 100 && label.LabelSrc === 'manual'"
v-else-if="
(label.LabelSrc === 'manual' && label.Uncertainty < 100) ||
(label.LabelSrc === 'batch' && label.Uncertainty === 0)
"
icon
density="comfortable"
variant="text"

View File

@@ -0,0 +1,110 @@
/* Chip Selector Styles */
.chip-selector {
display: flex;
flex-direction: column;
gap: 12px;
}
.chip-selector__title {
font-weight: 500;
font-size: 14px;
color: rgba(var(--v-theme-on-surface), 0.87);
margin-bottom: 8px;
}
.chip-selector__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
align-items: flex-start;
}
.chip-selector__input-container {
margin-top: 8px;
}
.chip-selector__input {
width: 100%;
}
/* Chip Styles */
.chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
user-select: none;
min-height: 32px;
transition: all 0.2s ease;
}
.chip__content {
display: flex;
align-items: center;
gap: 6px;
}
.chip__icon {
font-size: 16px;
opacity: 0.9;
}
/* Chip States */
.chip--gray {
background-color: #757575;
color: white;
}
.chip--gray-light {
background-color: rgba(117, 117, 117, 0.3);
color: #424242;
}
.chip--green {
background-color: rgb(var(--v-theme-download));
color: rgb(var(--v-theme-on-download));
}
.chip--green-light {
background-color: rgba(var(--v-theme-download), 0.3);
color: rgb(var(--v-theme-download));
}
.chip--red {
background-color: rgb(var(--v-theme-error));
color: rgb(var(--v-theme-on-error));
}
.chip--red-light {
background-color: rgba(var(--v-theme-error), 0.3);
color: rgb(var(--v-theme-error));
}
/* Empty state */
.chip-selector__empty {
padding: 16px;
text-align: center;
color: rgba(var(--v-theme-on-surface), 0.6);
font-style: italic;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.chip-selector__chips {
gap: 6px;
}
.chip {
padding: 4px 8px;
font-size: 12px;
min-height: 28px;
}
.chip__icon {
font-size: 14px;
}
}

View File

@@ -22,6 +22,11 @@
user-select: none !important;
}
/* Make thumbnails in batch edit dialog show pointer cursor */
.edit-batch .preview {
cursor: pointer !important;
}
.search-results.list-view .p-photo-select,
.search-results.list-view .p-photo-play {
margin-left: auto;
@@ -288,11 +293,31 @@
padding: 1px 0 1px 4px;
}
.search-results.list-view .v-table>.v-table__wrapper>table>tbody>tr>td.result .preview {
.search-results.list-view .v-table>.v-table__wrapper>table>tbody>tr>td.result .preview,
.edit-batch.list-view .v-table>table>tbody>tr>td.result .preview {
width: 50px;
height: 50px;
}
.edit-batch .v-expansion-panel-text__wrapper {
max-height: 300px;
overflow-y: auto;
}
.edit-batch .v-expansion-panel-text__wrapper tr {
height: 50px;
}
.edit-batch .edit-batch__file-name {
line-break: anywhere;
}
.p-photo-edit-batch .scroll-col {
height: 100%;
overflow-y: auto;
padding: 8px;
}
.list-view tbody td button {
display: inline-block;
overflow: hidden;
@@ -831,7 +856,8 @@
}
.search-results.list-view .input-select,
.search-results.list-view .input-open {
.search-results.list-view .input-open,
.edit-batch.list-view .input-open {
margin-left: auto;
margin-right: auto;
left: 0;

View File

@@ -130,7 +130,7 @@ body.dark-theme {
}
.v-overlay.v-dialog.v-dialog--sidepanel.v-dialog--sidepanel-wide:not(.v-dialog--fullscreen) .v-overlay__content {
min-width: 950px;
min-width: 1150px;
width: 56vw;
}

View File

@@ -0,0 +1,132 @@
import $api from "common/api";
import Model from "./model";
import { Photo } from "model/photo";
export class Batch extends Model {
constructor(values) {
super(values);
this.selectionById = new Map();
}
getDefaults() {
return {
models: [],
values: {},
selection: [],
};
}
getDefaultFormData() {
return {
Title: {},
DetailsSubject: {},
Caption: {},
Day: {},
Month: {},
Year: {},
TimeZone: {},
Country: {},
Altitude: {},
Lat: {},
Lng: {},
DetailsArtist: {},
DetailsCopyright: {},
DetailsLicense: {},
DetailsKeywords: {},
Type: {},
Scan: {},
Private: {},
Favorite: {},
Panorama: {},
Iso: {},
FocalLength: {},
FNumber: {},
Exposure: {},
CameraID: {},
LensID: {},
Albums: {
action: "none",
mixed: false,
items: [],
},
Labels: {
action: "none",
mixed: false,
items: [],
},
};
}
save(selection, values) {
return $api
.post("batch/photos/edit", { photos: selection, values: values })
.then((response) => {
if (response.data.values) {
this.values = response.data.values;
}
return Promise.resolve(this);
})
.catch((error) => {
throw error;
});
}
async getData(selection) {
try {
const response = await $api.post("batch/photos/edit", { photos: selection });
const models = response.data.models || [];
this.models = models.map((m) => {
const modelInstance = new Photo();
return modelInstance.setValues(m);
});
this.values = response.data.values;
this.setSelections(selection);
} catch (error) {
throw error;
}
}
async getValuesForSelection(selection) {
try {
const response = await $api.post("batch/photos/edit", { photos: selection });
this.values = response.data.values;
return this.values;
} catch (error) {
throw error;
}
}
setSelections(selection) {
this.selection = selection.map((id) => {
return {
id: id,
selected: true,
};
});
this.selectionById = new Map(this.selection.map((entry) => [entry.id, entry]));
}
isSelected(id) {
const entry = this.selectionById && this.selectionById.get(id);
return entry ? entry.selected : null;
}
getLengthOfAllSelected() {
return this.selection.filter((photo) => photo.selected).length;
}
toggle(id) {
const entry = this.selectionById && this.selectionById.get(id);
if (entry) {
entry.selected = !entry.selected;
}
}
toggleAll(isToggledAll) {
this.selection.forEach((element) => {
element.selected = isToggledAll;
});
}
}

View File

@@ -114,6 +114,35 @@ export const MonthsShort = () => {
return result;
};
// Objects for the Batch Dialog to have one more value -2 => mixed
export const TimeZonesBatchDialog = () => {
let result = TimeZones();
result.push({ ID: -2, Name: "mixed" });
return result;
};
export const DaysBatchDialog = () => {
let result = Days();
result.push({ value: -2, text: $gettext("mixed") });
return result;
};
export const YearsBatchDialog = (start) => {
let result = Years(start);
result.push({ value: -2, text: $gettext("mixed") });
return result;
};
export const MonthsShortBatchDialog = () => {
let result = MonthsShort();
result.push({ value: -2, text: $gettext("mixed") });
return result;
};
// Specifies the default language locale.
export let DefaultLocale = "en";
@@ -311,6 +340,13 @@ export const PhotoTypes = () => [
},
];
export const PhotoTypesBatchDialog = () => {
let result = PhotoTypes();
result.push({ text: $gettext("mixed"), value: "mixed" });
return result;
};
export const Timeouts = () => [
{
text: $gettext("Default"),

View File

@@ -4,6 +4,7 @@ import Menu from "../page-model/menu";
import Toolbar from "../page-model/toolbar";
import ContextMenu from "../page-model/context-menu";
import Photo from "../page-model/photo";
import Page from "../page-model/page";
import Subject from "../page-model/subject";
import PhotoEdit from "../page-model/photo-edit";
@@ -13,6 +14,7 @@ const menu = new Menu();
const toolbar = new Toolbar();
const contextmenu = new ContextMenu();
const photo = new Photo();
const page = new Page();
const subject = new Subject();
const photoedit = new PhotoEdit();
@@ -62,7 +64,7 @@ test.meta("testID", "people-001").meta({ type: "short", mode: "public" })(
await photo.triggerHoverAction("nth", 0, "select");
await photo.triggerHoverAction("nth", 1, "select");
await photo.triggerHoverAction("nth", 2, "select");
await contextmenu.triggerContextMenuAction("edit", "");
await t.click(page.cardTitle.nth(0));
await t.click(photoedit.peopleTab);
await t.expect(photoedit.inputName.nth(0).value).contains("Jane Doe");
@@ -81,7 +83,7 @@ test.meta("testID", "people-001").meta({ type: "short", mode: "public" })(
await subject.openSubjectWithUid(JaneUID);
await t.eval(() => location.reload());
await contextmenu.checkContextMenuCount("3");
await contextmenu.triggerContextMenuAction("edit", "");
await t.click(page.cardTitle.nth(0));
await t.click(photoedit.peopleTab);
await t.expect(photoedit.inputName.nth(0).value).contains("Max Mu");

View File

@@ -3,6 +3,7 @@ import testcafeconfig from "../../testcafeconfig.json";
import Menu from "../page-model/menu";
import Toolbar from "../page-model/toolbar";
import ContextMenu from "../page-model/context-menu";
import Page from "../page-model/page";
import Photo from "../page-model/photo";
import PhotoEdit from "../page-model/photo-edit";
import Album from "../page-model/album";
@@ -14,6 +15,7 @@ fixture`Test photos archive and private functionalities`.page`${testcafeconfig.u
const menu = new Menu();
const toolbar = new Toolbar();
const contextmenu = new ContextMenu();
const page = new Page();
const photo = new Photo();
const photoedit = new PhotoEdit();
const album = new Album();
@@ -48,11 +50,10 @@ test.meta("testID", "photos-archive-private-001").meta({ type: "short", mode: "p
await photo.triggerListViewActions("uid", SecondPhotoUid, "private");
await photo.triggerListViewActions("uid", SecondVideoUid, "private");*/
await t.click(toolbar.cardsViewAction);
await photo.triggerHoverAction("uid", ThirdPhotoUid, "select");
await photo.triggerHoverAction("uid", ThirdVideoUid, "select");
await contextmenu.triggerContextMenuAction("edit", "");
await page.clickCardTitleOfUID(ThirdPhotoUid);
await photoedit.turnSwitchOn("private");
await t.click(photoedit.dialogNext);
await t.click(photoedit.dialogClose);
await page.clickCardTitleOfUID(ThirdVideoUid);
await photoedit.turnSwitchOn("private");
await t.click(photoedit.dialogClose);
if (t.browser.platform === "mobile") {
@@ -60,7 +61,6 @@ test.meta("testID", "photos-archive-private-001").meta({ type: "short", mode: "p
} else {
await toolbar.triggerToolbarAction("refresh");
}
await photo.checkPhotoVisibility(FirstPhotoUid, false);
// await photo.checkPhotoVisibility(SecondPhotoUid, false);
await photo.checkPhotoVisibility(ThirdPhotoUid, false);
@@ -87,7 +87,6 @@ test.meta("testID", "photos-archive-private-001").meta({ type: "short", mode: "p
//await photo.checkPhotoVisibility(SecondVideoUid, true);
await photo.checkPhotoVisibility(ThirdVideoUid, true);
await contextmenu.clearSelection();
await photo.triggerHoverAction("uid", FirstPhotoUid, "select");
//await photo.triggerHoverAction("uid", SecondPhotoUid, "select");
await photo.triggerHoverAction("uid", ThirdPhotoUid, "select");

View File

@@ -347,16 +347,16 @@ test.meta("testID", "photos-007").meta({ mode: "public" })("Common: Mark photos/
await photo.checkPhotoVisibility(FirstVideoUid, false);
await menu.openPage("browse");
await photo.triggerHoverAction("uid", FirstPhotoUid, "select");
await photo.triggerHoverAction("uid", FirstVideoUid, "select");
await contextmenu.triggerContextMenuAction("edit", "");
await photoedit.turnSwitchOn("scan");
await photoedit.turnSwitchOn("panorama");
await t.click(photoedit.dialogNext);
await page.clickCardTitleOfUID(FirstPhotoUid);
await photoedit.turnSwitchOn("scan");
await photoedit.turnSwitchOn("panorama");
await t.click(photoedit.dialogClose);
await page.clickCardTitleOfUID(FirstVideoUid);
await photoedit.turnSwitchOn("scan");
await photoedit.turnSwitchOn("panorama");
await t.click(photoedit.dialogClose);
await contextmenu.clearSelection();
await photo.checkPhotoVisibility(FirstPhotoUid, true);
await photo.checkPhotoVisibility(FirstVideoUid, true);
@@ -371,18 +371,18 @@ test.meta("testID", "photos-007").meta({ mode: "public" })("Common: Mark photos/
await photo.checkPhotoVisibility(FirstPhotoUid, true);
await photo.checkPhotoVisibility(FirstVideoUid, true);
await photo.triggerHoverAction("uid", FirstPhotoUid, "select");
await photo.triggerHoverAction("uid", FirstVideoUid, "select");
await contextmenu.triggerContextMenuAction("edit", "");
await page.clickCardTitleOfUID(FirstPhotoUid);
await photoedit.turnSwitchOff("scan");
await photoedit.turnSwitchOff("panorama");
await t.click(photoedit.dialogNext);
await t.click(photoedit.dialogClose);
await page.clickCardTitleOfUID(FirstVideoUid);
await photoedit.turnSwitchOff("scan");
await photoedit.turnSwitchOff("panorama");
await t.click(photoedit.dialogClose);
await t.wait(9000);
await contextmenu.clearSelection();
if (t.browser.platform === "mobile") {
await t.eval(() => location.reload());
} else {

View File

@@ -111,7 +111,9 @@ test.meta("testID", "stacks-004").meta({ mode: "public" })("Common: Delete non p
await t.expect(FileCount).eql(2);
await t
.click(photoedit.toggleExpandFile.nth(1))
.click(photoedit.toggleExpandFile.nth(0))
.click(Selector(photoedit.deleteFile))
.click(Selector(".action-confirm"))
.wait(10000);

View File

@@ -15,7 +15,7 @@ export default class Page {
this.locationAction = Selector(".input-coordinates i.action-map", { timeout: 15000 });
this.locationSearch = Selector("div.p-location-dialog .v-autocomplete", { timeout: 15000 });
this.locationClear = Selector(".input-coordinates i.action-clear", { timeout: 15000 });
this.locationClear = Selector(".input-coordinates i.action-delete", { timeout: 15000 });
this.locationUndo = Selector("div.p-location-dialog .input-coordinates i.action-undo", { timeout: 15000 });
this.locationInput = Selector("div.p-location-dialog .input-coordinates input", { timeout: 15000 });
this.locationConfirm = Selector("div.p-location-dialog button.action-confirm", { timeout: 15000 });
@@ -81,7 +81,7 @@ export default class Page {
this.downloadFile = Selector("button.action-download", { timeout: 15000 });
this.unstackFile = Selector(".action-unstack", { timeout: 15000 });
this.deleteFile = Selector(".action-delete", { timeout: 15000 });
this.deleteFile = Selector("div.p-tab-photo-files .action-delete", { timeout: 15000 });
this.makeFilePrimary = Selector(".action-primary", { timeout: 15000 });
this.toggleExpandFile = Selector("button.v-expansion-panel-title", { timeout: 15000 });

View File

@@ -0,0 +1,262 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { nextTick } from "vue";
import ChipSelector from "component/file/chip-selector.vue";
describe("component/file/chip-selector", () => {
let wrapper;
const mockItems = [
{ value: "album1", title: "Album 1", mixed: false, action: "none" },
{ value: "album2", title: "Album 2", mixed: true, action: "add" },
{ value: "album3", title: "Album 3", mixed: false, action: "remove" },
];
const mockAvailableItems = [
{ value: "album1", title: "Album 1" },
{ value: "album2", title: "Album 2" },
{ value: "album3", title: "Album 3" },
{ value: "album4", title: "Album 4" },
];
beforeEach(() => {
const VIconStub = {
name: "VIcon",
props: ["icon"],
template: '<i class="chip__icon"><slot />{{ icon }}</i>',
};
const VTooltipStub = {
name: "VTooltip",
props: ["text", "location"],
template: '<div class="v-tooltip-stub"><slot name="activator" :props="{}"></slot><slot /></div>',
};
wrapper = mount(ChipSelector, {
props: {
items: mockItems,
availableItems: mockAvailableItems,
allowCreate: true,
emptyText: "No items",
inputPlaceholder: "Enter item name...",
},
global: {
stubs: {
VIcon: VIconStub,
VTooltip: VTooltipStub,
},
mocks: {
$gettext: (s) => s,
},
},
});
});
describe("Component Rendering", () => {
it("should show empty text and hide input when allowCreate is false and no items", async () => {
await wrapper.setProps({ items: [], allowCreate: false });
const emptyDiv = wrapper.find(".chip-selector__empty");
expect(emptyDiv.exists()).toBe(true);
expect(emptyDiv.text()).toBe("No items");
expect(wrapper.find(".chip-selector__input-container").exists()).toBe(false);
});
});
describe("Chip Icons", () => {
it.each([
{ idx: 0, expectedClass: "chip--gray", expectedIcon: null },
{ idx: 1, expectedClass: "chip--green-light", expectedIcon: "mdi-plus" },
{ idx: 2, expectedClass: "chip--red", expectedIcon: "mdi-minus" },
])("should render expected style/icon for chip at index $idx", ({ idx, expectedClass, expectedIcon }) => {
const chips = wrapper.findAll(".chip");
const chip = chips[idx];
expect(chip.find(".chip__text").exists()).toBe(true);
expect(chip.classes()).toContain(expectedClass);
const icon = chip.find(".chip__icon");
if (expectedIcon) {
expect(icon.exists()).toBe(true);
expect(icon.text()).toBe(expectedIcon);
} else {
expect(icon.exists()).toBe(false);
}
});
it("should show half-circle icon for mixed state without action", async () => {
const mixedItem = { value: "mixed1", title: "Mixed Item", mixed: true, action: "none" };
await wrapper.setProps({ items: [mixedItem] });
const chip = wrapper.find(".chip");
const icon = chip.find(".chip__icon");
expect(icon.exists()).toBe(true);
expect(icon.text()).toBe("mdi-circle-half-full");
});
});
describe("Chip Interactions", () => {
it("should emit update:items when chip is clicked", async () => {
const chip = wrapper.findAll(".chip")[0]; // First chip (action: none)
await chip.trigger("click");
const emitted = wrapper.emitted("update:items");
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toEqual(
expect.arrayContaining([expect.objectContaining({ value: "album1", action: "remove" })])
);
});
it.each(["keydown.enter", "keydown.space"])("should handle keyboard interactions (%s)", async (evt) => {
const chip = wrapper.findAll(".chip")[0];
await chip.trigger(evt);
const emitted = wrapper.emitted("update:items");
expect(emitted).toBeTruthy();
});
it.each([
{ prop: "loading", value: true },
{ prop: "disabled", value: true },
])("should not respond to clicks when %s", async ({ prop, value }) => {
await wrapper.setProps({ [prop]: value });
const chip = wrapper.findAll(".chip")[0];
await chip.trigger("click");
expect(wrapper.emitted("update:items")).toBeFalsy();
});
});
describe("Chip Action Cycling", () => {
it("should cycle through actions correctly for mixed items", async () => {
const mixedItem = { value: "mixed1", title: "Mixed Item", mixed: true, action: "none" };
await wrapper.setProps({ items: [mixedItem] });
const chip = wrapper.find(".chip");
// First click: none -> add
await chip.trigger("click");
let emitted = wrapper.emitted("update:items");
expect(emitted[0][0][0].action).toBe("add");
// Update props to simulate the new state
await wrapper.setProps({ items: [{ ...mixedItem, action: "add" }] });
// Second click: add -> remove
await chip.trigger("click");
emitted = wrapper.emitted("update:items");
expect(emitted[1][0][0].action).toBe("remove");
// Update props again
await wrapper.setProps({ items: [{ ...mixedItem, action: "remove" }] });
// Third click: remove -> none
await chip.trigger("click");
emitted = wrapper.emitted("update:items");
expect(emitted[2][0][0].action).toBe("none");
});
it("should handle new item removal correctly", async () => {
const newItem = { value: "", title: "New Item", mixed: false, action: "add", isNew: true };
await wrapper.setProps({ items: [newItem] });
const chip = wrapper.find(".chip");
await chip.trigger("click");
const emitted = wrapper.emitted("update:items");
expect(emitted[0][0]).toEqual([]); // Item should be completely removed
});
});
describe("Input Functionality", () => {
it("should add new item when Enter is pressed with text input", async () => {
const combobox = wrapper.findComponent({ name: "VCombobox" });
// Set the input value
wrapper.vm.newItemTitle = "New Album";
await nextTick();
// Trigger enter key
await combobox.trigger("keydown.enter");
const emitted = wrapper.emitted("update:items");
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "New Album",
action: "add",
isNew: true,
mixed: false,
value: "",
}),
])
);
});
it("should handle combobox selection change", async () => {
const combobox = wrapper.findComponent({ name: "VCombobox" });
const selectedItem = { value: "album4", title: "Album 4" };
await combobox.vm.$emit("update:model-value", selectedItem);
const emitted = wrapper.emitted("update:items");
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "album4",
title: "Album 4",
action: "add",
isNew: true,
}),
])
);
});
it("should not add duplicate items", async () => {
const combobox = wrapper.findComponent({ name: "VCombobox" });
// Try to add an existing item
wrapper.vm.newItemTitle = "Album 1"; // This already exists
await combobox.trigger("keydown.enter");
// Should not emit update:items for duplicate
expect(wrapper.emitted("update:items")).toBeFalsy();
});
it("should not add empty items", async () => {
const combobox = wrapper.findComponent({ name: "VCombobox" });
wrapper.vm.newItemTitle = " "; // Empty/whitespace string
await combobox.trigger("keydown.enter");
expect(wrapper.emitted("update:items")).toBeFalsy();
});
});
describe("Computed Properties", () => {
it("should process items correctly", () => {
const processed = wrapper.vm.processedItems;
expect(processed).toHaveLength(3);
expect(processed[0]).toMatchObject({
value: "album1",
title: "Album 1",
action: "none",
selected: false,
});
expect(processed[1]).toMatchObject({
value: "album2",
title: "Album 2",
action: "add",
selected: true,
});
});
it("should determine when to render chips correctly", async () => {
expect(wrapper.vm.shouldRenderChips).toBe(true);
// When no items and input is shown, should not render chips container
await wrapper.setProps({ items: [] });
expect(wrapper.vm.shouldRenderChips).toBe(false);
});
});
});

View File

@@ -126,7 +126,7 @@ describe("PLocationInput", () => {
// Wait for component to initialize and coordinateInput to be set
await nextTick();
const clearButton = wrapper.find(".action-clear");
const clearButton = wrapper.find(".action-delete");
expect(clearButton.exists()).toBe(true);
await clearButton.trigger("click");
@@ -143,7 +143,7 @@ describe("PLocationInput", () => {
await nextTick();
// Clear coordinates first
const clearButton = wrapper.find(".action-clear");
const clearButton = wrapper.find(".action-delete");
expect(clearButton.exists()).toBe(true);
await clearButton.trigger("click");
await nextTick();

View File

@@ -0,0 +1,423 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { shallowMount } from "@vue/test-utils";
import { nextTick } from "vue";
import PPhotoBatchEdit from "component/photo/edit/batch.vue";
import { Batch } from "model/batch-edit";
// Mock the models and dependencies
vi.mock("model/batch-edit");
vi.mock("model/album");
vi.mock("model/label");
vi.mock("model/thumb");
describe("component/photo/edit/batch", () => {
let wrapper;
let mockBatchInstance;
const mockSelection = ["uid1", "uid2", "uid3"];
const mockModels = [
{
ID: 1,
UID: "uid1",
Title: "Photo 1",
FileName: "photo1.jpg",
Type: "image",
getOriginalName: () => "photo1.jpg",
thumbnailUrl: (size) => `/thumb/${size}/photo1.jpg`,
},
{
ID: 2,
UID: "uid2",
Title: "Photo 2",
FileName: "photo2.jpg",
Type: "video",
getOriginalName: () => "photo2.jpg",
thumbnailUrl: (size) => `/thumb/${size}/photo2.jpg`,
},
{
ID: 3,
UID: "uid3",
Title: "Photo 3",
FileName: "photo3.jpg",
Type: "live",
getOriginalName: () => "photo3.jpg",
thumbnailUrl: (size) => `/thumb/${size}/photo3.jpg`,
},
];
const mockValues = {
Title: { value: "Test Title", mixed: false },
Caption: { value: "", mixed: true },
DetailsSubject: { value: "Test Subject", mixed: false },
Day: { value: 15, mixed: false },
Month: { value: 6, mixed: false },
Year: { value: 2023, mixed: false },
TimeZone: { value: "UTC", mixed: false },
Country: { value: "US", mixed: false },
Altitude: { value: 100, mixed: false },
Lat: { value: 37.7749, mixed: false },
Lng: { value: -122.4194, mixed: false },
DetailsArtist: { value: "Test Artist", mixed: false },
DetailsCopyright: { value: "Test Copyright", mixed: false },
DetailsLicense: { value: "Test License", mixed: false },
Type: { value: "image", mixed: false },
Scan: { value: true, mixed: false },
Favorite: { value: false, mixed: true },
Private: { value: false, mixed: false },
Panorama: { value: false, mixed: false },
Albums: { items: [], mixed: false, action: "none" },
Labels: { items: [], mixed: false, action: "none" },
};
const mockDefaultFormData = {
Title: { value: "Test", action: "none", mixed: false },
DetailsSubject: { value: "", action: "none", mixed: false },
Caption: { value: "", action: "none", mixed: false },
Day: { value: 0, action: "none", mixed: false },
Month: { value: 0, action: "none", mixed: false },
Year: { value: 0, action: "none", mixed: false },
TimeZone: { value: "UTC", action: "none", mixed: false },
Country: { value: "US", action: "none", mixed: false },
Altitude: { value: 0, action: "none", mixed: false },
Lat: { value: 37.7749, action: "none", mixed: false },
Lng: { value: -122.4194, action: "none", mixed: false },
DetailsArtist: { value: "", action: "none", mixed: false },
DetailsCopyright: { value: "", action: "none", mixed: false },
DetailsLicense: { value: "", action: "none", mixed: false },
DetailsKeywords: { value: "", action: "none", mixed: false },
Type: { value: "image", action: "none", mixed: false },
Iso: { value: 0, action: "none", mixed: false },
FocalLength: { value: 0, action: "none", mixed: false },
FNumber: { value: 0, action: "none", mixed: false },
Exposure: { value: "", action: "none", mixed: false },
CameraID: { value: 0, action: "none", mixed: false },
LensID: { value: 0, action: "none", mixed: false },
Scan: { value: false, action: "none", mixed: false },
Private: { value: false, action: "none", mixed: false },
Favorite: { value: false, action: "none", mixed: false },
Panorama: { value: false, action: "none", mixed: false },
Albums: { items: [], mixed: false, action: "none" },
Labels: { items: [], mixed: false, action: "none" },
};
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create a mock instance of Batch with proper method mocking
mockBatchInstance = {
models: mockModels,
values: mockValues,
selection: [
{ id: "uid1", selected: true },
{ id: "uid2", selected: true },
{ id: "uid3", selected: true },
],
getData: vi.fn(),
save: vi.fn(),
getValuesForSelection: vi.fn(),
getDefaultFormData: vi.fn(),
getLengthOfAllSelected: vi.fn(),
isSelected: vi.fn(),
toggle: vi.fn(),
toggleAll: vi.fn(),
};
// Configure mock method behaviors
mockBatchInstance.getData.mockResolvedValue(mockBatchInstance);
mockBatchInstance.save.mockResolvedValue(mockBatchInstance);
mockBatchInstance.getValuesForSelection.mockResolvedValue(mockValues);
mockBatchInstance.getDefaultFormData.mockReturnValue(mockDefaultFormData);
mockBatchInstance.getLengthOfAllSelected.mockReturnValue(3);
mockBatchInstance.isSelected.mockReturnValue(true);
// Mock the Batch constructor to return our mock instance
vi.mocked(Batch).mockImplementation(() => mockBatchInstance);
wrapper = shallowMount(PPhotoBatchEdit, {
props: {
visible: false, // Start with false to avoid initial rendering issues
selection: mockSelection,
openDate: vi.fn(),
openLocation: vi.fn(),
editPhoto: vi.fn(),
},
global: {
mocks: {
$notify: {
success: vi.fn(),
error: vi.fn(),
},
$lightbox: {
openModels: vi.fn(),
},
$event: {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
$config: {
feature: vi.fn().mockReturnValue(true),
},
$vuetify: { display: { mdAndDown: false } },
},
stubs: {
VDialog: {
template: '<div class="v-dialog">' + '<slot v-if="modelValue" />' + "</div>",
props: ["modelValue"],
},
VDataTable: {
template: '<div class="v-data-table"></div>',
props: ["headers", "items"],
},
PLocationInput: {
template: '<div class="p-location-input"></div>',
props: ["latlng", "label"],
emits: ["update:latlng", "changed", "open-map", "delete", "undo"],
},
PLocationDialog: {
template: '<div class="p-location-dialog"></div>',
props: ["visible", "latlng"],
emits: ["close", "confirm"],
},
BatchChipSelector: {
template: '<div class="batch-chip-selector"></div>',
props: ["items", "availableItems"],
emits: ["update:items"],
},
IconLivePhoto: {
template: '<i class="icon-live-photo"></i>',
},
},
},
});
// Initialize component state to simulate visible=true flow
wrapper.vm.values = { ...mockValues };
if (typeof wrapper.vm.setFormData === "function") {
wrapper.vm.setFormData();
}
wrapper.vm.allSelectedLength = mockBatchInstance.getLengthOfAllSelected();
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
describe("Computed Properties", () => {
beforeEach(() => {
// Set up component state for computed property tests
wrapper.vm.model = mockBatchInstance;
wrapper.vm.values = mockValues;
// Merge into existing complete formData to avoid template access errors
wrapper.vm.formData = {
...wrapper.vm.formData,
Lat: { value: 37.7749, action: "none", mixed: false },
Lng: { value: -122.4194, action: "none", mixed: false },
};
});
it("should compute form title correctly", () => {
expect(wrapper.vm.formTitle).toBe("Batch Edit (3)");
});
it("should compute current coordinates correctly", () => {
const coords = wrapper.vm.currentCoordinates;
expect(coords).toEqual([37.7749, -122.4194]);
});
it("should handle mixed location state", () => {
wrapper.vm.values = {
Lat: { mixed: true },
Lng: { mixed: true },
};
expect(wrapper.vm.isLocationMixed).toBe(true);
expect(wrapper.vm.currentCoordinates).toEqual([0, 0]);
});
});
describe("Form Data Management", () => {
beforeEach(() => {
wrapper.vm.model = mockBatchInstance;
wrapper.vm.formData = {
...wrapper.vm.formData,
Title: { value: "Changed", action: "update", mixed: false },
Caption: { value: "Original", action: "none", mixed: false },
};
});
it("should correctly detect unsaved changes true/false", async () => {
expect(wrapper.vm.hasUnsavedChanges()).toBe(true);
wrapper.vm.formData = {
Title: { value: "Original", action: "none" },
Caption: { value: "Original", action: "none" },
};
expect(wrapper.vm.hasUnsavedChanges()).toBe(false);
});
it("should filter form data correctly", () => {
const filtered = wrapper.vm.getFilteredFormData();
expect(filtered).toEqual({
Title: { action: "update", mixed: false, value: "Changed" },
});
});
});
describe("Location Functionality", () => {
beforeEach(() => {
wrapper.vm.formData = {
...wrapper.vm.formData,
Lat: { value: 37.7749, action: "none", mixed: false },
Lng: { value: -122.4194, action: "none", mixed: false },
};
wrapper.vm.previousFormData = {
Lat: { value: 40.7128 },
Lng: { value: -74.006 },
};
});
it("should handle location updates", () => {
const newCoords = [40.7128, -74.006];
wrapper.vm.updateLatLng(newCoords);
expect(wrapper.vm.formData.Lat.value).toBe(40.7128);
expect(wrapper.vm.formData.Lng.value).toBe(-74.006);
});
it("should handle location deletion", () => {
wrapper.vm.onLocationDelete();
expect(wrapper.vm.deletedFields.Lat).toBe(true);
expect(wrapper.vm.deletedFields.Lng).toBe(true);
expect(wrapper.vm.formData.Lat.value).toBe(0);
expect(wrapper.vm.formData.Lng.value).toBe(0);
});
it("should handle location undo", () => {
wrapper.vm.onLocationUndo();
expect(wrapper.vm.deletedFields.Lat).toBe(false);
expect(wrapper.vm.deletedFields.Lng).toBe(false);
expect(wrapper.vm.formData.Lat.action).toBe("none");
expect(wrapper.vm.formData.Lng.action).toBe("none");
});
it("should open location dialog", () => {
wrapper.vm.adjustLocation();
expect(wrapper.vm.locationDialog).toBe(true);
});
});
describe("Save Functionality", () => {
beforeEach(() => {
wrapper.vm.model = mockBatchInstance;
wrapper.vm.formData = {
...wrapper.vm.formData,
Title: { value: "New Title", action: "update", mixed: false },
Caption: { value: "New Caption", action: "update", mixed: false },
};
});
it("should save changes successfully", async () => {
await wrapper.vm.save(false);
expect(mockBatchInstance.save).toHaveBeenCalled();
expect(wrapper.vm.$notify.success).toHaveBeenCalledWith("Changes successfully saved");
expect(wrapper.vm.saving).toBe(false);
});
it("should handle save errors", async () => {
mockBatchInstance.save.mockRejectedValue(new Error("Save failed"));
await wrapper.vm.save(false);
expect(wrapper.vm.$notify.error).toHaveBeenCalledWith("Failed to save changes");
expect(wrapper.vm.saving).toBe(false);
});
});
describe("Form Field Updates", () => {
beforeEach(() => {
wrapper.vm.formData = {
...wrapper.vm.formData,
Title: { value: "Test", action: "none", mixed: false },
};
wrapper.vm.previousFormData = {
Title: { value: "Original", action: "none" },
};
});
it("should handle text field changes", () => {
wrapper.vm.changeValue("New Title", "text-field", "Title");
expect(wrapper.vm.formData.Title.value).toBe("New Title");
expect(wrapper.vm.formData.Title.action).toBe("update");
});
it("should reset action when value returns to original", () => {
wrapper.vm.changeValue("Original", "text-field", "Title");
expect(wrapper.vm.formData.Title.value).toBe("Original");
expect(wrapper.vm.formData.Title.action).toBe("none");
});
});
describe("Selection Management", () => {
beforeEach(() => {
wrapper.vm.model = mockBatchInstance;
});
it("should handle photo opening", () => {
wrapper.vm.openPhoto(0);
expect(wrapper.vm.$lightbox.openModels).toHaveBeenCalled();
});
});
describe("Date Validation", () => {
beforeEach(() => {
wrapper.vm.formData = {
...wrapper.vm.formData,
Year: { value: 2023, mixed: false },
Month: { value: 2, mixed: false },
Day: { value: 30, mixed: false, action: "update" },
};
wrapper.vm.actions = { update: "update", none: "none" };
});
it("should clamp day when date is resolvable", () => {
wrapper.vm.clampBatchDayIfResolvable();
// February 2023 has 28 days, so day should be clamped to 28
expect(wrapper.vm.formData.Day.value).toBe(28);
expect(wrapper.vm.formData.Day.action).toBe("update");
});
it("should not clamp when date is not resolvable", () => {
wrapper.vm.formData.Year.mixed = true; // Make it non-resolvable
wrapper.vm.clampBatchDayIfResolvable();
// Should remain unchanged
expect(wrapper.vm.formData.Day.value).toBe(30);
});
});
describe("Component Lifecycle", () => {
it("should initialize data when visible becomes true", async () => {
await wrapper.setProps({ visible: true });
await nextTick();
await nextTick();
expect(mockBatchInstance.getData).toHaveBeenCalledWith(mockSelection);
});
it("should emit close event", () => {
wrapper.vm.close();
expect(wrapper.emitted("close")).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import "../fixtures";
import { Batch } from "model/batch-edit";
describe("model/batch-edit", () => {
it("should return defaults", () => {
const b = new Batch();
const d = b.getDefaults();
expect(Array.isArray(d.models)).toBe(true);
expect(d.values).toEqual({});
expect(Array.isArray(d.selection)).toBe(true);
});
it("should return default form data", () => {
const b = new Batch();
const f = b.getDefaultFormData();
const expectedKeys = [
"Title",
"DetailsSubject",
"Caption",
"Day",
"Month",
"Year",
"TimeZone",
"Country",
"Altitude",
"Lat",
"Lng",
"DetailsArtist",
"DetailsCopyright",
"DetailsLicense",
"DetailsKeywords",
"Type",
"Scan",
"Private",
"Favorite",
"Panorama",
"Iso",
"FocalLength",
"FNumber",
"Exposure",
"CameraID",
"LensID",
"Albums",
"Labels",
];
expect(Object.keys(f).sort()).toEqual(expectedKeys.sort());
expect(f.Albums).toEqual({ action: "none", mixed: false, items: [] });
expect(f.Labels).toEqual({ action: "none", mixed: false, items: [] });
});
it("should set selections", () => {
const b = new Batch();
b.setSelections([1, 2, 3]);
expect(b.selection).toEqual([
{ id: 1, selected: true },
{ id: 2, selected: true },
{ id: 3, selected: true },
]);
});
it("should report selection state for a given id", () => {
const b = new Batch();
b.setSelections([1, 2]);
expect(b.isSelected(1)).toBe(true);
// toggle one and check again
b.toggle(1);
expect(b.isSelected(1)).toBe(false);
// unknown id returns null per implementation
expect(b.isSelected(999)).toBeNull();
});
it("should toggle and toggleAll", () => {
const b = new Batch();
b.setSelections([11, 12, 13]);
expect(b.getLengthOfAllSelected()).toBe(3);
b.toggle(12);
expect(b.isSelected(12)).toBe(false);
expect(b.getLengthOfAllSelected()).toBe(2);
b.toggleAll(false);
expect(b.getLengthOfAllSelected()).toBe(0);
b.toggleAll(true);
expect(b.getLengthOfAllSelected()).toBe(3);
});
it("should call save and update values from response", async () => {
const b = new Batch();
const selection = [5, 7];
const values = { Title: { value: "New" } };
// Mock endpoint expected by $api: baseURL is "/api/v1"
const { Mock } = await import("../fixtures");
Mock.onPost("api/v1/batch/photos/edit", { photos: selection, values }).reply(
200,
{ values: { Title: { value: "Saved" } } },
{ "Content-Type": "application/json; charset=utf-8" }
);
const result = await b.save(selection, values);
expect(result).toBe(b);
expect(b.values).toEqual({ Title: { value: "Saved" } });
});
it("should load data (models and values) via getData", async () => {
const b = new Batch();
const selection = [101, 102];
// Response should include models and values
const { Mock } = await import("../fixtures");
Mock.onPost("api/v1/batch/photos/edit", { photos: selection }).reply(
200,
{
models: [
{ ID: 1, UID: "ph1", Title: "A" },
{ ID: 2, UID: "ph2", Title: "B" },
],
values: { Title: { mixed: true } },
},
{ "Content-Type": "application/json; charset=utf-8" }
);
await b.getData(selection);
expect(Array.isArray(b.models)).toBe(true);
expect(b.models.length).toBe(2);
expect(b.values).toEqual({ Title: { mixed: true } });
expect(b.selection).toEqual([
{ id: 101, selected: true },
{ id: 102, selected: true },
]);
});
});

1
go.mod
View File

@@ -80,6 +80,7 @@ require (
github.com/davidbyttow/govips/v2 v2.16.0
github.com/go-co-op/gocron/v2 v2.16.5
github.com/go-sql-driver/mysql v1.9.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_model v0.6.2
github.com/robfig/cron/v3 v3.0.1

2
go.sum
View File

@@ -202,6 +202,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=

View File

@@ -29,14 +29,19 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
clientIp := ClientIP(c)
authToken := AuthToken(c)
// Disable response caching.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Find active session to perform authorization check or deny if no session was found.
if s = Session(clientIp, authToken); s == nil {
if s = authAnyJWT(c, clientIp, authToken, resource, perms); s != nil {
return s
}
event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource))
return entity.SessionStatusUnauthorized()
}
// Disable caching of responses and the client IP.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Set client IP.
s.SetClientIP(clientIp)
// If the request is from a client application, check its authorization based

View File

@@ -0,0 +1,179 @@
package api
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
)
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster node
// receives a request without an existing session. It verifies the token against
// the node's cached JWKS, ensures the issuer/audience/scope match the expected
// portal values, and, if valid, returns a client session mirroring the JWT
// claims. It returns nil on any validation failure so the caller can fall back
// to existing auth flows. By default, only cluster and vision resources are
// eligible, but nodes may opt in to additional scopes via PHOTOPRISM_JWT_SCOPE.
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
// Check if token may be a JWT.
if !shouldAttemptJWT(c, authToken) {
return nil
}
conf := get.Config()
// Determine whether JWT authentication is possible
// based on the local config and client IP address.
if !shouldAllowJWT(conf, clientIP) {
return nil
}
requiredScope := resource.String()
expected := expectedClaimsFor(conf, requiredScope)
// verifyTokenFromPortal handles cryptographic validation (signature, issuer,
// audience, temporal claims) and enforces that the token includes any scopes
// listed in expected.Scope. Local authorization still happens below so nodes
// can apply their own allow-list semantics.
claims := verifyTokenFromPortal(c.Request.Context(), authToken, expected, jwtIssuerCandidates(conf))
if claims == nil {
return nil
}
// Check if config allows resource access to be authorized with JWT.
allowedScopes := conf.JWTAllowedScopes()
if !acl.ScopeAttrPermits(allowedScopes, resource, perms) {
return nil
}
// Check if token allows access to specified resource.
tokenScopes := acl.ScopeAttr(claims.Scope)
if !acl.ScopeAttrPermits(tokenScopes, resource, perms) {
return nil
}
claims.Scope = tokenScopes.String()
return sessionFromJWTClaims(claims, clientIP)
}
// shouldAttemptJWT reports whether JWT verification should run for the supplied
// request context and token.
func shouldAttemptJWT(c *gin.Context, token string) bool {
if c == nil {
return false
}
if token == "" || strings.Count(token, ".") != 2 {
return false
}
return true
}
// shouldAllowJWT reports whether the current node configuration permits JWT
// authentication for the request originating from clientIP.
func shouldAllowJWT(conf *config.Config, clientIP string) bool {
if conf == nil || conf.IsPortal() {
return false
}
if conf.JWKSUrl() == "" {
return false
}
cidr := strings.TrimSpace(conf.ClusterCIDR())
if cidr == "" {
return true
}
ip := net.ParseIP(clientIP)
_, block, err := net.ParseCIDR(cidr)
if err != nil || ip == nil {
return false
}
return block.Contains(ip)
}
// expectedClaimsFor builds the ExpectedClaims used to validate JWTs for the
// current node and required scope.
func expectedClaimsFor(conf *config.Config, requiredScope string) clusterjwt.ExpectedClaims {
expected := clusterjwt.ExpectedClaims{
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
JWKSURL: conf.JWKSUrl(),
}
if requiredScope != "" {
expected.Scope = []string{requiredScope}
}
return expected
}
// verifyTokenFromPortal checks the token against each candidate issuer and
// returns the verified claims on success.
func verifyTokenFromPortal(ctx context.Context, token string, expected clusterjwt.ExpectedClaims, issuers []string) *clusterjwt.Claims {
if len(issuers) == 0 {
return nil
}
for _, issuer := range issuers {
expected.Issuer = issuer
claims, err := get.VerifyJWT(ctx, token, expected)
if err == nil {
return claims
}
}
return nil
}
// sessionFromJWTClaims constructs a Session populated with fields derived from
// the verified JWT claims.
func sessionFromJWTClaims(claims *clusterjwt.Claims, clientIP string) *entity.Session {
sess := &entity.Session{
Status: http.StatusOK,
ClientUID: claims.Subject,
AuthScope: clean.Scope(claims.Scope),
AuthIssuer: claims.Issuer,
AuthID: claims.ID,
GrantType: authn.GrantJwtBearer.String(),
AuthProvider: authn.ProviderClient.String(),
}
sess.SetMethod(authn.MethodJWT)
sess.SetClientName(claims.Subject)
sess.SetClientIP(clientIP)
return sess
}
// jwtIssuerCandidates returns the possible issuer values the node should accept
// for Portal JWTs. It prefers the explicit portal cluster identifier and then
// falls back to configured URLs so legacy installations migrate seamlessly.
func jwtIssuerCandidates(conf *config.Config) []string {
var out []string
if uuid := conf.ClusterUUID(); uuid != "" {
out = append(out, fmt.Sprintf("portal:%s", uuid))
}
if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
out = append(out, strings.TrimRight(portal, "/"))
}
if site := strings.TrimSpace(conf.SiteUrl()); site != "" {
out = append(out, strings.TrimRight(site, "/"))
}
return out
}

View File

@@ -0,0 +1,375 @@
package api
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
)
func TestAuthAnyJWT(t *testing.T) {
t.Run("ClusterScope", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-success")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.10:12345"
c.Request = req
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Equal(t, spec.Subject, session.ClientUID)
assert.Contains(t, session.AuthScope, "cluster")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
})
t.Run("ClusterCIDRAllowed", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-allow")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
origCIDR := fx.nodeConf.Options().ClusterCIDR
fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().ClusterCIDR = origCIDR
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.10:2222"
c.Request = req
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
require.NotNil(t, session)
assert.Equal(t, spec.Subject, session.ClientUID)
})
t.Run("ClusterCIDRBlocked", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-block")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
origCIDR := fx.nodeConf.Options().ClusterCIDR
fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().ClusterCIDR = origCIDR
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "203.0.113.10:2222"
c.Request = req
assert.Nil(t, authAnyJWT(c, "203.0.113.10", token, acl.ResourceCluster, nil))
})
t.Run("JWTScopeDefaultRejectsOtherResources", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-scope-default-reject")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"photos"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.60:1001"
c.Request = req
assert.Nil(t, authAnyJWT(c, "192.0.2.60", token, acl.ResourcePhotos, nil))
})
t.Run("JWTScopeAllowed", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-scope-allow")
token := fx.issue(t, fx.defaultClaimsSpec())
orig := fx.nodeConf.Options().JWTScope
fx.nodeConf.Options().JWTScope = "cluster vision"
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().JWTScope = orig
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.30:1001"
c.Request = req
sess := authAnyJWT(c, "192.0.2.30", token, acl.ResourceCluster, nil)
require.NotNil(t, sess)
})
t.Run("JWTScopeAllowsSuperset", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-scope-reject")
token := fx.issue(t, fx.defaultClaimsSpec())
orig := fx.nodeConf.Options().JWTScope
fx.nodeConf.Options().JWTScope = "cluster"
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().JWTScope = orig
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.40:1001"
c.Request = req
sess := authAnyJWT(c, "192.0.2.40", token, acl.ResourceCluster, nil)
require.NotNil(t, sess)
})
t.Run("JWTScopeCustomResource", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-scope-custom")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"photos"}
token := fx.issue(t, spec)
origScope := fx.nodeConf.Options().JWTScope
fx.nodeConf.Options().JWTScope = "photos"
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().JWTScope = origScope
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.50:2001"
c.Request = req
_, verifyErr := get.VerifyJWT(c.Request.Context(), token, clusterjwt.ExpectedClaims{
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
Scope: []string{"photos"},
JWKSURL: fx.nodeConf.JWKSUrl(),
})
require.NoError(t, verifyErr)
sess := authAnyJWT(c, "192.0.2.50", token, acl.ResourcePhotos, nil)
require.NotNil(t, sess)
})
t.Run("VisionScope", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"vision"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/vision/status", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "198.18.0.5:8080"
c.Request = req
session := authAnyJWT(c, "198.18.0.5", token, acl.ResourceVision, nil)
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Contains(t, session.AuthScope, "vision")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
})
t.Run("RejectsMalformedOrUnknown", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-invalid")
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer invalid-token-without-dots")
req.RemoteAddr = "192.0.2.10:12345"
c.Request = req
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "invalid-token-without-dots", acl.ResourceCluster, nil))
// Ensure we also bail out when JWKS URL is not configured.
fx.nodeConf.SetJWKSUrl("")
get.SetConfig(fx.nodeConf)
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "", acl.ResourceCluster, nil))
})
t.Run("NoIssuerMatch", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-no-issuer")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
// Remove all issuer candidates.
origPortal := fx.nodeConf.Options().PortalUrl
origSite := fx.nodeConf.Options().SiteUrl
origClusterUUID := fx.nodeConf.Options().ClusterUUID
fx.nodeConf.Options().PortalUrl = ""
fx.nodeConf.Options().SiteUrl = ""
fx.nodeConf.Options().ClusterUUID = ""
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().PortalUrl = origPortal
fx.nodeConf.Options().SiteUrl = origSite
fx.nodeConf.Options().ClusterUUID = origClusterUUID
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "203.0.113.5:2222"
c.Request = req
assert.Nil(t, authAnyJWT(c, "203.0.113.5", token, acl.ResourceCluster, nil))
})
t.Run("UnsupportedResource", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-unsupported")
token := fx.issue(t, fx.defaultClaimsSpec())
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "198.51.100.7:9999"
c.Request = req
assert.Nil(t, authAnyJWT(c, "198.51.100.7", token, acl.ResourcePhotos, nil))
})
}
func TestJwtIssuerCandidates(t *testing.T) {
t.Run("IncludesAllSources", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
conf.Options().PortalUrl = "https://portal.example.test/"
conf.Options().SiteUrl = "https://site.example.test/base/"
orig := get.Config()
get.SetConfig(conf)
t.Cleanup(func() { get.SetConfig(orig) })
cands := jwtIssuerCandidates(conf)
assert.Equal(t, []string{
"portal:11111111-1111-4111-8111-111111111111",
"https://portal.example.test",
"https://site.example.test/base",
}, cands)
})
t.Run("DefaultsToLocalhost", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().ClusterUUID = ""
conf.Options().PortalUrl = ""
conf.Options().SiteUrl = ""
assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
})
}
func TestShouldAttemptJWT(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
c.Request = req
assert.True(t, shouldAttemptJWT(c, "a.b.c"))
assert.False(t, shouldAttemptJWT(nil, "a.b.c"))
assert.False(t, shouldAttemptJWT(c, "invalidtoken"))
assert.False(t, shouldAttemptJWT(c, ""))
}
func TestNodeAllowsJWT(t *testing.T) {
fx := newPortalJWTFixture(t, "node-allows")
conf := fx.nodeConf
assert.True(t, shouldAllowJWT(conf, "192.0.2.9"))
origCIDR := conf.Options().ClusterCIDR
conf.Options().ClusterCIDR = "192.0.2.0/24"
assert.True(t, shouldAllowJWT(conf, "192.0.2.25"))
assert.False(t, shouldAllowJWT(conf, "203.0.113.1"))
conf.Options().ClusterCIDR = origCIDR
origJWKS := conf.JWKSUrl()
conf.SetJWKSUrl("")
assert.False(t, shouldAllowJWT(conf, "192.0.2.25"))
conf.SetJWKSUrl(origJWKS)
assert.False(t, shouldAllowJWT(nil, "192.0.2.25"))
}
func TestExpectedClaimsFor(t *testing.T) {
fx := newPortalJWTFixture(t, "expected-claims")
claims := expectedClaimsFor(fx.nodeConf, "cluster")
assert.Equal(t, fmt.Sprintf("node:%s", fx.nodeUUID), claims.Audience)
assert.Equal(t, []string{"cluster"}, claims.Scope)
assert.Equal(t, fx.nodeConf.JWKSUrl(), claims.JWKSURL)
noScope := expectedClaimsFor(fx.nodeConf, "")
assert.Nil(t, noScope.Scope)
}
func TestVerifyTokenFromPortal(t *testing.T) {
fx := newPortalJWTFixture(t, "verify-token")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
expected := expectedClaimsFor(fx.nodeConf, clean.Scope("cluster"))
claims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong", spec.Issuer})
require.NotNil(t, claims)
assert.Equal(t, spec.Issuer, claims.Issuer)
assert.Equal(t, spec.Subject, claims.Subject)
nilClaims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong"})
assert.Nil(t, nilClaims)
}
func TestSessionFromJWTClaims(t *testing.T) {
claims := &clusterjwt.Claims{
Scope: "cluster vision",
RegisteredClaims: gojwt.RegisteredClaims{
Issuer: "portal:test",
Subject: "portal:client",
ID: "token-id",
},
}
sess := sessionFromJWTClaims(claims, "192.0.2.100")
require.NotNil(t, sess)
assert.Equal(t, http.StatusOK, sess.HttpStatus())
assert.Equal(t, "portal:client", sess.ClientUID)
assert.Equal(t, clean.Scope("cluster vision"), sess.AuthScope)
assert.Equal(t, "portal:test", sess.AuthIssuer)
assert.Equal(t, "token-id", sess.AuthID)
assert.Equal(t, "192.0.2.100", sess.ClientIP)
}

View File

@@ -1,15 +1,23 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/auth/session"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@@ -137,3 +145,169 @@ func TestAuthToken(t *testing.T) {
assert.Equal(t, "", bearerToken)
})
}
func TestAuthAnyPortalJWT(t *testing.T) {
fx := newPortalJWTFixture(t, "ok")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.True(t, s.IsClient())
assert.Equal(t, http.StatusOK, s.HttpStatus())
assert.Contains(t, s.AuthScope, "cluster")
assert.Equal(t, fmt.Sprintf("portal:%s", fx.clusterUUID), s.AuthIssuer)
assert.Equal(t, "portal:client-test", s.ClientUID)
assert.False(t, s.Abort(c))
// Audience mismatch should reject the token once the node UUID changes.
req2, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.RemoteAddr = "10.0.0.5:1234"
c.Request = req2
fx.nodeConf.Options().NodeUUID = rnd.UUID()
get.SetConfig(fx.nodeConf)
s = AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_MissingScope(t *testing.T) {
fx := newPortalJWTFixture(t, "missing-scope")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"vision"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_InvalidIssuer(t *testing.T) {
fx := newPortalJWTFixture(t, "invalid-issuer")
spec := fx.defaultClaimsSpec()
spec.Issuer = "https://portal.invalid.test"
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_NoJWKSConfigured(t *testing.T) {
fx := newPortalJWTFixture(t, "no-jwks")
fx.nodeConf.SetJWKSUrl("")
get.SetConfig(fx.nodeConf)
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
type portalJWTFixture struct {
nodeConf *config.Config
issuer *clusterjwt.Issuer
clusterUUID string
nodeUUID string
}
func newPortalJWTFixture(t *testing.T, suffix string) portalJWTFixture {
t.Helper()
origConf := get.Config()
t.Cleanup(func() { get.SetConfig(origConf) })
nodeConf := config.NewMinimalTestConfigWithDb("auth-any-portal-jwt-"+suffix, t.TempDir())
nodeConf.Options().NodeRole = cluster.RoleInstance
nodeConf.Options().Public = false
clusterUUID := rnd.UUID()
nodeConf.Options().ClusterUUID = clusterUUID
nodeUUID := nodeConf.NodeUUID()
nodeConf.Options().PortalUrl = "https://portal.example.test"
portalConf := config.NewMinimalTestConfigWithDb("auth-any-portal-jwt-issuer-"+suffix, t.TempDir())
portalConf.Options().NodeRole = cluster.RolePortal
portalConf.Options().ClusterUUID = clusterUUID
mgr, err := clusterjwt.NewManager(portalConf)
require.NoError(t, err)
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
jwksBytes, err := json.Marshal(mgr.JWKS())
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(jwksBytes)
}))
t.Cleanup(srv.Close)
nodeConf.SetJWKSUrl(srv.URL + "/.well-known/jwks.json")
get.SetConfig(nodeConf)
return portalJWTFixture{
nodeConf: nodeConf,
issuer: clusterjwt.NewIssuer(mgr),
clusterUUID: clusterUUID,
nodeUUID: nodeUUID,
}
}
func (fx portalJWTFixture) defaultClaimsSpec() clusterjwt.ClaimsSpec {
return clusterjwt.ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
Subject: "portal:client-test",
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
Scope: []string{"cluster", "vision"},
}
}
func (fx portalJWTFixture) issue(t *testing.T, spec clusterjwt.ClaimsSpec) string {
t.Helper()
token, err := fx.issuer.Issue(spec)
require.NoError(t, err)
return token
}

View File

@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -1,14 +1,18 @@
package api
import (
"fmt"
"net/http"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/form/batch"
"github.com/photoprism/photoprism/internal/photoprism/batch"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
@@ -65,12 +69,73 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
return
}
// TODO: Implement photo metadata update based on submitted form values.
// Update photo metadata based on submitted form values.
if frm.Values != nil {
log.Debugf("batch: updating photo metadata %#v (not yet implemented)", frm.Values)
for _, photo := range photos {
log.Debugf("batch: updating metadata of photo %s (not yet implemented)", photo.PhotoUID)
log.Debugf("batch: updating photo metadata for %d photos", len(photos))
updatedCount := 0
for i, photo := range photos {
photoID := photo.PhotoUID
// Get the full photo entity with preloaded data
fullPhoto, err := query.PhotoPreloadByUID(photoID)
if err != nil {
log.Errorf("batch: failed to load photo %s: %s", photoID, err)
continue
}
// Convert batch form to regular photo form
photoForm, err := batch.ConvertToPhotoForm(&fullPhoto, frm.Values)
if err != nil {
log.Errorf("batch: failed to convert form for photo %s: %s", photoID, err)
continue
}
// Use the same save mechanism as normal edit
if err := entity.SavePhotoForm(&fullPhoto, *photoForm); err != nil {
log.Errorf("batch: failed to save photo %s: %s", photoID, err)
continue
}
// Apply Albums updates if requested
if frm.Values.Albums.Action == batch.ActionUpdate {
if err := batch.ApplyAlbums(photoID, frm.Values.Albums); err != nil {
log.Errorf("batch: failed to update albums for photo %s: %s", photoID, err)
}
}
// Apply Labels updates if requested
if frm.Values.Labels.Action == batch.ActionUpdate {
if err := batch.ApplyLabels(&fullPhoto, frm.Values.Labels); err != nil {
log.Errorf("batch: failed to update labels for photo %s: %s", photoID, err)
}
}
// Convert the updated entity.Photo back to search.Photo and update the results array
updatedSearchPhoto, convertErr := convertEntityToSearchPhoto(&fullPhoto)
if convertErr != nil {
log.Errorf("batch: failed to convert photo %s to search result: %s", photoID, convertErr)
} else {
photos[i] = *updatedSearchPhoto
}
updatedCount++
// Save sidecar YAML if enabled
SaveSidecarYaml(&fullPhoto)
log.Debugf("batch: successfully updated photo %s", photoID)
}
log.Infof("batch: successfully updated %d out of %d photos", updatedCount, len(photos))
// Publish photo update events
for _, photo := range photos {
PublishPhotoEvent(StatusUpdated, photo.PhotoUID, c)
}
// Update client config and flush cache
UpdateClientConfig()
FlushCoverCache()
}
// Create batch edit form values form from photo metadata.
@@ -85,3 +150,24 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
c.JSON(http.StatusOK, data)
})
}
// convertEntityToSearchPhoto converts an entity.Photo to search.Photo for API responses.
func convertEntityToSearchPhoto(photo *entity.Photo) (*search.Photo, error) {
searchPhoto := &search.Photo{}
// Copy common fields automatically
deepcopier.Copy(searchPhoto).From(photo)
// Set required fields manually
searchPhoto.CompositeID = fmt.Sprintf("%d", photo.ID)
// Copy details if they exist
if details := photo.GetDetails(); details != nil {
searchPhoto.DetailsSubject = details.Subject
searchPhoto.DetailsArtist = details.Artist
searchPhoto.DetailsCopyright = details.Copyright
searchPhoto.DetailsLicense = details.License
}
return searchPhoto, nil
}

View File

@@ -14,7 +14,7 @@ import (
)
func TestBatchPhotosEdit(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("SuccessNoChange", func(t *testing.T) {
// Create new API test instance.
app, router, _ := NewApiTest()
@@ -22,7 +22,7 @@ func TestBatchPhotosEdit(t *testing.T) {
BatchPhotosEdit(router)
// Specify the unique IDs of the photos used for testing.
photoUIDs := `["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8"]`
photoUIDs := `["pqkm36fjqvset9uy", "pqkm36fjqvset9uz"]`
// Get the photo models and current values for the batch edit form.
editResponse := PerformRequestWithBody(app,
@@ -40,7 +40,70 @@ func TestBatchPhotosEdit(t *testing.T) {
// Check the edit response values.
editValues := gjson.Get(editBody, "values").Raw
t.Logf("edit values: %#v", editValues)
timezoneBefore := gjson.Get(editValues, "TimeZone")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", timezoneBefore.String())
altitudeBefore := gjson.Get(editValues, "Altitude")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", altitudeBefore.String())
countryBefore := gjson.Get(editValues, "Country")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", countryBefore.String())
latBefore := gjson.Get(editValues, "Lat")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", latBefore.String())
lngBefore := gjson.Get(editValues, "Lng")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", lngBefore.String())
typeBefore := gjson.Get(editValues, "Type")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", typeBefore.String())
yearBefore := gjson.Get(editValues, "Year")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", yearBefore.String())
dayBefore := gjson.Get(editValues, "Day")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", dayBefore.String())
monthBefore := gjson.Get(editValues, "Month")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", monthBefore.String())
titleBefore := gjson.Get(editValues, "Title")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", titleBefore.String())
captionBefore := gjson.Get(editValues, "Caption")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", captionBefore.String())
subjectBefore := gjson.Get(editValues, "DetailsSubject")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", subjectBefore.String())
artistBefore := gjson.Get(editValues, "DetailsArtist")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", artistBefore.String())
copyrightBefore := gjson.Get(editValues, "DetailsCopyright")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", copyrightBefore.String())
licenseBefore := gjson.Get(editValues, "DetailsLicense")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", licenseBefore.String())
favoriteBefore := gjson.Get(editValues, "Favorite")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", favoriteBefore.String())
scanBefore := gjson.Get(editValues, "Scan")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", scanBefore.String())
privateBefore := gjson.Get(editValues, "Private")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", privateBefore.String())
panoramaBefore := gjson.Get(editValues, "Panorama")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", panoramaBefore.String())
albumsBefore := gjson.Get(editValues, "Albums")
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bipotaab19\",\"title\":\"IlikeFood\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bxpogaaba7\",\"title\":\"Christmas 2030\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bxpogaaba8\",\"title\":\"Holiday 2030\",\"mixed\":true,\"action\":\"none\"}")
labelsBefore := gjson.Get(editValues, "Labels")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy316\",\"title\":\"\\u0026friendship\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Cake\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c3\",\"title\":\"Flower\",\"mixed\":true,\"action\":\"none\"}")
cameraBefore := gjson.Get(editValues, "CameraID")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", cameraBefore.String())
lensBefore := gjson.Get(editValues, "LensID")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", lensBefore.String())
isoBefore := gjson.Get(editValues, "Iso")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", isoBefore.String())
fNumberBefore := gjson.Get(editValues, "FNumber")
assert.Equal(t, "{\"value\":3.5,\"mixed\":false,\"action\":\"none\"}", fNumberBefore.String())
focalLengthBefore := gjson.Get(editValues, "FocalLength")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", focalLengthBefore.String())
exposureBefore := gjson.Get(editValues, "Exposure")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", exposureBefore.String())
takenBefore := gjson.Get(editValues, "TakenAt")
assert.Equal(t, "{\"value\":\"2018-12-01T09:08:18Z\",\"mixed\":true,\"action\":\"none\"}", takenBefore.String())
takenLocalBefore := gjson.Get(editValues, "TakenAtLocal")
assert.Equal(t, "{\"value\":\"2018-12-01T09:08:18Z\",\"mixed\":true,\"action\":\"none\"}", takenLocalBefore.String())
keywordsBefore := gjson.Get(editValues, "DetailsKeywords")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", keywordsBefore.String())
// Send the edit form values back to the same API endpoint and check for errors.
saveResponse := PerformRequestWithBody(app,
@@ -57,8 +120,552 @@ func TestBatchPhotosEdit(t *testing.T) {
// Check the save response values.
saveValues := gjson.Get(saveBody, "values").Raw
t.Logf("save values: %#v", saveValues)
assert.Equal(t, editValues, saveValues)
//t.Logf("save values: %#v", saveValues)
timezoneAfter := gjson.Get(saveValues, "TimeZone")
assert.Equal(t, timezoneAfter.String(), timezoneBefore.String())
altitudeAfter := gjson.Get(saveValues, "Altitude")
assert.Equal(t, altitudeAfter.String(), altitudeBefore.String())
countryAfter := gjson.Get(saveValues, "Country")
assert.Equal(t, countryAfter.String(), countryBefore.String())
latAfter := gjson.Get(saveValues, "Lat")
assert.Equal(t, latAfter.String(), latBefore.String())
lngAfter := gjson.Get(saveValues, "Lng")
assert.Equal(t, lngAfter.String(), lngBefore.String())
typeAfter := gjson.Get(saveValues, "Type")
assert.Equal(t, typeAfter.String(), typeBefore.String())
yearAfter := gjson.Get(saveValues, "Year")
assert.Equal(t, yearAfter.String(), yearBefore.String())
dayAfter := gjson.Get(saveValues, "Day")
assert.Equal(t, dayAfter.String(), dayBefore.String())
monthAfter := gjson.Get(saveValues, "Month")
assert.Equal(t, monthAfter.String(), monthBefore.String())
titleAfter := gjson.Get(saveValues, "Title")
assert.Equal(t, titleAfter.String(), titleBefore.String())
captionAfter := gjson.Get(saveValues, "Caption")
assert.Equal(t, captionAfter.String(), captionBefore.String())
subjectAfter := gjson.Get(saveValues, "DetailsSubject")
assert.Equal(t, subjectAfter.String(), subjectBefore.String())
artistAfter := gjson.Get(saveValues, "DetailsArtist")
assert.Equal(t, artistAfter.String(), artistBefore.String())
copyrightAfter := gjson.Get(saveValues, "DetailsCopyright")
assert.Equal(t, copyrightAfter.String(), copyrightBefore.String())
licenseAfter := gjson.Get(saveValues, "DetailsLicense")
assert.Equal(t, licenseAfter.String(), licenseBefore.String())
favoriteAfter := gjson.Get(saveValues, "Favorite")
assert.Equal(t, favoriteAfter.String(), favoriteBefore.String())
scanAfter := gjson.Get(saveValues, "Scan")
assert.Equal(t, scanAfter.String(), scanBefore.String())
privateAfter := gjson.Get(saveValues, "Private")
assert.Equal(t, privateAfter.String(), privateBefore.String())
panoramaAfter := gjson.Get(saveValues, "Panorama")
assert.Equal(t, panoramaAfter.String(), panoramaBefore.String())
albumsAfter := gjson.Get(saveValues, "Albums")
assert.Equal(t, albumsAfter.String(), albumsBefore.String())
labelsAfter := gjson.Get(saveValues, "Labels")
assert.Equal(t, labelsAfter.String(), labelsBefore.String())
cameraAfter := gjson.Get(saveValues, "CameraID")
assert.Equal(t, cameraAfter.String(), cameraBefore.String())
lensAfter := gjson.Get(saveValues, "LensID")
assert.Equal(t, lensAfter.String(), lensBefore.String())
isoAfter := gjson.Get(saveValues, "Iso")
assert.Equal(t, isoAfter.String(), isoBefore.String())
fNumberAfter := gjson.Get(saveValues, "FNumber")
assert.Equal(t, fNumberAfter.String(), fNumberBefore.String())
focalLengthAfter := gjson.Get(saveValues, "FocalLength")
assert.Equal(t, focalLengthAfter.String(), focalLengthBefore.String())
exposureAfter := gjson.Get(saveValues, "Exposure")
assert.Equal(t, exposureAfter.String(), exposureBefore.String())
takenAfter := gjson.Get(saveValues, "TakenAt")
assert.Equal(t, takenAfter.String(), takenBefore.String())
takenLocalAfter := gjson.Get(saveValues, "TakenAtLocal")
assert.Equal(t, takenLocalAfter.String(), takenLocalBefore.String())
//TODO Uncomment once keywords may be supported
//keywordsAfter := gjson.Get(saveValues, "DetailsKeywords")
//assert.Equal(t, keywordsAfter.String(), keywordsBefore.String())
//assert.Equal(t, editValues, saveValues)
})
t.Run("SuccessChangeLocationValues", func(t *testing.T) {
// Create new API test instance.
app, router, _ := NewApiTest()
// Attach POST /api/v1/batch/photos/edit request handler.
BatchPhotosEdit(router)
// Specify the unique IDs of the photos used for testing.
photoUIDs := `["pqkm36fjqvset8uy", "pqkm36fjqvset9uz"]`
// Get the photo models and current values for the batch edit form.
editResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s}`, photoUIDs),
)
// Check the edit response status code.
assert.Equal(t, http.StatusOK, editResponse.Code)
// Check the edit response body.
editBody := editResponse.Body.String()
assert.NotEmpty(t, editBody)
// Check the edit response values.
editPhotos := gjson.Get(editBody, "models").Array()
assert.Equal(t, len(editPhotos), 2)
editValues := gjson.Get(editBody, "values").Raw
timezoneBefore := gjson.Get(editValues, "TimeZone")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", timezoneBefore.String())
altitudeBefore := gjson.Get(editValues, "Altitude")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", altitudeBefore.String())
countryBefore := gjson.Get(editValues, "Country")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", countryBefore.String())
latBefore := gjson.Get(editValues, "Lat")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", latBefore.String())
lngBefore := gjson.Get(editValues, "Lng")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", lngBefore.String())
// Send the edit form values back to the same API endpoint and check for errors.
saveResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s, "values": %s}`, photoUIDs,
"{"+
"\"Lat\":{\"value\":21.850195,\"mixed\":false,\"action\":\"update\"},"+
"\"Lng\":{\"value\":90.18015,\"mixed\":false,\"action\":\"update\"}"+
"}"),
)
// Check the save response status code.
assert.Equal(t, http.StatusOK, saveResponse.Code)
// Check the save response body.
saveBody := saveResponse.Body.String()
assert.NotEmpty(t, saveBody)
// Check the save response values.
saveValues := gjson.Get(saveBody, "values").Raw
timezoneAfter := gjson.Get(saveValues, "TimeZone")
assert.Equal(t, "{\"value\":\"Asia/Dhaka\",\"mixed\":false,\"action\":\"none\"}", timezoneAfter.String())
altitudeAfter := gjson.Get(saveValues, "Altitude")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", altitudeAfter.String())
countryAfter := gjson.Get(saveValues, "Country")
assert.Equal(t, "{\"value\":\"bd\",\"mixed\":false,\"action\":\"none\"}", countryAfter.String())
latAfter := gjson.Get(saveValues, "Lat")
assert.Equal(t, "{\"value\":21.850195,\"mixed\":false,\"action\":\"none\"}", latAfter.String())
lngAfter := gjson.Get(saveValues, "Lng")
assert.Equal(t, "{\"value\":90.18015,\"mixed\":false,\"action\":\"none\"}", lngAfter.String())
GetPhoto(router)
r1 := PerformRequest(app, "GET", "/api/v1/photos/pqkm36fjqvset9uz")
assert.Equal(t, http.StatusOK, r1.Code)
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "PlaceSrc").String())
assert.Equal(t, "meta", gjson.Get(r1.Body.String(), "TakenSrc").String())
})
t.Run("SuccessChangeValues", func(t *testing.T) {
// Create new API test instance.
app, router, _ := NewApiTest()
// Attach POST /api/v1/batch/photos/edit request handler.
BatchPhotosEdit(router)
// Specify the unique IDs of the photos used for testing.
photoUIDs := `["pqkm36fjqvset9uy", "pqkm36fjqvset9uz"]`
// Get the photo models and current values for the batch edit form.
editResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s}`, photoUIDs),
)
// Check the edit response status code.
assert.Equal(t, http.StatusOK, editResponse.Code)
// Check the edit response body.
editBody := editResponse.Body.String()
assert.NotEmpty(t, editBody)
// Check the edit response values.
editPhotos := gjson.Get(editBody, "models").Array()
assert.Equal(t, len(editPhotos), 2)
editValues := gjson.Get(editBody, "values").Raw
timezoneBefore := gjson.Get(editValues, "TimeZone")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", timezoneBefore.String())
altitudeBefore := gjson.Get(editValues, "Altitude")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", altitudeBefore.String())
typeBefore := gjson.Get(editValues, "Type")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", typeBefore.String())
yearBefore := gjson.Get(editValues, "Year")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", yearBefore.String())
dayBefore := gjson.Get(editValues, "Day")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", dayBefore.String())
monthBefore := gjson.Get(editValues, "Month")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", monthBefore.String())
titleBefore := gjson.Get(editValues, "Title")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", titleBefore.String())
captionBefore := gjson.Get(editValues, "Caption")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", captionBefore.String())
subjectBefore := gjson.Get(editValues, "DetailsSubject")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", subjectBefore.String())
artistBefore := gjson.Get(editValues, "DetailsArtist")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", artistBefore.String())
copyrightBefore := gjson.Get(editValues, "DetailsCopyright")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", copyrightBefore.String())
licenseBefore := gjson.Get(editValues, "DetailsLicense")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", licenseBefore.String())
favoriteBefore := gjson.Get(editValues, "Favorite")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", favoriteBefore.String())
scanBefore := gjson.Get(editValues, "Scan")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", scanBefore.String())
privateBefore := gjson.Get(editValues, "Private")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", privateBefore.String())
panoramaBefore := gjson.Get(editValues, "Panorama")
assert.Equal(t, "{\"value\":false,\"mixed\":true,\"action\":\"none\"}", panoramaBefore.String())
// Send the edit form values back to the same API endpoint and check for errors.
saveResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s, "values": %s}`, photoUIDs,
"{"+
"\"TimeZone\":{\"value\":\"Europe/Vienna\",\"mixed\":false,\"action\":\"update\"},"+
"\"Altitude\":{\"value\":145,\"mixed\":false,\"action\":\"update\"},"+
"\"Year\":{\"value\":2000,\"mixed\":false,\"action\":\"update\"},"+
"\"Month\":{\"value\":11,\"mixed\":true,\"action\":\"update\"},"+
"\"Day\":{\"value\":-1,\"mixed\":true,\"action\":\"update\"},"+
"\"Title\":{\"value\":\"My Batch Edited Title\",\"mixed\":false,\"action\":\"update\"},"+
"\"Caption\":{\"value\":\"Batch edited caption\",\"mixed\":false,\"action\":\"update\"},"+
"\"DetailsSubject\":{\"value\":\"Batch edited subject\",\"mixed\":false,\"action\":\"update\"},"+
"\"DetailsArtist\":{\"value\":\"Batchie\",\"mixed\":false,\"action\":\"update\"},"+
"\"DetailsCopyright\":{\"value\":\"Batch edited copyright\",\"mixed\":false,\"action\":\"update\"},"+
"\"DetailsLicense\":{\"value\":\"Batch edited license\",\"mixed\":false,\"action\":\"update\"},"+
"\"Type\":{\"value\":\"live\",\"mixed\":false,\"action\":\"update\"},"+
"\"Favorite\":{\"value\":false,\"mixed\":false,\"action\":\"update\"},"+
"\"Panorama\":{\"value\":true,\"mixed\":false,\"action\":\"update\"},"+
"\"Private\":{\"value\":true,\"mixed\":false,\"action\":\"update\"},"+
"\"Scan\":{\"value\":true,\"mixed\":false,\"action\":\"update\"}"+
"}"),
)
// Check the save response status code.
assert.Equal(t, http.StatusOK, saveResponse.Code)
// Check the save response body.
saveBody := saveResponse.Body.String()
assert.NotEmpty(t, saveBody)
// Check the save response values.
saveValues := gjson.Get(saveBody, "values").Raw
timezoneAfter := gjson.Get(saveValues, "TimeZone")
assert.Equal(t, "{\"value\":\"Europe/Vienna\",\"mixed\":false,\"action\":\"none\"}", timezoneAfter.String())
altitudeAfter := gjson.Get(saveValues, "Altitude")
assert.Equal(t, "{\"value\":145,\"mixed\":false,\"action\":\"none\"}", altitudeAfter.String())
typeAfter := gjson.Get(saveValues, "Type")
assert.Equal(t, "{\"value\":\"live\",\"mixed\":false,\"action\":\"none\"}", typeAfter.String())
yearAfter := gjson.Get(saveValues, "Year")
assert.Equal(t, "{\"value\":2000,\"mixed\":false,\"action\":\"none\"}", yearAfter.String())
dayAfter := gjson.Get(saveValues, "Day")
assert.Equal(t, "{\"value\":-1,\"mixed\":false,\"action\":\"none\"}", dayAfter.String())
monthAfter := gjson.Get(saveValues, "Month")
assert.Equal(t, "{\"value\":11,\"mixed\":false,\"action\":\"none\"}", monthAfter.String())
titleAfter := gjson.Get(saveValues, "Title")
assert.Equal(t, "{\"value\":\"My Batch Edited Title\",\"mixed\":false,\"action\":\"none\"}", titleAfter.String())
captionAfter := gjson.Get(saveValues, "Caption")
assert.Equal(t, "{\"value\":\"Batch edited caption\",\"mixed\":false,\"action\":\"none\"}", captionAfter.String())
subjectAfter := gjson.Get(saveValues, "DetailsSubject")
assert.Equal(t, "{\"value\":\"Batch edited subject\",\"mixed\":false,\"action\":\"none\"}", subjectAfter.String())
artistAfter := gjson.Get(saveValues, "DetailsArtist")
assert.Equal(t, "{\"value\":\"Batchie\",\"mixed\":false,\"action\":\"none\"}", artistAfter.String())
copyrightAfter := gjson.Get(saveValues, "DetailsCopyright")
assert.Equal(t, "{\"value\":\"Batch edited copyright\",\"mixed\":false,\"action\":\"none\"}", copyrightAfter.String())
licenseAfter := gjson.Get(saveValues, "DetailsLicense")
assert.Equal(t, "{\"value\":\"Batch edited license\",\"mixed\":false,\"action\":\"none\"}", licenseAfter.String())
favoriteAfter := gjson.Get(saveValues, "Favorite")
assert.Equal(t, "{\"value\":false,\"mixed\":false,\"action\":\"none\"}", favoriteAfter.String())
scanAfter := gjson.Get(saveValues, "Scan")
assert.Equal(t, "{\"value\":true,\"mixed\":false,\"action\":\"none\"}", scanAfter.String())
privateAfter := gjson.Get(saveValues, "Private")
assert.Equal(t, "{\"value\":true,\"mixed\":false,\"action\":\"none\"}", privateAfter.String())
panoramaAfter := gjson.Get(saveValues, "Panorama")
assert.Equal(t, "{\"value\":true,\"mixed\":false,\"action\":\"none\"}", panoramaAfter.String())
takenAfter := gjson.Get(saveValues, "TakenAt")
assert.Contains(t, takenAfter.String(), "{\"value\":\"2000-11")
takenLocalAfter := gjson.Get(saveValues, "TakenAtLocal")
assert.Contains(t, takenLocalAfter.String(), "{\"value\":\"2000-11")
GetPhoto(router)
r1 := PerformRequest(app, "GET", "/api/v1/photos/pqkm36fjqvset9uz")
assert.Equal(t, http.StatusOK, r1.Code)
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "PlaceSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "TakenSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "TypeSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "TitleSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "CaptionSrc").String())
assert.Equal(t, "meta", gjson.Get(r1.Body.String(), "Details.KeywordsSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.SubjectSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.ArtistSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.CopyrightSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.LicenseSrc").String())
})
t.Run("SuccessChangeAlbumAndLabels", func(t *testing.T) {
// Create new API test instance.
app, router, _ := NewApiTest()
// Attach POST /api/v1/batch/photos/edit request handler.
BatchPhotosEdit(router)
// Specify the unique IDs of the photos used for testing.
photoUIDs := `["pqkm36fjqvset9uy", "pqkm36fjqvset9uz"]`
// Get the photo models and current values for the batch edit form.
editResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s}`, photoUIDs),
)
// Check the edit response status code.
assert.Equal(t, http.StatusOK, editResponse.Code)
// Check the edit response body.
editBody := editResponse.Body.String()
assert.NotEmpty(t, editBody)
// Check the edit response values.
editPhotos := gjson.Get(editBody, "models").Array()
assert.Equal(t, len(editPhotos), 2)
editValues := gjson.Get(editBody, "values").Raw
//t.Logf(editValues)
albumsBefore := gjson.Get(editValues, "Albums")
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bipotaab19\",\"title\":\"IlikeFood\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bxpogaaba7\",\"title\":\"Christmas 2030\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bxpogaaba8\",\"title\":\"Holiday 2030\",\"mixed\":true,\"action\":\"none\"}")
labelsBefore := gjson.Get(editValues, "Labels")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy316\",\"title\":\"\\u0026friendship\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Cake\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c5\",\"title\":\"COW\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c2\",\"title\":\"Landscape\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c3\",\"title\":\"Flower\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy317\",\"title\":\"construction\\u0026failure\",\"mixed\":true,\"action\":\"none\"}")
// Send the edit form values back to the same API endpoint and check for errors.
saveResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s, "values": %s}`, photoUIDs,
"{"+
"\"Labels\":{\"items\":[{\"value\":\"ls6sg6b1wowuy317\",\"title\":\"construction\\u0026failure\",\"mixed\":false,\"action\":\"remove\"},{\"value\":\"ls6sg6b1wowuy3c2\",\"title\":\"Landscape\",\"mixed\":false,\"action\":\"remove\"},{\"value\":\"ls6sg6b1wowuy3c5\",\"title\":\"COW\",\"mixed\":false,\"action\":\"remove\"},{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Cake\",\"mixed\":false,\"action\":\"remove\"},{\"value\":\"ls6sg6b1wowuy3c3\",\"title\":\"Flower\",\"mixed\":false,\"action\":\"add\"},{\"value\":\"ls6sg6b1wowuy316\",\"title\":\"&friendship\",\"mixed\":false,\"action\":\"remove\"},{\"value\":\"\",\"title\":\"BatchLabel\",\"mixed\":false,\"action\":\"add\"}],\"mixed\":false,\"action\":\"update\"},"+
"\"Albums\":{\"items\":[{\"value\":\"as6sg6bipotaab19\",\"title\":\"IlikeFood\",\"mixed\":false,\"action\":\"remove\"},{\"value\":\"as6sg6bxpogaaba8\",\"title\":\"Holiday 2030\",\"mixed\":true,\"action\":\"none\"},{\"value\":\"as6sg6bxpogaaba7\",\"title\":\"Christmas 2030\",\"mixed\":false,\"action\":\"add\"}, {\"value\":\"\",\"title\":\"BatchAlbum\",\"mixed\":false,\"action\":\"add\"}],\"mixed\":true,\"action\":\"update\"}"+
"}"),
)
// Check the save response status code.
assert.Equal(t, http.StatusOK, saveResponse.Code)
// Check the save response body.
saveBody := saveResponse.Body.String()
assert.NotEmpty(t, saveBody)
// Check the save response values.
saveValues := gjson.Get(saveBody, "values").Raw
albumsAfter := gjson.Get(saveValues, "Albums")
assert.Contains(t, albumsAfter.String(), "{\"value\":\"as6sg6bxpogaaba8\",\"title\":\"Holiday 2030\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, albumsAfter.String(), "{\"value\":\"as6sg6bxpogaaba7\",\"title\":\"Christmas 2030\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, albumsAfter.String(), "\"title\":\"BatchAlbum\",\"mixed\":false,\"action\":\"none\"}")
assert.NotContains(t, albumsAfter.String(), "{\"value\":\"as6sg6bipotaab19\",\"title\":\"\\u0026IlikeFood\"")
labelsAfter := gjson.Get(saveValues, "Labels")
assert.Contains(t, labelsAfter.String(), "{\"value\":\"ls6sg6b1wowuy3c3\",\"title\":\"Flower\",\"mixed\":false,\"action\":\"none\"}")
assert.NotContains(t, labelsAfter.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Cake\"")
assert.Contains(t, labelsAfter.String(), "\"title\":\"BatchLabel\",\"mixed\":false,\"action\":\"none\"}")
assert.NotContains(t, labelsAfter.String(), "{\"value\":\"ls6sg6b1wowuy316\",\"title\":\"\\u0026friendship\"")
assert.NotContains(t, labelsAfter.String(), "{\"value\":\"ls6sg6b1wowuy3c5\",\"title\":\"COW\",\"mixed\":false,\"action\":\"none\"}")
assert.NotContains(t, labelsAfter.String(), "{\"value\":\"ls6sg6b1wowuy3c2\",\"title\":\"Landscape\",\"mixed\":false,\"action\":\"none\"}")
assert.NotContains(t, labelsAfter.String(), "{\"value\":\"ls6sg6b1wowuy317\",\"title\":\"construction\\u0026failure\",\"mixed\":true,\"action\":\"none\"}")
GetPhoto(router)
r1 := PerformRequest(app, "GET", "/api/v1/photos/pqkm36fjqvset9uz")
assert.Equal(t, http.StatusOK, r1.Code)
assert.Equal(t, "BatchLabel", gjson.Get(r1.Body.String(), "Labels.0.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.0.LabelSrc").String())
assert.Equal(t, "0", gjson.Get(r1.Body.String(), "Labels.0.Uncertainty").String())
assert.Equal(t, "Flower", gjson.Get(r1.Body.String(), "Labels.1.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.1.LabelSrc").String())
assert.Equal(t, "0", gjson.Get(r1.Body.String(), "Labels.1.Uncertainty").String())
assert.Equal(t, "&friendship", gjson.Get(r1.Body.String(), "Labels.2.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.2.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.2.Uncertainty").String())
assert.Equal(t, "COW", gjson.Get(r1.Body.String(), "Labels.3.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.3.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.3.Uncertainty").String())
assert.Equal(t, "Cake", gjson.Get(r1.Body.String(), "Labels.4.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.4.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.4.Uncertainty").String())
assert.Equal(t, "Landscape", gjson.Get(r1.Body.String(), "Labels.5.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.5.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.5.Uncertainty").String())
assert.Equal(t, "", gjson.Get(r1.Body.String(), "Labels.6.Label.Name").String())
r2 := PerformRequest(app, "GET", "/api/v1/photos/pqkm36fjqvset9uy")
assert.Equal(t, http.StatusOK, r2.Code)
assert.Equal(t, "BatchLabel", gjson.Get(r2.Body.String(), "Labels.0.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r2.Body.String(), "Labels.0.LabelSrc").String())
assert.Equal(t, "0", gjson.Get(r2.Body.String(), "Labels.0.Uncertainty").String())
assert.Equal(t, "Flower", gjson.Get(r2.Body.String(), "Labels.1.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r2.Body.String(), "Labels.1.LabelSrc").String())
assert.Equal(t, "0", gjson.Get(r2.Body.String(), "Labels.1.Uncertainty").String())
assert.Equal(t, "COW", gjson.Get(r2.Body.String(), "Labels.2.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r2.Body.String(), "Labels.2.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r2.Body.String(), "Labels.2.Uncertainty").String())
assert.Equal(t, "Landscape", gjson.Get(r2.Body.String(), "Labels.3.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r2.Body.String(), "Labels.3.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r2.Body.String(), "Labels.3.Uncertainty").String())
assert.Equal(t, "", gjson.Get(r2.Body.String(), "Labels.4.Label.Name").String())
})
t.Run("SuccessChangeCountry", func(t *testing.T) {
// Create new API test instance.
app, router, _ := NewApiTest()
// Attach POST /api/v1/batch/photos/edit request handler.
BatchPhotosEdit(router)
// Specify the unique IDs of the photos used for testing.
photoUIDs := `["pqkm36fjqvset9uy", "pqkm36fjqvset9uz"]`
// Get the photo models and current values for the batch edit form.
editResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s}`, photoUIDs),
)
// Check the edit response status code.
assert.Equal(t, http.StatusOK, editResponse.Code)
// Check the edit response body.
editBody := editResponse.Body.String()
assert.NotEmpty(t, editBody)
// Check the edit response values.
editPhotos := gjson.Get(editBody, "models").Array()
assert.Equal(t, len(editPhotos), 2)
editValues := gjson.Get(editBody, "values").Raw
timezoneBefore := gjson.Get(editValues, "TimeZone")
assert.Equal(t, "{\"value\":\"Europe/Vienna\",\"mixed\":false,\"action\":\"none\"}", timezoneBefore.String())
altitudeBefore := gjson.Get(editValues, "Altitude")
assert.Equal(t, "{\"value\":145,\"mixed\":false,\"action\":\"none\"}", altitudeBefore.String())
countryBefore := gjson.Get(editValues, "Country")
assert.Equal(t, "{\"value\":\"\",\"mixed\":true,\"action\":\"none\"}", countryBefore.String())
latBefore := gjson.Get(editValues, "Lat")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", latBefore.String())
lngBefore := gjson.Get(editValues, "Lng")
assert.Equal(t, "{\"value\":0,\"mixed\":true,\"action\":\"none\"}", lngBefore.String())
// Send the edit form values back to the same API endpoint and check for errors.
saveResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s, "values": %s}`, photoUIDs,
"{"+
"\"Country\":{\"value\":\"gb\",\"mixed\":false,\"action\":\"update\"},"+
"\"Lat\":{\"value\":0,\"mixed\":false,\"action\":\"update\"},"+
"\"Lng\":{\"value\":0,\"mixed\":false,\"action\":\"update\"}"+
"}"),
)
// Check the save response status code.
assert.Equal(t, http.StatusOK, saveResponse.Code)
// Check the save response body.
saveBody := saveResponse.Body.String()
assert.NotEmpty(t, saveBody)
// Check the save response values.
saveValues := gjson.Get(saveBody, "values").Raw
timezoneAfter := gjson.Get(saveValues, "TimeZone")
assert.Equal(t, "{\"value\":\"Europe/Vienna\",\"mixed\":false,\"action\":\"none\"}", timezoneAfter.String())
altitudeAfter := gjson.Get(saveValues, "Altitude")
assert.Equal(t, "{\"value\":145,\"mixed\":false,\"action\":\"none\"}", altitudeAfter.String())
countryAfter := gjson.Get(saveValues, "Country")
assert.Equal(t, "{\"value\":\"gb\",\"mixed\":false,\"action\":\"none\"}", countryAfter.String())
latAfter := gjson.Get(saveValues, "Lat")
assert.Equal(t, "{\"value\":0,\"mixed\":false,\"action\":\"none\"}", latAfter.String())
lngAfter := gjson.Get(saveValues, "Lng")
assert.Equal(t, "{\"value\":0,\"mixed\":false,\"action\":\"none\"}", lngAfter.String())
})
t.Run("SuccessRemoveValues", func(t *testing.T) {
// Create new API test instance.
app, router, _ := NewApiTest()
// Attach POST /api/v1/batch/photos/edit request handler.
BatchPhotosEdit(router)
// Specify the unique IDs of the photos used for testing.
photoUIDs := `["pqkm36fjqvset9uy", "pqkm36fjqvset9uz"]`
// Get the photo models and current values for the batch edit form.
editResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s}`, photoUIDs),
)
// Check the edit response status code.
assert.Equal(t, http.StatusOK, editResponse.Code)
// Check the edit response body.
editBody := editResponse.Body.String()
assert.NotEmpty(t, editBody)
// Check the edit response values.
editPhotos := gjson.Get(editBody, "models").Array()
assert.Equal(t, len(editPhotos), 2)
// Send the edit form values back to the same API endpoint and check for errors.
saveResponse := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
fmt.Sprintf(`{"photos": %s, "values": %s}`, photoUIDs,
"{"+
"\"Altitude\":{\"value\":0,\"mixed\":false,\"action\":\"update\"},"+
"\"Year\":{\"value\":-1,\"mixed\":false,\"action\":\"update\"},"+
"\"Month\":{\"value\":-1,\"mixed\":false,\"action\":\"update\"},"+
"\"Day\":{\"value\":-1,\"mixed\":false,\"action\":\"update\"},"+
"\"Title\":{\"value\":\"\",\"mixed\":false,\"action\":\"remove\"},"+
"\"Caption\":{\"value\":\"\",\"mixed\":false,\"action\":\"remove\"},"+
"\"DetailsSubject\":{\"value\":\"\",\"mixed\":false,\"action\":\"remove\"},"+
"\"DetailsArtist\":{\"value\":\"\",\"mixed\":false,\"action\":\"remove\"},"+
"\"DetailsCopyright\":{\"value\":\"\",\"mixed\":false,\"action\":\"remove\"},"+
"\"DetailsLicense\":{\"value\":\"\",\"mixed\":false,\"action\":\"remove\"}"+
"}"),
)
// Check the save response status code.
assert.Equal(t, http.StatusOK, saveResponse.Code)
// Check the save response body.
saveBody := saveResponse.Body.String()
assert.NotEmpty(t, saveBody)
// Check the save response values.
saveValues := gjson.Get(saveBody, "values").Raw
altitudeAfter := gjson.Get(saveValues, "Altitude")
assert.Equal(t, "{\"value\":0,\"mixed\":false,\"action\":\"none\"}", altitudeAfter.String())
yearAfter := gjson.Get(saveValues, "Year")
assert.Equal(t, "{\"value\":-1,\"mixed\":false,\"action\":\"none\"}", yearAfter.String())
dayAfter := gjson.Get(saveValues, "Day")
assert.Equal(t, "{\"value\":-1,\"mixed\":false,\"action\":\"none\"}", dayAfter.String())
monthAfter := gjson.Get(saveValues, "Month")
assert.Equal(t, "{\"value\":-1,\"mixed\":false,\"action\":\"none\"}", monthAfter.String())
titleAfter := gjson.Get(saveValues, "Title")
assert.Equal(t, "{\"value\":\"\",\"mixed\":false,\"action\":\"none\"}", titleAfter.String())
captionAfter := gjson.Get(saveValues, "Caption")
assert.Equal(t, "{\"value\":\"\",\"mixed\":false,\"action\":\"none\"}", captionAfter.String())
subjectAfter := gjson.Get(saveValues, "DetailsSubject")
assert.Equal(t, "{\"value\":\"\",\"mixed\":false,\"action\":\"none\"}", subjectAfter.String())
artistAfter := gjson.Get(saveValues, "DetailsArtist")
assert.Equal(t, "{\"value\":\"\",\"mixed\":false,\"action\":\"none\"}", artistAfter.String())
copyrightAfter := gjson.Get(saveValues, "DetailsCopyright")
assert.Equal(t, "{\"value\":\"\",\"mixed\":false,\"action\":\"none\"}", copyrightAfter.String())
licenseAfter := gjson.Get(saveValues, "DetailsLicense")
assert.Equal(t, "{\"value\":\"\",\"mixed\":false,\"action\":\"none\"}", licenseAfter.String())
GetPhoto(router)
r1 := PerformRequest(app, "GET", "/api/v1/photos/pqkm36fjqvset9uz")
assert.Equal(t, http.StatusOK, r1.Code)
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "PlaceSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "TakenSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "TypeSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "TitleSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "CaptionSrc").String())
assert.Equal(t, "meta", gjson.Get(r1.Body.String(), "Details.KeywordsSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.SubjectSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.ArtistSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.CopyrightSrc").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Details.LicenseSrc").String())
})
t.Run("ReturnPhotosAndValues", func(t *testing.T) {
app, router, conf := NewApiTest()

View File

@@ -0,0 +1,60 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
)
// ClusterMetrics returns lightweight metrics about the cluster.
//
// @Summary temporary cluster metrics (counts only)
// @Id ClusterMetrics
// @Tags Cluster
// @Produce json
// @Success 200 {object} cluster.MetricsResponse
// @Failure 401,403,429 {object} i18n.Response
// @Router /api/v1/cluster/metrics [get]
func ClusterMetrics(router *gin.RouterGroup) {
router.GET("/cluster/metrics", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionView)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
nodes, _ := regy.List()
counts := map[string]int{"total": len(nodes)}
for _, node := range nodes {
role := node.Role
if role == "" {
role = "unknown"
}
counts[role]++
}
c.JSON(http.StatusOK, cluster.MetricsResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: counts,
Time: time.Now().UTC().Format(time.RFC3339),
})
})
}

View File

@@ -0,0 +1,27 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/service/cluster"
)
func TestClusterMetrics_EmptyCounts(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().ClusterCIDR = "192.0.2.0/24"
ClusterMetrics(router)
token := AuthenticateAdmin(app, router)
resp := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/metrics", token)
assert.Equal(t, http.StatusOK, resp.Code)
body := resp.Body.String()
assert.Equal(t, "192.0.2.0/24", gjson.Get(body, "clusterCidr").String())
assert.Equal(t, int64(0), gjson.Get(body, "nodes.total").Int())
}

View File

@@ -24,10 +24,10 @@ func TestClusterListNodes_Redaction(t *testing.T) {
// Seed one node with internal URL and DB metadata.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List().
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
n.Database.Name = "pp_db"
n.Database.User = "pp_user"
n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}}
n.Database = &cluster.NodeDatabase{Name: "pp_db", User: "pp_user"}
assert.NoError(t, regy.Put(n))
// Admin session shows internal fields
@@ -55,10 +55,10 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Seed node with internal URL and DB meta.
n := &reg.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}
n.Database.Name = "pp_db2"
n.Database.User = "pp_user2"
n := &reg.Node{Node: cluster.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}}
n.Database = &cluster.NodeDatabase{Name: "pp_db2", User: "pp_user2"}
assert.NoError(t, regy.Put(n))
// Create client session with cluster scope and no user (redacted view expected).

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
@@ -68,18 +69,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
// Parse request.
var req struct {
NodeName string `json:"nodeName"`
NodeUUID string `json:"nodeUUID"`
NodeRole string `json:"nodeRole"`
Labels map[string]string `json:"labels"`
AdvertiseUrl string `json:"advertiseUrl"`
SiteUrl string `json:"siteUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
RotateDatabase bool `json:"rotateDatabase"`
RotateSecret bool `json:"rotateSecret"`
}
var req cluster.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "form invalid", "%s"}, clean.Error(err))
@@ -227,13 +217,23 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate db", event.Succeeded, "node %s"}, clean.LogQuote(name))
}
jwksURL := buildJWKSURL(conf)
// Build response with struct types.
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
dbInfo := cluster.NodeDatabase{}
if n.Database != nil {
dbInfo = *n.Database
}
resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Node: reg.BuildClusterNode(*n, opts),
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
Secrets: respSecret,
JWKSUrl: jwksURL,
AlreadyRegistered: true,
AlreadyProvisioned: true,
}
@@ -252,14 +252,18 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// New node (client UID will be generated in registry.Put).
n := &reg.Node{
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
UUID: requestedUUID,
Labels: req.Labels,
Node: cluster.Node{
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
UUID: requestedUUID,
Labels: req.Labels,
},
}
if n.UUID == "" {
n.UUID = rnd.UUIDv7()
}
// Derive a sensible default advertise URL when not provided by the client.
if req.AdvertiseUrl != "" {
n.AdvertiseUrl = req.AdvertiseUrl
@@ -281,6 +285,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if n.Database == nil {
n.Database = &cluster.NodeDatabase{}
}
n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt
n.Database.Driver = provisioner.DatabaseDriver
@@ -291,9 +300,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt},
JWKSUrl: buildJWKSURL(conf),
AlreadyRegistered: false,
AlreadyProvisioned: false,
}
@@ -348,5 +360,23 @@ func validateAdvertiseURL(u string) bool {
return false
}
func buildJWKSURL(conf *config.Config) string {
if conf == nil {
return "/.well-known/jwks.json"
}
path := conf.BaseUri("/.well-known/jwks.json")
if path == "" {
path = "/.well-known/jwks.json"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
site := strings.TrimRight(conf.SiteUrl(), "/")
if site == "" {
return path
}
return site + path
}
// validateSiteURL applies the same rules as validateAdvertiseURL.
func validateSiteURL(u string) bool { return validateAdvertiseURL(u) }

View File

@@ -25,13 +25,13 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create a node via registry and rotate to get a plaintext secret for tests
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}}
assert.NoError(t, regy.Put(n))
nr, err := regy.RotateSecret(n.UUID)
assert.NoError(t, err)
@@ -39,17 +39,17 @@ func TestClusterNodesRegister(t *testing.T) {
// Missing secret → 401
body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Wrong secret → 401
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"WRONG"}`
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Correct secret → 200 (existing-node path)
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"` + secret + `"}`
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("MissingToken", func(t *testing.T) {
@@ -63,12 +63,12 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Provisioner is independent of the main DB; with MariaDB admin DSN configured
// it should successfully provision and return 201.
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String()
assert.Contains(t, body, "\"database\"")
@@ -79,67 +79,68 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Pre-create node with a UUID
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}}
assert.NoError(t, regy.Put(n))
// Attempt to change UUID via name without client credentials → 409
newUUID := rnd.UUIDv7()
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusConflict, r.Code)
})
t.Run("BadAdvertiseUrlRejected", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// http scheme for public host must be rejected (require https unless localhost).
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// https is allowed for public host
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
// http is allowed for localhost
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
})
t.Run("SiteUrlValidation", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Reject http siteUrl for public host
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusBadRequest, r.Code)
// Accept https siteUrl
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
})
t.Run("NormalizeName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Mixed separators and case should normalize to DNS label
body := `{"nodeName":"My.Node/Name:Prod"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
regy, err := reg.NewClientRegistryWithConfig(conf)
@@ -153,17 +154,17 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("BadName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Empty nodeName → 400
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path
@@ -172,10 +173,10 @@ func TestClusterNodesRegister(t *testing.T) {
// used by OAuth tests running in the same package.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance"}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
assert.NoError(t, regy.Put(n))
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
// Secret should have rotated and been persisted even though DB ensure failed.
@@ -189,17 +190,17 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-02", Role: "instance"}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: "instance"}}
assert.NoError(t, regy.Put(n))
// Provisioner is independent; endpoint should respond 200 and persist metadata.
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
// Ensure normalized/persisted siteUrl.
@@ -210,11 +211,11 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Register without nodeUUID; server should assign one (UUID v7 preferred).
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
// Response must include node.uuid

View File

@@ -27,10 +27,13 @@ func TestClusterEndpoints(t *testing.T) {
// Seed nodes in the registry
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
n2 := &reg.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}
n2 := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n2))
// Resolve actual IDs (client-backed registry generates IDs)
n, err = regy.FindByName("pp-node-01")
assert.NoError(t, err)
@@ -87,8 +90,10 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
// Seed a node and resolve its actual ID.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
n, err = regy.FindByName("pp-node-99")
assert.NoError(t, err)
@@ -114,9 +119,11 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
// Excessively long ID (>64 chars) is rejected.
longID := make([]byte, 65)
for i := range longID {
longID[i] = 'a'
}
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+string(longID))
assert.Equal(t, http.StatusNotFound, r.Code)
}

View File

@@ -21,8 +21,9 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Seed node
n := &reg.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
n, err = regy.FindByName("pp-node-siteurl")
assert.NoError(t, err)

View File

@@ -26,15 +26,20 @@ func TestClusterPermissions(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
ClusterSummary(router)
ClusterMetrics(router)
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster")
assert.Equal(t, http.StatusUnauthorized, r.Code)
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/metrics")
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("ForbiddenFromCDN", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
ClusterListNodes(router)
ClusterMetrics(router)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/nodes", nil)
// Mark as CDN request, which Auth() forbids.
@@ -47,9 +52,13 @@ func TestClusterPermissions(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
ClusterSummary(router)
ClusterMetrics(router)
token := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token)
assert.Equal(t, http.StatusOK, r.Code)
r = AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/metrics", token)
assert.Equal(t, http.StatusOK, r.Code)
})
// Note: most fixture users have admin role; client-scope test below covers non-admin denial.
@@ -77,7 +86,11 @@ func TestClusterPermissions(t *testing.T) {
token := gjson.Get(w.Body.String(), "access_token").String()
ClusterSummary(router)
ClusterMetrics(router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token)
assert.Equal(t, http.StatusForbidden, r.Code)
r = AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/metrics", token)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}

View File

@@ -46,10 +46,11 @@ func ClusterSummary(router *gin.RouterGroup) {
nodes, _ := regy.List()
c.JSON(http.StatusOK, cluster.SummaryResponse{
UUID: conf.ClusterUUID(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
})
})
}

View File

@@ -25,12 +25,12 @@ func TestEcho(t *testing.T) {
t.Logf("Response Body: %s", r.Body.String())
body := r.Body.String()
url := gjson.Get(body, "url").String()
bodyUrl := gjson.Get(body, "url").String()
method := gjson.Get(body, "method").String()
request := gjson.Get(body, "headers.request")
response := gjson.Get(body, "headers.response")
assert.Equal(t, "/api/v1/echo", url)
assert.Equal(t, "/api/v1/echo", bodyUrl)
assert.Equal(t, "GET", method)
assert.Equal(t, "Bearer "+authToken, request.Get("Authorization.0").String())
assert.Equal(t, "application/json; charset=utf-8", response.Get("Content-Type.0").String())
@@ -49,12 +49,12 @@ func TestEcho(t *testing.T) {
r := AuthenticatedRequest(app, http.MethodPost, "/api/v1/echo", authToken)
body := r.Body.String()
url := gjson.Get(body, "url").String()
bodyUrl := gjson.Get(body, "url").String()
method := gjson.Get(body, "method").String()
request := gjson.Get(body, "headers.request")
response := gjson.Get(body, "headers.response")
assert.Equal(t, "/api/v1/echo", url)
assert.Equal(t, "/api/v1/echo", bodyUrl)
assert.Equal(t, "POST", method)
assert.Equal(t, "Bearer "+authToken, request.Get("Authorization.0").String())
assert.Equal(t, "application/json; charset=utf-8", response.Get("Content-Type.0").String())

View File

@@ -143,14 +143,13 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
return
}
if label.LabelSrc == classify.SrcManual ||
label.LabelSrc == classify.SrcTitle ||
label.LabelSrc == classify.SrcCaption ||
label.LabelSrc == classify.SrcSubject ||
label.LabelSrc == classify.SrcKeyword {
if (label.LabelSrc == classify.SrcManual || label.LabelSrc == entity.SrcBatch) && label.Uncertainty < 100 {
logErr("label", entity.Db().Delete(&label).Error)
} else {
} else if label.LabelSrc != classify.SrcManual && label.LabelSrc != entity.SrcBatch {
label.Uncertainty = 100
label.LabelSrc = entity.SrcManual
logErr("label", entity.Db().Save(&label).Error)
} else {
logErr("label", entity.Db().Save(&label).Error)
}
@@ -225,6 +224,11 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
return
}
// Ensure that re-activating a blocked label sets the source to manual.
if label.Uncertainty == 0 && label.LabelSrc != entity.SrcManual {
label.LabelSrc = entity.SrcManual
}
if err = label.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())})
return

View File

@@ -49,10 +49,13 @@ func TestRemovePhotoLabel(t *testing.T) {
RemovePhotoLabel(router)
r := PerformRequest(app, "DELETE", "/api/v1/photos/ps6sg6be2lvl0yh7/label/1000001")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000001).Uncertainty")
assert.Equal(t, "100", val.String())
uncertainty := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000001).Uncertainty")
src := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000001).LabelSrc")
name := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000001).Label.Name")
assert.Equal(t, "100", uncertainty.String())
assert.Equal(t, "manual", src.String())
assert.Equal(t, "Flower", name.String())
assert.Contains(t, r.Body.String(), "cake")
})
t.Run("remove manually added label", func(t *testing.T) {
app, router, _ := NewApiTest()
@@ -101,6 +104,17 @@ func TestUpdatePhotoLabel(t *testing.T) {
val := gjson.Get(r.Body.String(), "Title")
assert.Contains(t, val.String(), "NewLabelName")
})
t.Run("ReactivateRemovedLabel", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhotoLabel(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/photos/ps6sg6be2lvl0yh9/label/1000003", `{"Uncertainty": 0}`)
uncertainty := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000003).Uncertainty")
src := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000003).LabelSrc")
name := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000003).Label.Name")
assert.Equal(t, "0", uncertainty.String())
assert.Equal(t, "manual", src.String())
assert.Equal(t, "COW", name.String())
})
t.Run("photo not found", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhotoLabel(router)

View File

@@ -324,6 +324,26 @@
},
"type": "object"
},
"cluster.MetricsResponse": {
"properties": {
"clusterCidr": {
"type": "string"
},
"nodes": {
"additionalProperties": {
"type": "integer"
},
"type": "object"
},
"time": {
"type": "string"
},
"uuid": {
"type": "string"
}
},
"type": "object"
},
"cluster.Node": {
"properties": {
"advertiseUrl": {
@@ -419,9 +439,15 @@
"alreadyRegistered": {
"type": "boolean"
},
"clusterCidr": {
"type": "string"
},
"database": {
"$ref": "#/definitions/cluster.RegisterDatabase"
},
"jwksUrl": {
"type": "string"
},
"node": {
"$ref": "#/definitions/cluster.Node"
},
@@ -456,6 +482,9 @@
},
"cluster.SummaryResponse": {
"properties": {
"clusterCidr": {
"type": "string"
},
"database": {
"$ref": "#/definitions/cluster.DatabaseInfo"
},
@@ -6400,6 +6429,44 @@
]
}
},
"/api/v1/cluster/metrics": {
"get": {
"operationId": "ClusterMetrics",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/cluster.MetricsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
},
"summary": "temporary cluster metrics (counts only)",
"tags": [
"Cluster"
]
}
},
"/api/v1/cluster/nodes": {
"get": {
"operationId": "ClusterListNodes",

View File

@@ -1,5 +1,11 @@
package acl
import (
"strings"
"github.com/photoprism/photoprism/pkg/list"
)
// Permission scopes to Grant multiple Permissions for a Resource.
const (
ScopeRead Permission = "read"
@@ -35,3 +41,63 @@ var (
ActionManageOwn: true,
}
)
// ScopeAttr parses an auth scope string and returns a normalized Attr
// with duplicate and invalid entries removed.
func ScopeAttr(s string) list.Attr {
if s == "" {
return list.Attr{}
}
return list.ParseAttr(strings.ToLower(s))
}
// ScopePermits sanitizes the raw scope string and then calls ScopeAttrPermits for
// the actual authorization check.
func ScopePermits(scope string, resource Resource, perms Permissions) bool {
if scope == "" {
return false
}
// Parse scope to check for resources and permissions.
return ScopeAttrPermits(ScopeAttr(scope), resource, perms)
}
// ScopeAttrPermits evaluates an already-parsed scope attribute list against a
// resource and permission set, enforcing wildcard/read/write semantics.
func ScopeAttrPermits(attr list.Attr, resource Resource, perms Permissions) bool {
if len(attr) == 0 {
return false
}
scope := attr.String()
// Skip detailed check and allow all if scope is "*".
if scope == list.Any {
return true
}
// Skip resource check if scope includes all read operations.
if scope == ScopeRead.String() {
return !GrantScopeRead.DenyAny(perms)
}
// Check if resource is within scope.
if granted := attr.Contains(resource.String()); !granted {
return false
}
// Check if permission is within scope.
if len(perms) == 0 {
return true
}
// Check if scope is limited to read or write operations.
if a := attr.Find(ScopeRead.String()); a.Value == list.True && GrantScopeRead.DenyAny(perms) {
return false
} else if a = attr.Find(ScopeWrite.String()); a.Value == list.True && GrantScopeWrite.DenyAny(perms) {
return false
}
return true
}

View File

@@ -35,3 +35,136 @@ func TestGrantScopeWrite(t *testing.T) {
assert.False(t, GrantScopeWrite.DenyAny(Permissions{AccessAll}))
})
}
func TestScopePermits(t *testing.T) {
t.Run("AnyScope", func(t *testing.T) {
assert.True(t, ScopePermits("*", "", nil))
})
t.Run("ReadScope", func(t *testing.T) {
assert.True(t, ScopePermits("read", "metrics", nil))
assert.True(t, ScopePermits("read", "sessions", nil))
assert.True(t, ScopePermits("read", "metrics", Permissions{ActionView, AccessAll}))
assert.False(t, ScopePermits("read", "metrics", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read", "metrics", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read", "settings", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read", "settings", Permissions{ActionCreate}))
assert.False(t, ScopePermits("read", "sessions", Permissions{ActionDelete}))
})
t.Run("ReadAny", func(t *testing.T) {
assert.True(t, ScopePermits("read *", "metrics", nil))
assert.True(t, ScopePermits("read *", "sessions", nil))
assert.True(t, ScopePermits("read *", "metrics", Permissions{ActionView, AccessAll}))
assert.False(t, ScopePermits("read *", "metrics", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read *", "metrics", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read *", "settings", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read *", "settings", Permissions{ActionCreate}))
assert.False(t, ScopePermits("read *", "sessions", Permissions{ActionDelete}))
})
t.Run("ReadSettings", func(t *testing.T) {
assert.True(t, ScopePermits("read settings", "settings", Permissions{ActionView}))
assert.False(t, ScopePermits("read settings", "metrics", nil))
assert.False(t, ScopePermits("read settings", "sessions", nil))
assert.False(t, ScopePermits("read settings", "metrics", Permissions{ActionView, AccessAll}))
assert.False(t, ScopePermits("read settings", "metrics", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read settings", "metrics", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read settings", "settings", Permissions{ActionUpdate}))
assert.False(t, ScopePermits("read settings", "sessions", Permissions{ActionDelete}))
assert.False(t, ScopePermits("read settings", "sessions", Permissions{ActionDelete}))
})
}
func TestScopeAttr(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{name: "Empty", input: "", expected: nil},
{name: "Lowercase", input: "read metrics", expected: []string{"metrics", "read"}},
{name: "Uppercase", input: "READ SETTINGS", expected: []string{"read", "settings"}},
{name: "WithNoise", input: " Read\tSessions\nmetrics", expected: []string{"metrics", "read", "sessions"}},
{name: "Deduplicates", input: "metrics metrics", expected: []string{"metrics"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
attr := ScopeAttr(tc.input)
if len(tc.expected) == 0 {
assert.Len(t, attr, 0)
return
}
assert.ElementsMatch(t, tc.expected, attr.Strings())
})
}
}
func TestScopePermitsEdgeCases(t *testing.T) {
tests := []struct {
name string
scope string
resource Resource
perms Permissions
want bool
}{
{name: "EmptyScope", scope: "", resource: "metrics", perms: nil, want: false},
{name: "OnlyInvalidChars", scope: "()", resource: "metrics", perms: nil, want: false},
{name: "WildcardMixedOrder", scope: "* read metrics", resource: "metrics", perms: Permissions{ActionUpdate}, want: false},
{name: "WildcardOverridesReadRestrictions", scope: "read metrics *", resource: "metrics", perms: Permissions{ActionDelete}, want: false},
{name: "WildcardWithFalseValueIgnored", scope: "*:false read", resource: "metrics", perms: Permissions{ActionUpdate}, want: false},
{name: "ExplicitFalseResource", scope: "metrics:false", resource: "metrics", perms: nil, want: false},
{name: "ExplicitTrueResource", scope: "metrics:true", resource: "metrics", perms: nil, want: true},
{name: "CaseInsensitiveScopeAndResource", scope: "READ SETTINGS", resource: Resource("Settings"), perms: Permissions{ActionView}, want: true},
{name: "WhitespaceAndTabs", scope: "\tread\tsettings\n", resource: "settings", perms: Permissions{ActionView}, want: true},
{name: "DefaultResourceRead", scope: "read default", resource: "", perms: Permissions{ActionView}, want: true},
{name: "DefaultResourceUpdateDenied", scope: "read default", resource: "", perms: Permissions{ActionUpdate}, want: false},
{name: "WriteAllowsMutation", scope: "write settings", resource: "settings", perms: Permissions{ActionUpdate}, want: true},
{name: "WriteBlocksReadOnly", scope: "write settings", resource: "settings", perms: Permissions{ActionView}, want: false},
{name: "ReadGrantAllowsAccessAll", scope: "read", resource: "metrics", perms: Permissions{AccessAll}, want: true},
{name: "ReadGrantDeniesManage", scope: "read metrics", resource: "metrics", perms: Permissions{ActionManage}, want: false},
{name: "WriteGrantAllowsManage", scope: "write metrics", resource: "metrics", perms: Permissions{ActionManage}, want: true},
{name: "ResourceWildcard", scope: "metrics:*", resource: "metrics", perms: Permissions{ActionDelete}, want: true},
{name: "GlobalWildcardWithoutRead", scope: "* metrics", resource: "metrics", perms: Permissions{ActionDelete}, want: true},
{name: "ResourceWildcardWithRead", scope: "read metrics:*", resource: "metrics", perms: Permissions{ActionView}, want: true},
{name: "ResourceWildcardWriteDenied", scope: "read metrics:*", resource: "metrics", perms: Permissions{ActionUpdate}, want: false},
{name: "DuplicateAndNoise", scope: " read metrics metrics ", resource: "metrics", perms: nil, want: true},
{name: "FalseOverridesTrue", scope: "metrics metrics:false", resource: "metrics", perms: nil, want: false},
{name: "CaseInsensitiveResourceLookup", scope: "read metrics", resource: Resource("METRICS"), perms: Permissions{ActionView}, want: true},
{name: "MixedReadWriteConflict", scope: "read write settings", resource: "settings", perms: Permissions{ActionUpdate}, want: false},
{name: "PermissionsEmptySlice", scope: "read metrics", resource: "metrics", perms: Permissions{}, want: true},
{name: "SimpleNonReadScopeAllows", scope: "cluster vision", resource: "cluster", perms: nil, want: true},
{name: "SimpleNonReadScopeRejectsMissing", scope: "cluster vision", resource: "portal", perms: nil, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ScopePermits(tc.scope, tc.resource, tc.perms)
assert.Equalf(t, tc.want, got, "scope %q resource %q perms %v", tc.scope, tc.resource, tc.perms)
})
}
}
func TestScopeAttrPermits(t *testing.T) {
tests := []struct {
name string
scope string
resource Resource
perms Permissions
want bool
}{
{name: "EmptyAttr", scope: "", resource: "metrics", perms: nil, want: false},
{name: "Wildcard", scope: "*", resource: "metrics", perms: Permissions{ActionUpdate}, want: true},
{name: "ReadAllowsView", scope: "read", resource: "settings", perms: Permissions{ActionView}, want: true},
{name: "ReadBlocksUpdate", scope: "read", resource: "settings", perms: Permissions{ActionUpdate}, want: false},
{name: "ResourceMismatch", scope: "read metrics", resource: "settings", perms: nil, want: false},
{name: "WriteAllowsManage", scope: "write metrics", resource: "metrics", perms: Permissions{ActionManage}, want: true},
{name: "WriteBlocksView", scope: "write metrics", resource: "metrics", perms: Permissions{ActionView}, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
attr := ScopeAttr(tc.scope)
got := ScopeAttrPermits(attr, tc.resource, tc.perms)
assert.Equalf(t, tc.want, got, "scope %q resource %q perms %v", tc.scope, tc.resource, tc.perms)
})
}
}

107
internal/auth/jwt/issuer.go Normal file
View File

@@ -0,0 +1,107 @@
package jwt
import (
"errors"
"strings"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/pkg/rnd"
)
var (
// DefaultTokenTTL is the default lifetime for issued tokens.
DefaultTokenTTL = 300 * time.Second
// MaxTokenTTL clamps configurable lifetimes to a safe upper bound.
MaxTokenTTL = 900 * time.Second
)
// TokenTTL controls the default lifetime used when a ClaimsSpec does not override TTL.
var TokenTTL = DefaultTokenTTL
// ClaimsSpec describes the claims to embed in a signed token.
type ClaimsSpec struct {
Issuer string
Subject string
Audience string
Scope []string
TTL time.Duration
}
// validate performs sanity checks on the claim specification before issuing a token.
func (s ClaimsSpec) validate() error {
if strings.TrimSpace(s.Issuer) == "" {
return errors.New("jwt: issuer required")
}
if strings.TrimSpace(s.Subject) == "" {
return errors.New("jwt: subject required")
}
if strings.TrimSpace(s.Audience) == "" {
return errors.New("jwt: audience required")
}
if len(s.Scope) == 0 {
return errors.New("jwt: scope required")
}
return nil
}
// Issuer signs JWTs on behalf of the Portal using the manager's active key.
type Issuer struct {
manager *Manager
now func() time.Time
}
// NewIssuer returns an Issuer bound to the provided Manager.
func NewIssuer(m *Manager) *Issuer {
return &Issuer{manager: m, now: time.Now}
}
// Issue signs a JWT using the manager's active key according to spec.
func (i *Issuer) Issue(spec ClaimsSpec) (string, error) {
if i == nil || i.manager == nil {
return "", errors.New("jwt: issuer not initialized")
}
if err := spec.validate(); err != nil {
return "", err
}
ttl := spec.TTL
if ttl <= 0 {
ttl = TokenTTL
}
if ttl > MaxTokenTTL {
ttl = MaxTokenTTL
}
key, err := i.manager.EnsureActiveKey()
if err != nil {
return "", err
}
issuedAt := i.now().UTC()
expiresAt := issuedAt.Add(ttl)
claims := &Claims{
Scope: strings.Join(spec.Scope, " "),
RegisteredClaims: gojwt.RegisteredClaims{
Issuer: spec.Issuer,
Subject: spec.Subject,
Audience: gojwt.ClaimStrings{spec.Audience},
IssuedAt: gojwt.NewNumericDate(issuedAt),
NotBefore: gojwt.NewNumericDate(issuedAt),
ExpiresAt: gojwt.NewNumericDate(expiresAt),
ID: rnd.GenerateUID(rnd.PrefixMixed),
},
}
token := gojwt.NewWithClaims(gojwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.Kid
token.Header["typ"] = "JWT"
signed, err := token.SignedString(key.PrivateKey)
if err != nil {
return "", err
}
return signed, nil
}

27
internal/auth/jwt/jwt.go Normal file
View File

@@ -0,0 +1,27 @@
/*
Package jwt provides helpers for managing Ed25519 signing keys and issuing or
verifying short-lived JWTs used for secure communication between the Portal and
cluster nodes.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package jwt

View File

@@ -0,0 +1,31 @@
package jwt
import (
"os"
"testing"
"github.com/sirupsen/logrus"
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
// Init test logger.
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}
func newTestConfig(t *testing.T) *cfg.Config {
return cfg.NewMinimalTestConfig(t.TempDir())
}

View File

@@ -0,0 +1,6 @@
package jwt
import "github.com/photoprism/photoprism/internal/event"
// log provides package-wide logging using the shared event logger.
var log = event.Log

View File

@@ -0,0 +1,288 @@
package jwt
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
const (
privateKeyPrefix = "ed25519-"
privateKeyExt = ".jwk"
publicKeyExt = ".pub.jwk"
)
type keyRecord struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
D string `json:"d,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
NotAfter int64 `json:"notAfter,omitempty"`
}
// Manager handles Ed25519 key lifecycle for JWT issuance and JWKS exposure.
type Manager struct {
conf *config.Config
mu sync.RWMutex
keys []*Key
now func() time.Time
}
// ErrNoActiveKey indicates that the manager has no active key pair available.
var ErrNoActiveKey = errors.New("jwt: no active signing key")
// NewManager creates a Manager bound to the provided config.
func NewManager(conf *config.Config) (*Manager, error) {
if conf == nil {
return nil, errors.New("jwt: config is nil")
}
m := &Manager{
conf: conf,
now: time.Now,
}
if err := m.loadKeys(); err != nil {
return nil, err
}
return m, nil
}
// keyDir returns the directory in which key material is stored.
func (m *Manager) keyDir() string {
return filepath.Join(m.conf.PortalConfigPath(), "keys")
}
// EnsureActiveKey returns the current active key, generating one if necessary.
func (m *Manager) EnsureActiveKey() (*Key, error) {
if k, err := m.ActiveKey(); err == nil {
return k, nil
}
return m.generateKey()
}
// ActiveKey returns the most recent, non-expired signing key.
func (m *Manager) ActiveKey() (*Key, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := m.now().Unix()
for i := len(m.keys) - 1; i >= 0; i-- {
k := m.keys[i]
if k.NotAfter != 0 && now > k.NotAfter {
continue
}
return k.clone(), nil
}
return nil, ErrNoActiveKey
}
// JWKS returns the public JWKS representation of all non-expired keys.
func (m *Manager) JWKS() *JWKS {
m.mu.RLock()
defer m.mu.RUnlock()
now := m.now().Unix()
keys := make([]PublicJWK, 0, len(m.keys))
for _, k := range m.keys {
if k.NotAfter != 0 && now > k.NotAfter {
continue
}
keys = append(keys, PublicJWK{
Kty: keyTypeOKP,
Crv: curveEd25519,
Kid: k.Kid,
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
})
}
return &JWKS{Keys: keys}
}
// AllKeys returns a slice copy containing all loaded keys (for testing/inspection).
func (m *Manager) AllKeys() []*Key {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*Key, len(m.keys))
for i, k := range m.keys {
out[i] = k.clone()
}
return out
}
// loadKeys reads existing key records from disk into memory.
func (m *Manager) loadKeys() error {
dir := m.keyDir()
if err := fs.MkdirAll(dir); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
keys := make([]*Key, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
continue
}
if !strings.HasPrefix(name, privateKeyPrefix) || !strings.HasSuffix(name, privateKeyExt) {
continue
}
if strings.HasSuffix(name, publicKeyExt) {
// Skip public-only artifacts when reloading.
continue
}
keyPath := filepath.Join(dir, name)
b, err := os.ReadFile(keyPath)
if err != nil {
return err
}
var rec keyRecord
if err := json.Unmarshal(b, &rec); err != nil {
return err
}
if rec.Kty != keyTypeOKP || rec.Crv != curveEd25519 || rec.Kid == "" {
continue
}
privBytes, err := base64.RawURLEncoding.DecodeString(rec.D)
if err != nil {
return err
}
if len(privBytes) != ed25519.SeedSize {
return fmt.Errorf("jwt: invalid private key length %d", len(privBytes))
}
priv := ed25519.NewKeyFromSeed(privBytes)
pub := make([]byte, ed25519.PublicKeySize)
copy(pub, priv[ed25519.SeedSize:])
k := &Key{
Kid: rec.Kid,
CreatedAt: rec.CreatedAt,
NotAfter: rec.NotAfter,
PrivateKey: priv,
PublicKey: ed25519.PublicKey(pub),
}
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return keys[i].CreatedAt < keys[j].CreatedAt
})
m.mu.Lock()
m.keys = keys
m.mu.Unlock()
return nil
}
// generateKey creates a fresh Ed25519 key pair, persists it, and returns a clone.
func (m *Manager) generateKey() (*Key, error) {
seed := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
priv := ed25519.NewKeyFromSeed(seed)
pub := priv[ed25519.SeedSize:]
now := m.now().UTC()
fingerprint := sha256.Sum256(pub)
kid := fmt.Sprintf("%s-%s", now.Format("20060102T1504Z"), hex.EncodeToString(fingerprint[:4]))
k := &Key{
Kid: kid,
CreatedAt: now.Unix(),
NotAfter: 0,
PrivateKey: priv,
PublicKey: append(ed25519.PublicKey(nil), pub...),
}
if err := m.persistKey(k); err != nil {
return nil, err
}
m.mu.Lock()
m.keys = append(m.keys, k)
sort.Slice(m.keys, func(i, j int) bool {
return m.keys[i].CreatedAt < m.keys[j].CreatedAt
})
m.mu.Unlock()
return k.clone(), nil
}
// persistKey writes the private and public key records to disk using secure permissions.
func (m *Manager) persistKey(k *Key) error {
dir := m.keyDir()
if err := fs.MkdirAll(dir); err != nil {
return err
}
privRec := keyRecord{
Kty: keyTypeOKP,
Crv: curveEd25519,
Kid: k.Kid,
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
D: base64.RawURLEncoding.EncodeToString(k.PrivateKey.Seed()),
CreatedAt: k.CreatedAt,
NotAfter: k.NotAfter,
}
privPath := filepath.Join(dir, privateKeyPrefix+k.Kid+privateKeyExt)
pubPath := filepath.Join(dir, privateKeyPrefix+k.Kid+publicKeyExt)
privJSON, err := json.Marshal(privRec)
if err != nil {
return err
}
if err := os.WriteFile(privPath, privJSON, fs.ModeSecretFile); err != nil {
return err
}
// Public record omits private component.
pubRec := privRec
pubRec.D = ""
pubJSON, err := json.Marshal(pubRec)
if err != nil {
return err
}
if err := os.WriteFile(pubPath, pubJSON, fs.ModeFile); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,81 @@
package jwt
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestManagerEnsureActiveKey(t *testing.T) {
c := newTestConfig(t)
m, err := NewManager(c)
require.NoError(t, err)
require.NotNil(t, m)
fixed := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
m.now = func() time.Time { return fixed }
key, err := m.EnsureActiveKey()
require.NoError(t, err)
require.NotNil(t, key)
require.True(t, strings.HasPrefix(key.Kid, "20250924T1030Z-"))
// Key files should be persisted.
privPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+privateKeyExt)
pubPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+publicKeyExt)
require.True(t, fs.FileExists(privPath))
require.True(t, fs.FileExists(pubPath))
// Second call should reuse same key.
next, err := m.EnsureActiveKey()
require.NoError(t, err)
require.Equal(t, key.Kid, next.Kid)
// JWKS should expose the key.
jwks := m.JWKS()
require.Len(t, jwks.Keys, 1)
require.Equal(t, key.Kid, jwks.Keys[0].Kid)
// Reload manager from disk.
m2, err := NewManager(c)
require.NoError(t, err)
require.NotNil(t, m2)
reloaded, err := m2.ActiveKey()
require.NoError(t, err)
require.Equal(t, key.Kid, reloaded.Kid)
}
func TestManagerGenerateSecondKey(t *testing.T) {
c := newTestConfig(t)
m, err := NewManager(c)
require.NoError(t, err)
first := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
m.now = func() time.Time { return first }
k1, err := m.EnsureActiveKey()
require.NoError(t, err)
second := first.Add(24 * time.Hour)
m.now = func() time.Time { return second }
// Force generation by clearing in-memory keys to simulate expiration.
m.mu.Lock()
m.keys[len(m.keys)-1].NotAfter = first.Unix()
m.mu.Unlock()
k2, err := m.EnsureActiveKey()
require.NoError(t, err)
require.NotEqual(t, k1.Kid, k2.Kid)
// JWKS should include both keys (old not expired due to manual NotAfter=CreatedAt).
jwks := m.JWKS()
require.NotEmpty(t, jwks.Keys)
// Clean up generated files.
require.NoError(t, os.RemoveAll(filepath.Join(c.PortalConfigPath(), "keys")))
}

View File

@@ -0,0 +1,56 @@
package jwt
import (
"crypto/ed25519"
gojwt "github.com/golang-jwt/jwt/v5"
)
const (
keyTypeOKP = "OKP"
curveEd25519 = "Ed25519"
)
// PublicJWK represents the public portion of an Ed25519 key in JWK form.
type PublicJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
}
// JWKS represents a JSON Web Key Set.
type JWKS struct {
Keys []PublicJWK `json:"keys"`
}
// Claims represents cluster JWT claims.
type Claims struct {
Scope string `json:"scope"`
gojwt.RegisteredClaims
}
// Key encapsulates an Ed25519 keypair with metadata used for JWKS rotation.
type Key struct {
Kid string
CreatedAt int64
NotAfter int64
PrivateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
// clone returns a shallow copy of the key to avoid exposing internal slices.
func (k *Key) clone() *Key {
if k == nil {
return nil
}
c := *k
if k.PrivateKey != nil {
c.PrivateKey = append(ed25519.PrivateKey(nil), k.PrivateKey...)
}
if k.PublicKey != nil {
c.PublicKey = append(ed25519.PublicKey(nil), k.PublicKey...)
}
return &c
}

View File

@@ -0,0 +1,555 @@
package jwt
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
var (
errKeyNotFound = errors.New("jwt: key not found")
)
// VerifierStatus captures diagnostic information about a verifier's JWKS cache state.
type VerifierStatus struct {
CacheURL string `json:"cacheUrl,omitempty"`
CacheETag string `json:"cacheEtag,omitempty"`
KeyIDs []string `json:"keyIds,omitempty"`
KeyCount int `json:"keyCount"`
CacheFetchedAt time.Time `json:"cacheFetchedAt,omitempty"`
CacheAgeSeconds int64 `json:"cacheAgeSeconds"`
CacheTTLSeconds int `json:"cacheTtlSeconds"`
CacheStale bool `json:"cacheStale"`
CachePath string `json:"cachePath,omitempty"`
JWKSURL string `json:"jwksUrl,omitempty"`
}
const (
// jwksFetchMaxRetries caps the number of immediate retry attempts after a fetch error.
jwksFetchMaxRetries = 3
// jwksFetchBaseDelay is the initial retry delay (with jitter) applied after the first failure.
jwksFetchBaseDelay = 200 * time.Millisecond
// jwksFetchMaxDelay is the upper bound for retry delays to prevent unbounded backoff.
jwksFetchMaxDelay = 2 * time.Second
)
// randInt63n is defined for deterministic testing of jitter (overridable in tests).
var randInt63n = rand.Int63n
// cacheEntry stores the JWKS material cached on disk and in memory.
type cacheEntry struct {
URL string `json:"url"`
ETag string `json:"etag,omitempty"`
Keys []PublicJWK `json:"keys"`
FetchedAt int64 `json:"fetchedAt"`
}
// Verifier validates Portal-issued JWTs on Nodes using JWKS with caching.
type Verifier struct {
conf *config.Config
mu sync.Mutex
cache cacheEntry
cachePath string
httpClient *http.Client
now func() time.Time
}
// ExpectedClaims describes the constraints that must hold for a token.
type ExpectedClaims struct {
Issuer string
Audience string
Scope []string
JWKSURL string
}
// NewVerifier instantiates a verifier with sane defaults.
func NewVerifier(conf *config.Config) *Verifier {
v := &Verifier{
conf: conf,
httpClient: &http.Client{Timeout: 10 * time.Second},
now: time.Now,
}
if conf != nil {
v.cachePath = filepath.Join(conf.ConfigPath(), "jwks-cache.json")
}
_ = v.loadCache()
return v
}
// Prime ensures JWKS material is cached locally.
func (v *Verifier) Prime(ctx context.Context, jwksURL string) error {
_, err := v.keysForURL(ctx, jwksURL, true)
return err
}
// VerifyToken validates a JWT against the expected claims and returns decoded claims.
func (v *Verifier) VerifyToken(ctx context.Context, tokenString string, expected ExpectedClaims) (*Claims, error) {
if v == nil {
return nil, errors.New("jwt: verifier not initialized")
}
if strings.TrimSpace(tokenString) == "" {
return nil, errors.New("jwt: token is empty")
}
if strings.TrimSpace(expected.Issuer) == "" {
return nil, errors.New("jwt: expected issuer required")
}
if strings.TrimSpace(expected.Audience) == "" {
return nil, errors.New("jwt: expected audience required")
}
jwksUrl := strings.TrimSpace(expected.JWKSURL)
if jwksUrl == "" && v.conf != nil {
jwksUrl = strings.TrimSpace(v.conf.JWKSUrl())
}
if jwksUrl == "" {
return nil, errors.New("jwt: jwks url not configured")
}
leeway := 60 * time.Second
if v.conf != nil && v.conf.JWTLeeway() > 0 {
leeway = time.Duration(v.conf.JWTLeeway()) * time.Second
}
parser := gojwt.NewParser(
gojwt.WithLeeway(leeway),
gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}),
gojwt.WithIssuer(expected.Issuer),
gojwt.WithAudience(expected.Audience),
)
claims := &Claims{}
keyFunc := func(token *gojwt.Token) (interface{}, error) {
kid, _ := token.Header["kid"].(string)
if kid == "" {
return nil, errors.New("jwt: missing kid header")
}
pk, err := v.publicKeyForKid(ctx, jwksUrl, kid, false)
if errors.Is(err, errKeyNotFound) {
pk, err = v.publicKeyForKid(ctx, jwksUrl, kid, true)
}
if err != nil {
return nil, err
}
return pk, nil
}
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
return nil, err
}
if claims.IssuedAt == nil || claims.ExpiresAt == nil {
return nil, errors.New("jwt: missing temporal claims")
}
if ttl := claims.ExpiresAt.Time.Sub(claims.IssuedAt.Time); ttl > MaxTokenTTL {
return nil, errors.New("jwt: token ttl exceeds maximum")
}
scopeSet := map[string]struct{}{}
for _, s := range strings.Fields(claims.Scope) {
scopeSet[s] = struct{}{}
}
for _, req := range expected.Scope {
if _, ok := scopeSet[req]; !ok {
return nil, fmt.Errorf("jwt: missing scope %s", req)
}
}
return claims, nil
}
// VerifyTokenWithKeys verifies a token using the provided JWKS keys without performing HTTP fetches.
func VerifyTokenWithKeys(tokenString string, expected ExpectedClaims, keys []PublicJWK, leeway time.Duration) (*Claims, error) {
if strings.TrimSpace(tokenString) == "" {
return nil, errors.New("jwt: token is empty")
}
if len(keys) == 0 {
return nil, errors.New("jwt: no jwks keys provided")
}
if leeway <= 0 {
leeway = 60 * time.Second
}
keyMap := make(map[string]ed25519.PublicKey, len(keys))
for _, jwk := range keys {
if jwk.Kid == "" {
continue
}
raw, err := base64.RawURLEncoding.DecodeString(jwk.X)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("jwt: invalid public key length %d", len(raw))
}
pk := make(ed25519.PublicKey, ed25519.PublicKeySize)
copy(pk, raw)
keyMap[jwk.Kid] = pk
}
if len(keyMap) == 0 {
return nil, errors.New("jwt: no valid jwks keys provided")
}
options := []gojwt.ParserOption{
gojwt.WithLeeway(leeway),
gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}),
}
if iss := strings.TrimSpace(expected.Issuer); iss != "" {
options = append(options, gojwt.WithIssuer(iss))
}
if aud := strings.TrimSpace(expected.Audience); aud != "" {
options = append(options, gojwt.WithAudience(aud))
}
parser := gojwt.NewParser(options...)
claims := &Claims{}
keyFunc := func(token *gojwt.Token) (interface{}, error) {
kid, _ := token.Header["kid"].(string)
if kid == "" {
return nil, errors.New("jwt: missing kid header")
}
pk, ok := keyMap[kid]
if !ok {
return nil, errKeyNotFound
}
return pk, nil
}
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
return nil, err
}
if claims.IssuedAt == nil || claims.ExpiresAt == nil {
return nil, errors.New("jwt: missing temporal claims")
}
if ttl := claims.ExpiresAt.Time.Sub(claims.IssuedAt.Time); ttl > MaxTokenTTL {
return nil, errors.New("jwt: token ttl exceeds maximum")
}
if len(expected.Scope) > 0 {
scopeSet := map[string]struct{}{}
for _, s := range strings.Fields(claims.Scope) {
scopeSet[s] = struct{}{}
}
for _, req := range expected.Scope {
if _, ok := scopeSet[req]; !ok {
return nil, fmt.Errorf("jwt: missing scope %s", req)
}
}
}
return claims, nil
}
// Status returns diagnostic information about the verifier's current JWKS cache.
func (v *Verifier) Status(ttl time.Duration) VerifierStatus {
status := VerifierStatus{}
if ttl > 0 {
status.CacheTTLSeconds = int(ttl / time.Second)
}
v.mu.Lock()
defer v.mu.Unlock()
status.CacheURL = v.cache.URL
status.CacheETag = v.cache.ETag
status.JWKSURL = v.cache.URL
status.KeyCount = len(v.cache.Keys)
status.KeyIDs = make([]string, 0, len(v.cache.Keys))
for _, key := range v.cache.Keys {
status.KeyIDs = append(status.KeyIDs, key.Kid)
}
status.CachePath = v.cachePath
if v.cache.FetchedAt > 0 {
fetched := time.Unix(v.cache.FetchedAt, 0).UTC()
status.CacheFetchedAt = fetched
age := time.Since(fetched)
status.CacheAgeSeconds = int64(age.Seconds())
if ttl > 0 && age > ttl {
status.CacheStale = true
}
}
return status
}
// publicKeyForKid resolves the public key for the given key ID, fetching JWKS data if needed.
func (v *Verifier) publicKeyForKid(ctx context.Context, url, kid string, force bool) (ed25519.PublicKey, error) {
keys, err := v.keysForURL(ctx, url, force)
if err != nil {
return nil, err
}
for _, k := range keys {
if k.Kid != kid {
continue
}
raw, err := base64.RawURLEncoding.DecodeString(k.X)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("jwt: invalid public key length %d", len(raw))
}
pk := make(ed25519.PublicKey, ed25519.PublicKeySize)
copy(pk, raw)
return pk, nil
}
return nil, errKeyNotFound
}
// keysForURL returns JWKS keys for the specified endpoint, reusing cache when possible.
func (v *Verifier) keysForURL(ctx context.Context, url string, force bool) ([]PublicJWK, error) {
ttl := 300 * time.Second
if v.conf != nil && v.conf.JWKSCacheTTL() > 0 {
ttl = time.Duration(v.conf.JWKSCacheTTL()) * time.Second
}
attempts := 0
for {
cached := v.snapshotCache()
if keys, ok := v.cachedKeys(url, ttl, cached, force); ok {
return keys, nil
}
etag := ""
if !force && cached.URL == url {
etag = cached.ETag
}
result, err := v.fetchJWKS(ctx, url, etag)
if err != nil {
if !force && cached.URL == url && len(cached.Keys) > 0 {
return append([]PublicJWK(nil), cached.Keys...), nil
}
attempts++
if attempts >= jwksFetchMaxRetries {
return nil, err
}
delay := backoffDuration(attempts)
log.Debugf("jwt: jwks fetch retry %d for %s in %s (%s)", attempts, url, delay, err)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if keys, ok := v.updateCache(url, result); ok {
return keys, nil
}
// Cache changed by another goroutine between snapshot and update; retry.
}
}
// snapshotCache returns the current JWKS cache entry under lock for safe reading.
func (v *Verifier) snapshotCache() cacheEntry {
v.mu.Lock()
defer v.mu.Unlock()
cache := v.cache
return cache
}
// cachedKeys returns cached JWKS keys if they are fresh enough and match the target URL.
func (v *Verifier) cachedKeys(url string, ttl time.Duration, cache cacheEntry, force bool) ([]PublicJWK, bool) {
if force || cache.URL != url || len(cache.Keys) == 0 {
return nil, false
}
age := v.now().Unix() - cache.FetchedAt
if age < 0 {
return nil, false
}
if time.Duration(age)*time.Second > ttl {
return nil, false
}
return append([]PublicJWK(nil), cache.Keys...), true
}
type jwksFetchResult struct {
keys []PublicJWK
etag string
fetchedAt int64
notModified bool
}
// fetchJWKS downloads the JWKS document (respecting conditional requests) and returns the parsed keys.
func (v *Verifier) fetchJWKS(ctx context.Context, url, etag string) (*jwksFetchResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNotModified:
return &jwksFetchResult{
etag: etag,
fetchedAt: v.now().Unix(),
notModified: true,
}, nil
case http.StatusOK:
var body JWKS
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
if len(body.Keys) == 0 {
return nil, errors.New("jwt: jwks contains no keys")
}
return &jwksFetchResult{
keys: append([]PublicJWK(nil), body.Keys...),
etag: resp.Header.Get("ETag"),
fetchedAt: v.now().Unix(),
}, nil
default:
return nil, fmt.Errorf("jwt: jwks fetch failed: %s", resp.Status)
}
}
// updateCache stores the JWKS fetch result on success and returns the fresh keys.
func (v *Verifier) updateCache(url string, result *jwksFetchResult) ([]PublicJWK, bool) {
v.mu.Lock()
defer v.mu.Unlock()
if result.notModified {
if v.cache.URL != url {
return nil, false
}
v.cache.FetchedAt = result.fetchedAt
if result.etag != "" {
v.cache.ETag = result.etag
}
_ = v.saveCacheLocked()
return append([]PublicJWK(nil), v.cache.Keys...), true
}
v.cache = cacheEntry{
URL: url,
ETag: result.etag,
Keys: append([]PublicJWK(nil), result.keys...),
FetchedAt: result.fetchedAt,
}
_ = v.saveCacheLocked()
return append([]PublicJWK(nil), v.cache.Keys...), true
}
// loadCache restores a previously persisted JWKS cache entry from disk.
func (v *Verifier) loadCache() error {
if v.cachePath == "" || !fs.FileExists(v.cachePath) {
return nil
}
b, err := os.ReadFile(v.cachePath)
if err != nil || len(b) == 0 {
return err
}
var entry cacheEntry
if err = json.Unmarshal(b, &entry); err != nil {
return err
}
v.cache = entry
return nil
}
// saveCacheLocked persists the current cache entry to disk; caller must hold the mutex.
func (v *Verifier) saveCacheLocked() error {
if v.cachePath == "" {
return nil
}
if err := fs.MkdirAll(filepath.Dir(v.cachePath)); err != nil {
return err
}
data, err := json.Marshal(v.cache)
if err != nil {
return err
}
return os.WriteFile(v.cachePath, data, fs.ModeSecretFile)
}
// backoffDuration returns the retry delay for the given fetch attempt, adding jitter.
func backoffDuration(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
base := jwksFetchBaseDelay << (attempt - 1)
if base > jwksFetchMaxDelay {
base = jwksFetchMaxDelay
}
jitterRange := base / 2
if jitterRange > 0 {
base += time.Duration(randInt63n(int64(jitterRange) + 1))
}
return base
}

View File

@@ -0,0 +1,217 @@
package jwt
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestVerifierPrimeAndVerify(t *testing.T) {
portalCfg := newTestConfig(t)
clusterUUID := rnd.UUIDv7()
portalCfg.Options().ClusterUUID = clusterUUID
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
jwksBytes, err := json.Marshal(mgr.JWKS())
require.NoError(t, err)
etag := `"v1"`
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(jwksBytes)
}))
defer server.Close()
nodeCfg := newTestConfig(t)
nodeCfg.SetJWKSUrl(server.URL + "/.well-known/jwks.json")
nodeCfg.Options().ClusterUUID = clusterUUID
nodeUUID := nodeCfg.NodeUUID()
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Now().UTC() }
spec := ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", clusterUUID),
Subject: "portal:client-test",
Audience: fmt.Sprintf("node:%s", nodeUUID),
Scope: []string{"cluster", "vision"},
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
verifier := NewVerifier(nodeCfg)
ctx := context.Background()
require.NoError(t, verifier.Prime(ctx, nodeCfg.JWKSUrl()))
require.Equal(t, 1, requestCount)
claims, err := verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.NoError(t, err)
require.Equal(t, spec.Subject, claims.Subject)
require.Contains(t, claims.Scope, "cluster")
// Force cache refresh by expiring entry and verify 304 handling.
verifier.mu.Lock()
verifier.cache.FetchedAt -= 1000
verifier.mu.Unlock()
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.NoError(t, err)
require.Equal(t, 2, requestCount)
// Missing scope should fail.
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster", "unknown"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.Error(t, err)
}
func TestVerifyTokenWithKeys(t *testing.T) {
portalCfg := newTestConfig(t)
clusterUUID := rnd.UUIDv7()
portalCfg.Options().ClusterUUID = clusterUUID
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Now().UTC() }
spec := ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", clusterUUID),
Subject: "portal:client-test",
Audience: "node:1234",
Scope: []string{"cluster"},
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
keys := mgr.JWKS().Keys
claims, err := VerifyTokenWithKeys(token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
}, keys, 60*time.Second)
require.NoError(t, err)
require.Equal(t, spec.Subject, claims.Subject)
// Ensure scope filtering is honored when expected scope is empty.
claims, err = VerifyTokenWithKeys(token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
}, keys, 60*time.Second)
require.NoError(t, err)
require.Equal(t, spec.Subject, claims.Subject)
// Missing scope should fail when explicitly required.
_, err = VerifyTokenWithKeys(token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"vision"},
}, keys, 60*time.Second)
require.Error(t, err)
}
func TestIssuerClampTTL(t *testing.T) {
portalCfg := newTestConfig(t)
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Unix(0, 0) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Unix(1000, 0) }
spec := ClaimsSpec{
Issuer: "portal:test",
Subject: "portal:client",
Audience: "node:test",
Scope: []string{"cluster"},
TTL: 7200 * time.Second,
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
parsed := &Claims{}
parser := gojwt.NewParser(gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}), gojwt.WithoutClaimsValidation())
_, err = parser.ParseWithClaims(token, parsed, func(token *gojwt.Token) (interface{}, error) {
key, _ := mgr.ActiveKey()
return key.PublicKey, nil
})
require.NoError(t, err)
ttl := parsed.ExpiresAt.Time.Sub(parsed.IssuedAt.Time)
require.Equal(t, MaxTokenTTL, ttl)
}
func TestBackoffDuration(t *testing.T) {
origRand := randInt63n
randInt63n = func(n int64) int64 {
if n <= 0 {
return 0
}
return n - 1
}
t.Cleanup(func() { randInt63n = origRand })
tests := []struct {
name string
attempt int
expect time.Duration
}{
{"Attempt1", 1, 300 * time.Millisecond},
{"Attempt2", 2, 600 * time.Millisecond},
{"Attempt3", 3, 1200 * time.Millisecond},
{"Attempt4", 4, 2400 * time.Millisecond},
{"Attempt5", 5, 3 * time.Second},
{"AttemptZero", 0, 300 * time.Millisecond},
}
for _, tt := range tests {
if got := backoffDuration(tt.attempt); got != tt.expect {
t.Errorf("%s: expected %s, got %s", tt.name, tt.expect, got)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -20,5 +21,8 @@ func TestMain(m *testing.M) {
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -15,6 +15,7 @@ var AuthCommands = &cli.Command{
AuthShowCommand,
AuthRemoveCommand,
AuthResetCommand,
AuthJWTCommands,
},
}

View File

@@ -0,0 +1,16 @@
package commands
import "github.com/urfave/cli/v2"
// AuthJWTCommands groups JWT-related auth helpers under photoprism auth jwt.
var AuthJWTCommands = &cli.Command{
Name: "jwt",
Usage: "JWT issuance and diagnostics",
Hidden: true, // Required for cluster-management only.
Subcommands: []*cli.Command{
AuthJWTIssueCommand,
AuthJWTInspectCommand,
AuthJWTKeysCommand,
AuthJWTStatusCommand,
},
}

View File

@@ -0,0 +1,154 @@
package commands
import (
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/urfave/cli/v2"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
)
// AuthJWTInspectCommand inspects and verifies portal-issued JWTs.
var AuthJWTInspectCommand = &cli.Command{
Name: "inspect",
Usage: "Decodes and verifies a portal JWT",
ArgsUsage: "<token>",
Flags: []cli.Flag{
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "read token from file"},
&cli.StringFlag{Name: "expect-audience", Usage: "expected audience (e.g., node:<uuid>)"},
&cli.StringSliceFlag{Name: "require-scope", Usage: "require specific scope(s)"},
&cli.BoolFlag{Name: "skip-verify", Usage: "decode without signature verification"},
JsonFlag(),
},
Action: authJWTInspectAction,
}
// authJWTInspectAction decodes and optionally verifies a portal-issued JWT.
func authJWTInspectAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if err := requirePortal(conf); err != nil {
return err
}
token, err := readTokenInput(ctx)
if err != nil {
return err
}
header, claims, err := decodeJWTClaims(token)
if err != nil {
return cli.Exit(err, 1)
}
var verified bool
tokenScopes := clean.Scopes(claims.Scope)
if !ctx.Bool("skip-verify") {
expected := clusterjwt.ExpectedClaims{}
if clusterUUID := strings.TrimSpace(conf.ClusterUUID()); clusterUUID != "" {
expected.Issuer = fmt.Sprintf("portal:%s", clusterUUID)
} else if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
expected.Issuer = strings.TrimRight(portal, "/")
}
if expectAud := strings.TrimSpace(ctx.String("expect-audience")); expectAud != "" {
expected.Audience = expectAud
} else if len(claims.Audience) > 0 {
expected.Audience = claims.Audience[0]
}
if required := ctx.StringSlice("require-scope"); len(required) > 0 {
scopes, scopeErr := normalizeScopes(required)
if scopeErr != nil {
return scopeErr
}
expected.Scope = scopes
} else {
expected.Scope = tokenScopes
}
if _, err := verifyPortalToken(conf, token, expected); err != nil {
return cli.Exit(err, 1)
}
verified = true
}
if ctx.Bool("json") {
payload := map[string]any{
"token": token,
"verified": verified,
"header": header,
"claims": claims,
}
return printJSON(payload)
}
fmt.Println()
fmt.Println("JWT header:")
for k, v := range header {
fmt.Printf(" %s: %v\n", k, v)
}
fmt.Println("\nJWT claims:")
fmt.Printf(" issuer: %s\n", claims.Issuer)
fmt.Printf(" subject: %s\n", claims.Subject)
fmt.Printf(" audience: %s\n", strings.Join(claims.Audience, " "))
fmt.Printf(" scope: %s\n", strings.Join(tokenScopes, " "))
if claims.IssuedAt != nil {
fmt.Printf(" issuedAt: %s\n", claims.IssuedAt.Time.UTC().Format(time.RFC3339))
}
if claims.ExpiresAt != nil {
fmt.Printf(" expiresAt: %s\n", claims.ExpiresAt.Time.UTC().Format(time.RFC3339))
}
if claims.NotBefore != nil {
fmt.Printf(" notBefore: %s\n", claims.NotBefore.Time.UTC().Format(time.RFC3339))
}
if claims.ID != "" {
fmt.Printf(" jti: %s\n", claims.ID)
}
if verified {
fmt.Println("\nSignature: verified")
} else {
fmt.Println("\nSignature: not verified (skipped)")
}
fmt.Printf("\nToken:\n%s\n\n", token)
return nil
})
}
// readTokenInput loads the token from CLI args, file, or STDIN.
func readTokenInput(ctx *cli.Context) (string, error) {
if file := strings.TrimSpace(ctx.String("file")); file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", cli.Exit(err, 1)
}
return strings.TrimSpace(string(data)), nil
}
if ctx.Args().Len() == 0 {
return "", cli.Exit(errors.New("token argument required"), 2)
}
token := strings.TrimSpace(ctx.Args().First())
if token == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", cli.Exit(err, 1)
}
token = strings.TrimSpace(string(data))
}
if token == "" {
return "", cli.Exit(errors.New("token argument required"), 2)
}
return token, nil
}

View File

@@ -0,0 +1,117 @@
package commands
import (
"fmt"
"strings"
"time"
"github.com/urfave/cli/v2"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
// AuthJWTIssueCommand issues portal-signed JWTs for cluster nodes.
var AuthJWTIssueCommand = &cli.Command{
Name: "issue",
Usage: "Issues a portal-signed JWT for a node",
Flags: []cli.Flag{
&cli.StringFlag{Name: "node", Aliases: []string{"n"}, Usage: "target node uuid, client id, or DNS label", Required: true},
&cli.StringSliceFlag{Name: "scope", Aliases: []string{"s"}, Usage: "token scope", Value: cli.NewStringSlice("cluster")},
&cli.DurationFlag{Name: "ttl", Usage: "token lifetime", Value: clusterjwt.TokenTTL},
&cli.StringFlag{Name: "subject", Usage: "token subject (default portal:<clusterUUID>)"},
JsonFlag(),
},
Action: authJWTIssueAction,
}
// authJWTIssueAction handles CLI issuance of portal-signed JWTs for nodes.
func authJWTIssueAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if err := requirePortal(conf); err != nil {
return err
}
node, err := resolveNode(conf, ctx.String("node"))
if err != nil {
return err
}
scopes, err := normalizeScopes(ctx.StringSlice("scope"), "cluster")
if err != nil {
return err
}
ttl := ctx.Duration("ttl")
if ttl <= 0 {
ttl = clusterjwt.TokenTTL
}
clusterUUID := strings.TrimSpace(conf.ClusterUUID())
if clusterUUID == "" {
return cli.Exit(fmt.Errorf("cluster uuid not configured"), 1)
}
subject := strings.TrimSpace(ctx.String("subject"))
if subject == "" {
subject = fmt.Sprintf("portal:%s", clusterUUID)
}
var token string
if subject == fmt.Sprintf("portal:%s", clusterUUID) {
token, err = get.IssuePortalJWTForNode(node.UUID, scopes, ttl)
} else {
spec := clusterjwt.ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", clusterUUID),
Subject: subject,
Audience: fmt.Sprintf("node:%s", node.UUID),
Scope: scopes,
TTL: ttl,
}
token, err = get.IssuePortalJWT(spec)
}
if err != nil {
return cli.Exit(err, 1)
}
header, claims, err := decodeJWTClaims(token)
if err != nil {
return cli.Exit(err, 1)
}
if ctx.Bool("json") {
payload := map[string]any{
"token": token,
"header": header,
"claims": claims,
"node": map[string]string{
"uuid": node.UUID,
"clientId": node.ClientID,
"name": node.Name,
"role": string(node.Role),
},
}
return printJSON(payload)
}
expires := "unknown"
if claims.ExpiresAt != nil {
expires = claims.ExpiresAt.Time.UTC().Format(time.RFC3339)
}
audience := strings.Join(claims.Audience, " ")
if audience == "" {
audience = "(none)"
}
fmt.Printf("\nIssued JWT for node %s (%s)\n", node.Name, node.UUID)
fmt.Printf("Scopes: %s\n", strings.Join(scopes, " "))
fmt.Printf("Expires: %s\n", expires)
fmt.Printf("Audience: %s\n", audience)
fmt.Printf("Subject: %s\n", claims.Subject)
fmt.Printf("Key ID: %v\n", header["kid"])
fmt.Printf("\n%s\n", token)
return nil
})
}

View File

@@ -0,0 +1,107 @@
package commands
import (
"errors"
"fmt"
"strings"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
// AuthJWTKeysCommand groups JWT key management helpers.
var AuthJWTKeysCommand = &cli.Command{
Name: "keys",
Usage: "JWT signing key helpers",
Subcommands: []*cli.Command{
AuthJWTKeysListCommand,
},
}
// AuthJWTKeysListCommand lists JWT signing keys.
var AuthJWTKeysListCommand = &cli.Command{
Name: "ls",
Usage: "Lists JWT signing keys",
Aliases: []string{"list"},
ArgsUsage: "",
Flags: []cli.Flag{
JsonFlag(),
},
Action: authJWTKeysListAction,
}
// authJWTKeysListAction lists portal signing keys with metadata.
func authJWTKeysListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if err := requirePortal(conf); err != nil {
return err
}
manager := get.JWTManager()
if manager == nil {
return cli.Exit(errors.New("jwt manager not available"), 1)
}
keys := manager.AllKeys()
active, _ := manager.ActiveKey()
activeKid := ""
if active != nil {
activeKid = active.Kid
}
type keyInfo struct {
Kid string `json:"kid"`
CreatedAt string `json:"createdAt"`
NotAfter string `json:"notAfter,omitempty"`
Active bool `json:"active"`
}
rows := make([]keyInfo, 0, len(keys))
for _, k := range keys {
info := keyInfo{Kid: k.Kid, Active: k.Kid == activeKid}
if k.CreatedAt > 0 {
info.CreatedAt = time.Unix(k.CreatedAt, 0).UTC().Format(time.RFC3339)
}
if k.NotAfter > 0 {
info.NotAfter = time.Unix(k.NotAfter, 0).UTC().Format(time.RFC3339)
}
rows = append(rows, info)
}
if ctx.Bool("json") {
payload := map[string]any{
"keys": rows,
}
return printJSON(payload)
}
if len(rows) == 0 {
fmt.Println()
fmt.Println("No signing keys found.")
fmt.Println()
return nil
}
fmt.Println()
fmt.Println("JWT signing keys:")
for _, row := range rows {
status := ""
if row.Active {
status = " (active)"
}
parts := []string{fmt.Sprintf("KID: %s%s", row.Kid, status)}
if row.CreatedAt != "" {
parts = append(parts, fmt.Sprintf("created %s", row.CreatedAt))
}
if row.NotAfter != "" {
parts = append(parts, fmt.Sprintf("expires %s", row.NotAfter))
}
fmt.Printf("- %s\n", strings.Join(parts, ", "))
}
fmt.Println()
return nil
})
}

View File

@@ -0,0 +1,67 @@
package commands
import (
"errors"
"fmt"
"strings"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
// AuthJWTStatusCommand reports verifier cache diagnostics.
var AuthJWTStatusCommand = &cli.Command{
Name: "status",
Usage: "Shows JWT verifier cache status",
Flags: []cli.Flag{
JsonFlag(),
},
Action: authJWTStatusAction,
}
// authJWTStatusAction prints JWKS cache diagnostics for the current node.
func authJWTStatusAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
verifier := get.JWTVerifier()
if verifier == nil {
return cli.Exit(errors.New("jwt verifier not available"), 1)
}
ttl := time.Duration(conf.JWKSCacheTTL()) * time.Second
status := verifier.Status(ttl)
status.JWKSURL = strings.TrimSpace(conf.JWKSUrl())
if ctx.Bool("json") {
return printJSON(status)
}
fmt.Println()
fmt.Printf("JWKS URL: %s\n", status.JWKSURL)
fmt.Printf("Cache Path: %s\n", status.CachePath)
fmt.Printf("Cache URL: %s\n", status.CacheURL)
fmt.Printf("Cache ETag: %s\n", status.CacheETag)
fmt.Printf("Cached Keys: %d\n", status.KeyCount)
if len(status.KeyIDs) > 0 {
fmt.Printf("Key IDs: %s\n", strings.Join(status.KeyIDs, ", "))
}
if !status.CacheFetchedAt.IsZero() {
fmt.Printf("Last Fetch: %s\n", status.CacheFetchedAt.Format(time.RFC3339))
} else {
fmt.Println("Last Fetch: never")
}
fmt.Printf("Cache Age: %ds\n", status.CacheAgeSeconds)
if status.CacheTTLSeconds > 0 {
fmt.Printf("Cache TTL: %ds\n", status.CacheTTLSeconds)
}
if status.CacheStale {
fmt.Println("Cache Status: STALE")
} else {
fmt.Println("Cache Status: fresh")
}
fmt.Println()
return nil
})
}

View File

@@ -0,0 +1,94 @@
package commands
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestAuthJWTCommands(t *testing.T) {
conf := get.Config()
origEdition := conf.Options().Edition
origRole := conf.Options().NodeRole
origUUID := conf.Options().ClusterUUID
origPortal := conf.Options().PortalUrl
origJWKS := conf.JWKSUrl()
conf.Options().Edition = config.Portal
conf.Options().NodeRole = string(cluster.RolePortal)
conf.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
conf.Options().PortalUrl = "https://portal.test"
conf.SetJWKSUrl("https://portal.test/.well-known/jwks.json")
get.SetConfig(conf)
conf.RegisterDb()
require.True(t, conf.IsPortal())
manager := get.JWTManager()
require.NotNil(t, manager)
_, err := manager.EnsureActiveKey()
require.NoError(t, err)
registry, err := reg.NewClientRegistryWithConfig(conf)
require.NoError(t, err)
nodeUUID := rnd.UUID()
node := &reg.Node{}
node.UUID = nodeUUID
node.Name = "pp-node-01"
node.Role = string(cluster.RoleInstance)
require.NoError(t, registry.Put(node))
t.Cleanup(func() {
conf.Options().Edition = origEdition
conf.Options().NodeRole = origRole
conf.Options().ClusterUUID = origUUID
conf.Options().PortalUrl = origPortal
conf.SetJWKSUrl(origJWKS)
get.SetConfig(conf)
conf.RegisterDb()
})
output, err := RunWithTestContext(AuthJWTIssueCommand, []string{"issue", "--node", nodeUUID})
require.NoError(t, err)
assert.Contains(t, output, "Issued JWT")
jsonOut, err := RunWithTestContext(AuthJWTIssueCommand, []string{"issue", "--node", nodeUUID, "--json"})
require.NoError(t, err)
var payload struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal([]byte(jsonOut), &payload))
require.NotEmpty(t, payload.Token)
inspectOut, err := RunWithTestContext(AuthJWTInspectCommand, []string{"inspect", "--json", payload.Token})
require.NoError(t, err)
assert.Contains(t, inspectOut, "\"verified\": true")
inspectStrict, err := RunWithTestContext(AuthJWTInspectCommand, []string{"inspect", "--json", "--expect-audience", "node:" + nodeUUID, "--require-scope", "cluster", payload.Token})
require.NoError(t, err)
assert.Contains(t, inspectStrict, "\"verified\": true")
keysOut, err := RunWithTestContext(AuthJWTKeysListCommand, []string{"ls", "--json"})
require.NoError(t, err)
assert.Contains(t, keysOut, "\"keys\"")
statusOut, err := RunWithTestContext(AuthJWTStatusCommand, []string{"status"})
require.NoError(t, err)
assert.Contains(t, statusOut, "JWKS URL")
assert.Contains(t, statusOut, "Cached Keys")
// invalid scope should fail
_, err = RunWithTestContext(AuthJWTIssueCommand, []string{"issue", "--node", nodeUUID, "--scope", "unknown"})
require.Error(t, err)
}

View File

@@ -71,7 +71,7 @@ func clientsAddAction(ctx *cli.Context) error {
// Set a default client name if no specific name has been provided.
if frm.AuthScope == "" {
frm.AuthScope = list.All
frm.AuthScope = list.Any
}
client, addErr := entity.AddClient(frm)

View File

@@ -5,11 +5,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/service/cluster"
)
func TestExitCodes_Register_ValidationAndUnauthorized(t *testing.T) {
t.Run("MissingURL", func(t *testing.T) {
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", cluster.ExampleJoinToken})
err := ClusterRegisterCommand.Action(ctx)
assert.Error(t, err)
if ec, ok := err.(cli.ExitCoder); ok {

View File

@@ -19,8 +19,9 @@ type healthResponse struct {
// ClusterHealthCommand prints a minimal health response (Portal-only).
var ClusterHealthCommand = &cli.Command{
Name: "health",
Usage: "Shows cluster health (Portal-only)",
Usage: "Shows cluster health status",
Flags: report.CliFlags,
Hidden: true, // Required for cluster-management only.
Action: clusterHealthAction,
}

View File

@@ -14,8 +14,9 @@ import (
// ClusterNodesCommands groups node subcommands.
var ClusterNodesCommands = &cli.Command{
Name: "nodes",
Usage: "Node registry subcommands",
Name: "nodes",
Usage: "Node registry subcommands",
Hidden: true, // Required for cluster-management only.
Subcommands: []*cli.Command{
ClusterNodesListCommand,
ClusterNodesShowCommand,
@@ -28,9 +29,10 @@ var ClusterNodesCommands = &cli.Command{
// ClusterNodesListCommand lists registered nodes.
var ClusterNodesListCommand = &cli.Command{
Name: "ls",
Usage: "Lists registered cluster nodes (Portal-only)",
Usage: "Lists registered cluster nodes",
Flags: append(report.CliFlags, CountFlag, OffsetFlag),
ArgsUsage: "",
Hidden: true, // Required for cluster-management only.
Action: clusterNodesListAction,
}

View File

@@ -22,9 +22,10 @@ var (
// ClusterNodesModCommand updates node fields.
var ClusterNodesModCommand = &cli.Command{
Name: "mod",
Usage: "Updates node properties (Portal-only)",
Usage: "Updates node properties",
ArgsUsage: "<id|name>",
Flags: []cli.Flag{nodesModRoleFlag, nodesModInternal, nodesModLabel, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}},
Hidden: true, // Required for cluster-management only.
Action: clusterNodesModAction,
}

View File

@@ -14,12 +14,13 @@ import (
// ClusterNodesRemoveCommand deletes a node from the registry.
var ClusterNodesRemoveCommand = &cli.Command{
Name: "rm",
Usage: "Deletes a node from the registry (Portal-only)",
Usage: "Deletes a node from the registry",
ArgsUsage: "<id|name>",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"},
&cli.BoolFlag{Name: "all-ids", Usage: "delete all records that share the same UUID (admin cleanup)"},
},
Hidden: true, // Required for cluster-management only.
Action: clusterNodesRemoveAction,
}

View File

@@ -2,6 +2,7 @@ package commands
import (
"encoding/json"
"errors"
"fmt"
"os"
@@ -99,18 +100,20 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
}
}
body := map[string]interface{}{
"nodeName": name,
"rotate": rotateDatabase,
"rotateSecret": rotateSecret,
payload := cluster.RegisterRequest{
NodeName: name,
RotateDatabase: rotateDatabase,
RotateSecret: rotateSecret,
}
b, _ := json.Marshal(body)
b, _ := json.Marshal(payload)
endpointUrl := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse
if err := postWithBackoff(url, token, b, &resp); err != nil {
if err := postWithBackoff(endpointUrl, token, b, &resp); err != nil {
// Map common HTTP errors similarly to register command
if he, ok := err.(*httpError); ok {
var he *httpError
if errors.As(err, &he) {
switch he.Status {
case 401, 403:
return cli.Exit(fmt.Errorf("%s", he.Error()), 4)
@@ -151,6 +154,7 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
fmt.Printf("DSN: %s\n", resp.Database.DSN)
}
}
return nil
})
}

View File

@@ -15,9 +15,10 @@ import (
// ClusterNodesShowCommand shows node details.
var ClusterNodesShowCommand = &cli.Command{
Name: "show",
Usage: "Shows node details (Portal-only)",
Usage: "Shows node details",
ArgsUsage: "<id|name>",
Flags: report.CliFlags,
Hidden: true, // Required for cluster-management only.
Action: clusterNodesShowAction,
}

View File

@@ -20,11 +20,12 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// flags for register
// Supported cluster node register flags.
var (
regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"}
regRoleFlag = &cli.StringFlag{Name: "role", Usage: "node `ROLE` (instance, service)", Value: "instance"}
@@ -42,7 +43,7 @@ var (
// ClusterRegisterCommand registers a node with the Portal via HTTP.
var ClusterRegisterCommand = &cli.Command{
Name: "register",
Usage: "Registers/rotates a node via Portal (HTTP)",
Usage: "Registers a node or updates its credentials within a cluster",
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, regDryRun}, report.CliFlags...)),
Action: clusterRegisterAction,
}
@@ -52,15 +53,18 @@ func clusterRegisterAction(ctx *cli.Context) error {
// Resolve inputs
name := clean.DNSLabel(ctx.String("name"))
derivedName := false
if name == "" { // default from config if set
name = clean.DNSLabel(conf.NodeName())
if name != "" {
derivedName = true
}
}
if name == "" {
return cli.Exit(fmt.Errorf("node name is required (use --name or set node-name)"), 2)
}
nodeRole := clean.TypeLowerDash(ctx.String("role"))
switch nodeRole {
case "instance", "service":
@@ -76,7 +80,6 @@ func clusterRegisterAction(ctx *cli.Context) error {
derivedPortal = true
}
}
// In dry-run, we allow empty portalURL (will print derived/empty values).
// Derive advertise/site URLs when omitted.
advertise := ctx.String("advertise-url")
@@ -85,28 +88,31 @@ func clusterRegisterAction(ctx *cli.Context) error {
}
site := conf.SiteUrl()
body := map[string]interface{}{
"nodeName": name,
"nodeRole": nodeRole,
"labels": parseLabelSlice(ctx.StringSlice("label")),
"advertiseUrl": advertise,
"rotate": ctx.Bool("rotate"),
"rotateSecret": ctx.Bool("rotate-secret"),
payload := cluster.RegisterRequest{
NodeName: name,
NodeRole: nodeRole,
Labels: parseLabelSlice(ctx.StringSlice("label")),
AdvertiseUrl: advertise,
RotateDatabase: ctx.Bool("rotate"),
RotateSecret: ctx.Bool("rotate-secret"),
}
// If we already have client credentials (e.g., re-register), include them so the
// portal can verify and authorize UUID/name moves or metadata updates.
if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" {
body["clientId"] = id
body["clientSecret"] = secret
payload.ClientID = id
payload.ClientSecret = secret
}
if site != "" && site != advertise {
body["siteUrl"] = site
}
b, _ := json.Marshal(body)
if site != "" && site != advertise {
payload.SiteUrl = site
}
b, _ := json.Marshal(payload)
// In dry-run, we allow empty portalURL (will print derived/empty values).
if ctx.Bool("dry-run") {
if ctx.Bool("json") {
out := map[string]any{"portalUrl": portalURL, "payload": body}
out := map[string]any{"portalUrl": portalURL, "payload": payload}
jb, _ := json.Marshal(out)
fmt.Println(string(jb))
} else {
@@ -116,19 +122,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
fmt.Println("(derived defaults were used where flags were omitted)")
}
fmt.Printf("Advertise: %s\n", advertise)
if v, ok := body["siteUrl"].(string); ok && v != "" {
fmt.Printf("Site URL: %s\n", v)
if payload.SiteUrl != "" {
fmt.Printf("Site URL: %s\n", payload.SiteUrl)
}
// Warn if non-HTTPS on public host; server will enforce too.
if warnInsecurePublicURL(advertise) {
fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).")
}
if v, ok := body["siteUrl"].(string); ok && v != "" && warnInsecurePublicURL(v) {
if payload.SiteUrl != "" && warnInsecurePublicURL(payload.SiteUrl) {
fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).")
}
// Single-line summary for quick operator scan
if v, ok := body["siteUrl"].(string); ok && v != "" {
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, v)
if payload.SiteUrl != "" {
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, payload.SiteUrl)
} else {
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
}
@@ -140,18 +146,22 @@ func clusterRegisterAction(ctx *cli.Context) error {
if portalURL == "" {
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
}
token := ctx.String("join-token")
if token == "" {
token = conf.JoinToken()
}
if token == "" {
return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
}
// POST with bounded backoff on 429
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
endpointUrl := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse
if err := postWithBackoff(url, token, b, &resp); err != nil {
if err := postWithBackoff(endpointUrl, token, b, &resp); err != nil {
var httpErr *httpError
if errors.As(err, &httpErr) && httpErr.Status == http.StatusTooManyRequests {
return cli.Exit(fmt.Errorf("portal rate-limited registration attempts"), 6)
@@ -179,13 +189,17 @@ func clusterRegisterAction(ctx *cli.Context) error {
} else {
// Human-readable: node row and credentials if present (UUID first as primary identifier)
cols := []string{"UUID", "ClientID", "Name", "Role", "DB Driver", "DB Name", "DB User", "Host", "Port"}
var dbName, dbUser string
if resp.Database.Name != "" {
dbName = resp.Database.Name
}
if resp.Database.User != "" {
dbUser = resp.Database.User
}
rows := [][]string{{resp.Node.UUID, resp.Node.ClientID, resp.Node.Name, resp.Node.Role, resp.Database.Driver, dbName, dbUser, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
@@ -317,6 +331,16 @@ func parseLabelSlice(labels []string) map[string]string {
// Persistence helpers for --write-config
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
updates := map[string]any{}
if rnd.IsUUID(resp.UUID) {
updates["ClusterUUID"] = resp.UUID
}
if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" {
updates["ClusterCIDR"] = cidr
}
// Node client secret file
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
@@ -335,16 +359,18 @@ func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse
// DB settings (MySQL/MariaDB only)
if resp.Database.Name != "" && resp.Database.User != "" {
if err := mergeOptionsYaml(conf, map[string]any{
"DatabaseDriver": config.MySQL,
"DatabaseName": resp.Database.Name,
"DatabaseServer": fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port),
"DatabaseUser": resp.Database.User,
"DatabasePassword": resp.Database.Password,
}); err != nil {
updates["DatabaseDriver"] = config.MySQL
updates["DatabaseName"] = resp.Database.Name
updates["DatabaseServer"] = fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port)
updates["DatabaseUser"] = resp.Database.User
updates["DatabasePassword"] = resp.Database.Password
}
if len(updates) > 0 {
if err := mergeOptionsYaml(conf, updates); err != nil {
return err
}
log.Infof("updated options.yml with database settings for node %s", clean.LogQuote(resp.Node.Name))
log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name))
}
return nil
}

View File

@@ -14,6 +14,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
)
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
@@ -23,7 +24,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -32,7 +33,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": cluster.ExampleClientSecret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": false,
"alreadyProvisioned": false,
})
@@ -40,12 +41,12 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
"register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
})
assert.NoError(t, err)
// Parse JSON
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, cluster.ExampleClientSecret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "pwd", gjson.Get(out, "database.password").String())
dsn := gjson.Get(out, "database.dsn").String()
parsed := cfg.NewDSN(dsn)
@@ -58,12 +59,13 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
// Fake Portal register endpoint for rotation
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -72,7 +74,7 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret2", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -80,13 +82,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--secret", "--yes", "pp-node-03",
"rotate", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--secret", "--yes", "pp-node-03",
})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-03")
@@ -96,12 +98,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
func TestClusterNodesRotate_HTTPJson(t *testing.T) {
// Fake Portal register endpoint for rotation in JSON mode
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -110,7 +113,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret3", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -118,7 +121,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
@@ -128,7 +131,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret3", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "pwd3", gjson.Get(out, "database.password").String())
dsn := gjson.Get(out, "database.dsn").String()
parsed := cfg.NewDSN(dsn)
@@ -145,13 +148,13 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Read payload to assert rotate flags
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect DB rotation only
if !rotate || rotateSecret {
@@ -171,7 +174,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
_ = os.Setenv("PHOTOPRISM_YES", "true")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
@@ -193,17 +196,18 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
}
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect secret-only rotation
if rotate || !rotateSecret {
@@ -215,7 +219,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n4", "name": "pp-node-06", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret4", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -223,7 +227,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
@@ -231,7 +235,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret4", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String())
}
@@ -258,7 +262,7 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
"register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode())
@@ -302,7 +306,7 @@ func TestClusterRegister_HTTPBadRequest(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
"register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode())
@@ -331,7 +335,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate", "--json",
"register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String())
@@ -360,7 +364,7 @@ func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-x",
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-x",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode())
@@ -376,7 +380,7 @@ func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp node invalid",
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp node invalid",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode())
@@ -405,7 +409,7 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-rl2",
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-rl2",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl2", gjson.Get(out, "node.name").String())
@@ -417,12 +421,12 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
if !gjson.GetBytes(b, "rotateDatabase").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
@@ -438,7 +442,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate", "--json",
"register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String())
@@ -453,17 +457,18 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
}
func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
if gjson.GetBytes(b, "rotateDatabase").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
@@ -472,7 +477,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n6", "name": "pp-node-08", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "pwd8secret", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -480,10 +485,10 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate-secret", "--json",
"register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate-secret", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String())
}

View File

@@ -13,11 +13,12 @@ import (
"github.com/photoprism/photoprism/pkg/txt/report"
)
// ClusterSummaryCommand prints a minimal cluster summary (Portal-only).
// ClusterSummaryCommand prints a minimal cluster summary.
var ClusterSummaryCommand = &cli.Command{
Name: "summary",
Usage: "Shows cluster summary (Portal-only)",
Usage: "Shows cluster summary",
Flags: report.CliFlags,
Hidden: true, // Required for cluster-management only.
Action: clusterSummaryAction,
}
@@ -35,10 +36,11 @@ func clusterSummaryAction(ctx *cli.Context) error {
nodes, _ := r.List()
resp := cluster.SummaryResponse{
UUID: conf.ClusterUUID(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
}
if ctx.Bool("json") {
@@ -47,8 +49,8 @@ func clusterSummaryAction(ctx *cli.Context) error {
return nil
}
cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.UUID, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
cols := []string{"Portal UUID", "Cluster CIDR", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.UUID, resp.ClusterCIDR, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
return err

View File

@@ -23,6 +23,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -63,7 +64,7 @@ func TestClusterThemePullCommand(t *testing.T) {
func TestClusterRegisterCommand(t *testing.T) {
t.Run("ValidationMissingURL", func(t *testing.T) {
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", cluster.ExampleJoinToken})
assert.Error(t, err)
_ = out
})
@@ -95,7 +96,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
// Create a registry node via FileRegistry.
r, err := reg.NewClientRegistryWithConfig(c)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}}
assert.NoError(t, r.Put(n))
// nodes ls (JSON)
@@ -123,7 +124,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -139,11 +140,11 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=test-token"})
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken})
assert.NoError(t, err)
// Expect extracted file
assert.FileExists(t, filepath.Join(destDir, "test.txt"))

View File

@@ -81,7 +81,7 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
// Must have Bearer join token
if r.Header.Get("Authorization") != "Bearer jt" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -95,13 +95,14 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
// Return NodeClientID and a fresh secret
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
UUID: rnd.UUID(),
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
UUID: rnd.UUID(),
ClusterCIDR: "203.0.113.0/24",
Node: cluster.Node{ClientID: cluster.ExampleClientID, Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
})
case "/api/v1/oauth/token":
// Expect Basic for the returned creds
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cs5gfen1bgxz7s9i:s3cr3t")) {
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte(cluster.ExampleClientID+":"+cluster.ExampleClientSecret)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -124,7 +125,7 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
"pull", "--dest", dest, "-f",
"--portal-url=" + ts.URL,
"--join-token=jt",
"--join-token=" + cluster.ExampleJoinToken,
})
_ = out
assert.NoError(t, err)

View File

@@ -28,7 +28,13 @@ func TestMain(m *testing.M) {
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
c := config.NewTestConfig("commands")
tempDir, err := os.MkdirTemp("", "commands-test")
if err != nil {
panic(err)
}
defer os.RemoveAll(tempDir)
c := config.NewMinimalTestConfigWithDb("commands", tempDir)
get.SetConfig(c)
// Keep DB connection open for the duration of this package's tests to
@@ -42,7 +48,7 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
@@ -91,7 +97,6 @@ func RunWithTestContext(cmd *cli.Command, args []string) (output string, err err
// Ensure DB connection is open for each command run (some commands call Shutdown).
if c := get.Config(); c != nil {
_ = c.Init() // safe to call; re-opens DB if needed
c.RegisterDb() // (re)register provider
}
@@ -104,5 +109,11 @@ func RunWithTestContext(cmd *cli.Command, args []string) (output string, err err
err = cmd.Run(ctx, args...)
})
// Re-open the database after the command completed so follow-up checks
// (potentially issued by the test itself) have an active connection.
if c := get.Config(); c != nil {
c.RegisterDb()
}
return output, err
}

View File

@@ -81,9 +81,10 @@ func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) {
if conf == nil {
t.Fatalf("missing test config")
}
// Ensure DB is initialized and registered (bypassing CLI InitConfig)
_ = conf.Init()
conf.RegisterDb()
// Override yt-dlp after config init (config may set dl.YtDlpBin)
dl.YtDlpBin = fake
t.Logf("using yt-dlp binary: %s", dl.YtDlpBin)
@@ -125,7 +126,6 @@ func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
if conf == nil {
t.Fatalf("missing test config")
}
_ = conf.Init()
conf.RegisterDb()
dl.YtDlpBin = fake
@@ -196,8 +196,9 @@ func TestDownloadImpl_FileMethod_Always_RemuxFails(t *testing.T) {
if conf == nil {
t.Fatalf("missing test config")
}
_ = conf.Init()
conf.RegisterDb()
dl.YtDlpBin = fake
err := runDownload(conf, DownloadOpts{

View File

@@ -0,0 +1,8 @@
package commands
import "github.com/urfave/cli/v2"
// JsonFlag returns the shared CLI flag definition for JSON output across commands.
func JsonFlag() *cli.BoolFlag {
return &cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"}
}

View File

@@ -0,0 +1,173 @@
package commands
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
)
var allowedJWTScope = func() map[string]struct{} {
out := make(map[string]struct{}, len(acl.ResourceNames))
for _, res := range acl.ResourceNames {
out[res.String()] = struct{}{}
}
return out
}()
// requirePortal returns a CLI error when the active configuration is not a portal node.
func requirePortal(conf *config.Config) error {
if conf == nil || !conf.IsPortal() {
return cli.Exit(errors.New("command requires a Portal node"), 2)
}
return nil
}
// resolveNode finds a node by UUID, client ID, or DNS label using the portal registry.
func resolveNode(conf *config.Config, identifier string) (*reg.Node, error) {
if err := requirePortal(conf); err != nil {
return nil, err
}
key := strings.TrimSpace(identifier)
if key == "" {
return nil, cli.Exit(errors.New("node identifier required"), 2)
}
registry, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
return nil, cli.Exit(err, 1)
}
if node, err := registry.FindByNodeUUID(key); err == nil && node != nil {
return node, nil
}
if node, err := registry.FindByClientID(key); err == nil && node != nil {
return node, nil
}
name := clean.DNSLabel(key)
if name == "" {
return nil, cli.Exit(errors.New("invalid node identifier"), 2)
}
node, err := registry.FindByName(name)
if err != nil {
if errors.Is(err, reg.ErrNotFound) {
return nil, cli.Exit(fmt.Errorf("node %q not found", identifier), 3)
}
return nil, cli.Exit(err, 1)
}
return node, nil
}
// decodeJWTClaims decodes the compact JWT and returns header and claims without verifying the signature.
func decodeJWTClaims(token string) (map[string]any, *clusterjwt.Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, nil, errors.New("jwt: token must contain three segments")
}
decode := func(segment string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(segment)
}
headerBytes, err := decode(parts[0])
if err != nil {
return nil, nil, err
}
payloadBytes, err := decode(parts[1])
if err != nil {
return nil, nil, err
}
var header map[string]any
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, nil, err
}
claims := &clusterjwt.Claims{}
if err := json.Unmarshal(payloadBytes, claims); err != nil {
return nil, nil, err
}
return header, claims, nil
}
// verifyPortalToken verifies a JWT using the portal's in-memory key manager.
func verifyPortalToken(conf *config.Config, token string, expected clusterjwt.ExpectedClaims) (*clusterjwt.Claims, error) {
if err := requirePortal(conf); err != nil {
return nil, err
}
manager := get.JWTManager()
if manager == nil {
return nil, cli.Exit(errors.New("jwt issuer not available"), 1)
}
jwks := manager.JWKS()
if jwks == nil || len(jwks.Keys) == 0 {
return nil, cli.Exit(errors.New("jwks key set is empty"), 1)
}
leeway := time.Duration(conf.JWTLeeway()) * time.Second
if leeway <= 0 {
leeway = 60 * time.Second
}
claims, err := clusterjwt.VerifyTokenWithKeys(token, expected, jwks.Keys, leeway)
if err != nil {
return nil, err
}
return claims, nil
}
// normalizeScopes trims and de-duplicates scope values, falling back to defaults when necessary.
func normalizeScopes(values []string, defaults ...string) ([]string, error) {
src := values
if len(src) == 0 {
src = defaults
}
out := make([]string, 0, len(src))
seen := make(map[string]struct{}, len(src))
for _, raw := range src {
for _, parsed := range clean.Scopes(raw) {
scope := clean.Scope(parsed)
if scope == "" {
continue
}
if _, exists := seen[scope]; exists {
continue
}
if _, ok := allowedJWTScope[scope]; !ok {
return nil, cli.Exit(fmt.Errorf("unsupported scope %q", scope), 2)
}
seen[scope] = struct{}{}
out = append(out, scope)
}
}
if len(out) == 0 {
return nil, cli.Exit(errors.New("at least one scope is required"), 2)
}
return out, nil
}
// printJSON pretty-prints the payload as JSON.
func printJSON(payload any) error {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return cli.Exit(err, 1)
}
fmt.Printf("%s\n", data)
return nil
}

View File

@@ -16,7 +16,7 @@ var ShowCommandsCommand = &cli.Command{
Name: "commands",
Usage: "Displays a structured catalog of CLI commands",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"},
JsonFlag(),
&cli.BoolFlag{Name: "all", Usage: "include hidden commands and flags"},
&cli.BoolFlag{Name: "short", Usage: "omit flags in Markdown output"},
&cli.IntFlag{Name: "base-heading", Value: 2, Usage: "base Markdown heading level"},

View File

@@ -6,6 +6,7 @@ import (
"io"
"net"
"net/http"
"strings"
"time"
"github.com/tidwall/gjson"
@@ -43,9 +44,9 @@ func statusAction(ctx *cli.Context) error {
}
}
url := fmt.Sprintf("http://%s:%d/api/v1/status", conf.HttpHost(), conf.HttpPort())
endpointUrl := buildStatusEndpoint(conf)
req, err := http.NewRequest(http.MethodGet, url, nil)
req, err := http.NewRequest(http.MethodGet, endpointUrl, nil)
if err != nil {
return err
@@ -53,12 +54,12 @@ func statusAction(ctx *cli.Context) error {
var status string
if resp, err := client.Do(req); err != nil {
if resp, reqErr := client.Do(req); reqErr != nil {
return fmt.Errorf("cannot connect to %s:%d", conf.HttpHost(), conf.HttpPort())
} else if resp.StatusCode != 200 {
return fmt.Errorf("server running at %s:%d, bad status %d\n", conf.HttpHost(), conf.HttpPort(), resp.StatusCode)
} else if body, err := io.ReadAll(resp.Body); err != nil {
return err
} else if body, readErr := io.ReadAll(resp.Body); readErr != nil {
return readErr
} else {
status = string(body)
}
@@ -73,3 +74,21 @@ func statusAction(ctx *cli.Context) error {
return nil
}
// buildStatusEndpoint returns the status endpoint URL, preferring the public
// SiteUrl (which carries the correct scheme) and falling back to the local
// HTTP host/port. When a Unix socket is configured, an http+unix style URL is
// used so the custom transport can dial the socket.
func buildStatusEndpoint(conf *config.Config) string {
if socket := conf.HttpSocket(); socket != nil {
return fmt.Sprintf("%s://%s/api/v1/status", socket.Scheme, strings.TrimPrefix(socket.Path, "/"))
}
siteUrl := strings.TrimRight(conf.SiteUrl(), "/")
if siteUrl != "" {
return siteUrl + "/api/v1/status"
}
return fmt.Sprintf("http://%s:%d/api/v1/status", conf.HttpHost(), conf.HttpPort())
}

View File

@@ -82,7 +82,7 @@ func TestConfig_ClientShareConfig(t *testing.T) {
}
func TestConfig_ClientUser(t *testing.T) {
c := NewTestConfig("config")
c := NewMinimalTestConfigWithDb("client-user", t.TempDir())
c.SetAuthMode(AuthModePasswd)
assert.Equal(t, AuthModePasswd, c.AuthMode())
@@ -112,7 +112,7 @@ func TestConfig_ClientUser(t *testing.T) {
}
func TestConfig_ClientRoleConfig(t *testing.T) {
c := NewTestConfig("config")
c := NewMinimalTestConfigWithDb("client-role", t.TempDir())
c.SetAuthMode(AuthModePasswd)
assert.Equal(t, AuthModePasswd, c.AuthMode())

View File

@@ -2,6 +2,8 @@ package config
import (
"errors"
"net"
urlpkg "net/url"
"os"
"path/filepath"
"strings"
@@ -11,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@@ -102,18 +105,54 @@ func (c *Config) PortalThemePath() string {
return c.ThemePath()
}
// JoinToken returns the token required to access the portal API endpoints.
// JoinToken returns the token required to use the node register API endpoint.
// Example: k9sEFe6-A7gt6zqm-gY9gFh0
func (c *Config) JoinToken() string {
if c.options.JoinToken != "" {
return c.options.JoinToken
} else if fileName := FlagFilePath("JOIN_TOKEN"); fileName == "" {
return ""
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read portal token from %s (%s)", fileName, err)
return ""
} else {
return string(b)
if s := strings.TrimSpace(c.options.JoinToken); rnd.IsJoinToken(s, false) {
c.options.JoinToken = s
return s
}
if fileName := FlagFilePath("JOIN_TOKEN"); fileName != "" && fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: could not read portal token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
return s
} else {
log.Warnf("config: portal join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
}
}
if !c.IsPortal() {
return ""
}
fileName := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
if fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: could not read portal token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
c.options.JoinToken = s
return s
} else {
log.Warnf("config: portal join token stored in %s is shorter than %d characters; generating a new one", fileName, rnd.JoinTokenLength)
}
}
token := rnd.JoinToken()
if !rnd.IsJoinToken(token, true) {
return ""
}
if err := fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
log.Errorf("config: could not write portal join token (%s)", err)
return ""
}
c.options.JoinToken = token
return token
}
// deriveNodeNameAndDomainFromHttpHost attempts to derive cluster host and domain name from the site URL.
@@ -210,6 +249,84 @@ func (c *Config) NodeClientSecret() string {
}
}
// JWKSUrl returns the configured JWKS endpoint for portal-issued JWTs. Nodes normally
// persist this URL from the portal's register response, which derives it from SiteUrl;
// manual overrides are only required for custom deployments.
func (c *Config) JWKSUrl() string {
return strings.TrimSpace(c.options.JWKSUrl)
}
// SetJWKSUrl updates the configured JWKS endpoint for portal-issued JWTs.
func (c *Config) SetJWKSUrl(url string) {
if c == nil || c.options == nil {
return
}
trimmed := strings.TrimSpace(url)
if trimmed == "" {
c.options.JWKSUrl = ""
return
}
parsed, err := urlpkg.Parse(trimmed)
if err != nil || parsed == nil || parsed.Scheme == "" || parsed.Host == "" {
log.Warnf("config: ignoring JWKS URL %q (%v)", trimmed, err)
return
}
scheme := strings.ToLower(parsed.Scheme)
host := parsed.Hostname()
switch scheme {
case "https":
// Always allowed.
case "http":
if !isLoopbackHost(host) {
log.Warnf("config: rejecting JWKS URL %q (http only allowed for localhost/loopback)", trimmed)
return
}
default:
log.Warnf("config: rejecting JWKS URL %q (unsupported scheme)", trimmed)
return
}
c.options.JWKSUrl = trimmed
}
// JWKSCacheTTL returns the JWKS cache lifetime in seconds (default 300, max 3600).
func (c *Config) JWKSCacheTTL() int {
if c.options.JWKSCacheTTL <= 0 {
return 300
}
if c.options.JWKSCacheTTL > 3600 {
return 3600
}
return c.options.JWKSCacheTTL
}
// JWTLeeway returns the permitted clock skew in seconds (default 60, max 300).
func (c *Config) JWTLeeway() int {
if c.options.JWTLeeway <= 0 {
return 60
}
if c.options.JWTLeeway > 300 {
return 300
}
return c.options.JWTLeeway
}
// JWTAllowedScopes returns an optional allow-list of accepted JWT scopes.
func (c *Config) JWTAllowedScopes() list.Attr {
if s := strings.TrimSpace(c.options.JWTScope); s != "" {
parsed := list.ParseAttr(strings.ToLower(s))
if len(parsed) > 0 {
return parsed
}
}
return list.ParseAttr("cluster vision metrics")
}
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
func (c *Config) AdvertiseUrl() string {
if c.options.AdvertiseUrl != "" {
@@ -224,6 +341,23 @@ func (c *Config) AdvertiseUrl() string {
return c.SiteUrl()
}
// isLoopbackHost returns true when host represents localhost or a loopback IP.
func isLoopbackHost(host string) bool {
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
// SaveClusterUUID writes or updates the ClusterUUID key in options.yml without
// touching unrelated keys. Creates the file and directories if needed.
func (c *Config) SaveClusterUUID(uuid string) error {

View File

@@ -11,9 +11,12 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
)
const shortTestJoinToken = "short-token"
func TestConfig_PortalUrl(t *testing.T) {
t.Run("Unset", func(t *testing.T) {
c := NewConfig(CliTestContext())
@@ -22,6 +25,32 @@ func TestConfig_PortalUrl(t *testing.T) {
assert.Equal(t, "", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
t.Run("JoinTokenTooShort", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JoinToken = shortTestJoinToken
assert.Equal(t, "", c.JoinToken())
})
t.Run("PortalAutoGeneratesJoinToken", func(t *testing.T) {
tempCfg := t.TempDir()
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
c := NewConfig(ctx)
c.options.NodeRole = cluster.RolePortal
c.options.JoinToken = ""
token := c.JoinToken()
assert.NotEmpty(t, token)
assert.GreaterOrEqual(t, len(token), rnd.JoinTokenLength)
assert.True(t, rnd.IsJoinToken(token, false))
assert.True(t, rnd.IsJoinToken(token, true))
secretFile := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
assert.FileExists(t, secretFile)
info, err := os.Stat(secretFile)
assert.NoError(t, err)
assert.Equal(t, fs.ModeSecretFile, info.Mode().Perm())
assert.Equal(t, token, c.JoinToken())
})
t.Run("Default", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.PortalUrl = DefaultPortalUrl
@@ -72,6 +101,86 @@ func TestConfig_Cluster(t *testing.T) {
assert.True(t, c.IsPortal())
c.Options().NodeRole = ""
})
t.Run("JWKSUrlSetter", func(t *testing.T) {
const existing = "https://existing.example/.well-known/jwks.json"
tests := []struct {
name string
prev string
input string
expect string
}{
{
name: "TrimHTTPS",
prev: "",
input: " https://portal.example/.well-known/jwks.json ",
expect: "https://portal.example/.well-known/jwks.json",
},
{
name: "CaseInsensitiveScheme",
prev: "",
input: "HTTPS://portal.example/.well-known/jwks.json",
expect: "HTTPS://portal.example/.well-known/jwks.json",
},
{
name: "AllowHTTPOnLocalhost",
prev: "",
input: "http://localhost:2342/.well-known/jwks.json",
expect: "http://localhost:2342/.well-known/jwks.json",
},
{
name: "AllowHTTPOnLoopbackIPv4",
prev: "",
input: "http://127.0.0.1/.well-known/jwks.json",
expect: "http://127.0.0.1/.well-known/jwks.json",
},
{
name: "AllowHTTPOnLoopbackIPv6",
prev: "",
input: "http://[::1]/.well-known/jwks.json",
expect: "http://[::1]/.well-known/jwks.json",
},
{
name: "RejectHTTPNonLoopback",
prev: existing,
input: "http://portal.example/.well-known/jwks.json",
expect: existing,
},
{
name: "RejectUnsupportedScheme",
prev: existing,
input: "ftp://portal.example/.well-known/jwks.json",
expect: existing,
},
{
name: "RejectMalformedURL",
prev: existing,
input: "://not-a-url",
expect: existing,
},
{
name: "ClearValue",
prev: existing,
input: "",
expect: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JWKSUrl = tc.prev
c.SetJWKSUrl(tc.input)
assert.Equal(t, tc.expect, c.JWKSUrl())
})
}
})
t.Run("JWTAllowedScopes", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JWTScope = "cluster vision"
assert.Equal(t, list.ParseAttr("cluster vision"), c.JWTAllowedScopes())
c.options.JWTScope = ""
assert.Equal(t, list.ParseAttr("cluster vision metrics"), c.JWTAllowedScopes())
})
t.Run("Paths", func(t *testing.T) {
c := NewConfig(CliTestContext())
@@ -128,11 +237,11 @@ func TestConfig_Cluster(t *testing.T) {
// Set and read back values
c.options.PortalUrl = "https://portal.example.test"
c.options.JoinToken = "join-token"
c.options.JoinToken = cluster.ExampleJoinToken
c.options.NodeClientSecret = "node-secret"
assert.Equal(t, "https://portal.example.test", c.PortalUrl())
assert.Equal(t, "join-token", c.JoinToken())
assert.Equal(t, cluster.ExampleJoinToken, c.JoinToken())
assert.Equal(t, "node-secret", c.NodeClientSecret())
})
t.Run("AbsolutePaths", func(t *testing.T) {
@@ -217,8 +326,8 @@ func TestConfig_Cluster(t *testing.T) {
dir := t.TempDir()
nsFile := filepath.Join(dir, "node_client_secret")
tkFile := filepath.Join(dir, "portal_token")
assert.NoError(t, os.WriteFile(nsFile, []byte("s3cr3t"), 0o600))
assert.NoError(t, os.WriteFile(tkFile, []byte("t0k3n"), 0o600))
assert.NoError(t, os.WriteFile(nsFile, []byte(cluster.ExampleClientSecret), fs.ModeSecretFile))
assert.NoError(t, os.WriteFile(tkFile, []byte(cluster.ExampleJoinTokenAlt), fs.ModeSecretFile))
// Clear inline values so file-based lookup is used.
c.options.NodeClientSecret = ""
@@ -227,8 +336,8 @@ func TestConfig_Cluster(t *testing.T) {
// Point env vars at the files and verify.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", nsFile)
t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", tkFile)
assert.Equal(t, "s3cr3t", c.NodeClientSecret())
assert.Equal(t, "t0k3n", c.JoinToken())
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
assert.Equal(t, cluster.ExampleJoinTokenAlt, c.JoinToken())
// Empty / missing should yield empty strings.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", filepath.Join(dir, "missing"))

View File

@@ -342,12 +342,18 @@ func (c *Config) SetDbOptions() {
case Postgres:
// Ignore for now.
case SQLite3:
// Not required as unicode is default.
// Not required as Unicode is default.
}
}
// RegisterDb sets the database options and connection provider.
// RegisterDb opens a database connection if needed,
// sets the database options and connection provider.
func (c *Config) RegisterDb() {
if err := c.connectDb(); err != nil {
log.Errorf("config: %s (register db)")
return
}
c.SetDbOptions()
entity.SetDbProvider(c)
}
@@ -456,6 +462,11 @@ func (c *Config) connectDb() error {
mutex.Db.Lock()
defer mutex.Db.Unlock()
// Database connection already exists.
if c.db != nil {
return nil
}
// Get database driver and data source name.
dbDriver := c.DatabaseDriver()
dbDsn := c.DatabaseDSN()

View File

@@ -31,7 +31,7 @@ func TestMain(m *testing.M) {
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -9,7 +9,7 @@ import (
// ApplyScope updates the current settings based on the authorization scope passed.
func (s *Settings) ApplyScope(scope string) *Settings {
if scope == "" || scope == list.All {
if scope == "" || scope == list.Any {
return s
}

View File

@@ -689,7 +689,7 @@ var Flags = CliFlags{
}}, {
Flag: &cli.StringFlag{
Name: "join-token",
Usage: "secret `TOKEN` required to join the cluster",
Usage: "secret `TOKEN` required to join a cluster; min 24 chars",
EnvVars: EnvVars("JOIN_TOKEN"),
}}, {
Flag: &cli.StringFlag{
@@ -720,6 +720,28 @@ var Flags = CliFlags{
EnvVars: EnvVars("NODE_CLIENT_SECRET"),
Hidden: true,
}}, {
Flag: &cli.StringFlag{
Name: "jwks-url",
Usage: "JWKS endpoint `URL` provided by the cluster portal for JWT verification",
EnvVars: EnvVars("JWKS_URL"),
}}, {
Flag: &cli.IntFlag{
Name: "jwks-cache-ttl",
Usage: "JWKS cache lifetime in `SECONDS` (default 300, max 3600)",
Value: 300,
EnvVars: EnvVars("JWKS_CACHE_TTL"),
}}, {
Flag: &cli.StringFlag{
Name: "jwt-scope",
Usage: "allowed JWT `SCOPES` (space separated). Leave empty to accept defaults",
EnvVars: EnvVars("JWT_SCOPE"),
}}, {
Flag: &cli.IntFlag{
Name: "jwt-leeway",
Usage: "JWT clock skew allowance in `SECONDS` (default 60, max 300)",
Value: 60,
EnvVars: EnvVars("JWT_LEEWAY"),
}}, {
Flag: &cli.StringFlag{
Name: "advertise-url",
Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])",

View File

@@ -152,6 +152,10 @@ type Options struct {
NodeRole string `yaml:"-" json:"-" flag:"node-role"`
NodeClientID string `yaml:"NodeClientID" json:"-" flag:"node-client-id"`
NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"`
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
JWTScope string `yaml:"JWTScope" json:"-" flag:"jwt-scope"`
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`

View File

@@ -188,6 +188,10 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"node-uuid", c.NodeUUID()},
{"node-client-id", c.NodeClientID()},
{"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))},
{"jwks-url", c.JWKSUrl()},
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
{"jwt-scope", c.JWTAllowedScopes().String()},
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
{"advertise-url", c.AdvertiseUrl()},
// Proxy Servers.

View File

@@ -45,13 +45,7 @@ func testDataPath(assetsPath string) string {
var PkgNameRegexp = regexp.MustCompile("[^a-zA-Z\\-_]+")
// NewTestOptions returns valid config options for tests.
func NewTestOptions(pkg string) *Options {
// Find assets path.
assetsPath := os.Getenv("PHOTOPRISM_ASSETS_PATH")
if assetsPath == "" {
fs.Abs("../../assets")
}
func NewTestOptions(dbName string) *Options {
// Find storage path.
storagePath := os.Getenv("PHOTOPRISM_STORAGE_PATH")
if storagePath == "" {
@@ -60,7 +54,43 @@ func NewTestOptions(pkg string) *Options {
dataPath := filepath.Join(storagePath, fs.TestdataDir)
pkg = PkgNameRegexp.ReplaceAllString(pkg, "")
return NewTestOptionsForPath(dbName, dataPath)
}
// NewTestOptionsForPath returns new test Options using the specified data path as storage.
func NewTestOptionsForPath(dbName, dataPath string) *Options {
// Default to storage/testdata is no path was specified.
if dataPath == "" {
storagePath := os.Getenv("PHOTOPRISM_STORAGE_PATH")
if storagePath == "" {
storagePath = fs.Abs("../../storage")
}
dataPath = filepath.Join(storagePath, fs.TestdataDir)
}
dataPath = fs.Abs(dataPath)
if err := fs.MkdirAll(dataPath); err != nil {
log.Errorf("config: %s (create test data path)", err)
return &Options{}
}
configPath := filepath.Join(dataPath, "config")
if err := fs.MkdirAll(configPath); err != nil {
log.Errorf("config: %s (create test config path)", err)
return &Options{}
}
// Find assets path.
assetsPath := os.Getenv("PHOTOPRISM_ASSETS_PATH")
if assetsPath == "" {
fs.Abs("../../assets")
}
dbName = PkgNameRegexp.ReplaceAllString(dbName, "")
driver := os.Getenv("PHOTOPRISM_TEST_DRIVER")
dsn := os.Getenv("PHOTOPRISM_TEST_DSN")
@@ -75,16 +105,16 @@ func NewTestOptions(pkg string) *Options {
// Set default database DSN.
if driver == SQLite3 {
if dsn == "" && pkg != "" {
if dsn = fmt.Sprintf(".%s.db", clean.TypeLower(pkg)); !fs.FileExists(dsn) {
log.Debugf("sqlite: test database %s does not already exist", clean.Log(dsn))
if dsn == "" && dbName != "" {
if dsn = fmt.Sprintf(".%s.db", clean.TypeLower(dbName)); !fs.FileExists(dsn) {
log.Tracef("sqlite: test database %s does not already exist", clean.Log(dsn))
} else if err := os.Remove(dsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
}
} else if dsn == "" || dsn == SQLiteTestDB {
dsn = SQLiteTestDB
if !fs.FileExists(dsn) {
log.Debugf("sqlite: test database %s does not already exist", clean.Log(dsn))
log.Tracef("sqlite: test database %s does not already exist", clean.Log(dsn))
} else if err := os.Remove(dsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
}
@@ -92,7 +122,7 @@ func NewTestOptions(pkg string) *Options {
}
// Test config options.
c := &Options{
opts := &Options{
Name: "PhotoPrism",
Version: "0.0.0",
Copyright: "(c) 2018-2025 PhotoPrism UG. All rights reserved.",
@@ -111,12 +141,14 @@ func NewTestOptions(pkg string) *Options {
IndexSchedule: DefaultIndexSchedule,
AutoImport: 7200,
StoragePath: dataPath,
CachePath: dataPath + "/cache",
OriginalsPath: dataPath + "/originals",
ImportPath: dataPath + "/import",
ConfigPath: dataPath + "/config",
SidecarPath: dataPath + "/sidecar",
TempPath: dataPath + "/temp",
CachePath: filepath.Join(dataPath, "cache"),
OriginalsPath: filepath.Join(dataPath, "originals"),
ImportPath: filepath.Join(dataPath, "import"),
ConfigPath: configPath,
DefaultsYaml: filepath.Join(configPath, "defaults.yml"),
OptionsYaml: filepath.Join(configPath, "options.yml"),
SidecarPath: filepath.Join(dataPath, "sidecar"),
TempPath: filepath.Join(dataPath, "temp"),
BackupRetain: DefaultBackupRetain,
BackupSchedule: DefaultBackupSchedule,
DatabaseDriver: driver,
@@ -128,7 +160,7 @@ func NewTestOptions(pkg string) *Options {
DetectNSFW: true,
}
return c
return opts
}
// NewTestOptionsError returns invalid config options for tests.
@@ -162,11 +194,94 @@ func TestConfig() *Config {
return testConfig
}
// NewTestConfig returns a valid test config.
// NewMinimalTestConfig creates a lightweight test Config (no DB, minimal filesystem).
//
// Not suitable for tests requiring a database or pre-created storage directories.
func NewMinimalTestConfig(dataPath string) *Config {
return NewIsolatedTestConfig("", dataPath, false)
}
var testDbCache []byte
var testDbMutex sync.Mutex
// NewMinimalTestConfigWithDb creates a lightweight test Config (minimal filesystem).
//
// Creates an isolated SQLite DB (cached after first run) without seeding media fixtures.
func NewMinimalTestConfigWithDb(dbName, dataPath string) *Config {
c := NewIsolatedTestConfig(dbName, dataPath, true)
cachedDb := false
// Try to restore test db from cache.
if len(testDbCache) > 0 && c.DatabaseDriver() == SQLite3 && !fs.FileExists(c.DatabaseDSN()) {
if err := os.WriteFile(c.DatabaseDSN(), testDbCache, fs.ModeFile); err != nil {
log.Warnf("config: %s (restore test database)", err)
} else {
cachedDb = true
}
}
if err := c.Init(); err != nil {
log.Fatalf("config: %s (init)", err.Error())
}
c.RegisterDb()
if cachedDb {
return c
}
c.InitTestDb()
if testDbCache == nil && c.DatabaseDriver() == SQLite3 && fs.FileExistsNotEmpty(c.DatabaseDSN()) {
testDbMutex.Lock()
defer testDbMutex.Unlock()
if testDbCache != nil {
return c
}
if testDb, readErr := os.ReadFile(c.DatabaseDSN()); readErr != nil {
log.Warnf("config: could not cache test database (%s)", readErr)
} else {
testDbCache = testDb
}
}
return c
}
// NewIsolatedTestConfig constructs a lightweight Config backed by the provided config path.
//
// It avoids running migrations or loading test fixtures, making it useful for unit tests that
// only need basic access to config options (for example, JWT helpers). The caller should provide
// an isolated directory (e.g. via testing.T.TempDir) so temporary files are cleaned up automatically.
func NewIsolatedTestConfig(dbName, dataPath string, createDirs bool) *Config {
if dataPath == "" {
dataPath = filepath.Join(os.TempDir(), "photoprism-test-"+rnd.Base36(6))
}
opts := NewTestOptionsForPath(dbName, dataPath)
c := &Config{
options: opts,
token: rnd.Base36(8),
}
if !createDirs {
return c
}
if err := c.CreateDirectories(); err != nil {
log.Errorf("config: %s (create test directories)", err)
}
return c
}
// NewTestConfig initializes test data so required directories exist before tests run.
// See AGENTS.md (Test Data & Fixtures) and specs/dev/backend-testing.md for guidance.
func NewTestConfig(pkg string) *Config {
func NewTestConfig(dbName string) *Config {
defer log.Debug(capture.Time(time.Now(), "config: new test config created"))
testConfigMutex.Lock()
@@ -174,7 +289,7 @@ func NewTestConfig(pkg string) *Config {
c := &Config{
cliCtx: CliTestContext(),
options: NewTestOptions(pkg),
options: NewTestOptions(dbName),
token: rnd.Base36(8),
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/time/unix"
@@ -492,40 +491,7 @@ func (m *Session) Scope() string {
// ValidateScope checks if the scope does not exclude access to specified resource.
func (m *Session) ValidateScope(resource acl.Resource, perms acl.Permissions) bool {
// Get scope string.
scope := m.Scope()
// Skip detailed check and allow all if scope is "*".
if scope == list.All {
return true
}
// Skip resource check if scope includes all read operations.
if scope == acl.ScopeRead.String() {
return !acl.GrantScopeRead.DenyAny(perms)
}
// Parse scope to check for resources and permissions.
attr := list.ParseAttr(scope)
// Check if resource is within scope.
if granted := attr.Contains(resource.String()); !granted {
return false
}
// Check if permission is within scope.
if len(perms) == 0 {
return true
}
// Check if scope is limited to read or write operations.
if a := attr.Find(acl.ScopeRead.String()); a.Value == list.True && acl.GrantScopeRead.DenyAny(perms) {
return false
} else if a = attr.Find(acl.ScopeWrite.String()); a.Value == list.True && acl.GrantScopeWrite.DenyAny(perms) {
return false
}
return true
return acl.ScopePermits(m.AuthScope, resource, perms)
}
// InsufficientScope checks if the scope does not include access to specified resource.

View File

@@ -74,6 +74,57 @@ var DetailsFixtures = DetailsMap{
CopyrightSrc: "manual",
LicenseSrc: "manual",
},
"1000057": {
PhotoID: 1000057,
Keywords: "dog, beach",
Notes: "notes",
Subject: "Wuff",
Artist: "John",
Copyright: "My copyright B",
License: "N/A",
CreatedAt: Now(),
UpdatedAt: Now(),
KeywordsSrc: "meta",
NotesSrc: "manual",
SubjectSrc: "meta",
ArtistSrc: "meta",
CopyrightSrc: "manual",
LicenseSrc: "manual",
},
"1000058": {
PhotoID: 1000058,
Keywords: "dog, beach",
Notes: "notes",
Subject: "Wuff",
Artist: "John",
Copyright: "My copyright B",
License: "N/A",
CreatedAt: Now(),
UpdatedAt: Now(),
KeywordsSrc: "meta",
NotesSrc: "manual",
SubjectSrc: "meta",
ArtistSrc: "meta",
CopyrightSrc: "manual",
LicenseSrc: "manual",
},
"1000059": {
PhotoID: 1000059,
Keywords: "christmas, tree",
Notes: "notes",
Subject: "Tree with lights",
Artist: "Santa",
Copyright: "My copyright A",
License: "MIT",
CreatedAt: Now(),
UpdatedAt: Now(),
KeywordsSrc: "meta",
NotesSrc: "manual",
SubjectSrc: "meta",
ArtistSrc: "meta",
CopyrightSrc: "manual",
LicenseSrc: "manual",
},
}
// CreateDetailsFixtures inserts known entities into the database for testing.

View File

@@ -24,7 +24,7 @@ func TestMain(m *testing.M) {
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

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