Compare commits

...

25 Commits

Author SHA1 Message Date
Michael Mayer
87f206406a Merge branch 'develop' into feature/batch-edit 2025-09-24 08:30:12 +02:00
Michael Mayer
c202a09241 Frontend: Update deps in package.json and package-lock.json
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-24 08:29:09 +02:00
Michael Mayer
61ced7119c Auth: Refactor cluster configuration and provisioning API endpoints #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-24 08:28:38 +02:00
Michael Mayer
3baabebf50 Docs: Update Go test guidelines
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-23 16:39:13 +02:00
Michael Mayer
0a66f1476d Develop: Upgrade base image from 250912-plucky to 250922-plucky
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 11:10:06 +02:00
Michael Mayer
59fb8e2b4c API: Update Swagger usage notes
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 10:56:34 +02:00
Michael Mayer
8930cb7b79 Frontend: Update deps in package.json and package-lock.json
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 10:46:22 +02:00
Michael Mayer
ade3b40a42 Docker: Add "python" symlink to develop/plucky/Dockerfile
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 10:45:12 +02:00
Michael Mayer
9ea5f0596c Backend: Add security-focused tests, harden WebDAV and use safe.Download
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 10:42:53 +02:00
Michael Mayer
a22babe3d1 API: Update swagger.json
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 06:20:47 +02:00
Michael Mayer
bfd26c55e3 Config: Update visibility/order of cluster options and flags #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 05:37:54 +02:00
Michael Mayer
578fbe4d10 API: Add missing Swagger endpoint annotations and update swagger.json
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 04:12:02 +02:00
Michael Mayer
c8964fdc6b Make: Improve "reset-sqlite" target to delete all SQLite test databases
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 04:06:24 +02:00
Michael Mayer
eca06dcdfb Config: Remove redundant InitializeTestData tests
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 03:34:51 +02:00
Michael Mayer
38cdde5518 Backend: Update deps in go.mod and go.sum
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 03:33:32 +02:00
Michael Mayer
2a113f167d Docs: Update CODEMAP.md and AGENTS.md
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 03:24:56 +02:00
Michael Mayer
91804b9652 Backend: Improve Copy()/Move() and increase pkg/internal test coverage
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-22 03:07:51 +02:00
Michael Mayer
458a320bb8 Pkg: Add fs.Exists() function to check for any existing file/dir/link
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 23:09:33 +02:00
Michael Mayer
c312c0d109 Docs: Update CODEMAP.md and AGENTS.md #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 19:58:56 +02:00
Michael Mayer
6e33575ba7 CLI: Skip help sub-commands in "photoprism show commands" output #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 19:51:22 +02:00
Michael Mayer
d6cb6b7a2e CLI: Add "photoprism show commands" command to generate CLI docs #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 19:34:39 +02:00
Michael Mayer
f1c57c72d8 CLI: Flatten config options output when using the "--json" flag #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 16:52:56 +02:00
Michael Mayer
25253afcf2 Docs: Update AGENTS.md
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 14:41:57 +02:00
Michael Mayer
f878ca0cb0 Docs: Update AGENTS.md
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 14:39:56 +02:00
Michael Mayer
93493aba28 Docs: Update AGENTS.md
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-21 14:17:54 +02:00
336 changed files with 18580 additions and 8969 deletions

136
AGENTS.md
View File

@@ -13,16 +13,17 @@ Learn more: https://agents.md/
- Contributing: https://github.com/photoprism/photoprism/blob/develop/CONTRIBUTING.md
- Security: https://github.com/photoprism/photoprism/blob/develop/SECURITY.md
- REST API: https://docs.photoprism.dev/ (Swagger), https://docs.photoprism.app/developer-guide/api/ (Docs)
- Backend CODEMAP: CODEMAP.md
- Frontend CODEMAP: frontend/CODEMAP.md
- Code Maps: `CODEMAP.md` (Backend/Go), `frontend/CODEMAP.md` (Frontend/JS)
### Specifications (Versioning & Usage)
- Always use the latest spec version for a topic (highest `-vN`), as linked from `specs/README.md` and the portal cheatsheet (`specs/portal/README.md`).
- Testing Guides: `specs/dev/backend-testing.md` (Backend/Go), `specs/dev/frontend-testing.md` (Frontend/JS)
- Whenever the Change Management instructions for a document require it, publish changes as a new file with an incremented version suffix (e.g., `*-v3.md`) rather than overwriting the original file.
- Older spec versions remain in the repo for historical reference but are not linked from the main TOC. Do not base new work on superseded files (e.g., `*-v1.md` when `*-v2.md` exists).
- When adding or updating specs, publish changes under a new file with an incremented version suffix (e.g., `*-v3.md`) instead of overwriting. Refer to the Change Management section of each document for specific instructions.
- Developer Cheatsheet Portal & Cluster: specs/portal/README.md
- Backend (Go) Testing Guide: specs/dev/backend-testing.md
Note on specs repository availability
- The `specs/` repository may be private and is not guaranteed to be present in every clone or environment. Do not add Makefile targets in the main project that depend on `specs/` paths. When `specs/` is available, run its tools directly (e.g., `bash specs/scripts/lint-status.sh`).
## Project Structure & Languages
@@ -120,10 +121,26 @@ Note: Across our public documentation, official images, and in production, the c
- Full unit test suite: `make test` (runs backend and frontend tests)
- Test frontend/backend: `make test-js` and `make test-go`
- Go packages: `go test` (all tests) or `go test -run <name>` (specific tests only)
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful).
- Frontend unit tests are driven by Vitest; see scripts in `frontend/package.json`
- Vitest watch/coverage: `make vitest-watch` and `make vitest-coverage`
- Acceptance tests: use the `acceptance-*` targets in the `Makefile`
### FFmpeg Tests & Hardware Gating
- By default, do not run GPU/HW encoder integrations in CI. Gate with `PHOTOPRISM_FFMPEG_ENCODER` (one of: `vaapi`, `intel`, `nvidia`).
- Negative-path tests should remain fast and always run:
- Missing ffmpeg binary → immediate exec error.
- Unwritable destination → command fails without creating files.
- Prefer command-string assertions when hardware is unavailable; enable HW runs locally only when a device is configured.
### Fast, Focused Test Recipes
- Filesystem + archives (fast): `go test ./pkg/fs -run 'Copy|Move|Unzip' -count=1`
- Media helpers (fast): `go test ./pkg/media/... -count=1`
- Thumbnails (libvips, moderate): `go test ./internal/thumb/... -count=1`
- FFmpeg command builders (moderate): `go test ./internal/ffmpeg -run 'Remux|Transcode|Extract' -count=1`
### CLI Testing Gotchas (Go)
- Exit codes and `os.Exit`:
@@ -143,31 +160,92 @@ Note: Across our public documentation, official images, and in production, the c
- 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.
### Filesystem Permissions & io/fs Aliasing (Go)
- Always use our shared permission variables from `pkg/fs` when creating files/directories:
- Directories: `fs.ModeDir` (default 0o755)
- Regular files: `fs.ModeFile` (default 0o644)
- Config files: `fs.ModeConfigFile` (default 0o664)
- Secrets/tokens: `fs.ModeSecret` (default 0o600)
- Backups: `fs.ModeBackupFile` (default 0o600)
- Do not pass stdlib `io/fs` flags (e.g., `fs.ModeDir`) to functions expecting permission bits.
- When importing the stdlib package, alias it to avoid collisions: `iofs "io/fs"` or `gofs "io/fs"`.
- 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)
- 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).
- Open destinations with `O_WRONLY|O_CREATE|O_TRUNC` to avoid trailing bytes when overwriting; use `O_EXCL` when the caller must detect collisions.
- Where this lives:
- App-level helpers: `internal/photoprism/mediafile.go` (`MediaFile.Copy/Move`).
- Reusable utils: `pkg/fs/copy.go`, `pkg/fs/move.go`.
- When to set `force=true`:
- 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
- Always validate ZIP entry names with a safe join; reject:
- absolute paths (e.g., `/etc/passwd`).
- Windows drive/volume paths (e.g., `C:\\…` or `C:/…`).
- any entry that escapes the target directory after cleaning (path traversal via `..`).
- Enforce per-file and total size budgets to prevent resource exhaustion.
- Skip OS metadata directories (e.g., `__MACOSX`) and reject suspicious names.
- Where this lives: `pkg/fs/zip.go` (`Unzip`, `UnzipFile`, `safeJoin`).
- Tests to keep:
- Absolute/volume paths rejected (Windows-specific backslash path covered on Windows).
- `..` traversal skipped; `__MACOSX` skipped.
- Per-file and total size limits enforced; directory entries created; nested paths extracted safely.
- Examples assume a Linux/Unix shell. For Windows specifics, see the Developer Guide FAQ:
https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows
### HTTP Download — Security Checklist
- Use the shared safe HTTP helper instead of adhoc `net/http` code:
- Package: `pkg/service/http/safe``safe.Download(destPath, url, *safe.Options)`.
- Default policy in this repo: allow only `http/https`, enforce timeouts and max size, write to a `0600` temp file then rename.
- SSRF protection (mandatory unless explicitly needed for tests):
- Set `AllowPrivate=false` to block private/loopback/multicast/linklocal ranges.
- All redirect targets are validated; the final connected peer IP is also checked.
- Prefer an imagefocused `Accept` header for image downloads: `"image/jpeg, image/png, */*;q=0.1"`.
- Avatars and small images: use the thin wrapper in `internal/thumb/avatar.SafeDownload` which applies stricter defaults (15s timeout, 10 MiB, `AllowPrivate=false`).
- Tests using `httptest.Server` on 127.0.0.1 must pass `AllowPrivate=true` explicitly to succeed.
- Keep perresource size budgets small; rely on `io.LimitReader` + `Content-Length` prechecks.
If anything in this file conflicts with the `Makefile` or the Developer Guide, the `Makefile` and the documentation win. When unsure, **ask** for clarification before proceeding.
## Agent Quick Tips (Do This)
### Testing
- Go tests: When adding tests for sources in `path/to/pkg/<file>.go`, always place them in `path/to/pkg/<file>_test.go` (create this file if it does not yet exist). For the same function, group related cases as sub-tests with `t.Run(...)` (table-driven where helpful).
- Client IDs & UUIDs in tests:
- For OAuth client IDs, generate valid IDs via `rnd.GenerateUID(entity.ClientUID)` or use a static, valid ID (16 chars, starts with `c`). To validate shape, use `rnd.IsUID(id, entity.ClientUID)`.
- For node UUIDs, prefer `rnd.UUIDv7()` and treat it as required in node responses (`node.uuid`).
### NextSession Reminders
- 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 CLI (e.g., `nodes show --creds`) if operators request it.
- Prefer targeted runs for speed:
- Unit/subpackage: `go test ./internal/<pkg> -run <Name> -count=1`
- Commands: `go test ./internal/commands -run <Name> -count=1`
- Avoid `./...` unless you intend to run the whole suite.
- Heavy tests (migrations/fixtures): internal/entity and internal/photoprism run DB migrations and load fixtures; expect 30120s on first run. Narrow with `-run` and keep iterations low.
- Heavy tests (migrations/fixtures): `internal/entity` and `internal/photoprism` run DB migrations and load fixtures; expect 30120s on first run. Narrow with `-run` and keep iterations low.
- PhotoPrism config in tests: inside `internal/photoprism`, use the package global `photoprism.Config()` for runtimeaccurate behavior. Only construct a new config if you replace it via `photoprism.SetConfig`.
- CLI command tests: use `RunWithTestContext(cmd, args)` to capture output and avoid `os.Exit`; assert `cli.ExitCoder` codes when you need them.
- Reports are quoted: strings in CLI "show" output are rendered with quotes by the report helpers. Prefer `assert.Contains`/regex over strict, fully formatted equality when validating content.
#### Test Data & Fixtures (storage/testdata)
- Shared test files live under `storage/testdata`. The lifecycle is managed by `internal/config/test.go`.
- `NewTestConfig("<pkg>")` now calls `InitializeTestData()` so required directories exist (originals, import, cache, temp) before tests run.
- If you build a custom `*config.Config`, call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) before asserting on filesystem paths.
@@ -193,6 +271,15 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- Capture output with `RunWithTestContext`; usage and report values may be quoted and reordered (e.g., set semantics). Use substring checks or regex for the final ", or <last>" rule from `CliUsageString`.
- Prefer JSON output (`--json`) for stable machine assertions when commands offer it.
- Cataloging CLI commands (new):
- Use `internal/commands/catalog` to enumerate commands/flags without invoking the CLI or capturing stdout.
- Default format for `photoprism show commands` is Markdown; pass `--json` for machine output and `--nested` to get a tree. Hidden commands/flags appear only with `--all`.
- Nested `help` subcommands are omitted; the toplevel `photoprism help` remains included.
- When asserting large JSON documents, build DTOs via `catalog.BuildFlat/BuildNode` and marshal directly to avoid pipe backpressure in tests.
- JSON shapes for `show` commands:
- Most return a toplevel array of row objects (keys = snake_case columns).
- `photoprism show config` returns `{ sections: [{ title, items[] }] }`.
- `photoprism show config-options --json` and `photoprism show config-yaml --json` return a flat toplevel array (no `sections`).
### API Development & Config Options
@@ -228,7 +315,17 @@ The following conventions summarize the insights gained when adding new configur
- Portal mode: set `PHOTOPRISM_NODE_ROLE=portal` and `PHOTOPRISM_JOIN_TOKEN`.
- Pagination defaults: for new list endpoints, prefer `count` default 100 (max 1000) and `offset` ≥ 0; document both in Swagger and validate bounds in handlers.
- Document parameters explicitly in Swagger annotations (path, query, and body) so `make swag` produces accurate docs.
- Swagger: `make fmt-go swag-fmt && make swag` after adding or changing API annotations.
- Swagger: `make fmt-go swag-fmt && make swag` after adding or changing API annotations.
### Swagger & API Docs
- Annotations live next to handlers in `internal/api/*.go`. Only annotate public handlers that are registered in `internal/server/routes.go`.
- Always include the full prefix in `@Router` paths: `/api/v1/...` (not relative segments).
- Avoid annotating internal helpers (e.g., generic link creators) to prevent generating undocumented placeholder paths.
- Generate docs locally with:
- `make swag-fmt` (formats annotations)
- `make swag-json` (generates `internal/api/swagger.json` and then runs `swaggerfix` to remove unstable `time.Duration` enums for deterministic diffs)
- `time.Duration` fields are represented as integer nanoseconds in the API. The Makefile target `swag-json` automatically post-processes `swagger.json` to strip duplicated enums for this type.
- Focused tests: `go test ./internal/api -run Cluster -count=1` (or limit to the package you changed).
- Registry & secrets
@@ -261,6 +358,8 @@ The following conventions summarize the insights gained when adding new configur
- Use the clientbacked registry (`NewClientRegistryWithConfig`).
- The filebacked registry is historical; do not add new references to it.
- Migration “done” checklist: swap callsites → build → API tests → CLI tests → remove legacy references.
- Primary node identifier: UUID v7 (called `NodeUUID` in code/config). In API/CLI responses this is exposed as `uuid`. The OAuth client identifier (`NodeClientID`) is used only for OAuth and is exposed as `clientId`.
- Lookups should prefer `uuid``clientId` (legacy) → DNSlabel name. Portal endpoints for nodes use `/api/v1/cluster/nodes/{uuid}`.
### API/CLI Tests: Known Pitfalls
@@ -349,7 +448,28 @@ The following conventions summarize the insights gained when adding new configur
- Registration (instance bootstrap):
- Send `rotate=true` only if driver is MySQL/MariaDB and no DSN/name/user/password is configured (including *_FILE for password); never for SQLite.
- Treat 401/403/404 as terminal; apply bounded retries with delay for transient/network/429.
- Persist only missing `NodeSecret` and DB settings when rotation was requested.
- Identity changes (UUID/name): include `clientId` and `clientSecret` in the registration payload to authorize UUID/name changes for existing nodes. Without the secret, name-based UUID changes return HTTP 409.
- Persist only missing `NodeClientSecret` and DB settings when rotation was requested.
### Cluster Registry, Provisioner, and DTOs
- UUID-first Identity & endpoints
- Nodes use UUID v7 as the only primary identifier. All Portal node endpoints use `{uuid}`. Client IDs are OAuthonly.
- Registry interface is UUIDfirst: `Get(uuid)`, `FindByNodeUUID`, `FindByClientID`, `Delete(uuid)`, `RotateSecret(uuid)`, and `DeleteAllByUUID(uuid)` for admin cleanup.
- DTO shapes
- API `cluster.Node`: `uuid` (required) first, `clientId` optional. `database` includes `driver`.
- Registry `Node`: mirrors the API shape; `ClientID` optional.
- DTO fields are normalized:
- `database` (not `db`) with fields `name`, `user`, `driver`, `rotatedAt`.
- Node rotation timestamps use `rotatedAt`.
- Registration secrets expose `clientSecret` in API responses; the CLI persists it into config options as `NodeClientSecret`.
- Admin responses may include `advertiseUrl` and `database`; non-admin responses are redacted by default.
- CLI
- Resolution order is `uuid → clientId → name`. `nodes rm` supports `--all-ids` to delete all client rows that share a UUID. Tables include a “DB Driver” column.
- Provisioner
- DB/user names are UUIDbased without slugs: `photoprism_d<hmac11>`, `photoprism_u<hmac11>`. HMAC is scoped by ClusterUUID+NodeUUID.
- BuildDSN accepts `driver`; unsupported values fall back to MySQL format with a warning.
- ClientData cleanup
- `NodeUUID` removed from `ClientData`; it lives on the toplevel client row (`auth_clients.node_uuid`). `ClientDatabase` now includes `driver`.
- Testing patterns:
- Use `httptest` for Portal endpoints and `pkg/fs.Unzip` with size caps for extraction tests.

View File

@@ -19,6 +19,7 @@ Executables & Entry Points
- CLI app (binary name across docs/images is `photoprism`):
- Main: `cmd/photoprism/photoprism.go`
- Commands registry: `internal/commands/commands.go` (array `commands.PhotoPrism`)
- Catalog helpers: `internal/commands/catalog` (DTOs and builders to enumerate commands/flags; Markdown renderer)
- Web server:
- Startup: `internal/commands/start.go``server.Start` (starts HTTP(S), workers, session cleanup)
- HTTP server: `internal/server/start.go` (compression, security, healthz, readiness, TLS/AutoTLS/unix socket)
@@ -27,6 +28,7 @@ Executables & Entry Points
High-Level Package Map (Go)
- `internal/api` — Gin handlers and Swagger annotations; only glue, no business logic
- `internal/commands/catalog` — DTOs (App, Command, Flag, Node), builders (BuildFlat/BuildNode, CommandInfo, FlagsToCatalog), and a templated Markdown renderer (RenderMarkdown) for the CLI commands catalog. Depends only on `urfave/cli/v2` and stdlib.
- `internal/server` — HTTP server, middleware, routing, static/ui/webdav
- `internal/config` — configuration, flags/env/options, client config, DB init/migrate
- `internal/entity` — GORM v1 models, queries, search helpers, migrations
@@ -42,6 +44,10 @@ HTTP API
- Handlers live in `internal/api/*.go` and are registered in `internal/server/routes.go`.
- Annotate new endpoints in handler files; generate docs with: `make fmt-go swag-fmt && make swag`.
- Do not edit `internal/api/swagger.json` by hand.
- Swagger notes:
- Use full `/api/v1/...` in every `@Router` annotation (match the group prefix).
- Annotate only public handlers; skip internal helpers to avoid stray generic paths.
- `make swag-json` runs a stabilization step (`swaggerfix`) removing duplicated enums for `time.Duration`; API uses integer nanoseconds for durations.
- Common groups in `routes.go`: sessions, OAuth/OIDC, config, users, services, thumbnails, video, downloads/zip, index/import, photos/files/labels/subjects/faces, batch ops, cluster, technical (metrics, status, echo).
Configuration & Flags
@@ -50,9 +56,10 @@ Configuration & Flags
- If needed: `yaml:"-"` disables YAML processing; `flag:"-"` prevents `ApplyCliContext()` from assigning CLI values (flags/env variables) to a field, without affecting the flags in `internal/config/flags.go`.
- Annotations may include edition tags like `tags:"plus,pro"` to control visibility (see `internal/config/options_report.go` logic).
- Global flags/env: `internal/config/flags.go` (`EnvVars(...)`)
- Available flags/env: `internal/config/cli_flags_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-options --md`
- YAML options mapping: `internal/config/options_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-yaml --md`
- Available flags/env: `internal/config/cli_flags_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-options --md/--json`
- YAML options mapping: `internal/config/options_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-yaml --md/--json`
- Report current values: `internal/config/report.go` → surfaced by `photoprism show config` (alias `photoprism config --md`).
- CLI commands catalog: `internal/commands/show_commands.go` → surfaced by `photoprism show commands` (Markdown by default; `--json` alternative; `--nested` optional tree; `--all` includes hidden commands/flags; nested `help` subcommands omitted).
- Precedence: `defaults.yml` < CLI/env < `options.yml` (global options rule). See Agent Tips in `AGENTS.md`.
- Getters are grouped by topic, e.g. DB in `internal/config/config_db.go`, server in `config_server.go`, TLS in `config_tls.go`, etc.
- Client Config (read-only)
@@ -135,6 +142,27 @@ Testing
- SQLite DSN in tests is persuite (not empty). Clean up files if you capture the DSN.
- Frontend unit tests via Vitest are separate; see `frontend/CODEMAP.md`.
Security & Hot Spots (Where to Look)
- Zip extraction (path traversal prevention): `pkg/fs/zip.go`
- Uses `safeJoin` to reject absolute/volume paths and `..` traversal; enforces per-file and total size limits.
- Tests: `pkg/fs/zip_extra_test.go` cover abs/volume/.. cases and limits.
- Force-aware Copy/Move and truncation-safe writes:
- App helpers: `internal/photoprism/mediafile.go` (`MediaFile.Copy/Move` with `force`).
- Utils: `pkg/fs/copy.go`, `pkg/fs/move.go` (use `O_TRUNC` to avoid trailing bytes).
- FFmpeg command builders and encoders:
- Core: `internal/ffmpeg/transcode_cmd.go`, `internal/ffmpeg/remux.go`.
- Encoders (string builders only): `internal/ffmpeg/{apple,intel,nvidia,vaapi,v4l}/avc.go`.
- Tests guard HW runs with `PHOTOPRISM_FFMPEG_ENCODER`; otherwise assert command strings and negative paths.
- libvips thumbnails:
- Pipeline: `internal/thumb/vips.go` (VipsInit, VipsRotate, export params).
- Sizes & names: `internal/thumb/sizes.go`, `internal/thumb/names.go`, `internal/thumb/filter.go`.
- Safe HTTP downloader:
- Shared utility: `pkg/service/http/safe` (`Download`, `Options`).
- Protections: scheme allowlist (http/https), preDNS + perredirect hostname/IP validation, final peer IP check, size and timeout enforcement, temp file `0600` + rename.
- Avatars: wrapper `internal/thumb/avatar.SafeDownload` applies stricter defaults (15s, 10MiB, `AllowPrivate=false`, imagefocused `Accept`).
- Tests: `go test ./pkg/service/http/safe -count=1` (includes redirect SSRF cases); avatars: `go test ./internal/thumb/avatar -count=1`.
Performance & Limits
- Prefer existing caches/workers/batching as per Makefile and code.
- When adding list endpoints, default `count=100` (max `1000`); set `Cache-Control: no-store` for secrets.
@@ -145,6 +173,28 @@ Conventions & Rules of Thumb
- Never log secrets; compare tokens constanttime.
- Dont import Portal internals from cluster instance/service bootstraps; use HTTP.
- Prefer small, hermetic unit tests; isolate filesystem paths with `t.TempDir()` and env like `PHOTOPRISM_STORAGE_PATH`.
- Cluster nodes: identify by UUID v7 (internally stored as `NodeUUID`; exposed as `uuid` in API/CLI). The OAuth client ID (`NodeClientID`, exposed as `clientId`) is for OAuth only. Registry lookups and CLI commands accept uuid, clientId, or DNSlabel name (priority in that order).
Filesystem Permissions & io/fs Aliasing
- Use `github.com/photoprism/photoprism/pkg/fs` permission variables when creating files/dirs:
- `fs.ModeDir` (0o755), `fs.ModeFile` (0o644), `fs.ModeConfigFile` (0o664), `fs.ModeSecret` (0o600), `fs.ModeBackupFile` (0o600).
- Do not use stdlib `io/fs` mode bits as permission arguments. When importing stdlib `io/fs`, alias it (`iofs`/`gofs`) to avoid `fs.*` collisions with our package.
- Prefer `filepath.Join` for filesystem paths across platforms; use `path.Join` for URLs only.
Cluster Registry & Provisioner Cheatsheet
- UUIDfirst everywhere: API paths `{uuid}`, Registry `Get/Delete/RotateSecret` by UUID; explicit `FindByClientID` exists for OAuth.
- Node/DTO fields: `uuid` required; `clientId` optional; database metadata includes `driver`.
- Provisioner naming (no slugs):
- database: `photoprism_d<hmac11>`
- username: `photoprism_u<hmac11>`
HMAC is base32 of ClusterUUID+NodeUUID; drivers currently `mysql|mariadb`.
- DSN builder: `BuildDSN(driver, host, port, user, pass, name)`; warns and falls back to MySQL format for unsupported drivers.
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful).
- Public API and internal registry DTOs use normalized field names:
- `database` (not `db`) with `name`, `user`, `driver`, `rotatedAt`.
- Node-level rotation timestamps use `rotatedAt`.
- Registration returns `secrets.clientSecret`; the CLI persists it under config `NodeClientSecret`.
- Admin responses may include `advertiseUrl` and `database`; non-admin responses are redacted by default.
Frequently Touched Files (by topic)
- CLI wiring: `cmd/photoprism/photoprism.go`, `internal/commands/commands.go`
@@ -187,4 +237,10 @@ Useful Make Targets (selection)
See Also
- AGENTS.md (repository rules and tips for agents)
- Developer Guide (Setup/Tests/API) links in AGENTS.md Sources of Truth
- Specs: `specs/dev/backend-testing.md`, `specs/portal/README.md`
- Specs: `specs/dev/backend-testing.md`, `specs/dev/api-docs-swagger.md`, `specs/portal/README.md`
Fast Test Recipes
- Filesystem + archives (fast): `go test ./pkg/fs -run 'Copy|Move|Unzip' -count=1`
- Media helpers (fast): `go test ./pkg/media/... -count=1`
- Thumbnails (libvips, moderate): `go test ./internal/thumb/... -count=1`
- FFmpeg command builders (moderate): `go test ./internal/ffmpeg -run 'Remux|Transcode|Extract' -count=1`

