mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Compare commits
260 Commits
4ea6e12a10
...
feature/ba
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d9fc05806b | ||
![]() |
a3dac7c707 | ||
![]() |
4d91f5ffdf | ||
![]() |
1b48cb2a25 | ||
![]() |
f7464772cb | ||
![]() |
58180accee | ||
![]() |
da943defd1 | ||
![]() |
52337eba27 | ||
![]() |
90f62a732e | ||
![]() |
bc6c34cb2b | ||
![]() |
c12cec0e63 | ||
![]() |
9f119a8cfa | ||
![]() |
66e2027c10 | ||
![]() |
bd66110c18 | ||
![]() |
07658dac69 | ||
![]() |
108b2c2df4 | ||
![]() |
48a965a7cc | ||
![]() |
c2a4875b43 | ||
![]() |
32c054da7a | ||
![]() |
566eed05e0 | ||
![]() |
660c0a89db | ||
![]() |
ebb0410b20 | ||
![]() |
7e419f7419 | ||
![]() |
633d4222ab | ||
![]() |
bae8ceb3a7 | ||
![]() |
4828c0423d | ||
![]() |
cb81f9be12 | ||
![]() |
bbee14b1ac | ||
![]() |
73d891ca65 | ||
![]() |
5903758063 | ||
![]() |
87f206406a | ||
![]() |
2c0f6d47cd | ||
![]() |
bd3de03c79 | ||
![]() |
64182a9b8a | ||
![]() |
1736701fe5 | ||
![]() |
1003f649e2 | ||
![]() |
8fc0227b73 | ||
![]() |
7664ee7556 | ||
![]() |
1342b1d1a2 | ||
![]() |
0277419f3b | ||
![]() |
2d690afe21 | ||
![]() |
5c75f6b035 | ||
![]() |
716e856110 | ||
![]() |
3283941ba0 | ||
![]() |
7ca6b45217 | ||
![]() |
a920e10b7d | ||
![]() |
46b8913ca4 | ||
![]() |
7032a071e5 | ||
![]() |
ffba22034a | ||
![]() |
52beb0580c | ||
![]() |
30609fbd17 | ||
![]() |
2b850b42b7 | ||
![]() |
74982c8d75 | ||
![]() |
933606873a | ||
![]() |
db9e9e8624 | ||
![]() |
ad925ded45 | ||
![]() |
4f326faeea | ||
![]() |
d932f6f49f | ||
![]() |
07dd5a1b8b | ||
![]() |
1a738c936a | ||
![]() |
21e48a1876 | ||
![]() |
7a7a7353f0 | ||
![]() |
f8f94ad814 | ||
![]() |
b7c6ab1c90 | ||
![]() |
b5fdbf7c5e | ||
![]() |
44a2d902c2 | ||
![]() |
1c8450d94c | ||
![]() |
406ef8b645 | ||
![]() |
14241c4d25 | ||
![]() |
fc781b5647 | ||
![]() |
01cd1c32c9 | ||
![]() |
b096361d51 | ||
![]() |
b0b03ef631 | ||
![]() |
55fbb474bb | ||
![]() |
9160bb2fea | ||
![]() |
39391e94a2 | ||
![]() |
c05e892ad4 | ||
![]() |
72fa563edc | ||
![]() |
e3e624b4b4 | ||
![]() |
53703c5eb2 | ||
![]() |
5e6c5d4b9f | ||
![]() |
b0b74db258 | ||
![]() |
7380244f6d | ||
![]() |
f04a84fd40 | ||
![]() |
0ad7ed4021 | ||
![]() |
41ecea6360 | ||
![]() |
f0667dab83 | ||
![]() |
c9e7fc51b6 | ||
![]() |
3c5eaeb1df | ||
![]() |
bd4f8a2582 | ||
![]() |
c9e306274c | ||
![]() |
d1f9f681c8 | ||
![]() |
4f86c04ebe | ||
![]() |
ecb9d6c9dc | ||
![]() |
9e747aa72d | ||
![]() |
3056623934 | ||
![]() |
0b74d21e58 | ||
![]() |
3ef0f1a646 | ||
![]() |
6eb85d9419 | ||
![]() |
3996c165d1 | ||
![]() |
b6d3af9c58 | ||
![]() |
0ea64c68ea | ||
![]() |
9fc72f3178 | ||
![]() |
5d5ed13cf5 | ||
![]() |
40b5b938f5 | ||
![]() |
28ed71988f | ||
![]() |
fde47ebd3a | ||
![]() |
6f6d985848 | ||
![]() |
0d10020eba | ||
![]() |
12ed1ab507 | ||
![]() |
e5cd2f1ceb | ||
![]() |
95dc2e9a10 | ||
![]() |
bbd7759d3b | ||
![]() |
51f877aef2 | ||
![]() |
b8cd22d14b | ||
![]() |
8e91f077b8 | ||
![]() |
cdb13c9711 | ||
![]() |
3a07e7049f | ||
![]() |
8a82b10d23 | ||
![]() |
3d030601ea | ||
![]() |
423d232bf2 | ||
![]() |
939e912f2d | ||
![]() |
b83dbf1cb5 | ||
![]() |
31916ef7ae | ||
![]() |
583777f065 | ||
![]() |
44d2a6e771 | ||
![]() |
4c16b8d9c5 | ||
![]() |
8f417febb4 | ||
![]() |
fca60eb816 | ||
![]() |
0b5f9a783f | ||
![]() |
f2b2697da0 | ||
![]() |
5813e8dbba | ||
![]() |
00bf7ad680 | ||
![]() |
b9a1282f8e | ||
![]() |
6e8575840e | ||
![]() |
18799b1481 | ||
![]() |
bdec8e25ce | ||
![]() |
2e229d8a98 | ||
![]() |
9c24a30126 | ||
![]() |
204c944804 | ||
![]() |
61f85604c6 | ||
![]() |
d23c3b10a1 | ||
![]() |
3656cefb23 | ||
![]() |
811d359343 | ||
![]() |
7ba6171ff9 | ||
![]() |
1809c12d9d | ||
![]() |
db16a9d546 | ||
![]() |
910aac9545 | ||
![]() |
822dc87d8e | ||
![]() |
58ce717004 | ||
![]() |
718ed254b8 | ||
![]() |
f96b49d5a3 | ||
![]() |
9f11c2c773 | ||
![]() |
96cf07dd94 | ||
![]() |
c71c1db568 | ||
![]() |
61c34713d8 | ||
![]() |
ae0162c8d8 | ||
![]() |
4fccb0ff6c | ||
![]() |
3561f3fcc9 | ||
![]() |
458ac57ce3 | ||
![]() |
49ab48a5a1 | ||
![]() |
4341bb2fb4 | ||
![]() |
cd3be44a3c | ||
![]() |
f1b59fd01c | ||
![]() |
889fc20fc1 | ||
![]() |
45914d1eac | ||
![]() |
c26573eb5b | ||
![]() |
9a19a71551 | ||
![]() |
d553508449 | ||
![]() |
3f2022f693 | ||
![]() |
e98889b09e | ||
![]() |
04630f25f1 | ||
![]() |
26a80227b9 | ||
![]() |
e75bb20027 | ||
![]() |
b24631244c | ||
![]() |
b6b260fa36 | ||
![]() |
1269668edd | ||
![]() |
829bc180a9 | ||
![]() |
d14097f335 | ||
![]() |
ba67081c56 | ||
![]() |
b3d839de49 | ||
![]() |
acdfde36c8 | ||
![]() |
b0781b6451 | ||
![]() |
76eaaa812c | ||
![]() |
2996d64600 | ||
![]() |
b8469d6ced | ||
![]() |
2b85d47d3b | ||
![]() |
2b511e87ca | ||
![]() |
de2e322a4e | ||
![]() |
11d0cc3d26 | ||
![]() |
d61dd9c16e | ||
![]() |
1f53458111 | ||
![]() |
66ae99e486 | ||
![]() |
1469b5ae78 | ||
![]() |
0d6fba0f62 | ||
![]() |
05113217e4 | ||
![]() |
2531616b53 | ||
![]() |
a2880be47d | ||
![]() |
5bfa14702b | ||
![]() |
ed005962bd | ||
![]() |
940533c59e | ||
![]() |
c6cdcb6b1f | ||
![]() |
2a044832be | ||
![]() |
f06c65c217 | ||
![]() |
ca9bac63e6 | ||
![]() |
2a4627cc47 | ||
![]() |
efa57f417c | ||
![]() |
96356caaa6 | ||
![]() |
568fd0d62a | ||
![]() |
5b139b5c9e | ||
![]() |
f57792511e | ||
![]() |
5c57d23990 | ||
![]() |
aba5faa870 | ||
![]() |
915e9b4b6c | ||
![]() |
70665e6c24 | ||
![]() |
a62fb24d8d | ||
![]() |
efcfafd5b1 | ||
![]() |
57f08d5b30 | ||
![]() |
2b77e5f929 | ||
![]() |
402f674618 | ||
![]() |
92440bcb75 | ||
![]() |
e025e5a077 | ||
![]() |
011b3c0c77 | ||
![]() |
0227d495e7 | ||
![]() |
7d74d58a98 | ||
![]() |
a2e9eae388 | ||
![]() |
78143a08ee | ||
![]() |
15b6a3331d | ||
![]() |
d691f59594 | ||
![]() |
d01823d130 | ||
![]() |
6240d2e31e | ||
![]() |
91d65a9b07 | ||
![]() |
9230b585c1 | ||
![]() |
86bf05293b | ||
![]() |
f840efd90e | ||
![]() |
e17bd03a14 | ||
![]() |
1b87d57442 | ||
![]() |
ff8a5308d2 | ||
![]() |
832ae90652 | ||
![]() |
bf2ece0023 | ||
![]() |
9c16aa6227 | ||
![]() |
e0034ec1b0 | ||
![]() |
de9ee0a04d | ||
![]() |
d44233d811 | ||
![]() |
a9d87aa8c8 | ||
![]() |
6462ddc628 | ||
![]() |
576d6c1201 | ||
![]() |
6e999eb2cc | ||
![]() |
8264cd090f | ||
![]() |
275e6b62ea | ||
![]() |
1d9dbbef2f | ||
![]() |
b64202aefd | ||
![]() |
4b8191a308 | ||
![]() |
1ec4adf44f | ||
![]() |
9c0a5d4101 | ||
![]() |
97ad4710b4 | ||
![]() |
2672f23bbf | ||
![]() |
19cf3e4b38 | ||
![]() |
f4e94f4e9f | ||
![]() |
159d097383 |
38
AGENTS.md
38
AGENTS.md
@@ -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)
|
||||
|
||||
### Next‑Session 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 30–120s 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.
|
||||
|
@@ -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 constant‑time.
|
||||
- ACL: `internal/auth/acl/*` — roles, grants, scopes; use constants; avoid logging secrets, compare tokens constant‑time; 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`.
|
||||
|
18
Makefile
18
Makefile
@@ -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
|
||||
|
@@ -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;
|
||||
|
@@ -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) {
|
||||
|
@@ -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;
|
||||
|
279
frontend/src/component/file/chip-selector.vue
Normal file
279
frontend/src/component/file/chip-selector.vue
Normal 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>
|
@@ -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() {
|
||||
|
@@ -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: "",
|
||||
|
@@ -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;
|
||||
|
1522
frontend/src/component/photo/edit/batch.vue
Normal file
1522
frontend/src/component/photo/edit/batch.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
@@ -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"
|
||||
|
110
frontend/src/css/chip-selector.css
Normal file
110
frontend/src/css/chip-selector.css
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
132
frontend/src/model/batch-edit.js
Normal file
132
frontend/src/model/batch-edit.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@@ -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"),
|
||||
|
@@ -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");
|
||||
|
@@ -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");
|
||||
|
@@ -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 {
|
||||
|
@@ -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);
|
||||
|
@@ -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 });
|
||||
|
||||
|
262
frontend/tests/vitest/component/file/chip-selector.test.js
Normal file
262
frontend/tests/vitest/component/file/chip-selector.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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();
|
||||
|
423
frontend/tests/vitest/component/photo/edit/batch.test.js
Normal file
423
frontend/tests/vitest/component/photo/edit/batch.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
135
frontend/tests/vitest/model/batch-edit.test.js
Normal file
135
frontend/tests/vitest/model/batch-edit.test.js
Normal 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
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
@@ -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
|
||||
|
179
internal/api/api_auth_jwt.go
Normal file
179
internal/api/api_auth_jwt.go
Normal 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
|
||||
}
|
375
internal/api/api_auth_jwt_test.go
Normal file
375
internal/api/api_auth_jwt_test.go
Normal 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)
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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()
|
||||
|
60
internal/api/cluster_metrics.go
Normal file
60
internal/api/cluster_metrics.go
Normal 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),
|
||||
})
|
||||
})
|
||||
}
|
27
internal/api/cluster_metrics_test.go
Normal file
27
internal/api/cluster_metrics_test.go
Normal 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())
|
||||
}
|
@@ -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 := ®.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 := ®.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 := ®.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 := ®.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).
|
||||
|
@@ -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 := ®.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) }
|
||||
|
@@ -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 := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
|
||||
n := ®.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 := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
|
||||
n := ®.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 := ®.Node{Name: "pp-node-01", Role: "instance"}
|
||||
n := ®.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 := ®.Node{Name: "pp-node-02", Role: "instance"}
|
||||
n := ®.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
|
||||
|
@@ -27,10 +27,13 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
// Seed nodes in the registry
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n2 := ®.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}
|
||||
|
||||
n2 := ®.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 := ®.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
|
||||
n := ®.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)
|
||||
}
|
||||
|
@@ -21,8 +21,9 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Seed node
|
||||
n := ®.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
n := ®.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)
|
||||
|
@@ -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)
|
||||
})
|
||||
}
|
||||
|
@@ -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),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@@ -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())
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
107
internal/auth/jwt/issuer.go
Normal 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
27
internal/auth/jwt/jwt.go
Normal 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
|
31
internal/auth/jwt/jwt_test.go
Normal file
31
internal/auth/jwt/jwt_test.go
Normal 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())
|
||||
}
|
6
internal/auth/jwt/logger.go
Normal file
6
internal/auth/jwt/logger.go
Normal 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
|
288
internal/auth/jwt/manager.go
Normal file
288
internal/auth/jwt/manager.go
Normal 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
|
||||
}
|
81
internal/auth/jwt/manager_test.go
Normal file
81
internal/auth/jwt/manager_test.go
Normal 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")))
|
||||
}
|
56
internal/auth/jwt/types.go
Normal file
56
internal/auth/jwt/types.go
Normal 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
|
||||
}
|
555
internal/auth/jwt/verifier.go
Normal file
555
internal/auth/jwt/verifier.go
Normal 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
|
||||
}
|
217
internal/auth/jwt/verifier_test.go
Normal file
217
internal/auth/jwt/verifier_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ var AuthCommands = &cli.Command{
|
||||
AuthShowCommand,
|
||||
AuthRemoveCommand,
|
||||
AuthResetCommand,
|
||||
AuthJWTCommands,
|
||||
},
|
||||
}
|
||||
|
||||
|
16
internal/commands/auth_jwt.go
Normal file
16
internal/commands/auth_jwt.go
Normal 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,
|
||||
},
|
||||
}
|
154
internal/commands/auth_jwt_inspect.go
Normal file
154
internal/commands/auth_jwt_inspect.go
Normal 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
|
||||
}
|
117
internal/commands/auth_jwt_issue.go
Normal file
117
internal/commands/auth_jwt_issue.go
Normal 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
|
||||
})
|
||||
}
|
107
internal/commands/auth_jwt_keys.go
Normal file
107
internal/commands/auth_jwt_keys.go
Normal 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
|
||||
})
|
||||
}
|
67
internal/commands/auth_jwt_status.go
Normal file
67
internal/commands/auth_jwt_status.go
Normal 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
|
||||
})
|
||||
}
|
94
internal/commands/auth_jwt_test.go
Normal file
94
internal/commands/auth_jwt_test.go
Normal 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 := ®.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)
|
||||
}
|
@@ -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)
|
||||
|
@@ -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 {
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
})
|
||||
}
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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())
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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 := ®.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
|
||||
n := ®.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"))
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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{
|
||||
|
8
internal/commands/flags.go
Normal file
8
internal/commands/flags.go
Normal 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"}
|
||||
}
|
173
internal/commands/jwt_helpers.go
Normal file
173
internal/commands/jwt_helpers.go
Normal 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
|
||||
}
|
@@ -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"},
|
||||
|
@@ -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())
|
||||
}
|
||||
|
@@ -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())
|
||||
|
@@ -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 {
|
||||
|
@@ -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"))
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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])",
|
||||
|
@@ -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"`
|
||||
|
@@ -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.
|
||||
|
@@ -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),
|
||||
}
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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
Reference in New Issue
Block a user