mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Compare commits
25 Commits
2c0f6d47cd
...
87f206406a
Author | SHA1 | Date | |
---|---|---|---|
![]() |
87f206406a | ||
![]() |
c202a09241 | ||
![]() |
61ced7119c | ||
![]() |
3baabebf50 | ||
![]() |
0a66f1476d | ||
![]() |
59fb8e2b4c | ||
![]() |
8930cb7b79 | ||
![]() |
ade3b40a42 | ||
![]() |
9ea5f0596c | ||
![]() |
a22babe3d1 | ||
![]() |
bfd26c55e3 | ||
![]() |
578fbe4d10 | ||
![]() |
c8964fdc6b | ||
![]() |
eca06dcdfb | ||
![]() |
38cdde5518 | ||
![]() |
2a113f167d | ||
![]() |
91804b9652 | ||
![]() |
458a320bb8 | ||
![]() |
c312c0d109 | ||
![]() |
6e33575ba7 | ||
![]() |
d6cb6b7a2e | ||
![]() |
f1c57c72d8 | ||
![]() |
25253afcf2 | ||
![]() |
f878ca0cb0 | ||
![]() |
93493aba28 |
136
AGENTS.md
136
AGENTS.md
@@ -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 ad‑hoc `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/link‑local ranges.
|
||||
- All redirect targets are validated; the final connected peer IP is also checked.
|
||||
- Prefer an image‑focused `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 per‑resource 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`).
|
||||
|
||||
### Next‑Session 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 30–120s 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 30–120s on first run. Narrow with `-run` and keep iterations low.
|
||||
- PhotoPrism config in tests: inside `internal/photoprism`, use the package global `photoprism.Config()` for runtime‑accurate 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 re‑ordered (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 top‑level `photoprism help` remains included.
|
||||
- When asserting large JSON documents, build DTOs via `catalog.BuildFlat/BuildNode` and marshal directly to avoid pipe back‑pressure in tests.
|
||||
- JSON shapes for `show` commands:
|
||||
- Most return a top‑level 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 top‑level 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 client‑backed registry (`NewClientRegistryWithConfig`).
|
||||
- The file‑backed 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) → DNS‑label 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 OAuth‑only.
|
||||
- Registry interface is UUID‑first: `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 UUID‑based 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 top‑level 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.
|
||||
|
62
CODEMAP.md
62
CODEMAP.md
@@ -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 per‑suite (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 allow‑list (http/https), pre‑DNS + per‑redirect 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, 10 MiB, `AllowPrivate=false`, image‑focused `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 constant‑time.
|
||||
- Don’t 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 DNS‑label 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
|
||||
- UUID‑first 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`
|
||||
|
@@ -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
|
||||
|
6
Makefile
6
Makefile
@@ -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/...
|
||||
|
@@ -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 && \
|
||||
|
631
frontend/package-lock.json
generated
631
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
19
go.mod
@@ -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
34
go.sum
@@ -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=
|
||||
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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))
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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) {
|
||||
|
@@ -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"})
|
||||
})
|
||||
}
|
||||
|
@@ -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 := ®.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 := ®.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 := ®.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).
|
||||
|
@@ -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-], 1–32, 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 := ®.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) }
|
||||
|
@@ -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 := ®.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 := ®.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 := ®.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
|
||||
n := ®.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 := ®.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
41
internal/api/cluster_nodes_register_url_test.go
Normal file
41
internal/api/cluster_nodes_register_url_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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 := ®.Node{Name: "pp-node-01", Role: "instance"}
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n2 := ®.Node{Name: "pp-node-02", Role: "service"}
|
||||
n2 := ®.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 := ®.Node{Name: "pp-node-99", Role: "instance"}
|
||||
n := ®.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.
|
||||
|
@@ -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 := ®.Node{Name: "pp-node-siteurl", Role: "instance"}
|
||||
n := ®.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)
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@@ -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))
|
||||
})
|
||||
}
|
||||
|
@@ -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"))
|
||||
|
13
internal/api/doc_overrides.go
Normal file
13
internal/api/doc_overrides.go
Normal 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
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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.
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
153
internal/api/oauth_token_ratelimit_test.go
Normal file
153
internal/api/oauth_token_ratelimit_test.go
Normal 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)
|
||||
}
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
44
internal/api/session_ratelimit_test.go
Normal file
44
internal/api/session_ratelimit_test.go
Normal 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)
|
||||
}
|
@@ -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
@@ -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"
|
||||
|
@@ -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()
|
||||
|
@@ -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.
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
@@ -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.
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
@@ -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})
|
||||
|
677
internal/api/users_upload_multipart_test.go
Normal file
677
internal/api/users_upload_multipart_test.go
Normal 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)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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))))
|
||||
|
@@ -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])
|
||||
|
359
internal/commands/catalog/catalog.go
Normal file
359
internal/commands/catalog/catalog.go
Normal 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
|
||||
}
|
170
internal/commands/catalog/catalog_test.go
Normal file
170
internal/commands/catalog/catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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",
|
||||
|
@@ -44,9 +44,9 @@ func obtainClientCredentialsViaRegister(portalURL, joinToken, nodeName string) (
|
||||
if err := json.NewDecoder(resp.Body).Decode(®Resp); 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")
|
||||
|
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
})
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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())
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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`
|
||||
|
||||
|
@@ -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 }()
|
||||
|
@@ -18,5 +18,6 @@ var ShowCommands = &cli.Command{
|
||||
ShowThumbSizesCommand,
|
||||
ShowVideoSizesCommand,
|
||||
ShowMetadataCommand,
|
||||
ShowCommandsCommand,
|
||||
},
|
||||
}
|
||||
|
143
internal/commands/show_commands.go
Normal file
143
internal/commands/show_commands.go
Normal 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
|
||||
}
|
127
internal/commands/show_commands_test.go
Normal file
127
internal/commands/show_commands_test.go
Normal 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))])
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
Reference in New Issue
Block a user