View File

@@ -1,5 +1,5 @@
# Ubuntu 25.04 (Plucky Puffin)
FROM photoprism/develop:250912-plucky
FROM photoprism/develop:250922-plucky
# Harden npm usage by default (applies to npm ci / install in dev container)
ENV NPM_CONFIG_IGNORE_SCRIPTS=true

View File

@@ -115,6 +115,8 @@ swag: swag-json
swag-json:
@echo "Generating ./internal/api/swagger.json..."
swag init --ot json --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./internal/api
@echo "Fixing unstable time.Duration enums in swagger.json..."
@GO111MODULE=on go run scripts/tools/swaggerfix/main.go internal/api/swagger.json || { echo "swaggerfix failed"; exit 1; }
swag-yaml:
@echo "Generating ./internal/api/swagger.yaml..."
swag init --ot yaml --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./internal/api
@@ -387,12 +389,12 @@ reset-mariadb-local:
reset-mariadb-acceptance:
$(info Resetting acceptance database...)
mysql < scripts/sql/reset-acceptance.sql
reset-mariadb-all: reset-mariadb-testdb reset-mariadb-local reset-mariadb-acceptance reset-mariadb-photoprism
reset-mariadb-all: reset-mariadb-testdb reset-mariadb-local reset-mariadb-acceptance
reset-testdb: reset-sqlite reset-mariadb-testdb
reset-acceptance: reset-mariadb-acceptance
reset-sqlite:
$(info Removing test database files...)
find ./internal -type f -name ".test.*" -delete
find ./internal -type f \( -iname '.*.db' -o -iname '.*.db-journal' -o -iname '.test.*' \) -delete
run-test-short:
$(info Running short Go tests in parallel mode...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -short -timeout 5m ./pkg/... ./internal/...

View File

@@ -74,6 +74,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
&& \
ln -sf /usr/bin/fdfind /usr/local/bin/fd && \
ln -sf /usr/bin/batcat /usr/local/bin/bat && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \
/scripts/install-nodejs.sh && \
/scripts/install-mariadb.sh mariadb-client && \
/scripts/install-tensorflow.sh && \

File diff suppressed because it is too large Load Diff

View File

@@ -51,9 +51,9 @@
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vue/compiler-sfc": "^3.5.18",
"@vue/language-server": "^3.0.7",
"@vue/language-server": "^3.0.8",
"@vue/test-utils": "^2.4.6",
"@vvo/tzdb": "^6.183.0",
"@vvo/tzdb": "^6.184.0",
"axios": "^1.12.2",
"axios-mock-adapter": "^2.1.0",
"babel-loader": "^10.0.0",
@@ -66,14 +66,14 @@
"css-loader": "^7.1.2",
"cssnano": "^7.1.1",
"easygettext": "^2.17.0",
"eslint": "^9.35.0",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-formatter-pretty": "^6.0.1",
"eslint-plugin-html": "^8.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.4.0",
"eslint-plugin-vue": "^10.5.0",
"eslint-plugin-vuetify": "^2.5.3",
"eslint-webpack-plugin": "^5.0.2",
"eventsource-polyfill": "^0.9.6",
@@ -81,22 +81,22 @@
"file-saver": "^2.0.5",
"floating-vue": "^5.2.2",
"globals": "^16.4.0",
"hls.js": "^1.6.12",
"hls.js": "^1.6.13",
"i": "^0.3.7",
"jsdom": "^26.1.0",
"luxon": "^3.7.2",
"maplibre-gl": "^5.7.2",
"maplibre-gl": "^5.7.3",
"memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.9.4",
"minimist": "^1.2.8",
"node-storage-shim": "^2.0.1",
"passive-events-support": "^1.1.0",
"photoswipe": "^5.4.4",
"playwright": "^1.55.0",
"playwright": "^1.55.1",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-loader": "^8.2.0",
"postcss-preset-env": "^10.3.1",
"postcss-preset-env": "^10.4.0",
"postcss-reporter": "^7.1.0",
"postcss-url": "^10.1.3",
"prettier": "^3.6.2",
@@ -104,13 +104,13 @@
"regenerator-runtime": "^0.14.1",
"resolve-url-loader": "^5.0.0",
"sanitize-html": "^2.17.0",
"sass": "^1.92.1",
"sass": "^1.93.2",
"sass-loader": "^16.0.5",
"server": "^1.0.42",
"sockette": "^2.0.6",
"style-loader": "^4.0.0",
"svg-url-loader": "^8.0.0",
"tar": "^7.4.3",
"tar": "^7.5.1",
"url-loader": "^4.1.1",
"util": "^0.12.5",
"vite-tsconfig-paths": "^5.1.4",

19
go.mod
View File

@@ -13,8 +13,8 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/esimov/pigo v1.4.6
github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.1
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3
github.com/gin-gonic/gin v1.11.0
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6
github.com/google/open-location-code/go v0.0.0-20250620134813-83986da0156b
github.com/gorilla/websocket v1.5.3
github.com/gosimple/slug v1.15.0
@@ -51,7 +51,7 @@ require (
golang.org/x/image v0.31.0
)
require github.com/olekukonko/tablewriter v1.0.9
require github.com/olekukonko/tablewriter v1.1.0
require github.com/google/uuid v1.6.0
@@ -79,6 +79,7 @@ require (
github.com/IGLOU-EU/go-wildcard v1.0.3
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/pquerna/otp v1.5.0
github.com/prometheus/client_model v0.6.2
github.com/robfig/cron/v3 v3.0.1
@@ -130,8 +131,8 @@ require (
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -147,10 +148,13 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/ll v0.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -165,9 +169,10 @@ require (
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/tools v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -178,7 +183,7 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0
golang.org/x/arch v0.20.0 // indirect
golang.org/x/arch v0.21.0 // indirect
)
require (

34
go.sum
View File

@@ -129,8 +129,8 @@ github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
@@ -200,14 +200,16 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
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-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=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3 h1:vLpszxHK60Falbg9wHLAFI5LPqXlxnbvQRGxCJJHvLY=
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6 h1:8VSqEdhJCzXKe/SYulyQ7jgHNxP+BoCPmqzWF5STIyw=
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -346,12 +348,14 @@ github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/ll v0.1.1 h1:9Dfeed5/Mgaxb9lHRAftLK9pVfYETvHn+If6lywVhJc=
github.com/olekukonko/ll v0.1.1/go.mod h1:2dJo+hYZcJMLMbKwHEWvxCUbAOLc/CXWS9noET22Mdo=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -375,6 +379,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -457,13 +465,15 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -657,8 +667,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -28,7 +28,6 @@ func TestLabelRule_Find(t *testing.T) {
assert.Equal(t, -2, result.Priority)
assert.Equal(t, float32(1), result.Threshold)
})
t.Run("not existing rule", func(t *testing.T) {
result, ok := rules.Find("fish")
assert.False(t, ok)

View File

@@ -20,7 +20,6 @@ func TestLabel_NewLocationLabel(t *testing.T) {
assert.Equal(t, 24, LocLabel.Uncertainty)
assert.Equal(t, "locationtest", LocLabel.Name)
})
t.Run("locationtest - minus", func(t *testing.T) {
LocLabel := LocationLabel("locationtest - minus", 80)
t.Log(LocLabel)
@@ -28,7 +27,6 @@ func TestLabel_NewLocationLabel(t *testing.T) {
assert.Equal(t, 80, LocLabel.Uncertainty)
assert.Equal(t, "locationtest", LocLabel.Name)
})
t.Run("label as name", func(t *testing.T) {
LocLabel := LocationLabel("barracouta", 80)
t.Log(LocLabel)
@@ -45,7 +43,6 @@ func TestLabel_Title(t *testing.T) {
LocLabel := LocationLabel("locationtest123", 23)
assert.Equal(t, "Locationtest123", LocLabel.Title())
})
t.Run("Berlin/Neukölln", func(t *testing.T) {
LocLabel := LocationLabel("berlin/neukölln_hasenheide", 23)
assert.Equal(t, "Berlin / Neukölln Hasenheide", LocLabel.Title())

View File

@@ -21,7 +21,6 @@ func TestLabel_AppendLabel(t *testing.T) {
assert.Equal(t, "cat", labelsNew[0].Name)
assert.Equal(t, "cow", labelsNew[2].Name)
})
t.Run("labelWithoutName", func(t *testing.T) {
assert.Equal(t, 2, labels.Len())
cow := Label{Name: "", Source: "location", Uncertainty: 80, Priority: 5}
@@ -39,7 +38,6 @@ func TestLabels_Title(t *testing.T) {
assert.Equal(t, "cat", labels.Title("fallback"))
})
t.Run("second", func(t *testing.T) {
cat := Label{Name: "cat", Source: "location", Uncertainty: 61, Priority: 5}
dog := Label{Name: "dog", Source: "location", Uncertainty: 10, Priority: 4}
@@ -47,7 +45,6 @@ func TestLabels_Title(t *testing.T) {
assert.Equal(t, "dog", labels.Title("fallback"))
})
t.Run("fallback", func(t *testing.T) {
cat := Label{Name: "cat", Source: "location", Uncertainty: 80, Priority: 5}
dog := Label{Name: "dog", Source: "location", Uncertainty: 80, Priority: 4}
@@ -55,13 +52,11 @@ func TestLabels_Title(t *testing.T) {
assert.Equal(t, "fallback", labels.Title("fallback"))
})
t.Run("empty labels", func(t *testing.T) {
labels := Labels{}
assert.Equal(t, "", labels.Title(""))
})
t.Run("label priority < 0", func(t *testing.T) {
cat := Label{Name: "cat", Source: "location", Uncertainty: 59, Priority: -1}
dog := Label{Name: "dog", Source: "location", Uncertainty: 10, Priority: -1}
@@ -69,7 +64,6 @@ func TestLabels_Title(t *testing.T) {
assert.Equal(t, "fallback", labels.Title("fallback"))
})
t.Run("label priority = 0", func(t *testing.T) {
cat := Label{Name: "cat", Source: "location", Uncertainty: 59, Priority: 0}
dog := Label{Name: "dog", Source: "location", Uncertainty: 62, Priority: 0}

View File

@@ -2,7 +2,7 @@ package tensorflow
import (
"bufio"
"io/fs"
iofs "io/fs"
"os"
"path/filepath"
@@ -35,7 +35,7 @@ func loadLabelsFromPath(path string) (labels []string, err error) {
func LoadLabels(modelPath string, expectedLabels int) (labels []string, err error) {
dir := os.DirFS(modelPath)
matches, err := fs.Glob(dir, "labels*.txt")
matches, err := iofs.Glob(dir, "labels*.txt")
if err != nil {
return nil, err
}

View File

@@ -66,14 +66,12 @@ func TestUpdateAlbum(t *testing.T) {
assert.Equal(t, "false", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("Invalid", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateAlbum(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/albums"+uid, `{"Title": 333, "Description": "Created via unit test", "Notes": "", "Favorite": true}`)
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateAlbum(router)

View File

@@ -16,7 +16,13 @@ func UpdateClientConfig() {
// GetClientConfig returns the client configuration values as JSON.
//
// GET /api/v1/config
// @Summary get client configuration
// @Id GetClientConfig
// @Tags Config
// @Produce json
// @Success 200 {object} gin.H
// @Failure 401 {object} i18n.Response
// @Router /api/v1/config [get]
func GetClientConfig(router *gin.RouterGroup) {
router.GET("/config", func(c *gin.Context) {
sess := Session(ClientIP(c), AuthToken(c))

View File

@@ -48,6 +48,9 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -176,7 +176,7 @@ func BatchPhotosRestore(router *gin.RouterGroup) {
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/approve [post]
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
router.POST("/batch/photos/approve", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Abort(c) {

View File

@@ -114,18 +114,18 @@ func ClusterListNodes(router *gin.RouterGroup) {
})
}
// ClusterGetNode returns a single node by id.
// ClusterGetNode returns a single node by uuid.
//
// @Summary get node by id
// @Summary get node by uuid
// @Id ClusterGetNode
// @Tags Cluster
// @Produce json
// @Param id path string true "node id"
// @Param uuid path string true "node uuid"
// @Success 200 {object} cluster.Node
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{id} [get]
// @Router /api/v1/cluster/nodes/{uuid} [get]
func ClusterGetNode(router *gin.RouterGroup) {
router.GET("/cluster/nodes/:id", func(c *gin.Context) {
router.GET("/cluster/nodes/:uuid", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionView)
if s.Abort(c) {
@@ -139,10 +139,10 @@ func ClusterGetNode(router *gin.RouterGroup) {
return
}
id := c.Param("id")
uuid := c.Param("uuid")
// Validate id to avoid path traversal and unexpected file access.
if !isSafeNodeID(id) {
if !isSafeNodeID(uuid) {
AbortEntityNotFound(c)
return
}
@@ -154,9 +154,9 @@ func ClusterGetNode(router *gin.RouterGroup) {
return
}
n, err := regy.Get(id)
if err != nil {
// Prefer NodeUUID identifier for cluster nodes.
n, err := regy.FindByNodeUUID(uuid)
if err != nil || n == nil {
AbortEntityNotFound(c)
return
}
@@ -166,7 +166,7 @@ func ClusterGetNode(router *gin.RouterGroup) {
resp := reg.BuildClusterNode(*n, opts)
// Audit get access.
event.AuditInfo([]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "nodes", "get", n.ID, event.Succeeded}, s.RefID)
event.AuditInfo([]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "nodes", "get", uuid, event.Succeeded}, s.RefID)
c.JSON(http.StatusOK, resp)
})
@@ -179,13 +179,13 @@ func ClusterGetNode(router *gin.RouterGroup) {
// @Tags Cluster
// @Accept json
// @Produce json
// @Param id path string true "node id"
// @Param uuid path string true "node uuid"
// @Param node body object true "properties to update (role, labels, advertiseUrl, siteUrl)"
// @Success 200 {object} cluster.StatusResponse
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{id} [patch]
// @Router /api/v1/cluster/nodes/{uuid} [patch]
func ClusterUpdateNode(router *gin.RouterGroup) {
router.PATCH("/cluster/nodes/:id", func(c *gin.Context) {
router.PATCH("/cluster/nodes/:uuid", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionManage)
if s.Abort(c) {
@@ -199,7 +199,7 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
return
}
id := c.Param("id")
uuid := c.Param("uuid")
var req struct {
Role string `json:"role"`
@@ -220,9 +220,9 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
return
}
n, err := regy.Get(id)
if err != nil {
// Resolve by NodeUUID first (preferred).
n, err := regy.FindByNodeUUID(uuid)
if err != nil || n == nil {
AbortEntityNotFound(c)
return
}
@@ -249,23 +249,23 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
return
}
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "update", n.ID, event.Succeeded})
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "update", uuid, event.Succeeded})
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
})
}
// ClusterDeleteNode removes a node entry from the registry.
//
// @Summary delete node by id
// @Summary delete node by uuid
// @Id ClusterDeleteNode
// @Tags Cluster
// @Produce json
// @Param id path string true "node id"
// @Param uuid path string true "node uuid"
// @Success 200 {object} cluster.StatusResponse
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{id} [delete]
// @Router /api/v1/cluster/nodes/{uuid} [delete]
func ClusterDeleteNode(router *gin.RouterGroup) {
router.DELETE("/cluster/nodes/:id", func(c *gin.Context) {
router.DELETE("/cluster/nodes/:uuid", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionManage)
if s.Abort(c) {
@@ -279,7 +279,12 @@ func ClusterDeleteNode(router *gin.RouterGroup) {
return
}
id := c.Param("id")
uuid := c.Param("uuid")
// Validate uuid format to avoid path traversal or unexpected input.
if !isSafeNodeID(uuid) {
AbortEntityNotFound(c)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
@@ -288,17 +293,17 @@ func ClusterDeleteNode(router *gin.RouterGroup) {
return
}
if _, err = regy.Get(id); err != nil {
AbortEntityNotFound(c)
// Delete by NodeUUID
if err = regy.Delete(uuid); err != nil {
if err == reg.ErrNotFound {
AbortEntityNotFound(c)
} else {
AbortUnexpectedError(c)
}
return
}
if err = regy.Delete(id); err != nil {
AbortUnexpectedError(c)
return
}
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "delete", id, event.Succeeded})
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "delete", uuid, event.Succeeded})
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
})
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Verifies redaction differences between admin and non-admin on list endpoint.
@@ -23,9 +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)
n := &reg.Node{Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
n.DB.Name = "pp_db"
n.DB.User = "pp_user"
// Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List().
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
n.Database.Name = "pp_db"
n.Database.User = "pp_user"
assert.NoError(t, regy.Put(n))
// Admin session shows internal fields
@@ -55,8 +57,8 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
assert.NoError(t, err)
// Seed node with internal URL and DB meta.
n := &reg.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}
n.DB.Name = "pp_db2"
n.DB.User = "pp_user2"
n.Database.Name = "pp_db2"
n.Database.User = "pp_user2"
assert.NoError(t, regy.Put(n))
// Create client session with cluster scope and no user (redacted view expected).

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/limiter"
@@ -20,14 +21,18 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// RegisterRequireClientSecret controls whether registrations that reference an
// existing ClientID must also present the matching client secret. Enabled by default.
var RegisterRequireClientSecret = true
// ClusterNodesRegister registers the Portal-only node registration endpoint.
//
// @Summary registers a node, provisions DB credentials, and issues nodeSecret
// @Summary registers a node, provisions DB credentials, and issues clientSecret
// @Id ClusterNodesRegister
// @Tags Cluster
// @Accept json
// @Produce json
// @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl, rotateDatabase, rotateSecret)"
// @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl; to authorize UUID/name changes include clientId+clientSecret; rotation: rotateDatabase, rotateSecret)"
// @Success 200,201 {object} cluster.RegisterResponse
// @Failure 400,401,403,409,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/register [post]
@@ -65,10 +70,13 @@ 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"`
}
@@ -79,13 +87,58 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
return
}
name := clean.TypeLowerDash(req.NodeName)
// If an existing ClientID is provided, require the corresponding client secret for verification.
if RegisterRequireClientSecret && req.ClientID != "" {
if !rnd.IsUID(req.ClientID, entity.ClientUID) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid client id"})
AbortBadRequest(c)
return
}
pw := entity.FindPassword(req.ClientID)
if pw == nil || req.ClientSecret == "" || !pw.Valid(req.ClientSecret) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid client secret"})
AbortUnauthorized(c)
return
}
}
if name == "" || len(name) < 1 || len(name) > 63 {
name := clean.DNSLabel(req.NodeName)
// Enforce DNS label semantics for node names: lowercase [a-z0-9-], 132, start/end alnum.
if name == "" || len(name) > 32 || name[0] == '-' || name[len(name)-1] == '-' {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid name"})
AbortBadRequest(c)
return
}
for i := 0; i < len(name); i++ {
b := name[i]
if !(b == '-' || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid name chars"})
AbortBadRequest(c)
return
}
}
// Validate advertise URL if provided (https required for non-local domains).
if u := strings.TrimSpace(req.AdvertiseUrl); u != "" {
if !validateAdvertiseURL(u) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid advertise url"})
AbortBadRequest(c)
return
}
}
// Validate site URL if provided (https required for non-local domains).
if su := strings.TrimSpace(req.SiteUrl); su != "" {
if !validateSiteURL(su) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid site url"})
AbortBadRequest(c)
return
}
}
// Sanitize requested NodeUUID; generation happens later depending on path (existing vs new).
requestedUUID := rnd.SanitizeUUID(req.NodeUUID)
// Registry (client-backed).
regy, err := reg.NewClientRegistryWithConfig(conf)
@@ -98,6 +151,14 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Try to find existing node.
if n, _ := regy.FindByName(name); n != nil {
// If caller attempts to change UUID by name without proving client secret, block with 409.
if RegisterRequireClientSecret {
if requestedUUID != "" && n.UUID != "" && requestedUUID != n.UUID && req.ClientID == "" {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "uuid change requires client secret", event.Denied, "name %s", clean.LogQuote(name)})
c.JSON(http.StatusConflict, gin.H{"error": "client secret required to change node uuid"})
return
}
}
// Update mutable metadata when provided.
if req.AdvertiseUrl != "" {
n.AdvertiseUrl = req.AdvertiseUrl
@@ -108,6 +169,19 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
if s := normalizeSiteURL(req.SiteUrl); s != "" {
n.SiteUrl = s
}
// Apply UUID changes for existing node: if a UUID was requested and differs, or if none exists yet.
if requestedUUID != "" {
oldUUID := n.UUID
if oldUUID != requestedUUID {
n.UUID = requestedUUID
// Emit audit event for UUID change.
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "uuid changed", event.Succeeded, "name %s", "old %s", "new %s"}, clean.LogQuote(name), clean.Log(oldUUID), clean.Log(requestedUUID))
}
} else if n.UUID == "" {
// Assign a fresh UUID if missing and none requested.
n.UUID = rnd.UUIDv7()
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "uuid changed", event.Succeeded, "name %s", "old %s", "new %s"}, clean.LogQuote(name), clean.Log(""), clean.Log(n.UUID))
}
// Persist metadata changes so UpdatedAt advances.
if putErr := regy.Put(n); putErr != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr))
@@ -117,12 +191,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Optional rotations.
var respSecret *cluster.RegisterSecrets
if req.RotateSecret {
if n, err = regy.RotateSecret(n.ID); err != nil {
if n, err = regy.RotateSecret(n.UUID); err != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate secret", event.Failed, "%s"}, clean.Error(err))
AbortUnexpectedError(c)
return
}
respSecret = &cluster.RegisterSecrets{NodeSecret: n.Secret, SecretRotatedAt: n.SecretRot}
respSecret = &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt}
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate secret", event.Succeeded, "node %s"}, clean.LogQuote(name))
// Extra safety: ensure the updated secret is persisted even if subsequent steps fail.
@@ -134,7 +208,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
// Ensure that a database for this node exists (rotation optional).
creds, _, credsErr := provisioner.EnsureNodeDatabase(c, conf, name, req.RotateDatabase)
creds, _, credsErr := provisioner.GetCredentials(c, conf, n.UUID, name, req.RotateDatabase)
if credsErr != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(credsErr))
@@ -143,7 +217,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
if req.RotateDatabase {
n.DB.RotAt = creds.LastRotatedAt
n.Database.RotatedAt = creds.RotatedAt
n.Database.Driver = provisioner.DatabaseDriver
if putErr := regy.Put(n); putErr != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr))
AbortUnexpectedError(c)
@@ -155,8 +230,9 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Build response with struct types.
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(),
Node: reg.BuildClusterNode(*n, opts),
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.DB.Name, User: n.DB.User},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver},
Secrets: respSecret,
AlreadyRegistered: true,
AlreadyProvisioned: true,
@@ -166,7 +242,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
if req.RotateDatabase {
resp.Database.Password = creds.Password
resp.Database.DSN = creds.DSN
resp.Database.RotatedAt = creds.LastRotatedAt
resp.Database.RotatedAt = creds.RotatedAt
}
c.Header(header.CacheControl, header.CacheControlNoStore)
@@ -174,30 +250,39 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
return
}
// New node.
// New node (client UID will be generated in registry.Put).
n := &reg.Node{
ID: rnd.UUID(),
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
Labels: req.Labels,
AdvertiseUrl: req.AdvertiseUrl,
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
} else if d := conf.ClusterDomain(); d != "" {
n.AdvertiseUrl = "https://" + name + "." + d
}
if s := normalizeSiteURL(req.SiteUrl); s != "" {
n.SiteUrl = s
}
// Generate node secret.
n.Secret = rnd.Base62(48)
n.SecretRot = nowRFC3339()
// Generate node secret (must satisfy client secret format for entity.Client).
n.ClientSecret = rnd.ClientSecret()
n.RotatedAt = nowRFC3339()
// Ensure DB (force rotation at create path to return password).
creds, _, err := provisioner.EnsureNodeDatabase(c, conf, name, true)
creds, _, err := provisioner.GetCredentials(c, conf, n.UUID, name, true)
if err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(err))
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
n.DB.Name, n.DB.User, n.DB.RotAt = creds.Name, creds.User, creds.LastRotatedAt
n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt
n.Database.Driver = provisioner.DatabaseDriver
if err = regy.Put(n); err != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(err))
@@ -207,8 +292,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
resp := cluster.RegisterResponse{
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
Secrets: &cluster.RegisterSecrets{NodeSecret: n.Secret, SecretRotatedAt: n.SecretRot},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.LastRotatedAt},
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},
AlreadyRegistered: false,
AlreadyProvisioned: false,
}
@@ -242,3 +327,26 @@ func normalizeSiteURL(u string) string {
parsed.Host = strings.ToLower(parsed.Host)
return parsed.String()
}
// validateAdvertiseURL checks that the URL is absolute with a host and scheme,
// and requires https for non-local hosts. http is allowed only for localhost/127.0.0.1/::1.
func validateAdvertiseURL(u string) bool {
parsed, err := url.Parse(strings.TrimSpace(u))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return false
}
host := strings.ToLower(parsed.Hostname())
if parsed.Scheme == "https" {
return true
}
if parsed.Scheme == "http" {
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
return false
}
return false
}
// validateSiteURL applies the same rules as validateAdvertiseURL.
func validateSiteURL(u string) bool { return validateAdvertiseURL(u) }

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestClusterNodesRegister(t *testing.T) {
@@ -20,6 +21,37 @@ func TestClusterNodesRegister(t *testing.T) {
assert.Equal(t, http.StatusForbidden, r.Code)
})
// Register with existing ClientID requires clientSecret
t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Pre-create a node via registry and rotate to get a plaintext secret for tests
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
assert.NoError(t, regy.Put(n))
nr, err := regy.RotateSecret(n.UUID)
assert.NoError(t, err)
secret := nr.ClientSecret
// Missing secret → 401
body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
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")
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")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("MissingToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -28,19 +60,96 @@ func TestClusterNodesRegister(t *testing.T) {
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`)
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("DriverConflict", func(t *testing.T) {
t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// With SQLite driver in tests, provisioning should fail with conflict.
// 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")
assert.Equal(t, http.StatusConflict, r.Code)
assert.Contains(t, r.Body.String(), "portal database must be MySQL/MariaDB")
assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String()
assert.Contains(t, body, "\"database\"")
assert.Contains(t, body, "\"secrets\"")
// New nodes return the client secret; include alias for clarity.
assert.Contains(t, body, "\"clientSecret\"")
})
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Pre-create node with a UUID
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
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")
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"
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")
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"
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")
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")
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"
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")
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")
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"
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")
assert.Equal(t, http.StatusCreated, r.Code)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n, err := regy.FindByName("my-node-name-prod")
assert.NoError(t, err)
if assert.NotNil(t, n) {
assert.Equal(t, "my-node-name-prod", n.Name)
}
})
t.Run("BadName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -51,22 +160,23 @@ func TestClusterNodesRegister(t *testing.T) {
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) {
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path
// and rotates the secret before attempting DB ensure.
// and rotates the secret before attempting DB ensure. Don't reuse the
// Monitoring fixture client ID to avoid changing its secret, which is
// used by OAuth tests running in the same package.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
n := &reg.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")
assert.Equal(t, http.StatusConflict, r.Code) // DB conflict under SQLite
assert.Equal(t, http.StatusOK, r.Code)
// Secret should have rotated and been persisted even though DB ensure failed.
// Fetch by name (most-recently-updated) to avoid flakiness if another test adds
@@ -74,10 +184,9 @@ func TestClusterNodesRegister(t *testing.T) {
n2, err := regy.FindByName("pp-node-01")
assert.NoError(t, err)
// With client-backed registry, plaintext secret is not persisted; only rotation timestamp is updated.
assert.NotEmpty(t, n2.SecretRot)
assert.NotEmpty(t, n2.RotatedAt)
})
t.Run("ExistingNodeSiteUrlPersistsEvenOnDBConflict", func(t *testing.T) {
t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
@@ -89,13 +198,36 @@ func TestClusterNodesRegister(t *testing.T) {
n := &reg.Node{Name: "pp-node-02", Role: "instance"}
assert.NoError(t, regy.Put(n))
// With SQLite driver in tests, provisioning should fail with 409, but metadata should still persist.
// 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")
assert.Equal(t, http.StatusConflict, r.Code)
assert.Equal(t, http.StatusOK, r.Code)
// Ensure normalized/persisted siteUrl.
n2, err := regy.FindByName("pp-node-02")
assert.NoError(t, err)
assert.Equal(t, "https://photos.example.com", n2.SiteUrl)
})
t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
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")
assert.Equal(t, http.StatusCreated, r.Code)
// Response must include node.uuid
body := r.Body.String()
assert.Contains(t, body, "\"uuid\"")
// Verify it is persisted in the registry
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n, err := regy.FindByName("pp-node-uuid")
assert.NoError(t, err)
if assert.NotNil(t, n) {
assert.NotEmpty(t, n.UUID)
}
})
}

View File

@@ -0,0 +1,41 @@
package api
import "testing"
func TestValidateAdvertiseURL(t *testing.T) {
cases := []struct {
u string
ok bool
}{
{"https://example.com", true},
{"http://example.com", false},
{"http://localhost:2342", true},
{"https://127.0.0.1", true},
{"ftp://example.com", false},
{"https://", false},
{"", false},
}
for _, c := range cases {
if got := validateAdvertiseURL(c.u); got != c.ok {
t.Fatalf("validateAdvertiseURL(%q) = %v, want %v", c.u, got, c.ok)
}
}
}
func TestValidateSiteURL(t *testing.T) {
cases := []struct {
u string
ok bool
}{
{"https://photos.example.com", true},
{"http://photos.example.com", false},
{"http://127.0.0.1:2342", true},
{"mailto:me@example.com", false},
{"://bad", false},
}
for _, c := range cases {
if got := validateSiteURL(c.u); got != c.ok {
t.Fatalf("validateSiteURL(%q) = %v, want %v", c.u, got, c.ok)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestClusterEndpoints(t *testing.T) {
@@ -26,16 +27,16 @@ func TestClusterEndpoints(t *testing.T) {
// Seed nodes in the registry
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance"}
n := &reg.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
assert.NoError(t, regy.Put(n))
n2 := &reg.Node{Name: "pp-node-02", Role: "service"}
n2 := &reg.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)
// Get by id
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.ID)
// Get by UUID
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.UUID)
assert.Equal(t, http.StatusOK, r.Code)
// 404 for missing id
@@ -43,7 +44,7 @@ func TestClusterEndpoints(t *testing.T) {
assert.Equal(t, http.StatusNotFound, r.Code)
// Patch (manage requires Auth; our Auth() in tests allows admin; skip strict role checks here)
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.ID, `{"advertiseUrl":"http://n1:2342"}`)
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"advertiseUrl":"http://n1:2342"}`)
assert.Equal(t, http.StatusOK, r.Code)
// Pagination: count=1 returns exactly one
@@ -55,11 +56,11 @@ func TestClusterEndpoints(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
// Delete existing
r = PerformRequest(app, http.MethodDelete, "/api/v1/cluster/nodes/"+n.ID)
r = PerformRequest(app, http.MethodDelete, "/api/v1/cluster/nodes/"+n.UUID)
assert.Equal(t, http.StatusOK, r.Code)
// GET after delete -> 404
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.ID)
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.UUID)
assert.Equal(t, http.StatusNotFound, r.Code)
// DELETE nonexistent id -> 404
@@ -75,8 +76,8 @@ func TestClusterEndpoints(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
}
// Test that ClusterGetNode validates the :id path parameter and rejects unsafe values.
func TestClusterGetNode_IDValidation(t *testing.T) {
// Test that ClusterGetNode validates the :uuid path parameter and rejects unsafe values.
func TestClusterGetNode_UUIDValidation(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -86,13 +87,13 @@ func TestClusterGetNode_IDValidation(t *testing.T) {
// Seed a node and resolve its actual ID.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-99", Role: "instance"}
n := &reg.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)
// Valid ID returns 200.
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.ID)
// Valid UUID returns 200.
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.UUID)
assert.Equal(t, http.StatusOK, r.Code)
// Uppercase letters are not allowed.

View File

@@ -8,9 +8,10 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Verifies that PATCH /cluster/nodes/{id} normalizes/validates siteUrl and persists only when valid.
// Verifies that PATCH /cluster/nodes/{uuid} normalizes/validates siteUrl and persists only when valid.
func TestClusterUpdateNode_SiteUrl(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -21,22 +22,22 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Seed node
n := &reg.Node{Name: "pp-node-siteurl", Role: "instance"}
n := &reg.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)
// Invalid scheme: ignored (200 OK but no update)
r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.ID, `{"siteUrl":"ftp://invalid"}`)
r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"siteUrl":"ftp://invalid"}`)
assert.Equal(t, http.StatusOK, r.Code)
n2, err := regy.Get(n.ID)
n2, err := regy.FindByNodeUUID(n.UUID)
assert.NoError(t, err)
assert.Equal(t, "", n2.SiteUrl)
// Valid https URL: persisted and normalized
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.ID, `{"siteUrl":"HTTPS://PHOTOS.EXAMPLE.COM"}`)
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"siteUrl":"HTTPS://PHOTOS.EXAMPLE.COM"}`)
assert.Equal(t, http.StatusOK, r.Code)
n3, err := regy.Get(n.ID)
n3, err := regy.FindByNodeUUID(n.UUID)
assert.NoError(t, err)
assert.Equal(t, "https://photos.example.com", n3.SiteUrl)
}

View File

@@ -30,7 +30,6 @@ func TestClusterPermissions(t *testing.T) {
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster")
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("ForbiddenFromCDN", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -44,7 +43,6 @@ func TestClusterPermissions(t *testing.T) {
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("AdminCanAccess", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal

View File

@@ -3,6 +3,7 @@ package api
import (
"archive/zip"
gofs "io/fs"
"net"
"path/filepath"
"github.com/gin-gonic/gin"
@@ -26,11 +27,28 @@ import (
// @Router /api/v1/cluster/theme [get]
func ClusterGetTheme(router *gin.RouterGroup) {
router.GET("/cluster/theme", func(c *gin.Context) {
// Check if client has cluster download privileges.
s := Auth(c, acl.ResourceCluster, acl.ActionDownload)
// Get app config and client IP.
conf := get.Config()
clientIp := ClientIP(c)
if s.Abort(c) {
return
// Optional IP-based allowance via ClusterCIDR.
refID := "-"
if cidr := conf.ClusterCIDR(); cidr != "" {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ip := net.ParseIP(clientIp); ip != nil && ipnet.Contains(ip) {
// Allowed by CIDR; proceed without session.
refID = "cidr"
}
}
}
// If not allowed by CIDR, require regular auth.
if refID == "-" {
s := Auth(c, acl.ResourceCluster, acl.ActionDownload)
if s.Abort(c) {
return
}
refID = s.RefID
}
/*
@@ -40,21 +58,16 @@ func ClusterGetTheme(router *gin.RouterGroup) {
3. Optionally, return a 404 or 204 error code when no files are added, though an empty zip file is acceptable.
*/
// Get app config.
conf := get.Config()
// Abort if this is not a portal server.
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
clientIp := ClientIP(c)
themePath := conf.ThemePath()
// Resolve symbolic links.
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to resolve path"}, s.RefID, clean.Error(err))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to resolve path"}, refID, clean.Error(err))
AbortNotFound(c)
return
} else {
@@ -63,7 +76,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Check if theme path exists.
if !fs.PathExists(themePath) {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "theme path not found"}, s.RefID)
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "theme path not found"}, refID)
AbortNotFound(c)
return
}
@@ -72,12 +85,12 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// This aligns with bootstrap behavior, which only installs a theme when
// app.js exists locally or can be fetched from the Portal.
if !fs.FileExistsNotEmpty(filepath.Join(themePath, "app.js")) {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "app.js missing or empty"}, s.RefID)
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "app.js missing or empty"}, refID)
AbortNotFound(c)
return
}
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, s.RefID, clean.Log(themePath))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, refID, clean.Log(themePath))
// Add response headers.
AddDownloadHeader(c, "theme.zip")
@@ -87,14 +100,14 @@ func ClusterGetTheme(router *gin.RouterGroup) {
zipWriter := zip.NewWriter(c.Writer)
defer func(w *zip.Writer) {
if closeErr := w.Close(); closeErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to close", "%s"}, s.RefID, clean.Error(closeErr))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to close", "%s"}, refID, clean.Error(closeErr))
}
}(zipWriter)
err := filepath.WalkDir(themePath, func(filePath string, info gofs.DirEntry, walkErr error) error {
// Handle errors.
if walkErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to traverse theme path", "%s"}, s.RefID, clean.Error(walkErr))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to traverse theme path", "%s"}, refID, clean.Error(walkErr))
// If the error occurs on a directory, skip descending to avoid cascading errors.
if info != nil && info.IsDir() {
@@ -130,11 +143,11 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Get the relative file name to use as alias in the zip.
alias := filepath.ToSlash(fs.RelName(filePath, themePath))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "adding %s to archive"}, s.RefID, clean.Log(alias))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "adding %s to archive"}, refID, clean.Log(alias))
// Stream zipped file contents.
if zipErr := fs.ZipFile(zipWriter, filePath, alias, false); zipErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to add %s", "%s"}, s.RefID, clean.Log(alias), clean.Error(zipErr))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to add %s", "%s"}, refID, clean.Log(alias), clean.Error(zipErr))
}
return nil
@@ -142,9 +155,9 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Log result.
if err != nil {
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Failed, "%s"}, s.RefID, clean.Error(err))
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Failed, "%s"}, refID, clean.Error(err))
} else {
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Succeeded}, s.RefID)
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Succeeded}, refID)
}
})
}

View File

@@ -26,7 +26,6 @@ func TestClusterGetTheme(t *testing.T) {
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/theme")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
// Enable portal feature flag for this endpoint.
@@ -44,7 +43,6 @@ func TestClusterGetTheme(t *testing.T) {
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("Success", func(t *testing.T) {
app, router, conf := NewApiTest()
// Enable portal feature flag for this endpoint.
@@ -56,19 +54,19 @@ func TestClusterGetTheme(t *testing.T) {
defer func() { _ = os.RemoveAll(tempTheme) }()
conf.SetThemePath(tempTheme)
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "sub"), 0o755))
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "sub"), fs.ModeDir))
// Visible files
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "sub", "visible.txt"), []byte("ok\n"), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "sub", "visible.txt"), []byte("ok\n"), fs.ModeFile))
// Hidden file
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden.txt"), []byte("secret\n"), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden.txt"), []byte("secret\n"), fs.ModeFile))
// Hidden directory
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".git"), 0o755))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".git"), fs.ModeDir))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), fs.ModeFile))
// Hidden directory pattern "_.folder"
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "_.folder"), 0o755))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "_.folder", "secret.txt"), []byte("hidden\n"), 0o644))
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "_.folder"), fs.ModeDir))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "_.folder", "secret.txt"), []byte("hidden\n"), fs.ModeFile))
// Symlink (should be skipped); best-effort
_ = os.Symlink(filepath.Join(tempTheme, "style.css"), filepath.Join(tempTheme, "link.css"))
@@ -100,7 +98,6 @@ func TestClusterGetTheme(t *testing.T) {
assert.NotContains(t, names, "_.folder/secret.txt")
assert.NotContains(t, names, "link.css")
})
t.Run("Empty", func(t *testing.T) {
app, router, conf := NewApiTest()
// Enable portal feature flag for this endpoint.
@@ -114,9 +111,9 @@ func TestClusterGetTheme(t *testing.T) {
conf.SetThemePath(tempTheme)
// Hidden-only content and no app.js should yield 404.
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".hidden-dir"), 0o755))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden-dir", "file.txt"), []byte("secret\n"), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden"), []byte("secret\n"), 0o644))
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".hidden-dir"), fs.ModeDir))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden-dir", "file.txt"), []byte("secret\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden"), []byte("secret\n"), fs.ModeFile))
req := httptest.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Accept", "application/json")
@@ -124,4 +121,26 @@ func TestClusterGetTheme(t *testing.T) {
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("CIDRAllowWithoutAuth", func(t *testing.T) {
app, router, conf := NewApiTest()
// Enable portal role and set CIDR to loopback/10.0.0.0/8 for test.
conf.Options().NodeRole = cluster.RolePortal
conf.Options().ClusterCIDR = "10.0.0.0/8"
ClusterGetTheme(router)
tempTheme, err := os.MkdirTemp("", "pp-theme-cidr-*")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tempTheme) }()
conf.SetThemePath(tempTheme)
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), fs.ModeFile))
req := httptest.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
// Simulate request from 10.1.2.3
req.RemoteAddr = "10.1.2.3:12345"
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, header.ContentTypeZip, w.Header().Get(header.ContentType))
})
}

View File

@@ -15,7 +15,16 @@ import (
// Connect confirms external service accounts using a token.
//
// PUT /api/v1/connect/:name
// @Summary confirm external service accounts using a token
// @Id ConnectService
// @Tags Config
// @Accept json
// @Produce json
// @Param name path string true "service name (e.g., hub)"
// @Param connect body form.Connect true "connection token"
// @Success 200 {object} gin.H
// @Failure 400,401,403 {object} i18n.Response
// @Router /api/v1/connect/{name} [put]
func Connect(router *gin.RouterGroup) {
router.PUT("/connect/:name", func(c *gin.Context) {
name := clean.ID(c.Param("name"))

View File

@@ -0,0 +1,13 @@
package api
import "time"
// Schema Overrides for Swagger generation.
// Override the generated schema for time.Duration to avoid unstable enums
// from the standard library constants (Nanosecond, Minute, etc.). Using
// a simple integer schema is accurate (nanoseconds) and deterministic.
//
// @name time.Duration
// @description Duration in nanoseconds (int64). Examples: 1000000000 (1s), 60000000000 (1m).
type SwaggerTimeDuration = time.Duration

View File

@@ -23,7 +23,6 @@ func TestGetFace(t *testing.T) {
assert.LessOrEqual(t, int64(4), val2.Int())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("Lowercase", func(t *testing.T) {
app, router, _ := NewApiTest()
GetFace(router)
@@ -34,7 +33,6 @@ func TestGetFace(t *testing.T) {
assert.Equal(t, "js6sg6b1qekk9jx8", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetFace(router)
@@ -57,7 +55,6 @@ func TestUpdateFace(t *testing.T) {
assert.Equal(t, "js6sg6b1qekk9jx8", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateFace(router)

View File

@@ -16,14 +16,12 @@ func TestGetFolderCover(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -32,7 +30,6 @@ func TestGetFolderCover(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
FolderCover(router)

View File

@@ -28,7 +28,13 @@ type FoldersResponse struct {
// SearchFoldersOriginals returns folders in originals as JSON.
//
// GET /api/v1/folders/originals
// @Summary list folders in originals
// @Id SearchFoldersOriginals
// @Tags Folders
// @Produce json
// @Success 200 {object} api.FoldersResponse
// @Failure 401,403 {object} i18n.Response
// @Router /api/v1/folders/originals [get]
func SearchFoldersOriginals(router *gin.RouterGroup) {
conf := get.Config()
SearchFolders(router, "originals", entity.RootOriginals, conf.OriginalsPath())
@@ -36,7 +42,13 @@ func SearchFoldersOriginals(router *gin.RouterGroup) {
// SearchFoldersImport returns import folders as JSON.
//
// GET /api/v1/folders/import
// @Summary list folders in import
// @Id SearchFoldersImport
// @Tags Folders
// @Produce json
// @Success 200 {object} api.FoldersResponse
// @Failure 401,403 {object} i18n.Response
// @Router /api/v1/folders/import [get]
func SearchFoldersImport(router *gin.RouterGroup) {
conf := get.Config()
SearchFolders(router, "import", entity.RootImport, conf.ImportPath())

View File

@@ -20,14 +20,12 @@ func TestUpdateLabel(t *testing.T) {
assert.Equal(t, "updated01", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateLabel(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/labels/ls6sg6b1wowuy3c7", `{"Name": 123, "Priority": 4, "Uncertainty": 80}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateLabel(router)
@@ -104,7 +102,6 @@ func TestDislikeLabel(t *testing.T) {
val2 := gjson.Get(r3.Body.String(), `#(Slug=="landscape").Favorite`)
assert.Equal(t, "false", val2.String())
})
t.Run("dislike existing label with prio < 0", func(t *testing.T) {
app, router, _ := NewApiTest()
DislikeLabel(router)

View File

@@ -87,10 +87,10 @@ func DeleteLink(c *gin.Context) {
c.JSON(http.StatusOK, link)
}
// CreateLink adds a new share link and return it as JSON.
//
// @Tags Links
// @Router /api/v1/{entity}/{uid}/links [post]
// CreateLink adds a new share link and returns it as JSON.
// Note: Internal helper used by resource-specific endpoints (e.g., albums, photos).
// Swagger annotations are defined on those public handlers to avoid generating
// undocumented generic paths like "/api/v1/{entity}/{uid}/links".
func CreateLink(c *gin.Context) {
s := Auth(c, acl.ResourceShares, acl.ActionCreate)

View File

@@ -232,7 +232,6 @@ func TestUpdateAlbumLink(t *testing.T) {
assert.Equal(t, "8000", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("bad request", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateAlbumLink(router)
@@ -286,7 +285,6 @@ func TestGetAlbumLinks(t *testing.T) {
assert.GreaterOrEqual(t, len.Int(), int64(1))
assert.Equal(t, http.StatusOK, r2.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetAlbumLinks(router)
@@ -362,7 +360,6 @@ func TestUpdatePhotoLink(t *testing.T) {
assert.Equal(t, "8000", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("bad request", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhotoLink(router)
@@ -417,7 +414,6 @@ func TestGetPhotoLinks(t *testing.T) {
//assert.GreaterOrEqual(t, len.Int(), int64(1))
assert.Equal(t, http.StatusOK, r2.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhotoLinks(router)
@@ -489,7 +485,6 @@ func TestUpdateLabelLink(t *testing.T) {
assert.Equal(t, "8000", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("bad request", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateLabelLink(router)
@@ -543,7 +538,6 @@ func TestGetLabelLinks(t *testing.T) {
//assert.GreaterOrEqual(t, len.Int(), int64(1))
assert.Equal(t, http.StatusOK, r2.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetLabelLinks(router)

View File

@@ -171,11 +171,16 @@ func CreateMarker(router *gin.RouterGroup) {
// UpdateMarker updates an existing file area marker to assign faces or other subjects.
//
// The request parameters are:
//
// - marker_uid: string Marker UID as returned by the API
//
// PUT /api/v1/markers/:marker_uid
// @Summary update a marker (face/subject region)
// @Id UpdateMarker
// @Tags Files
// @Accept json
// @Produce json
// @Param marker_uid path string true "marker uid"
// @Param marker body form.Marker true "marker properties"
// @Success 200 {object} entity.Marker
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/markers/{marker_uid} [put]
func UpdateMarker(router *gin.RouterGroup) {
router.PUT("/markers/:marker_uid", func(c *gin.Context) {
// Abort if workers runs less than once per hour.
@@ -261,15 +266,16 @@ func UpdateMarker(router *gin.RouterGroup) {
})
}
// ClearMarkerSubject removes an existing marker subject association.
// ClearMarkerSubject removes the subject association from a marker.
//
// The request parameters are:
//
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
// - id: int Marker ID as returned by the API
//
// DELETE /api/v1/markers/:marker_uid/subject
// @Summary clear the subject of a marker
// @Id ClearMarkerSubject
// @Tags Files
// @Produce json
// @Param marker_uid path string true "marker uid"
// @Success 200 {object} entity.Marker
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/markers/{marker_uid}/subject [delete]
func ClearMarkerSubject(router *gin.RouterGroup) {
router.DELETE("/markers/:marker_uid/subject", func(c *gin.Context) {
// Abort if workers runs less than once per hour.

View File

@@ -29,7 +29,6 @@ func TestGetMetrics(t *testing.T) {
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="folders"} \d+`), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="files"} \d+`), body)
})
t.Run("expose build information", func(t *testing.T) {
app, router, _ := NewApiTest()
@@ -45,7 +44,6 @@ func TestGetMetrics(t *testing.T) {
assert.Regexp(t, regexp.MustCompile(`photoprism_build_info{edition=".+",goversion=".+",version=".+"} 1`), body)
})
t.Run("has prometheus exposition format as content type", func(t *testing.T) {
app, router, _ := NewApiTest()

View File

@@ -12,10 +12,14 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// OAuthAuthorize should gather consent and authorization from resource owners when using the
// Authorization Code Grant flow, see https://github.com/photoprism/photoprism/issues/4368.
// OAuthAuthorize (placeholder) for Authorization Code Grant consent.
//
// GET /api/v1/oauth/authorize
// @Summary OAuth2 authorization endpoint (not implemented)
// @Id OAuthAuthorize
// @Tags Authentication
// @Produce json
// @Failure 405 {object} i18n.Response
// @Router /api/v1/oauth/authorize [get]
func OAuthAuthorize(router *gin.RouterGroup) {
router.GET("/oauth/authorize", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -18,10 +18,17 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// OAuthRevoke takes an access token and deletes it. A client may only delete its own tokens.
// OAuthRevoke revokes an access token or session. A client may only revoke its own tokens.
//
// @Tags Authentication
// @Router /api/v1/oauth/revoke [post]
// @Summary revoke an OAuth2 access token or session
// @Id OAuthRevoke
// @Tags Authentication
// @Accept json
// @Produce json
// @Param request body form.OAuthRevokeToken true "revoke request"
// @Success 200 {object} gin.H
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/oauth/revoke [post]
func OAuthRevoke(router *gin.RouterGroup) {
router.POST("/oauth/revoke", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -19,10 +19,17 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// OAuthToken creates a new access token for clients that authenticate with valid OAuth2 client credentials.
// OAuthToken creates a new access token for clients using OAuth2 grant types.
//
// @Tags Authentication
// @Router /api/v1/oauth/token [post]
// @Summary create an OAuth2 access token
// @Id OAuthToken
// @Tags Authentication
// @Accept json
// @Produce json
// @Param request body form.OAuthCreateToken true "token request (supports client_credentials, password, or session grant)"
// @Success 200 {object} gin.H
// @Failure 400,401,429 {object} i18n.Response
// @Router /api/v1/oauth/token [post]
func OAuthToken(router *gin.RouterGroup) {
router.POST("/oauth/token", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -0,0 +1,153 @@
package api
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"golang.org/x/time/rate"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
func TestOAuthToken_RateLimit_ClientCredentials(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
OAuthToken(router)
// Tighten rate limits
oldLogin, oldAuth := limiter.Login, limiter.Auth
defer func() { limiter.Login, limiter.Auth = oldLogin, oldAuth }()
limiter.Login = limiter.NewLimit(rate.Every(24*time.Hour), 3) // burst 3
limiter.Auth = limiter.NewLimit(rate.Every(24*time.Hour), 3)
// Invalid client secret repeatedly (from UnknownIP: no headers set)
path := "/api/v1/oauth/token"
for i := 0; i < 3; i++ {
data := url.Values{
"grant_type": {authn.GrantClientCredentials.String()},
"client_id": {"cs5cpu17n6gj2qo5"},
"client_secret": {"INVALID"},
"scope": {"metrics"},
}
req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(data.Encode()))
req.Header.Set(header.ContentType, header.ContentTypeForm)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
// Next call should be rate limited
data := url.Values{
"grant_type": {authn.GrantClientCredentials.String()},
"client_id": {"cs5cpu17n6gj2qo5"},
"client_secret": {"INVALID"},
"scope": {"metrics"},
}
req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(data.Encode()))
req.Header.Set(header.ContentType, header.ContentTypeForm)
req.Header.Set("X-Forwarded-For", "198.51.100.99")
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
}
func TestOAuthToken_ResponseFields_ClientSuccess(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
OAuthToken(router)
data := url.Values{
"grant_type": {authn.GrantClientCredentials.String()},
"client_id": {"cs5cpu17n6gj2qo5"},
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
"scope": {"metrics"},
}
req, _ := http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode()))
req.Header.Set(header.ContentType, header.ContentTypeForm)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
assert.NotEmpty(t, gjson.Get(body, "access_token").String())
tokType := gjson.Get(body, "token_type").String()
assert.True(t, strings.EqualFold(tokType, "bearer"))
assert.GreaterOrEqual(t, gjson.Get(body, "expires_in").Int(), int64(0))
assert.Equal(t, "metrics", gjson.Get(body, "scope").String())
}
func TestOAuthToken_ResponseFields_UserSuccess(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
sess := AuthenticateUser(app, router, "alice", "Alice123!")
OAuthToken(router)
data := url.Values{
"grant_type": {authn.GrantPassword.String()},
"client_name": {"TestApp"},
"username": {"alice"},
"password": {"Alice123!"},
"scope": {"*"},
}
req, _ := http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode()))
req.Header.Set(header.ContentType, header.ContentTypeForm)
req.Header.Set(header.XAuthToken, sess)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
assert.NotEmpty(t, gjson.Get(body, "access_token").String())
tokType := gjson.Get(body, "token_type").String()
assert.True(t, strings.EqualFold(tokType, "bearer"))
assert.GreaterOrEqual(t, gjson.Get(body, "expires_in").Int(), int64(0))
assert.Equal(t, "*", gjson.Get(body, "scope").String())
}
func TestOAuthToken_BadRequestsAndErrors(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
OAuthToken(router)
// Missing grant_type & creds -> invalid credentials
req, _ := http.NewRequest(http.MethodPost, "/api/v1/oauth/token", nil)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Unknown grant type
data := url.Values{
"grant_type": {"unknown"},
}
req, _ = http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode()))
req.Header.Set(header.ContentType, header.ContentTypeForm)
w = httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
// Password grant with wrong password
sess := AuthenticateUser(app, router, "alice", "Alice123!")
data = url.Values{
"grant_type": {authn.GrantPassword.String()},
"client_name": {"AppPasswordAlice"},
"username": {"alice"},
"password": {"WrongPassword!"},
"scope": {"*"},
}
req, _ = http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode()))
req.Header.Set(header.ContentType, header.ContentTypeForm)
req.Header.Set(header.XAuthToken, sess)
w = httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}

View File

@@ -13,9 +13,15 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// OIDCLogin redirects a browser to the login page of the configured OpenID Connect provider, if any.
// OIDCLogin redirects a browser to the login page of the configured OpenID Connect provider.
//
// GET /api/v1/oidc/login
// @Summary start OpenID Connect login (browser redirect)
// @Id OIDCLogin
// @Tags Authentication
// @Produce html
// @Success 307 {string} string "redirect to provider login page"
// @Failure 401,403,429 {object} i18n.Response
// @Router /api/v1/oidc/login [get]
func OIDCLogin(router *gin.RouterGroup) {
router.GET("/oidc/login", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -23,10 +23,17 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// OIDCRedirect creates a new API access token when a user has been successfully authenticated via OIDC,
// and then redirects the browser back to the app.
// OIDCRedirect completes the OIDC flow, creates a session, and renders a page that stores the token client-side.
//
// GET /api/v1/oidc/redirect
// @Summary complete OIDC login (callback)
// @Id OIDCRedirect
// @Tags Authentication
// @Produce html
// @Param state query string true "opaque OAuth2 state value"
// @Param code query string true "authorization code"
// @Success 200 {string} string "HTML page bootstrapping token storage"
// @Failure 401,403,429 {string} string "rendered error page"
// @Router /api/v1/oidc/redirect [get]
func OIDCRedirect(router *gin.RouterGroup) {
router.GET("/oidc/redirect", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -117,7 +117,10 @@ func PhotoUnstack(router *gin.RouterGroup) {
destName := fmt.Sprintf("%s.%s%s", unstackFile.AbsPrefix(false), unstackFile.Checksum(), unstackFile.Extension())
if err := unstackFile.Move(destName); err != nil {
// MediaFile.Move/Copy allow replacing an existing empty destination file
// without force, but will not overwrite non-empty files.
if moveErr := unstackFile.Move(destName, true); moveErr != nil {
log.Error(moveErr)
log.Errorf("photo: cannot rename %s to %s (unstack)", clean.Log(unstackFile.BaseName()), clean.Log(filepath.Base(destName)))
AbortUnexpectedError(c)
return
@@ -134,8 +137,8 @@ func PhotoUnstack(router *gin.RouterGroup) {
newPhoto.PhotoPath = unstackFile.RootRelPath()
newPhoto.PhotoName = unstackFile.BasePrefix(false)
if err := newPhoto.Create(); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(baseName))
if createErr := newPhoto.Create(); createErr != nil {
log.Errorf("photo: %s (unstack %s)", createErr.Error(), clean.Log(baseName))
AbortSaveFailed(c)
return
}
@@ -149,23 +152,26 @@ func PhotoUnstack(router *gin.RouterGroup) {
relRoot = file.FileRoot
}
if err := entity.UnscopedDb().Exec(`UPDATE files
if updateErr := entity.UnscopedDb().Exec(`UPDATE files
SET photo_id = ?, photo_uid = ?, file_name = ?, file_missing = 0
WHERE file_name = ? AND file_root = ?`,
newPhoto.ID, newPhoto.PhotoUID, r.RootRelName(),
relName, relRoot).Error; err != nil {
relName, relRoot).Error; updateErr != nil {
// Handle error...
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName()))
log.Errorf("photo: %s (unstack %s)", updateErr.Error(), clean.Log(r.BaseName()))
// Remove new photo from index.
if _, err := newPhoto.Delete(true); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName()))
if _, deleteErr := newPhoto.Delete(true); deleteErr != nil {
log.Errorf("photo: %s (unstack %s)", deleteErr.Error(), clean.Log(r.BaseName()))
}
// Revert file rename.
if unstackSingle {
if err := r.Move(photoprism.FileName(relRoot, relName)); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName()))
// MediaFile.Move/Copy allow replacing an existing empty destination file
// without force, but will not overwrite non-empty files.
if moveErr := r.Move(photoprism.FileName(relRoot, relName), true); moveErr != nil {
log.Error(moveErr)
log.Errorf("photo: file name could not be reverted (unstack %s)", clean.Log(r.BaseName()))
}
}
@@ -184,8 +190,8 @@ func PhotoUnstack(router *gin.RouterGroup) {
}
// Reset type for existing photo stack to image.
if err := stackPhoto.Update("PhotoType", entity.MediaImage); err != nil {
log.Errorf("photo: %s (unstack %s)", err, clean.Log(baseName))
if updateErr := stackPhoto.Update("PhotoType", entity.MediaImage); updateErr != nil {
log.Errorf("photo: %s (unstack %s)", updateErr, clean.Log(baseName))
AbortUnexpectedError(c)
return
}

View File

@@ -16,7 +16,6 @@ func TestPhotoUnstack(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, r.Code)
// t.Logf("RESP: %s", r.Body.String())
})
t.Run("unstack bridge3.jpg", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoUnstack(router)
@@ -25,7 +24,6 @@ func TestPhotoUnstack(t *testing.T) {
assert.Equal(t, http.StatusNotFound, r.Code)
// t.Logf("RESP: %s", r.Body.String())
})
t.Run("not existing file", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoUnstack(router)

View File

@@ -18,7 +18,6 @@ func TestSearchPhotos(t *testing.T) {
assert.LessOrEqual(t, int64(2), count.Int())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("ViewerJSON", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchPhotos(router)
@@ -31,7 +30,6 @@ func TestSearchPhotos(t *testing.T) {
assert.LessOrEqual(t, int64(2), count.Int())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchPhotos(router)

View File

@@ -64,7 +64,6 @@ func TestGetPhoto(t *testing.T) {
val := gjson.Get(r.Body.String(), "Iso")
assert.Equal(t, "", val.String())
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhoto(router)
@@ -84,14 +83,12 @@ func TestUpdatePhoto(t *testing.T) {
assert.Equal(t, "de", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("BadRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhoto(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/photos/ps6sg6be2lvl0y13", `{"Name": "Updated01", "Country": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhoto(router)
@@ -109,14 +106,12 @@ func TestGetPhotoDownload(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7/dl?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
GetPhotoDownload(router)
r := PerformRequest(app, "GET", "/api/v1/photos/xxx/dl?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -138,7 +133,6 @@ func TestLikePhoto(t *testing.T) {
val := gjson.Get(r2.Body.String(), "Favorite")
assert.Equal(t, "true", val.String())
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
LikePhoto(router)
@@ -158,7 +152,6 @@ func TestDislikePhoto(t *testing.T) {
val := gjson.Get(r2.Body.String(), "Favorite")
assert.Equal(t, "false", val.String())
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
DislikePhoto(router)
@@ -181,7 +174,6 @@ func TestPhotoPrimary(t *testing.T) {
val2 := gjson.Get(r3.Body.String(), "Primary")
assert.Equal(t, "false", val2.String())
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoPrimary(router)
@@ -199,7 +191,6 @@ func TestGetPhotoYaml(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7/yaml")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhotoYaml(router)
@@ -222,7 +213,6 @@ func TestApprovePhoto(t *testing.T) {
val := gjson.Get(r2.Body.String(), "Quality")
assert.Equal(t, "3", val.String())
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
ApprovePhoto(router)

View File

@@ -108,7 +108,6 @@ func TestUpdateService(t *testing.T) {
assert.Equal(t, "CreateTestUpdated", val3.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateService(router)
@@ -117,7 +116,6 @@ func TestUpdateService(t *testing.T) {
assert.Equal(t, i18n.Msg(i18n.ErrAccountNotFound), val.String())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("SaveFailed", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateService(router)
@@ -150,7 +148,6 @@ func TestDeleteService(t *testing.T) {
assert.Equal(t, i18n.Msg(i18n.ErrAccountNotFound), val2.String())
assert.Equal(t, http.StatusNotFound, r2.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
DeleteService(router)

View File

@@ -16,11 +16,17 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// CreateSession creates a new client session and returns it as JSON if authentication was successful.
// CreateSession creates a new client session (login) and returns session data.
//
// @Tags Authentication
// @Router /api/v1/session [post]
// @Router /api/v1/sessions [post]
// @Summary create a session (login)
// @Tags Authentication
// @Accept json
// @Produce json
// @Param credentials body form.Login true "login credentials"
// @Success 200 {object} gin.H
// @Failure 400,401,429 {object} i18n.Response
// @Router /api/v1/session [post]
// @Router /api/v1/sessions [post]
func CreateSession(router *gin.RouterGroup) {
createSessionHandler := func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -20,9 +20,15 @@ import (
// DeleteSession deletes an existing client session (logout).
//
// DELETE /api/v1/session
// DELETE /api/v1/session/:id
// DELETE /api/v1/sessions/:id
// @Summary delete a session (logout)
// @Tags Authentication
// @Produce json
// @Param id path string false "session id or ref id"
// @Success 200 {object} gin.H
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/session [delete]
// @Router /api/v1/session/{id} [delete]
// @Router /api/v1/sessions/{id} [delete]
func DeleteSession(router *gin.RouterGroup) {
deleteSessionHandler := func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -12,11 +12,17 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// GetSession returns the session data as JSON if authentication was successful.
// GetSession returns session data for the current or specified session.
//
// GET /api/v1/session
// GET /api/v1/session/:id
// GET /api/v1/sessions/:id
// @Summary get the current session or a session by id
// @Tags Authentication
// @Produce json
// @Param id path string false "session id"
// @Success 200 {object} gin.H
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/session [get]
// @Router /api/v1/session/{id} [get]
// @Router /api/v1/sessions/{id} [get]
func GetSession(router *gin.RouterGroup) {
getSessionHandler := func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.

View File

@@ -0,0 +1,44 @@
package api
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"golang.org/x/time/rate"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/server/limiter"
)
func TestCreateSession_RateLimitExceeded(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
// Tighten rate limits and do repeated bad logins from UnknownIP
oldLogin, oldAuth := limiter.Login, limiter.Auth
defer func() { limiter.Login, limiter.Auth = oldLogin, oldAuth }()
limiter.Login = limiter.NewLimit(rate.Every(24*time.Hour), 3)
limiter.Auth = limiter.NewLimit(rate.Every(24*time.Hour), 3)
for i := 0; i < 3; i++ {
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "wrong"}`)
assert.Equal(t, http.StatusUnauthorized, r.Code)
}
// Next attempt should be 429
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "wrong"}`)
assert.Equal(t, http.StatusTooManyRequests, r.Code)
}
func TestCreateSession_MissingFields(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
// Empty object -> unauthorized (invalid credentials)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{}`)
assert.Equal(t, http.StatusUnauthorized, r.Code)
}

View File

@@ -105,14 +105,12 @@ func TestUpdateSubject(t *testing.T) {
assert.Equal(t, "Updated Name", val.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateSubject(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/subjects/js6sg6b1qekk9jx8", `{"Name": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateSubject(router)

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,10 @@ import (
// @Description Fore more information see:
// @Description - https://docs.photoprism.app/developer-guide/api/thumbnails/#image-endpoint-uri
// @Id GetThumb
// @Produce image/jpeg
// @Produce image/jpeg, image/svg+xml
// @Tags Images, Files
// @Failure 403 {file} image/svg+xml
// @Failure 200 {file} image/svg+xml
// @Success 200 {file} image/svg+xml
// @Success 200 {file} image/jpg
// @Param thumb path string true "SHA1 file hash, optionally with a crop area suffixed, e.g. '-016014058037'"
// @Param token path string true "user-specific security token provided with session or 'public' when running PhotoPrism in public mode"

View File

@@ -17,10 +17,19 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// UploadUserAvatar updates the avatar image of the currently authenticated user.
// UploadUserAvatar updates the avatar image of the specified user.
//
// @Tags Users
// @Router /api/v1/users/{uid}/avatar [post]
// @Summary upload a new avatar image for a user
// @Description Accepts a single PNG or JPEG file (max 20 MB) in a multipart form field named "files" and sets it as the user's avatar.
// @Id UploadUserAvatar
// @Tags Users
// @Accept multipart/form-data
// @Produce json
// @Param uid path string true "user uid"
// @Param files formData file true "avatar image (png or jpeg, <= 20 MB)"
// @Success 200 {object} entity.User
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/avatar [post]
func UploadUserAvatar(router *gin.RouterGroup) {
router.POST("/users/:uid/avatar", func(c *gin.Context) {
conf := get.Config()

View File

@@ -21,10 +21,18 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// CreateUserPasscode sets up a new two-factor authentication passcode.
// CreateUserPasscode sets up a new two-factor authentication passcode for a user.
//
// @Tags Users
// @Router /api/v1/users/{uid}/passcode [post]
// @Summary create a new 2FA passcode for a user
// @Id CreateUserPasscode
// @Tags Users
// @Accept json
// @Produce json
// @Param uid path string true "user uid"
// @Param request body form.Passcode true "passcode setup (password required)"
// @Success 200 {object} entity.Passcode
// @Failure 400,401,403,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/passcode [post]
func CreateUserPasscode(router *gin.RouterGroup) {
router.POST("/users/:uid/passcode", func(c *gin.Context) {
// Check authentication and authorization.
@@ -80,10 +88,18 @@ func CreateUserPasscode(router *gin.RouterGroup) {
})
}
// ConfirmUserPasscode checks a new passcode and flags it as verified so that it can be activated.
// ConfirmUserPasscode verifies a newly created passcode so that it can be activated.
//
// @Tags Users
// @Router /api/v1/users/{uid}/passcode/confirm [post]
// @Summary verify a new 2FA passcode
// @Id ConfirmUserPasscode
// @Tags Users
// @Accept json
// @Produce json
// @Param uid path string true "user uid"
// @Param request body form.Passcode true "verification code"
// @Success 200 {object} entity.Passcode
// @Failure 400,401,403,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/passcode/confirm [post]
func ConfirmUserPasscode(router *gin.RouterGroup) {
router.POST("/users/:uid/passcode/confirm", func(c *gin.Context) {
// Check authentication and authorization.
@@ -126,10 +142,16 @@ func ConfirmUserPasscode(router *gin.RouterGroup) {
})
}
// ActivateUserPasscode activates two-factor authentication if a passcode has been created and verified.
// ActivateUserPasscode activates 2FA after a passcode has been created and verified.
//
// @Tags Users
// @Router /api/v1/users/{uid}/passcode/activate [post]
// @Summary activate 2FA with a verified passcode
// @Id ActivateUserPasscode
// @Tags Users
// @Produce json
// @Param uid path string true "user uid"
// @Success 200 {object} entity.Passcode
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/passcode/activate [post]
func ActivateUserPasscode(router *gin.RouterGroup) {
router.POST("/users/:uid/passcode/activate", func(c *gin.Context) {
// Check authentication and authorization.
@@ -163,10 +185,18 @@ func ActivateUserPasscode(router *gin.RouterGroup) {
})
}
// DeactivateUserPasscode disables removes a passcode key to disable two-factor authentication.
// DeactivateUserPasscode removes a passcode key to disable two-factor authentication.
//
// @Tags Users
// @Router /api/v1/users/{uid}/passcode/deactivate [post]
// @Summary deactivate 2FA and remove the passcode
// @Id DeactivateUserPasscode
// @Tags Users
// @Accept json
// @Produce json
// @Param uid path string true "user uid"
// @Param request body form.Passcode true "password for confirmation"
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/passcode/deactivate [post]
func DeactivateUserPasscode(router *gin.RouterGroup) {
router.POST("/users/:uid/passcode/deactivate", func(c *gin.Context) {
// Check authentication and authorization.

View File

@@ -16,10 +16,18 @@ import (
"github.com/photoprism/photoprism/pkg/i18n"
)
// UpdateUserPassword changes the password of the currently authenticated user.
// UpdateUserPassword changes the password of the specified user.
//
// @Tags Users, Authentication
// @Router /api/v1/users/{uid}/password [put]
// @Summary change a user's password
// @Id UpdateUserPassword
// @Tags Users, Authentication
// @Accept json
// @Produce json
// @Param uid path string true "user uid"
// @Param request body form.ChangePassword true "old and new password"
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/password [put]
func UpdateUserPassword(router *gin.RouterGroup) {
router.PUT("/users/:uid/password", func(c *gin.Context) {
conf := get.Config()

View File

@@ -19,7 +19,6 @@ func TestChangePassword(t *testing.T) {
r := PerformRequestWithBody(app, "PUT", "/api/v1/users/xxx/password", `{}`)
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("Unauthorized", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -39,7 +38,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, r.Code)
}
})
t.Run("InvalidRequestBody", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -51,7 +49,6 @@ func TestChangePassword(t *testing.T) {
"{OldPassword: old}", sessId)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("AliceProvidesWrongPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -71,7 +68,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, r.Code)
}
})
t.Run("Success", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -109,7 +105,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
}
})
t.Run("AliceChangesOtherUsersPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -129,7 +124,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusForbidden, r.Code)
}
})
t.Run("BobProvidesWrongPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -149,7 +143,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, r.Code)
}
})
t.Run("SameNewPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -169,7 +162,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
}
})
t.Run("BobChangesOtherUsersPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -189,7 +181,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, http.StatusForbidden, r.Code)
}
})
t.Run("AliceAppPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -214,7 +205,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, "Permission denied", val.String())
}
})
t.Run("AliceAppPasswordWebdav", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -239,7 +229,6 @@ func TestChangePassword(t *testing.T) {
assert.Equal(t, "Permission denied", val.String())
}
})
t.Run("AccessToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)

View File

@@ -18,8 +18,17 @@ import (
// FindUserSessions finds user sessions and returns them as JSON.
//
// @Tags Users, Authentication
// @Router /api/v1/users/{uid}/sessions [get]
// @Summary list sessions for a user
// @Id FindUserSessions
// @Tags Users, Authentication
// @Produce json
// @Param uid path string true "user uid"
// @Param count query int true "maximum number of results" minimum(1) maximum(100000)
// @Param offset query int false "result offset" minimum(0)
// @Param q query string false "filter by username or client name"
// @Success 200 {object} entity.Sessions
// @Failure 401,403,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/sessions [get]
func FindUserSessions(router *gin.RouterGroup) {
router.GET("/users/:uid/sessions", func(c *gin.Context) {
// Check if the session user is has user management privileges.

View File

@@ -16,10 +16,18 @@ import (
"github.com/photoprism/photoprism/pkg/i18n"
)
// UpdateUser updates the profile information of the currently authenticated user.
// UpdateUser updates profile information for the specified user.
//
// @Tags Users
// @Router /api/v1/users/{uid} [put]
// @Summary update user profile information
// @Id UpdateUser
// @Tags Users
// @Accept json
// @Produce json
// @Param uid path string true "user uid"
// @Param user body form.User true "properties to be updated"
// @Success 200 {object} entity.User
// @Failure 400,401,403,404,409,429 {object} i18n.Response
// @Router /api/v1/users/{uid} [put]
func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) {
conf := get.Config()

View File

@@ -26,7 +26,6 @@ func TestUpdateUser(t *testing.T) {
r := AuthenticatedRequestWithBody(app, "PUT", reqUrl, "{Email:\"admin@example.com\",Details:{Location:\"WebStorm\"}}", sessId)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("PublicMode", func(t *testing.T) {
app, router, _ := NewApiTest()
adminUid := entity.Admin.UserUID
@@ -35,7 +34,6 @@ func TestUpdateUser(t *testing.T) {
r := PerformRequestWithBody(app, "PUT", reqUrl, "{foo:123}")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("Unauthorized", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -55,7 +53,6 @@ func TestUpdateUser(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, r.Code)
}
})
t.Run("AliceChangeOwn", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -78,7 +75,6 @@ func TestUpdateUser(t *testing.T) {
assert.Contains(t, r.Body.String(), "\"UploadPath\":\"uploads-alice\"")
}
})
t.Run("AliceChangeBob", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -102,7 +98,6 @@ func TestUpdateUser(t *testing.T) {
assert.Contains(t, r.Body.String(), "\"UploadPath\":\"uploads-bob\"")
}
})
t.Run("BobChangeOwn", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
@@ -123,7 +118,6 @@ func TestUpdateUser(t *testing.T) {
assert.Contains(t, r.Body.String(), "\"DisplayName\":\"Bobo\"")
}
})
t.Run("UserNotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)

View File

@@ -25,10 +25,19 @@ import (
"github.com/photoprism/photoprism/pkg/media"
)
// UploadUserFiles adds files to the user upload folder, from where they can be moved and indexed.
// UploadUserFiles adds files to the user's upload folder from where they can be processed and indexed.
//
// @Tags Users, Files
// @Router /users/{uid}/upload/{token} [post]
// @Summary upload files to a user's upload folder
// @Id UploadUserFiles
// @Tags Users, Files
// @Accept multipart/form-data
// @Produce json
// @Param uid path string true "user uid"
// @Param token path string true "upload token"
// @Param files formData file true "one or more files to upload (repeat the field for multiple files)"
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,413,429,507 {object} i18n.Response
// @Router /api/v1/users/{uid}/upload/{token} [post]
func UploadUserFiles(router *gin.RouterGroup) {
router.POST("/users/:uid/upload/:token", func(c *gin.Context) {
conf := get.Config()
@@ -252,9 +261,19 @@ func UploadCheckFile(destName string, rejectRaw bool, totalSizeLimit int64) (rem
}
}
// ProcessUserUpload triggers processing once all files have been uploaded.
// ProcessUserUpload triggers processing and import of previously uploaded files.
//
// PUT /users/:uid/upload/:token
// @Summary process previously uploaded files for a user
// @Id ProcessUserUpload
// @Tags Users, Files
// @Accept json
// @Produce json
// @Param uid path string true "user uid"
// @Param token path string true "upload token"
// @Param options body form.UploadOptions true "processing options"
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,409,429 {object} i18n.Response
// @Router /api/v1/users/{uid}/upload/{token} [put]
func ProcessUserUpload(router *gin.RouterGroup) {
router.PUT("/users/:uid/upload/:token", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload})

View File

@@ -0,0 +1,677 @@
package api
import (
"archive/zip"
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// buildMultipart builds a multipart form with one field name "files" and provided files.
func buildMultipart(files map[string][]byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
mw := multipart.NewWriter(body)
for name, data := range files {
fw, cerr := mw.CreateFormFile("files", name)
if cerr != nil {
return nil, "", cerr
}
if _, werr := fw.Write(data); werr != nil {
return nil, "", werr
}
}
cerr := mw.Close()
return body, mw.FormDataContentType(), cerr
}
// buildMultipartTwo builds a multipart form with exactly two files (same field name: "files").
func buildMultipartTwo(name1 string, data1 []byte, name2 string, data2 []byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
mw := multipart.NewWriter(body)
for _, it := range [][2]interface{}{{name1, data1}, {name2, data2}} {
fw, cerr := mw.CreateFormFile("files", it[0].(string))
if cerr != nil {
return nil, "", cerr
}
if _, werr := fw.Write(it[1].([]byte)); werr != nil {
return nil, "", werr
}
}
cerr := mw.Close()
return body, mw.FormDataContentType(), cerr
}
// buildZipWithDirsAndFiles creates a zip archive bytes with explicit directory entries and files.
func buildZipWithDirsAndFiles(dirs []string, files map[string][]byte) []byte {
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
// Directories (ensure trailing slash)
for _, d := range dirs {
name := d
if !strings.HasSuffix(name, "/") {
name += "/"
}
_, _ = zw.Create(name)
}
// Files
for name, data := range files {
f, _ := zw.Create(name)
_, _ = f.Write(data)
}
_ = zw.Close()
return zbuf.Bytes()
}
func findUploadedFiles(t *testing.T, base string) []string {
t.Helper()
var out []string
_ = filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
out = append(out, path)
}
return nil
})
return out
}
// findUploadedFilesForToken lists files only under upload subfolders whose name ends with token suffix.
func findUploadedFilesForToken(t *testing.T, base string, tokenSuffix string) []string {
t.Helper()
var out []string
entries, _ := os.ReadDir(base)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, tokenSuffix) {
continue
}
dir := filepath.Join(base, name)
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
out = append(out, p)
}
return nil
})
}
return out
}
// removeUploadDirsForToken removes upload subdirectories whose name ends with tokenSuffix.
func removeUploadDirsForToken(t *testing.T, base string, tokenSuffix string) {
t.Helper()
entries, _ := os.ReadDir(base)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, tokenSuffix) {
_ = os.RemoveAll(filepath.Join(base, name))
}
}
}
func TestUploadUserFiles_Multipart_SingleJPEG(t *testing.T) {
app, router, conf := NewApiTest()
// Limit allowed upload extensions to ensure text files get rejected in tests
conf.Options().UploadAllow = "jpg"
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
// Cleanup: remove token-specific upload dir after test
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "abc123")
// Load a real tiny JPEG from testdata
jpgPath := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")
data, err := os.ReadFile(jpgPath)
if err != nil {
t.Skipf("missing example.jpg: %v", err)
}
body, ctype, err := buildMultipart(map[string][]byte{"example.jpg": data})
if err != nil {
t.Fatal(err)
}
reqUrl := "/api/v1/users/" + adminUid + "/upload/abc123"
req := httptest.NewRequest(http.MethodPost, reqUrl, body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Verify file written somewhere under users/<uid>/upload/*
uploadBase := filepath.Join(conf.UserStoragePath(adminUid), "upload")
files := findUploadedFilesForToken(t, uploadBase, "abc123")
// At least one file written
assert.NotEmpty(t, files)
// Expect the filename to appear somewhere
var found bool
for _, f := range files {
if strings.HasSuffix(f, "example.jpg") {
found = true
break
}
}
assert.True(t, found, "uploaded JPEG not found")
}
func TestUploadUserFiles_Multipart_ZipExtract(t *testing.T) {
app, router, conf := NewApiTest()
// Allow archives and restrict allowed extensions to images
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,png,zip"
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
// Cleanup after test
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "ziptok")
// Create an in-memory zip with one JPEG (valid) and one TXT (rejected)
jpgPath := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")
jpg, err := os.ReadFile(jpgPath)
if err != nil {
t.Skip("missing example.jpg")
}
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
// add jpeg
jf, _ := zw.Create("a.jpg")
_, _ = jf.Write(jpg)
// add txt
tf, _ := zw.Create("note.txt")
_, _ = io.WriteString(tf, "hello")
_ = zw.Close()
body, ctype, err := buildMultipart(map[string][]byte{"upload.zip": zbuf.Bytes()})
if err != nil {
t.Fatal(err)
}
reqUrl := "/api/v1/users/" + adminUid + "/upload/zipoff"
req := httptest.NewRequest(http.MethodPost, reqUrl, body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
uploadBase := filepath.Join(conf.UserStoragePath(adminUid), "upload")
files := findUploadedFilesForToken(t, uploadBase, "zipoff")
// Expect extracted jpeg present and txt absent
var jpgFound, txtFound bool
for _, f := range files {
if strings.HasSuffix(f, "a.jpg") {
jpgFound = true
}
if strings.HasSuffix(f, "note.txt") {
txtFound = true
}
}
assert.True(t, jpgFound, "extracted jpeg not found")
assert.False(t, txtFound, "text file should be rejected")
}
func TestUploadUserFiles_Multipart_ArchivesDisabled(t *testing.T) {
app, router, conf := NewApiTest()
// disallow archives while allowing the .zip extension in filter
conf.Options().UploadArchives = false
conf.Options().UploadAllow = "jpg,zip"
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
// Cleanup after test
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipoff")
// zip with one jpeg inside
jpgPath := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")
jpg, err := os.ReadFile(jpgPath)
if err != nil {
t.Skip("missing example.jpg")
}
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
jf, _ := zw.Create("a.jpg")
_, _ = jf.Write(jpg)
_ = zw.Close()
body, ctype, err := buildMultipart(map[string][]byte{"upload.zip": zbuf.Bytes()})
if err != nil {
t.Fatal(err)
}
reqUrl := "/api/v1/users/" + adminUid + "/upload/ziptok"
req := httptest.NewRequest(http.MethodPost, reqUrl, body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
// server returns 200 even if rejected internally; nothing extracted/saved
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
uploadBase := filepath.Join(conf.UserStoragePath(adminUid), "upload")
files := findUploadedFilesForToken(t, uploadBase, "ziptok")
assert.Empty(t, files, "no files should remain when archives disabled")
}
func TestUploadUserFiles_Multipart_PerFileLimitExceeded(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadAllow = "jpg"
conf.Options().OriginalsLimit = 1 // 1 MiB per-file
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "size1")
// Build a 2MiB dummy payload (not a real JPEG; that's fine for pre-save size check)
big := bytes.Repeat([]byte("A"), 2*1024*1024)
body, ctype, err := buildMultipart(map[string][]byte{"big.jpg": big})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/size1", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Ensure nothing saved
files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "size1")
assert.Empty(t, files)
}
func TestUploadUserFiles_Multipart_TotalLimitExceeded(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadAllow = "jpg"
conf.Options().UploadLimit = 1 // 1 MiB total
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "total")
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
// build multipart with two images so sum > 1 MiB (2*~63KiB = ~126KiB) -> still <1MiB, so use 16 copies
// build two bigger bodies by concatenation
times := 9
big1 := bytes.Repeat(data, times)
big2 := bytes.Repeat(data, times)
body, ctype, err := buildMultipartTwo("a.jpg", big1, "b.jpg", big2)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/total", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Expect at most one file saved (second should be rejected by total limit)
files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "total")
assert.LessOrEqual(t, len(files), 1)
}
func TestUploadUserFiles_Multipart_ZipPartialExtraction(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,zip"
conf.Options().UploadLimit = 1 // 1 MiB total
conf.Options().OriginalsLimit = 50 // 50 MiB per file
conf.Options().UploadNSFW = true // skip nsfw scanning to speed up test
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "partial")
// Build a zip containing multiple JPEG entries so that total extracted size > 1 MiB
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
for i := 0; i < 20; i++ { // ~20 * 63 KiB ≈ 1.2 MiB
f, _ := zw.Create(fmt.Sprintf("pic%02d.jpg", i+1))
_, _ = f.Write(data)
}
_ = zw.Close()
body, ctype, err := buildMultipart(map[string][]byte{"multi.zip": zbuf.Bytes()})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/partial", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "partial")
// At least one extracted, but not all 20 due to total limit
var countJPG int
for _, f := range files {
if strings.HasSuffix(f, ".jpg") {
countJPG++
}
}
assert.GreaterOrEqual(t, countJPG, 1)
assert.Less(t, countJPG, 20)
}
func TestUploadUserFiles_Multipart_ZipDeepNestingStress(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,zip"
conf.Options().UploadNSFW = true
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipdeep")
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
// Build a deeply nested path (20 levels)
deep := ""
for i := 0; i < 20; i++ {
if i == 0 {
deep = "deep"
} else {
deep = filepath.Join(deep, fmt.Sprintf("lvl%02d", i))
}
}
name := filepath.Join(deep, "deep.jpg")
zbytes := buildZipWithDirsAndFiles(nil, map[string][]byte{name: data})
body, ctype, err := buildMultipart(map[string][]byte{"deepnest.zip": zbytes})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipdeep", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
base := filepath.Join(conf.UserStoragePath(adminUid), "upload")
files := findUploadedFilesForToken(t, base, "zipdeep")
// Only one file expected, deep path created
assert.Equal(t, 1, len(files))
assert.True(t, strings.Contains(files[0], filepath.Join("deep", "lvl01")))
}
func TestUploadUserFiles_Multipart_ZipRejectsHiddenAndTraversal(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,zip"
conf.Options().UploadNSFW = true // skip scanning
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "rejects")
// Prepare a valid jpg payload
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
// Hidden file
f1, _ := zw.Create(".hidden.jpg")
_, _ = f1.Write(data)
// @ file
f2, _ := zw.Create("@meta.jpg")
_, _ = f2.Write(data)
// Traversal path (will be skipped by safe join in unzip)
f3, _ := zw.Create("dir/../traverse.jpg")
_, _ = f3.Write(data)
// Valid file
f4, _ := zw.Create("ok.jpg")
_, _ = f4.Write(data)
_ = zw.Close()
body, ctype, err := buildMultipart(map[string][]byte{"test.zip": zbuf.Bytes()})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/rejects", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "rejects")
var hasOk, hasHidden, hasAt, hasTraverse bool
for _, f := range files {
if strings.HasSuffix(f, "ok.jpg") {
hasOk = true
}
if strings.HasSuffix(f, ".hidden.jpg") {
hasHidden = true
}
if strings.HasSuffix(f, "@meta.jpg") {
hasAt = true
}
if strings.HasSuffix(f, "traverse.jpg") {
hasTraverse = true
}
}
assert.True(t, hasOk)
assert.False(t, hasHidden)
assert.False(t, hasAt)
assert.False(t, hasTraverse)
}
func TestUploadUserFiles_Multipart_ZipNestedDirectories(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,zip"
conf.Options().UploadNSFW = true
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipnest")
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
// Create nested dirs and files
dirs := []string{"nested", "nested/sub"}
files := map[string][]byte{
"nested/a.jpg": data,
"nested/sub/b.jpg": data,
}
zbytes := buildZipWithDirsAndFiles(dirs, files)
body, ctype, err := buildMultipart(map[string][]byte{"nested.zip": zbytes})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipnest", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
base := filepath.Join(conf.UserStoragePath(adminUid), "upload")
filesOut := findUploadedFilesForToken(t, base, "zipnest")
var haveA, haveB bool
for _, f := range filesOut {
if strings.HasSuffix(f, filepath.Join("nested", "a.jpg")) {
haveA = true
}
if strings.HasSuffix(f, filepath.Join("nested", "sub", "b.jpg")) {
haveB = true
}
}
assert.True(t, haveA)
assert.True(t, haveB)
// Directories exist
// Locate token dir
entries, _ := os.ReadDir(base)
var tokenDir string
for _, e := range entries {
if e.IsDir() && strings.HasSuffix(e.Name(), "zipnest") {
tokenDir = filepath.Join(base, e.Name())
break
}
}
if tokenDir != "" {
_, errA := os.Stat(filepath.Join(tokenDir, "nested"))
_, errB := os.Stat(filepath.Join(tokenDir, "nested", "sub"))
assert.NoError(t, errA)
assert.NoError(t, errB)
} else {
t.Fatalf("token dir not found under %s", base)
}
}
func TestUploadUserFiles_Multipart_ZipImplicitDirectories(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,zip"
conf.Options().UploadNSFW = true
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipimpl")
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
// Create zip containing only files with nested paths (no explicit directory entries)
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
f1, _ := zw.Create(filepath.Join("nested", "a.jpg"))
_, _ = f1.Write(data)
f2, _ := zw.Create(filepath.Join("nested", "sub", "b.jpg"))
_, _ = f2.Write(data)
_ = zw.Close()
body, ctype, err := buildMultipart(map[string][]byte{"nested-files-only.zip": zbuf.Bytes()})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipimpl", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
base := filepath.Join(conf.UserStoragePath(adminUid), "upload")
files := findUploadedFilesForToken(t, base, "zipimpl")
var haveA, haveB bool
for _, f := range files {
if strings.HasSuffix(f, filepath.Join("nested", "a.jpg")) {
haveA = true
}
if strings.HasSuffix(f, filepath.Join("nested", "sub", "b.jpg")) {
haveB = true
}
}
assert.True(t, haveA)
assert.True(t, haveB)
// Confirm directories were implicitly created
entries, _ := os.ReadDir(base)
var tokenDir string
for _, e := range entries {
if e.IsDir() && strings.HasSuffix(e.Name(), "zipimpl") {
tokenDir = filepath.Join(base, e.Name())
break
}
}
if tokenDir == "" {
t.Fatalf("token dir not found under %s", base)
}
_, errA := os.Stat(filepath.Join(tokenDir, "nested"))
_, errB := os.Stat(filepath.Join(tokenDir, "nested", "sub"))
assert.NoError(t, errA)
assert.NoError(t, errB)
}
func TestUploadUserFiles_Multipart_ZipAbsolutePathRejected(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().UploadArchives = true
conf.Options().UploadAllow = "jpg,zip"
conf.Options().UploadNSFW = true
UploadUserFiles(router)
token := AuthenticateAdmin(app, router)
adminUid := entity.Admin.UserUID
defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipabs")
data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg"))
if err != nil {
t.Skip("missing example.jpg")
}
// Zip with an absolute path entry
var zbuf bytes.Buffer
zw := zip.NewWriter(&zbuf)
f, _ := zw.Create("/abs.jpg")
_, _ = f.Write(data)
_ = zw.Close()
body, ctype, err := buildMultipart(map[string][]byte{"abs.zip": zbuf.Bytes()})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipabs", body)
req.Header.Set("Content-Type", ctype)
header.SetAuthorization(req, token)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// No files should be extracted/saved for this token
base := filepath.Join(conf.UserStoragePath(adminUid), "upload")
files := findUploadedFilesForToken(t, base, "zipabs")
assert.Empty(t, files)
}

View File

@@ -3,6 +3,8 @@ package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -43,3 +45,64 @@ func TestUploadUserFiles(t *testing.T) {
config.Options().FilesQuota = 0
})
}
func TestUploadCheckFile_AcceptsAndReducesLimit(t *testing.T) {
dir := t.TempDir()
// Copy a small known-good JPEG test file from pkg/fs/testdata
src := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")
dst := filepath.Join(dir, "example.jpg")
b, err := os.ReadFile(src)
if err != nil {
t.Skipf("skip if test asset not present: %v", err)
}
if err := os.WriteFile(dst, b, 0o600); err != nil {
t.Fatal(err)
}
orig := int64(len(b))
rem, err := UploadCheckFile(dst, false, orig+100)
assert.NoError(t, err)
assert.Equal(t, int64(100), rem)
// file remains
assert.FileExists(t, dst)
}
func TestUploadCheckFile_TotalLimitReachedDeletes(t *testing.T) {
dir := t.TempDir()
// Make a tiny file
dst := filepath.Join(dir, "tiny.txt")
assert.NoError(t, os.WriteFile(dst, []byte("hello"), 0o600))
// Very small total limit (0) → should remove file and error
_, err := UploadCheckFile(dst, false, 0)
assert.Error(t, err)
_, statErr := os.Stat(dst)
assert.True(t, os.IsNotExist(statErr), "file should be removed when limit reached")
}
func TestUploadCheckFile_UnsupportedTypeDeletes(t *testing.T) {
dir := t.TempDir()
// Create a file with an unknown extension; should be rejected
dst := filepath.Join(dir, "unknown.xyz")
assert.NoError(t, os.WriteFile(dst, []byte("not-an-image"), 0o600))
_, err := UploadCheckFile(dst, false, 1<<20)
assert.Error(t, err)
_, statErr := os.Stat(dst)
assert.True(t, os.IsNotExist(statErr), "unsupported file should be removed")
}
func TestUploadCheckFile_SizeAccounting(t *testing.T) {
dir := t.TempDir()
// Use known-good JPEG
src := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")
data, err := os.ReadFile(src)
if err != nil {
t.Skip("asset missing; skip")
}
f := filepath.Join(dir, "a.jpg")
assert.NoError(t, os.WriteFile(f, data, 0o600))
size := int64(len(data))
// Set remaining limit to size+1 so it does not hit the removal branch (which triggers on <=0)
rem, err := UploadCheckFile(f, false, size+1)
assert.NoError(t, err)
assert.Equal(t, int64(1), rem)
}

View File

@@ -19,49 +19,42 @@ func TestGetVideo(t *testing.T) {
mimeType := fmt.Sprintf("video/mp4; codecs=\"%s\"", clean.Codec("avc1"))
assert.Equal(t, header.ContentTypeMp4AvcMain, video.ContentType(mimeType, "mp4", "avc1", false))
})
t.Run("NoHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos//"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("NoType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/")
assert.Equal(t, http.StatusMovedPermanently, r.Code)
})
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("FileError", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd832/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)

View File

@@ -14,7 +14,6 @@ func TestWebsocket(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/ws")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("NoRouter", func(t *testing.T) {
app, _, _ := NewApiTest()
WebSocket(nil)

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -83,10 +82,11 @@ func ZipCreate(router *gin.RouterGroup) {
// Configure file names.
dlName := DownloadName(c)
zipPath := path.Join(conf.TempPath(), fs.ZipDir)
// Build filesystem paths using filepath for OS compatibility.
zipPath := filepath.Join(conf.TempPath(), fs.ZipDir)
zipToken := rnd.Base36(8)
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
zipFileName := path.Join(zipPath, zipBaseName)
zipFileName := filepath.Join(zipPath, zipBaseName)
// Create temp directory.
if err = os.MkdirAll(zipPath, 0700); err != nil {
@@ -99,15 +99,10 @@ func ZipCreate(router *gin.RouterGroup) {
if newZipFile, err = os.Create(zipFileName); err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
return
} else {
defer newZipFile.Close()
}
// Create zip writer.
zipWriter := zip.NewWriter(newZipFile)
defer func(w *zip.Writer) {
logErr("zip", w.Close())
}(zipWriter)
var aliases = make(map[string]int)
@@ -145,6 +140,18 @@ func ZipCreate(router *gin.RouterGroup) {
}
}
// Ensure all data is flushed to disk before responding to the client
// to avoid rare races where the follow-up GET happens before the
// zip writer/file have been fully closed.
if cerr := zipWriter.Close(); cerr != nil {
Error(c, http.StatusInternalServerError, cerr, i18n.ErrZipFailed)
return
}
if ferr := newZipFile.Close(); ferr != nil {
Error(c, http.StatusInternalServerError, ferr, i18n.ErrZipFailed)
return
}
elapsed := int(time.Since(start).Seconds())
log.Infof("download: created %s [%s]", clean.Log(zipBaseName), time.Since(start))
@@ -172,8 +179,8 @@ func ZipDownload(router *gin.RouterGroup) {
conf := get.Config()
zipBaseName := clean.FileName(filepath.Base(c.Param("filename")))
zipPath := path.Join(conf.TempPath(), fs.ZipDir)
zipFileName := path.Join(zipPath, zipBaseName)
zipPath := filepath.Join(conf.TempPath(), fs.ZipDir)
zipFileName := filepath.Join(zipPath, zipBaseName)
if !fs.FileExists(zipFileName) {
log.Errorf("download: %s", c.AbortWithError(http.StatusNotFound, fmt.Errorf("%s not found", clean.Log(zipFileName))))

View File

@@ -36,18 +36,15 @@ func TestRoleStrings_CliUsageString(t *testing.T) {
t.Run("empty", func(t *testing.T) {
assert.Equal(t, "", (RoleStrings{}).CliUsageString())
})
t.Run("single", func(t *testing.T) {
m := RoleStrings{"admin": RoleAdmin}
assert.Equal(t, "admin", m.CliUsageString())
})
t.Run("two", func(t *testing.T) {
m := RoleStrings{"guest": RoleGuest, "admin": RoleAdmin}
// Note the comma before "or" matches current implementation.
assert.Equal(t, "admin, or guest", m.CliUsageString())
})
t.Run("three", func(t *testing.T) {
m := RoleStrings{"visitor": RoleVisitor, "guest": RoleGuest, "admin": RoleAdmin}
assert.Equal(t, "admin, guest, or visitor", m.CliUsageString())
@@ -63,7 +60,6 @@ func TestRoles_Allow(t *testing.T) {
assert.True(t, roles.Allow(RoleVisitor, ActionDownload))
assert.False(t, roles.Allow(RoleVisitor, ActionDelete))
})
t.Run("default fallback used", func(t *testing.T) {
roles := Roles{
RoleDefault: GrantViewAll, // allows view, denies delete
@@ -71,7 +67,6 @@ func TestRoles_Allow(t *testing.T) {
assert.True(t, roles.Allow(RoleUser, ActionView))
assert.False(t, roles.Allow(RoleUser, ActionDelete))
})
t.Run("specific overrides default (no fallback)", func(t *testing.T) {
roles := Roles{
RoleVisitor: GrantViewShared, // denies delete
@@ -79,7 +74,6 @@ func TestRoles_Allow(t *testing.T) {
}
assert.False(t, roles.Allow(RoleVisitor, ActionDelete))
})
t.Run("no match and no default", func(t *testing.T) {
roles := Roles{
RoleVisitor: GrantViewShared,
@@ -98,7 +92,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
assert.NotEqual(t, "", s)
}
})
t.Run("UserRoles Strings include alias none, exclude empty", func(t *testing.T) {
got := UserRoles.Strings()
assert.ElementsMatch(t, []string{"admin", "guest", "none", "visitor"}, got)
@@ -106,7 +99,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
assert.NotEqual(t, "", s)
}
})
t.Run("ClientRoles CliUsageString includes none and or before last", func(t *testing.T) {
u := ClientRoles.CliUsageString()
// Should list known roles and end with "or none" (alias present).
@@ -115,7 +107,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
}
assert.Regexp(t, `, or none$`, u)
})
t.Run("UserRoles CliUsageString includes none and or before last", func(t *testing.T) {
u := UserRoles.CliUsageString()
for _, s := range []string{"admin", "guest", "visitor", "none"} {
@@ -123,7 +114,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
}
assert.Regexp(t, `, or none$`, u)
})
t.Run("Alias none maps to RoleNone", func(t *testing.T) {
assert.Equal(t, RoleNone, ClientRoles[RoleAliasNone])
assert.Equal(t, RoleNone, UserRoles[RoleAliasNone])

View File

@@ -0,0 +1,359 @@
/*
Package catalog provides DTOs, builders, and a templated renderer to export the PhotoPrism CLI command tree (and flags) as Markdown or JSON for documentation purposes.
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 catalog
import (
"bytes"
"sort"
"strings"
"text/template"
"github.com/urfave/cli/v2"
)
// Flag describes a CLI flag.
type Flag struct {
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
Type string `json:"type"`
Required bool `json:"required,omitempty"`
Default string `json:"default,omitempty"`
Env []string `json:"env,omitempty"`
Category string `json:"category,omitempty"`
Usage string `json:"usage,omitempty"`
Hidden bool `json:"hidden"`
}
// Command describes a CLI command (flat form).
type Command struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Parent string `json:"parent,omitempty"`
Depth int `json:"depth"`
Usage string `json:"usage,omitempty"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Aliases []string `json:"aliases,omitempty"`
ArgsUsage string `json:"args_usage,omitempty"`
Hidden bool `json:"hidden"`
Flags []Flag `json:"flags,omitempty"`
}
// Node is a nested representation of commands.
type Node struct {
Command
Subcommands []Node `json:"subcommands,omitempty"`
}
// App carries app metadata for top-level JSON/MD.
type App struct {
Name string `json:"name"`
Edition string `json:"edition"`
Version string `json:"version"`
Build string `json:"build,omitempty"`
}
// MarkdownData is the data model used by the Markdown template.
type MarkdownData struct {
App App
GeneratedAt string
BaseHeading int
Short bool
All bool
Commands []Command
}
// BuildFlat returns a depth-first flat list of commands starting at c.
func BuildFlat(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) []Command {
// Omit nested 'help' subcommands; keep only top-level 'photoprism help'
if skipHelp(c, parentFull) {
return nil
}
var out []Command
info := CommandInfo(c, depth, parentFull, includeHidden, global)
out = append(out, info)
for _, sub := range c.Subcommands {
if sub == nil || (sub.Hidden && !includeHidden) || skipHelp(sub, info.FullName) {
continue
}
out = append(out, BuildFlat(sub, depth+1, info.FullName, includeHidden, global)...)
}
return out
}
// BuildNode returns a nested representation of c and its subcommands.
func BuildNode(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Node {
info := CommandInfo(c, depth, parentFull, includeHidden, global)
node := Node{Command: info}
for _, sub := range c.Subcommands {
if sub == nil || (sub.Hidden && !includeHidden) || skipHelp(sub, info.FullName) {
continue
}
node.Subcommands = append(node.Subcommands, BuildNode(sub, depth+1, info.FullName, includeHidden, global))
}
return node
}
// skipHelp returns true for nested 'help' commands so they are omitted from output.
// Top-level 'photoprism help' remains included.
func skipHelp(c *cli.Command, parentFull string) bool {
if c == nil {
return false
}
if strings.EqualFold(c.Name, "help") {
// Keep only at the root where parent is 'photoprism'
return parentFull != "photoprism"
}
return false
}
// CommandInfo converts a cli.Command to a Command DTO.
func CommandInfo(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Command {
pathName := c.Name
fullName := strings.TrimSpace(parentFull + " " + pathName)
parent := parentFull
cmd := Command{
Name: pathName,
FullName: fullName,
Parent: parent,
Depth: depth,
Usage: c.Usage,
Description: strings.TrimSpace(c.Description),
Category: c.Category,
Aliases: c.Aliases,
ArgsUsage: c.ArgsUsage,
Hidden: c.Hidden,
}
// Build set of canonical global flag names to exclude from per-command flags
globalSet := map[string]struct{}{}
for _, gf := range global {
globalSet[strings.TrimLeft(gf.Name, "-")] = struct{}{}
}
// Convert flags and optionally filter hidden/global
flags := FlagsToCatalog(c.Flags, includeHidden)
keep := make([]Flag, 0, len(flags))
for _, f := range flags {
name := strings.TrimLeft(f.Name, "-")
if _, isGlobal := globalSet[name]; isGlobal {
continue
}
if !includeHidden && f.Hidden {
continue
}
keep = append(keep, f)
}
sort.Slice(keep, func(i, j int) bool { return keep[i].Name < keep[j].Name })
cmd.Flags = keep
return cmd
}
// FlagsToCatalog converts cli flags to Flag DTOs, filtering hidden if needed.
func FlagsToCatalog(flags []cli.Flag, includeHidden bool) []Flag {
out := make([]Flag, 0, len(flags))
for _, f := range flags {
if f == nil {
continue
}
cf := DescribeFlag(f)
if !includeHidden && cf.Hidden {
continue
}
out = append(out, cf)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// DescribeFlag inspects a cli.Flag and returns a Flag with metadata.
func DescribeFlag(f cli.Flag) Flag {
// Names and aliases
names := f.Names()
primary := ""
aliases := make([]string, 0, len(names))
for i, n := range names {
if i == 0 {
primary = n
}
if primary == "" || (len(n) > 1 && primary != n) {
primary = n
}
}
for _, n := range names {
if n == primary {
continue
}
if len(n) == 1 {
aliases = append(aliases, "-"+n)
} else {
aliases = append(aliases, "--"+n)
}
}
type hasUsage interface{ GetUsage() string }
type hasCategory interface{ GetCategory() string }
type hasEnv interface{ GetEnvVars() []string }
type hasDefault interface{ GetDefaultText() string }
type hasRequired interface{ IsRequired() bool }
type hasVisible interface{ IsVisible() bool }
usage, category, def := "", "", ""
env := []string{}
required, hidden := false, false
if hf, ok := f.(hasUsage); ok {
usage = hf.GetUsage()
}
if hf, ok := f.(hasCategory); ok {
category = hf.GetCategory()
}
if hf, ok := f.(hasEnv); ok {
env = append(env, hf.GetEnvVars()...)
}
if hf, ok := f.(hasDefault); ok {
def = hf.GetDefaultText()
}
if hf, ok := f.(hasRequired); ok {
required = hf.IsRequired()
}
if hv, ok := f.(hasVisible); ok {
hidden = !hv.IsVisible()
}
t := flagTypeString(f)
name := primary
if len(name) == 1 {
name = "-" + name
} else {
name = "--" + name
}
return Flag{
Name: name, Aliases: aliases, Type: t, Required: required, Default: def,
Env: env, Category: category, Usage: usage, Hidden: hidden,
}
}
func flagTypeString(f cli.Flag) string {
switch f.(type) {
case *cli.BoolFlag:
return "bool"
case *cli.StringFlag:
return "string"
case *cli.IntFlag:
return "int"
case *cli.Int64Flag:
return "int64"
case *cli.UintFlag:
return "uint"
case *cli.Uint64Flag:
return "uint64"
case *cli.Float64Flag:
return "float64"
case *cli.DurationFlag:
return "duration"
case *cli.TimestampFlag:
return "timestamp"
case *cli.PathFlag:
return "path"
case *cli.StringSliceFlag:
return "stringSlice"
case *cli.IntSliceFlag:
return "intSlice"
case *cli.Int64SliceFlag:
return "int64Slice"
case *cli.Float64SliceFlag:
return "float64Slice"
case *cli.GenericFlag:
return "generic"
default:
return "unknown"
}
}
// Default Markdown template (adjustable in source via rebuild).
var commandsMDTemplate = `# {{ .App.Name }} CLI Commands ({{ .App.Edition }}) — {{ .App.Version }}
_Generated: {{ .GeneratedAt }}_
{{- $base := .BaseHeading -}}
{{- range .Commands }}
{{ heading (add $base (dec .Depth)) }} {{ .FullName }}
**Usage:** {{ .Usage }}
{{- if .Description }}
**Description:** {{ .Description }}
{{- end }}
{{- if .Aliases }}
**Aliases:** {{ join .Aliases ", " }}
{{- end }}
{{- if .ArgsUsage }}
**Args:** ` + "`" + `{{ .ArgsUsage }}` + "`" + `
{{- end }}
{{- if and (not $.Short) .Flags }}
| Flag | Aliases | Type | Default | Env | Required |{{ if $.All }} Hidden |{{ end }} Usage |
|:-----|:--------|:-----|:--------|:----|:---------|{{ if $.All }}:------:|{{ end }}:------|
{{- range .Flags }}
| ` + "`" + `{{ .Name }}` + "`" + ` | {{ join .Aliases ", " }} | {{ .Type }} | {{ .Default }} | {{ join .Env ", " }} | {{ .Required }} |{{ if $.All }} {{ .Hidden }} |{{ end }} {{ .Usage }} |
{{- end }}
{{- end }}
{{- end }}`
// RenderMarkdown renders the catalog to Markdown using the embedded template.
func RenderMarkdown(data MarkdownData) (string, error) {
tmpl, err := template.New("commands").Funcs(template.FuncMap{
"heading": func(n int) string {
if n < 1 {
n = 1
} else if n > 6 {
n = 6
}
return strings.Repeat("#", n)
},
"join": strings.Join,
"add": func(a, b int) int { return a + b },
"dec": func(a int) int {
if a > 0 {
return a - 1
}
return 0
},
}).Parse(commandsMDTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -0,0 +1,170 @@
package catalog
import (
"strings"
"testing"
"github.com/urfave/cli/v2"
)
func TestFlagsToCatalog_Visibility(t *testing.T) {
flags := []cli.Flag{
&cli.StringFlag{Name: "config-path", Aliases: []string{"c"}, Usage: "config path", EnvVars: []string{"PHOTOPRISM_CONFIG_PATH"}},
&cli.BoolFlag{Name: "trace", Usage: "enable trace", Hidden: true},
&cli.IntFlag{Name: "count", Usage: "max results", Value: 5, Required: true},
}
vis := FlagsToCatalog(flags, false)
if len(vis) != 2 {
t.Fatalf("expected 2 visible flags, got %d", len(vis))
}
all := FlagsToCatalog(flags, true)
if len(all) != 3 {
t.Fatalf("expected 3 flags with --all, got %d", len(all))
}
// Check hidden is marked correctly when included
var hiddenOK bool
for _, f := range all {
if f.Name == "--trace" && f.Hidden {
hiddenOK = true
}
}
if !hiddenOK {
t.Fatalf("expected hidden flag '--trace' with hidden=true")
}
}
func TestCommandInfo_GlobalFlagElimination(t *testing.T) {
// Define a command with a global-like flag and a local one
cmd := &cli.Command{
Name: "auth",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"}, // should be filtered out as global
&cli.BoolFlag{Name: "force"},
},
}
globals := FlagsToCatalog([]cli.Flag{&cli.BoolFlag{Name: "json"}}, false)
info := CommandInfo(cmd, 1, "photoprism", false, globals)
if len(info.Flags) != 1 || info.Flags[0].Name != "--force" {
t.Fatalf("expected only '--force' flag, got %+v", info.Flags)
}
}
func TestBuildFlatAndNode(t *testing.T) {
add := &cli.Command{Name: "add"}
help := &cli.Command{Name: "help"}
rmHidden := &cli.Command{Name: "rm", Hidden: true}
auth := &cli.Command{Name: "auth", Subcommands: []*cli.Command{add, rmHidden, help}}
globals := FlagsToCatalog(nil, false)
// Flat without hidden
flat := BuildFlat(auth, 1, "photoprism", false, globals)
if len(flat) != 2 { // auth + add (help omitted)
t.Fatalf("expected 2 commands (auth, add), got %d", len(flat))
}
if flat[0].FullName != "photoprism auth" || flat[0].Depth != 1 {
t.Fatalf("unexpected root entry: %+v", flat[0])
}
if flat[1].FullName != "photoprism auth add" || flat[1].Depth != 2 {
t.Fatalf("unexpected child entry: %+v", flat[1])
}
// Nested with hidden
node := BuildNode(auth, 1, "photoprism", true, globals)
if len(node.Subcommands) != 2 { // add + rm (help omitted)
t.Fatalf("expected 2 subcommands when including hidden, got %d", len(node.Subcommands))
}
}
func TestRenderMarkdown_Headings(t *testing.T) {
data := MarkdownData{
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
BaseHeading: 2,
Short: true, // hide flags table to focus on headings
All: false,
Commands: []Command{
{Name: "auth", FullName: "photoprism auth", Depth: 1},
{Name: "auth add", FullName: "photoprism auth add", Depth: 2},
},
}
out, err := RenderMarkdown(data)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if !strings.Contains(out, "## photoprism auth") {
t.Fatalf("expected '## photoprism auth' heading, got:\n%s", out)
}
if !strings.Contains(out, "### photoprism auth add") {
t.Fatalf("expected '### photoprism auth add' heading, got:\n%s", out)
}
}
func TestRenderMarkdown_HiddenColumn(t *testing.T) {
cmd := Command{
Name: "auth",
FullName: "photoprism auth",
Depth: 1,
Flags: []Flag{
{Name: "--visible", Type: "bool", Hidden: false, Usage: "visible flag"},
{Name: "--secret", Type: "bool", Hidden: true, Usage: "hidden flag"},
},
}
base := MarkdownData{
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
BaseHeading: 2,
Short: false,
Commands: []Command{cmd},
}
// Default: no Hidden column
base.All = false
out, err := RenderMarkdown(base)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if strings.Contains(out, " Hidden ") {
t.Fatalf("did not expect 'Hidden' column without --all:\n%s", out)
}
// With --all: Hidden column and boolean value present
base.All = true
out, err = RenderMarkdown(base)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if !strings.Contains(out, " Hidden ") {
t.Fatalf("expected 'Hidden' column with --all:\n%s", out)
}
if !strings.Contains(out, "hidden flag") || !strings.Contains(out, " true ") {
t.Fatalf("expected hidden flag row to include 'true':\n%s", out)
}
}
func TestRenderMarkdown_ShortOmitsFlags(t *testing.T) {
cmd := Command{
Name: "auth",
FullName: "photoprism auth",
Depth: 1,
Flags: []Flag{
{Name: "--json", Type: "bool", Hidden: false, Usage: "json output"},
},
}
data := MarkdownData{
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
BaseHeading: 2,
Short: true,
All: false,
Commands: []Command{cmd},
}
out, err := RenderMarkdown(data)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if strings.Contains(out, "| Flag | Aliases | Type |") {
t.Fatalf("did not expect flags table when Short=true:\n%s", out)
}
}

View File

@@ -21,7 +21,6 @@ func TestClientRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
}
assert.Contains(t, roleFlag.Usage, "none")
})
t.Run("ModCommand role flag includes none", func(t *testing.T) {
var roleFlag *cli.StringFlag
for _, f := range ClientsModCommand.Flags {

View File

@@ -4,12 +4,6 @@ import (
"github.com/urfave/cli/v2"
)
// JsonFlag enables machine-readable JSON output for cluster commands.
var JsonFlag = &cli.BoolFlag{
Name: "json",
Usage: "print machine-readable JSON",
}
// OffsetFlag for pagination offset (>= 0).
var OffsetFlag = &cli.IntFlag{
Name: "offset",

View File

@@ -44,9 +44,9 @@ func obtainClientCredentialsViaRegister(portalURL, joinToken, nodeName string) (
if err := json.NewDecoder(resp.Body).Decode(&regResp); err != nil {
return "", "", err
}
id = regResp.Node.ID
id = regResp.Node.ClientID
if regResp.Secrets != nil {
secret = regResp.Secrets.NodeSecret
secret = regResp.Secrets.ClientSecret
}
if id == "" || secret == "" {
return "", "", fmt.Errorf("missing client credentials in response")

View File

@@ -78,15 +78,15 @@ func clusterNodesListAction(ctx *cli.Context) error {
return nil
}
cols := []string{"ID", "Name", "Role", "Labels", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
cols := []string{"UUID", "ClientID", "Name", "Role", "Labels", "Internal URL", "DB Driver", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
rows := make([][]string, 0, len(out))
for _, n := range out {
var dbName, dbUser, dbRot string
var dbName, dbUser, dbRot, dbDriver string
if n.Database != nil {
dbName, dbUser, dbRot = n.Database.Name, n.Database.User, n.Database.RotatedAt
dbName, dbUser, dbRot, dbDriver = n.Database.Name, n.Database.User, n.Database.RotatedAt, n.Database.Driver
}
rows = append(rows, []string{
n.ID, n.Name, n.Role, formatLabels(n.Labels), n.AdvertiseUrl, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
n.UUID, n.ClientID, n.Name, n.Role, formatLabels(n.Labels), n.AdvertiseUrl, dbDriver, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
})
}

View File

@@ -44,9 +44,14 @@ func clusterNodesModAction(ctx *cli.Context) error {
return cli.Exit(err, 1)
}
n, getErr := r.Get(key)
if getErr != nil {
name := clean.TypeLowerDash(key)
// Resolve by NodeUUID first, then by client UID, then by normalized name.
var n *reg.Node
var getErr error
if n, getErr = r.FindByNodeUUID(key); getErr != nil || n == nil {
n, getErr = r.FindByClientID(key)
}
if getErr != nil || n == nil {
name := clean.DNSLabel(key)
if name == "" {
return cli.Exit(fmt.Errorf("invalid node identifier"), 2)
}

View File

@@ -18,6 +18,7 @@ var ClusterNodesRemoveCommand = &cli.Command{
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)"},
},
Action: clusterNodesRemoveAction,
}
@@ -39,29 +40,36 @@ func clusterNodesRemoveAction(ctx *cli.Context) error {
}
// Resolve to id for deletion, but also support name.
id := key
if _, getErr := r.Get(id); getErr != nil {
if n, err2 := r.FindByName(clean.TypeLowerDash(key)); err2 == nil && n != nil {
id = n.ID
} else {
return cli.Exit(fmt.Errorf("node not found"), 3)
}
// Resolve UUID to delete: accept uuid → clientId → name.
uuid := key
if n, err2 := r.FindByNodeUUID(uuid); err2 == nil && n != nil {
uuid = n.UUID
} else if n, err2 := r.FindByClientID(uuid); err2 == nil && n != nil {
uuid = n.UUID
} else if n, err2 := r.FindByName(clean.DNSLabel(key)); err2 == nil && n != nil {
uuid = n.UUID
} else {
return cli.Exit(fmt.Errorf("node not found"), 3)
}
confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed {
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(id)), IsConfirm: true}
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(uuid)), IsConfirm: true}
if _, err := prompt.Run(); err != nil {
log.Infof("node %s was not deleted", clean.Log(id))
log.Infof("node %s was not deleted", clean.Log(uuid))
return nil
}
}
if err := r.Delete(id); err != nil {
if ctx.Bool("all-ids") {
if err := r.DeleteAllByUUID(uuid); err != nil {
return cli.Exit(err, 1)
}
} else if err := r.Delete(uuid); err != nil {
return cli.Exit(err, 1)
}
log.Infof("node %s has been deleted", clean.Log(id))
log.Infof("node %s has been deleted", clean.Log(uuid))
return nil
})
}

View File

@@ -39,12 +39,14 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
}
// Determine node name. On portal, resolve id->name via registry; otherwise treat key as name.
name := clean.TypeLowerDash(key)
name := clean.DNSLabel(key)
if conf.IsPortal() {
if r, err := reg.NewClientRegistryWithConfig(conf); err == nil {
if n, err := r.Get(key); err == nil && n != nil {
if n, err := r.FindByNodeUUID(key); err == nil && n != nil {
name = n.Name
} else if n, err := r.FindByName(clean.TypeLowerDash(key)); err == nil && n != nil {
} else if n, err := r.FindByClientID(key); err == nil && n != nil {
name = n.Name
} else if n, err := r.FindByName(clean.DNSLabel(key)); err == nil && n != nil {
name = n.Name
}
}
@@ -131,17 +133,17 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
return nil
}
cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "Port"}
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Role, resp.Database.Name, resp.Database.User, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
cols := []string{"UUID", "ClientID", "Name", "Role", "DB Driver", "DB Name", "DB User", "Host", "Port"}
rows := [][]string{{resp.Node.UUID, resp.Node.ClientID, resp.Node.Name, resp.Node.Role, resp.Database.Driver, resp.Database.Name, resp.Database.User, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.Database.Password != "" {
if (resp.Secrets != nil && resp.Secrets.ClientSecret != "") || resp.Database.Password != "" {
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.Database.Password))
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" && resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "DB Password", resp.Database.Password))
} else if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "", ""))
} else if resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
}

View File

@@ -38,9 +38,12 @@ func clusterNodesShowAction(ctx *cli.Context) error {
}
// Resolve by id first, then by normalized name.
n, getErr := r.Get(key)
if getErr != nil {
name := clean.TypeLowerDash(key)
n, getErr := r.FindByNodeUUID(key)
if getErr != nil || n == nil {
n, getErr = r.FindByClientID(key)
}
if getErr != nil || n == nil {
name := clean.DNSLabel(key)
if name == "" {
return cli.Exit(fmt.Errorf("invalid node identifier"), 2)
}
@@ -59,12 +62,12 @@ func clusterNodesShowAction(ctx *cli.Context) error {
return nil
}
cols := []string{"ID", "Name", "Role", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
var dbName, dbUser, dbRot string
cols := []string{"UUID", "ClientID", "Name", "Role", "Internal URL", "DB Driver", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
var dbName, dbUser, dbRot, dbDriver string
if dto.Database != nil {
dbName, dbUser, dbRot = dto.Database.Name, dto.Database.User, dto.Database.RotatedAt
dbName, dbUser, dbRot, dbDriver = dto.Database.Name, dto.Database.User, dto.Database.RotatedAt, dto.Database.Driver
}
rows := [][]string{{dto.ID, dto.Name, dto.Role, dto.AdvertiseUrl, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
rows := [][]string{{dto.UUID, dto.ClientID, dto.Name, dto.Role, dto.AdvertiseUrl, dbDriver, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
if err != nil {

View File

@@ -7,12 +7,14 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/urfave/cli/v2"
yaml "gopkg.in/yaml.v2"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
@@ -34,22 +36,27 @@ var (
regPortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"}
regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"}
regDryRun = &cli.BoolFlag{Name: "dry-run", Usage: "print derived values and payload without performing registration"}
)
// ClusterRegisterCommand registers a node with the Portal via HTTP.
var ClusterRegisterCommand = &cli.Command{
Name: "register",
Usage: "Registers/rotates a node via Portal (HTTP)",
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag}, report.CliFlags...)),
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, regDryRun}, report.CliFlags...)),
Action: clusterRegisterAction,
}
func clusterRegisterAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
// Resolve inputs
name := clean.TypeLowerDash(ctx.String("name"))
name := clean.DNSLabel(ctx.String("name"))
derivedName := false
if name == "" { // default from config if set
name = clean.TypeLowerDash(conf.NodeName())
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)
@@ -62,9 +69,74 @@ func clusterRegisterAction(ctx *cli.Context) error {
}
portalURL := ctx.String("portal-url")
derivedPortal := false
if portalURL == "" {
portalURL = conf.PortalUrl()
if portalURL != "" {
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")
if advertise == "" {
advertise = conf.AdvertiseUrl()
}
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"),
}
// 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
}
if site != "" && site != advertise {
body["siteUrl"] = site
}
b, _ := json.Marshal(body)
if ctx.Bool("dry-run") {
if ctx.Bool("json") {
out := map[string]any{"portalUrl": portalURL, "payload": body}
jb, _ := json.Marshal(out)
fmt.Println(string(jb))
} else {
fmt.Printf("Portal URL: %s\n", portalURL)
fmt.Printf("Node Name: %s\n", name)
if derivedPortal || derivedName || advertise == conf.AdvertiseUrl() {
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)
}
// 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) {
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)
} else {
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
}
}
return nil
}
// For actual registration, require portal URL and token.
if portalURL == "" {
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
}
@@ -76,16 +148,6 @@ func clusterRegisterAction(ctx *cli.Context) error {
return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
}
body := map[string]interface{}{
"nodeName": name,
"nodeRole": nodeRole,
"labels": parseLabelSlice(ctx.StringSlice("label")),
"advertiseUrl": ctx.String("advertise-url"),
"rotate": ctx.Bool("rotate"),
"rotateSecret": ctx.Bool("rotate-secret"),
}
b, _ := json.Marshal(body)
// POST with bounded backoff on 429
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse
@@ -115,8 +177,8 @@ func clusterRegisterAction(ctx *cli.Context) error {
jb, _ := json.Marshal(resp)
fmt.Println(string(jb))
} else {
// Human-readable: node row and credentials if present
cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "Port"}
// 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
@@ -124,18 +186,18 @@ func clusterRegisterAction(ctx *cli.Context) error {
if resp.Database.User != "" {
dbUser = resp.Database.User
}
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Role, dbName, dbUser, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
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)
// Secrets/credentials block if any
// Show secrets in up to two tables, then print DSN if present
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.Database.Password != "" {
if (resp.Secrets != nil && resp.Secrets.ClientSecret != "") || resp.Database.Password != "" {
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.Database.Password))
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" && resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "DB Password", resp.Database.Password))
} else if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "", ""))
} else if resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
}
@@ -218,6 +280,22 @@ func stringsTrimRightSlash(s string) string {
return s
}
// warnInsecurePublicURL returns true if the URL uses http and the host is not localhost/127.0.0.1/::1.
func warnInsecurePublicURL(u string) bool {
parsed, err := url.Parse(u)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return false
}
if parsed.Scheme != "http" {
return false
}
h := parsed.Hostname()
if h == "localhost" || h == "127.0.0.1" || h == "::1" {
return false
}
return true
}
// Persistence helpers for --write-config
func parseLabelSlice(labels []string) map[string]string {
if len(labels) == 0 {
@@ -239,20 +317,20 @@ func parseLabelSlice(labels []string) map[string]string {
// Persistence helpers for --write-config
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
// Node secret file
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
// Prefer PHOTOPRISM_NODE_SECRET_FILE; otherwise config cluster path
fileName := os.Getenv(config.FlagFileVar("NODE_SECRET"))
// Node client secret file
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
fileName := os.Getenv(config.FlagFileVar("NODE_CLIENT_SECRET"))
if fileName == "" {
fileName = filepath.Join(conf.PortalConfigPath(), "node-secret")
}
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
return err
}
if err := os.WriteFile(fileName, []byte(resp.Secrets.NodeSecret), 0o600); err != nil {
if err := os.WriteFile(fileName, []byte(resp.Secrets.ClientSecret), 0o600); err != nil {
return err
}
log.Infof("wrote node secret to %s", clean.Log(fileName))
log.Infof("wrote node client secret to %s", clean.Log(fileName))
}
// DB settings (MySQL/MariaDB only)
@@ -293,5 +371,5 @@ func mergeOptionsYaml(conf *config.Config, kv map[string]any) error {
if err != nil {
return err
}
return os.WriteFile(fileName, b, 0o644)
return os.WriteFile(fileName, b, fs.ModeFile)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/urfave/cli/v2"
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
@@ -30,8 +31,8 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
w.WriteHeader(http.StatusCreated)
_ = 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", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret", "secretRotatedAt": "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"},
"alreadyRegistered": false,
"alreadyProvisioned": false,
})
@@ -44,7 +45,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
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.nodeSecret").String())
assert.Equal(t, "secret", 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)
@@ -70,8 +71,8 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = 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", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret2", "secretRotatedAt": "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"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -89,7 +90,7 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-03")
assert.Contains(t, out, "Node Secret")
assert.Contains(t, out, "Node Client Secret")
assert.Contains(t, out, "DB Password")
}
@@ -108,8 +109,8 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = 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", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret3", "secretRotatedAt": "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"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -127,7 +128,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.nodeSecret").String())
assert.Equal(t, "secret3", 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)
@@ -161,7 +162,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n3", "name": "pp-node-05", "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": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
// secrets omitted on DB-only rotate
"alreadyRegistered": true,
"alreadyProvisioned": true,
@@ -188,7 +189,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
assert.Equal(t, "", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "", gjson.Get(out, "secrets.clientSecret").String())
}
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
@@ -213,8 +214,8 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = 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", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret4", "secretRotatedAt": "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"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -230,7 +231,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.nodeSecret").String())
assert.Equal(t, "secret4", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String())
}
@@ -266,6 +267,34 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
}
}
func TestClusterRegister_DryRun_JSON(t *testing.T) {
// No server needed; dry-run avoids HTTP
get.Config().Options().PortalUrl = cfg.DefaultPortalUrl
get.Config().Options().ClusterDomain = "cluster.dev"
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--dry-run", "--json",
})
// Should not fail; output must include portalUrl and payload
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assert.NotEmpty(t, gjson.Get(out, "portalUrl").String())
assert.Equal(t, "instance", gjson.Get(out, "payload.nodeRole").String())
// nodeName may be derived; ensure non-empty
assert.NotEmpty(t, gjson.Get(out, "payload.nodeName").String())
}
func TestClusterRegister_DryRun_Text(t *testing.T) {
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assert.Contains(t, out, "Portal URL:")
assert.Contains(t, out, "Node Name:")
}
func TestClusterRegister_HTTPBadRequest(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
@@ -294,7 +323,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "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": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -368,7 +397,7 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "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": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -401,7 +430,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n5", "name": "pp-node-07", "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": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -442,8 +471,8 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
w.WriteHeader(http.StatusOK)
_ = 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", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "pwd8secret", "secretRotatedAt": "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"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -455,6 +484,6 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String())
}

View File

@@ -34,8 +34,8 @@ var ClusterThemePullCommand = &cli.Command{
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"},
&cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"},
&cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"},
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"},
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeClientID from config)"},
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeClientSecret from config)"},
// JSON output supported via report.CliFlags on parent command where applicable
},
Action: clusterThemePullAction,
@@ -58,11 +58,11 @@ func clusterThemePullAction(ctx *cli.Context) error {
// Credentials: prefer OAuth client credentials (client-id/secret), fallback to join-token for compatibility.
clientID := ctx.String("client-id")
if clientID == "" {
clientID = conf.NodeID()
clientID = conf.NodeClientID()
}
clientSecret := ctx.String("client-secret")
if clientSecret == "" {
clientSecret = conf.NodeSecret()
clientSecret = conf.NodeClientSecret()
}
token := ""
if clientID != "" && clientSecret != "" {
@@ -75,7 +75,7 @@ func clusterThemePullAction(ctx *cli.Context) error {
}
}
if token == "" {
// Try join-token assisted path. If NodeID/NodeSecret not available, attempt register to obtain them, then OAuth.
// Try join-token assisted path. If NodeClientID/NodeClientSecret not available, attempt register to obtain them, then OAuth.
jt := ctx.String("join-token")
if jt == "" {
jt = conf.JoinToken()

View File

@@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Verifies OAuth path in cluster theme pull using client_id/client_secret.
@@ -92,14 +93,15 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
_ = json.NewDecoder(r.Body).Decode(&req)
sawRotateSecret = req.RotateSecret
w.Header().Set("Content-Type", "application/json")
// Return NodeID and a fresh secret
// Return NodeClientID and a fresh secret
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
Node: cluster.Node{ID: "cid123", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"},
UUID: rnd.UUID(),
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
})
case "/api/v1/oauth/token":
// Expect Basic for the returned creds
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cid123:s3cr3t")) {
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cs5gfen1bgxz7s9i:s3cr3t")) {
w.WriteHeader(http.StatusUnauthorized)
return
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/capture"
"github.com/photoprism/photoprism/pkg/fs"
)
// TODO: Several CLI commands defer conf.Shutdown(), which closes the shared
@@ -41,6 +42,9 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -33,7 +33,6 @@ photoprism dl --cookies cookies.txt \
--dl-method file --file-remux auto -- \
https://example.com/a.mp4 https://example.com/b.jpg
# Add two headers (repeatable flag)
photoprism dl -a 'Authorization: Bearer <token>' \
-a 'Accept: application/json' -- URL`

View File

@@ -1,3 +1,5 @@
//go:build yt
package commands
import (
@@ -6,6 +8,7 @@ import (
"runtime"
"strings"
"testing"
"time"
"github.com/photoprism/photoprism/internal/photoprism/dl"
"github.com/photoprism/photoprism/internal/photoprism/get"
@@ -17,7 +20,22 @@ import (
// with %(id)s -> abc and %(ext)s -> mp4, then prints the path
func createFakeYtDlp(t *testing.T) string {
t.Helper()
dir := t.TempDir()
// Prefer the app's TempPath to avoid CI environments where OS /tmp is mounted noexec.
base := ""
if c := get.Config(); c != nil {
base = c.TempPath()
}
if base == "" {
base = t.TempDir()
} else {
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatalf("failed to create base temp dir: %v", err)
}
}
dir, derr := os.MkdirTemp(base, "ydlp_")
if derr != nil {
t.Fatalf("failed to create temp dir: %v", derr)
}
path := filepath.Join(dir, "yt-dlp")
if runtime.GOOS == "windows" {
// Not needed in CI/dev container. Keep simple stub.
@@ -43,6 +61,10 @@ func createFakeYtDlp(t *testing.T) string {
}
func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) {
// Ensure our fake script runs via shell even on noexec mounts.
t.Setenv("YTDLP_FORCE_SHELL", "1")
// Prefer using in-process fake to avoid exec restrictions.
t.Setenv("YTDLP_FAKE", "1")
fake := createFakeYtDlp(t)
orig := dl.YtDlpBin
defer func() { dl.YtDlpBin = orig }()
@@ -83,6 +105,10 @@ func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) {
}
func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
// Ensure our fake script runs via shell even on noexec mounts.
t.Setenv("YTDLP_FORCE_SHELL", "1")
// Prefer using in-process fake to avoid exec restrictions.
t.Setenv("YTDLP_FAKE", "1")
fake := createFakeYtDlp(t)
orig := dl.YtDlpBin
defer func() { dl.YtDlpBin = orig }()
@@ -111,27 +137,51 @@ func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
t.Fatalf("runDownload failed with skip remux: %v", err)
}
// Verify an mp4 exists under Originals/dest
// Verify an mp4 exists under Originals/dest. On some filesystems (e.g.,
// Windows/CI or slow containers) directory listings can lag slightly after
// moves. Poll briefly to avoid flakes.
c := get.Config()
outDir := filepath.Join(c.OriginalsPath(), dest)
found := false
_ = filepath.WalkDir(outDir, func(path string, d os.DirEntry, err error) error {
if err != nil || d == nil {
var found bool
deadline := time.Now().Add(2 * time.Second)
for !found && time.Now().Before(deadline) {
_ = filepath.WalkDir(outDir, func(path string, d os.DirEntry, err error) error {
if err != nil || d == nil {
return nil
}
if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".mp4") {
found = true
return filepath.SkipDir
}
return nil
})
if !found {
time.Sleep(50 * time.Millisecond)
}
if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".mp4") {
found = true
return filepath.SkipDir
}
return nil
})
}
if !found {
t.Fatalf("expected at least one mp4 in %s", outDir)
// Help debugging by listing the directory tree.
var listing []string
_ = filepath.WalkDir(outDir, func(path string, d os.DirEntry, err error) error {
if err == nil && d != nil {
rel, _ := filepath.Rel(outDir, path)
if rel == "." {
rel = d.Name()
}
listing = append(listing, rel)
}
return nil
})
t.Fatalf("expected at least one mp4 in %s; found: %v", outDir, listing)
}
_ = os.RemoveAll(outDir)
}
func TestDownloadImpl_FileMethod_Always_RemuxFails(t *testing.T) {
// Ensure our fake script runs via shell even on noexec mounts.
t.Setenv("YTDLP_FORCE_SHELL", "1")
// Prefer using in-process fake to avoid exec restrictions.
t.Setenv("YTDLP_FAKE", "1")
fake := createFakeYtDlp(t)
orig := dl.YtDlpBin
defer func() { dl.YtDlpBin = orig }()

View File

@@ -18,5 +18,6 @@ var ShowCommands = &cli.Command{
ShowThumbSizesCommand,
ShowVideoSizesCommand,
ShowMetadataCommand,
ShowCommandsCommand,
},
}

View File

@@ -0,0 +1,143 @@
package commands
import (
"encoding/json"
"fmt"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/commands/catalog"
"github.com/photoprism/photoprism/internal/config"
)
// ShowCommandsCommand configures the command name, flags, and action.
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"},
&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"},
&cli.BoolFlag{Name: "nested", Usage: "emit nested JSON structure instead of a flat array"},
},
Action: showCommandsAction,
}
type showCommandsOut struct {
App catalog.App `json:"app"`
GeneratedAt string `json:"generated_at"`
GlobalFlags []catalog.Flag `json:"global_flags,omitempty"`
Commands json.RawMessage `json:"commands"`
}
// showCommandsAction displays a structured catalog of CLI commands.
func showCommandsAction(ctx *cli.Context) error {
// Prefer fast app metadata from the running app; avoid heavy config init in tests
includeHidden := ctx.Bool("all")
wantJSON := ctx.Bool("json")
nested := ctx.Bool("nested")
baseHeading := ctx.Int("base-heading")
if baseHeading < 1 {
baseHeading = 1
}
// Collect the app metadata to be included in the output.
app := catalog.App{}
if ctx != nil && ctx.App != nil && ctx.App.Metadata != nil {
if n, ok := ctx.App.Metadata["Name"].(string); ok {
app.Name = n
}
if e, ok := ctx.App.Metadata["Edition"].(string); ok {
app.Edition = e
}
if v, ok := ctx.App.Metadata["Version"].(string); ok {
app.Version = v
app.Build = v
}
}
if app.Name == "" || app.Version == "" {
conf := config.NewConfig(ctx)
app.Name = conf.Name()
app.Edition = conf.Edition()
app.Version = conf.Version()
app.Build = conf.Version()
}
// Collect global flags from the running app.
var globalFlags []catalog.Flag
if ctx != nil && ctx.App != nil {
globalFlags = catalog.FlagsToCatalog(ctx.App.Flags, includeHidden)
} else {
globalFlags = catalog.FlagsToCatalog(config.Flags.Cli(), includeHidden)
}
// Traverse commands registry using runtime app commands to avoid init cycles.
var flat []catalog.Command
var tree []catalog.Node
var roots []*cli.Command
if ctx != nil && ctx.App != nil {
roots = ctx.App.Commands
}
for _, c := range roots {
if c == nil {
continue
}
if c.Hidden && !includeHidden {
continue
}
if nested {
node := catalog.BuildNode(c, 1, "photoprism", includeHidden, globalFlags)
tree = append(tree, node)
} else {
flat = append(flat, catalog.BuildFlat(c, 1, "photoprism", includeHidden, globalFlags)...)
}
}
// Render JSON output using json.Marshal().
if wantJSON {
var cmds json.RawMessage
var err error
if nested {
cmds, err = json.Marshal(tree)
} else {
cmds, err = json.Marshal(flat)
}
if err != nil {
return err
}
out := showCommandsOut{
App: app,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
GlobalFlags: globalFlags,
Commands: cmds,
}
b, err := json.Marshal(out)
if err != nil {
return err
}
fmt.Println(string(b))
return nil
}
// Render Markdown using embedded template.
data := catalog.MarkdownData{
App: app,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
BaseHeading: baseHeading,
Short: ctx.Bool("short"),
All: includeHidden,
Commands: flat,
}
md, err := catalog.RenderMarkdown(data)
if err != nil {
return err
}
fmt.Println(md)
return nil
}

View File

@@ -0,0 +1,127 @@
package commands
import (
"encoding/json"
"strings"
"testing"
catalogpkg "github.com/photoprism/photoprism/internal/commands/catalog"
)
func TestShowCommands_JSON_Flat(t *testing.T) {
// Build JSON without capturing stdout to avoid pipe blocking on large outputs
ctx := NewTestContext([]string{"commands"})
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
var flat []catalogpkg.Command
for _, c := range ctx.App.Commands {
if c.Hidden {
continue
}
flat = append(flat, catalogpkg.BuildFlat(c, 1, "photoprism", false, global)...)
}
out := struct {
App catalogpkg.App `json:"app"`
GeneratedAt string `json:"generated_at"`
GlobalFlags []catalogpkg.Flag `json:"global_flags"`
Commands []catalogpkg.Command `json:"commands"`
}{
App: catalogpkg.App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
GlobalFlags: global,
Commands: flat,
}
b, err := json.Marshal(out)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
// Basic structural checks via unmarshal into a light struct
var v struct {
Commands []struct {
Name, FullName string
Depth int
} `json:"commands"`
GlobalFlags []map[string]interface{} `json:"global_flags"`
}
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("invalid json: %v", err)
}
if len(v.Commands) == 0 {
t.Fatalf("expected at least one command")
}
// Expect at least one top-level auth and at least one subcommand overall
var haveAuth, haveAnyChild bool
for _, c := range v.Commands {
if c.Name == "auth" && c.Depth == 1 {
haveAuth = true
}
if c.Depth >= 2 {
haveAnyChild = true
}
}
if !haveAuth {
t.Fatalf("expected to find 'auth' top-level command in list")
}
if !haveAnyChild {
t.Fatalf("expected to find at least one subcommand (depth >= 2)")
}
if len(v.GlobalFlags) == 0 {
t.Fatalf("expected non-empty global_flags")
}
}
func TestShowCommands_JSON_Nested(t *testing.T) {
ctx := NewTestContext([]string{"commands"})
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
var tree []catalogpkg.Node
for _, c := range ctx.App.Commands {
if c.Hidden {
continue
}
tree = append(tree, catalogpkg.BuildNode(c, 1, "photoprism", false, global))
}
b, err := json.Marshal(struct {
Commands []catalogpkg.Node `json:"commands"`
}{Commands: tree})
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var v struct {
Commands []struct {
Name string `json:"name"`
Depth int `json:"depth"`
Subcommands []struct {
Name string `json:"name"`
} `json:"subcommands"`
} `json:"commands"`
}
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("invalid json: %v", err)
}
if len(v.Commands) == 0 {
t.Fatalf("expected top-level commands")
}
var hasAuthWithChild bool
for _, c := range v.Commands {
if c.Name == "auth" && c.Depth == 1 && len(c.Subcommands) > 0 {
hasAuthWithChild = true
break
}
}
if !hasAuthWithChild {
t.Fatalf("expected auth with at least one subcommand")
}
}
func TestShowCommands_Markdown_Default(t *testing.T) {
out, err := RunWithTestContext(ShowCommandsCommand, []string{"commands"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expect Markdown headings for commands
if !strings.Contains(out, "## photoprism auth") {
t.Fatalf("expected '## photoprism auth' heading in output\n%s", out[:min(400, len(out))])
}
if !strings.Contains(out, "### photoprism auth ") { // subcommand headings begin with parent
t.Fatalf("expected '### photoprism auth <sub>' heading in output\n%s", out[:min(600, len(out))])
}
}

View File

@@ -41,9 +41,9 @@ func showConfigAction(ctx *cli.Context) error {
log.Debug(err)
}
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
if format == report.JSON {
@@ -65,8 +65,10 @@ func showConfigAction(ctx *cli.Context) error {
rows, cols := rep.Report(conf)
opt := report.Options{Format: format, NoWrap: rep.NoWrap}
result, _ := report.Render(rows, cols, opt)
if opt.Format == report.Default {
fmt.Printf("\n%s\n\n", strings.ToUpper(rep.Title))
if opt.Format == report.Markdown {
fmt.Printf("### %s\n\n", rep.Title)
} else if opt.Format == report.Default {
fmt.Printf("%s\n\n", strings.ToUpper(rep.Title))
}
fmt.Println(result)
}

View File

@@ -1,7 +1,6 @@
package commands
import (
"encoding/json"
"fmt"
"strings"
@@ -14,10 +13,11 @@ import (
// ShowConfigOptionsCommand configures the command name, flags, and action.
var ShowConfigOptionsCommand = &cli.Command{
Name: "config-options",
Usage: "Displays supported environment variables and CLI flags",
Flags: report.CliFlags,
Action: showConfigOptionsAction,
Name: "config-options",
Usage: "Displays supported environment variables and CLI flags",
Description: "For readability, standard and Markdown text output is divided into sections. The --json, --csv, and --tsv options return a flat list.",
Flags: report.CliFlags,
Action: showConfigOptionsAction,
}
// showConfigOptionsAction displays supported environment variables and CLI flags.
@@ -26,20 +26,20 @@ func showConfigOptionsAction(ctx *cli.Context) error {
conf.SetLogLevel(logrus.FatalLevel)
rows, cols := config.Flags.Report()
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
// CSV/TSV exports use default single-table rendering
if format == report.CSV || format == report.TSV {
// CSV/TSV/JSON exports use default single-table rendering.
if format == report.CSV || format == report.TSV || format == report.JSON {
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)
return err
}
// JSON aggregation path
if format == report.JSON {
// JSON aggregation path (commented out because non-nested output is preferred for now).
/* if format == report.JSON {
type section struct {
Title string `json:"title"`
Info string `json:"info,omitempty"`
@@ -72,7 +72,7 @@ func showConfigOptionsAction(ctx *cli.Context) error {
b, _ := json.Marshal(map[string]interface{}{"sections": agg})
fmt.Println(string(b))
return nil
}
} */
markDown := ctx.Bool("md")
sections := config.OptionsReportSections
@@ -113,7 +113,7 @@ func showConfigOptionsAction(ctx *cli.Context) error {
}
}
// JSON handled earlier; Markdown and default render per section below
// JSON handled earlier; Markdown and default render per section below.
result, err := report.RenderFormat(secRows, cols, format)
if err != nil {

View File

@@ -1,7 +1,6 @@
package commands
import (
"encoding/json"
"fmt"
"strings"
@@ -14,10 +13,11 @@ import (
// ShowConfigYamlCommand configures the command name, flags, and action.
var ShowConfigYamlCommand = &cli.Command{
Name: "config-yaml",
Usage: "Displays supported YAML config options and CLI flags",
Flags: report.CliFlags,
Action: showConfigYamlAction,
Name: "config-yaml",
Usage: "Displays supported YAML config options and CLI flags",
Description: "For readability, standard and Markdown text output is divided into sections. The --json, --csv, and --tsv options return a flat list.",
Flags: report.CliFlags,
Action: showConfigYamlAction,
}
// showConfigYamlAction displays supported YAML config options and CLI flag.
@@ -26,20 +26,20 @@ func showConfigYamlAction(ctx *cli.Context) error {
conf.SetLogLevel(logrus.TraceLevel)
rows, cols := conf.Options().Report()
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
// CSV/TSV exports use default single-table rendering
if format == report.CSV || format == report.TSV {
// CSV/TSV/JSON exports use default single-table rendering.
if format == report.CSV || format == report.TSV || format == report.JSON {
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)
return err
}
// JSON aggregation path
if format == report.JSON {
// JSON aggregation path (commented out because non-nested output is preferred for now).
/* if format == report.JSON {
type section struct {
Title string `json:"title"`
Info string `json:"info,omitempty"`
@@ -72,7 +72,7 @@ func showConfigYamlAction(ctx *cli.Context) error {
b, _ := json.Marshal(map[string]interface{}{"sections": agg})
fmt.Println(string(b))
return nil
}
} */
markDown := ctx.Bool("md")
sections := config.YamlReportSections
@@ -113,7 +113,7 @@ func showConfigYamlAction(ctx *cli.Context) error {
}
}
// JSON handled earlier; Markdown and default render per section below
// JSON handled earlier; Markdown and default render per section below.
result, err := report.RenderFormat(secRows, cols, format)
if err != nil {

View File

@@ -26,9 +26,9 @@ var ShowFileFormatsCommand = &cli.Command{
// showFileFormatsAction displays supported media and sidecar file formats.
func showFileFormatsAction(ctx *cli.Context) error {
rows, cols := media.Report(fs.Extensions.Types(true), !ctx.Bool("short"), true, true)
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -8,11 +8,14 @@ import (
func TestShowThumbSizes_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowThumbSizesCommand, []string{"thumb-sizes", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v []map[string]string
if err := json.Unmarshal([]byte(out), &v); err != nil {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {
@@ -28,11 +31,14 @@ func TestShowThumbSizes_JSON(t *testing.T) {
func TestShowSources_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowSourcesCommand, []string{"sources", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v []map[string]string
if err := json.Unmarshal([]byte(out), &v); err != nil {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {
@@ -48,16 +54,20 @@ func TestShowSources_JSON(t *testing.T) {
func TestShowMetadata_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowMetadataCommand, []string{"metadata", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v struct {
Items []map[string]string `json:"items"`
Docs []map[string]string `json:"docs"`
}
if err := json.Unmarshal([]byte(out), &v); err != nil {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v.Items) == 0 {
t.Fatalf("expected items")
}
@@ -65,18 +75,22 @@ func TestShowMetadata_JSON(t *testing.T) {
func TestShowConfig_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowConfigCommand, []string{"config", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v struct {
Sections []struct {
Title string `json:"title"`
Items []map[string]string `json:"items"`
} `json:"sections"`
}
if err := json.Unmarshal([]byte(out), &v); err != nil {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 {
t.Fatalf("expected sections with items")
}
@@ -84,47 +98,49 @@ func TestShowConfig_JSON(t *testing.T) {
func TestShowConfigOptions_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowConfigOptionsCommand, []string{"config-options", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v struct {
Sections []struct {
Title string `json:"title"`
Items []map[string]string `json:"items"`
} `json:"sections"`
}
if err := json.Unmarshal([]byte(out), &v); err != nil {
type options = []map[string]string
var v = options{}
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 {
if len(v) == 0 {
t.Fatalf("expected sections with items")
}
}
func TestShowConfigYaml_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowConfigYamlCommand, []string{"config-yaml", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v struct {
Sections []struct {
Title string `json:"title"`
Items []map[string]string `json:"items"`
} `json:"sections"`
}
if err := json.Unmarshal([]byte(out), &v); err != nil {
type options = []map[string]string
var v = options{}
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 {
if len(v) == 0 {
t.Fatalf("expected sections with items")
}
}
func TestShowFormatConflict_Error(t *testing.T) {
out, err := RunWithTestContext(ShowSourcesCommand, []string{"sources", "--json", "--csv"})
if err == nil {
t.Fatalf("expected error for conflicting flags, got nil; output=%s", out)
}
// Expect an ExitCoder with code 2
if ec, ok := err.(interface{ ExitCode() int }); ok {
if ec.ExitCode() != 2 {
@@ -154,11 +170,14 @@ func min(a, b int) int {
func TestShowFileFormats_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowFileFormatsCommand, []string{"file-formats", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v []map[string]string
if err := json.Unmarshal([]byte(out), &v); err != nil {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {
@@ -178,11 +197,14 @@ func TestShowFileFormats_JSON(t *testing.T) {
func TestShowVideoSizes_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowVideoSizesCommand, []string{"video-sizes", "--json"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var v []map[string]string
if err := json.Unmarshal([]byte(out), &v); err != nil {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {

View File

@@ -38,9 +38,9 @@ func showMetadataAction(ctx *cli.Context) error {
})
// Output overview of supported metadata tags.
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
if format == report.JSON {
resp := struct {

View File

@@ -30,9 +30,9 @@ func showSearchFiltersAction(ctx *cli.Context) error {
}
})
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -20,9 +20,9 @@ var ShowSourcesCommand = &cli.Command{
// showSourcesAction displays supported metadata sources.
func showSourcesAction(ctx *cli.Context) error {
rows, cols := entity.SrcPriority.Report()
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -21,9 +21,9 @@ var ShowThumbSizesCommand = &cli.Command{
// showThumbSizesAction displays supported standard thumbnail sizes.
func showThumbSizesAction(ctx *cli.Context) error {
rows, cols := thumb.Report(thumb.Sizes.All(), false)
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -20,9 +20,9 @@ var ShowVideoSizesCommand = &cli.Command{
// showVideoSizesAction displays supported standard video sizes.
func showVideoSizesAction(ctx *cli.Context) error {
rows, cols := thumb.Report(thumb.VideoSizes, true)
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -21,7 +21,6 @@ func TestUserRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
}
assert.Contains(t, roleFlag.Usage, "none")
})
t.Run("ModCommand user role flag includes none", func(t *testing.T) {
var roleFlag *cli.StringFlag
for _, f := range UsersModCommand.Flags {

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