From 61ced7119cc59e44dcaf6dd6c120f44c8d494ea9 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 24 Sep 2025 08:28:38 +0200 Subject: [PATCH] Auth: Refactor cluster configuration and provisioning API endpoints #98 Signed-off-by: Michael Mayer --- AGENTS.md | 45 +- CODEMAP.md | 21 + go.mod | 2 +- internal/ai/classify/label_rule_test.go | 1 - internal/ai/classify/label_test.go | 3 - internal/ai/classify/labels_test.go | 6 - internal/ai/tensorflow/labels.go | 4 +- internal/api/albums_test.go | 2 - internal/api/api_test.go | 3 + internal/api/cluster_nodes.go | 69 +-- internal/api/cluster_nodes_redaction_test.go | 12 +- internal/api/cluster_nodes_register.go | 154 ++++++- internal/api/cluster_nodes_register_test.go | 162 ++++++- .../api/cluster_nodes_register_url_test.go | 41 ++ internal/api/cluster_nodes_test.go | 25 +- .../api/cluster_nodes_update_siteurl_test.go | 13 +- internal/api/cluster_permissions_test.go | 2 - internal/api/cluster_theme.go | 51 ++- internal/api/cluster_theme_test.go | 49 +- internal/api/faces_test.go | 3 - internal/api/folders_cover_test.go | 3 - internal/api/labels_test.go | 3 - internal/api/links_test.go | 6 - internal/api/metrics_test.go | 2 - internal/api/photo_unstack_test.go | 2 - internal/api/photos_search_test.go | 2 - internal/api/photos_test.go | 10 - internal/api/services_test.go | 3 - internal/api/subjects_test.go | 2 - internal/api/swagger.json | 55 ++- internal/api/users_password_test.go | 11 - internal/api/users_update_test.go | 6 - internal/api/video_test.go | 7 - internal/api/websocket_test.go | 1 - internal/api/zip.go | 27 +- internal/auth/acl/roles_test.go | 10 - internal/commands/clients_flags_test.go | 1 - internal/commands/cluster_helpers.go | 4 +- internal/commands/cluster_nodes_list.go | 8 +- internal/commands/cluster_nodes_mod.go | 11 +- internal/commands/cluster_nodes_remove.go | 30 +- internal/commands/cluster_nodes_rotate.go | 22 +- internal/commands/cluster_nodes_show.go | 17 +- internal/commands/cluster_register.go | 136 ++++-- .../commands/cluster_register_http_test.go | 69 ++- internal/commands/cluster_theme_pull.go | 10 +- .../commands/cluster_theme_pull_oauth_test.go | 10 +- internal/commands/commands_test.go | 4 + internal/commands/download_e2e_test.go | 74 ++- internal/commands/users_flags_test.go | 1 - internal/config/client_assets_test.go | 1 - internal/config/cluster_defaults.go | 101 +++++ internal/config/cluster_defaults_test.go | 44 ++ internal/config/config.go | 1 - internal/config/config_backup_test.go | 2 +- internal/config/config_cluster.go | 235 ++++++++-- internal/config/config_cluster_test.go | 156 +++++-- internal/config/config_const.go | 1 + internal/config/config_db.go | 35 +- internal/config/config_db_test.go | 44 +- internal/config/config_storage_test.go | 7 - internal/config/config_test.go | 4 +- internal/config/config_thumb_test.go | 4 +- internal/config/customize/acl_test.go | 1 - internal/config/customize/scope_test.go | 4 - internal/config/expand.go | 17 + internal/config/expand_test.go | 66 +++ internal/config/flags.go | 64 ++- internal/config/options.go | 428 +++++++++--------- internal/config/options_test.go | 2 +- internal/config/report.go | 28 +- internal/config/report_sections.go | 4 +- internal/config/test.go | 4 +- internal/config/testdata/config.yml | 2 +- internal/entity/album_test.go | 2 - internal/entity/auth_client.go | 13 + internal/entity/auth_client_add_test.go | 1 - internal/entity/auth_client_data.go | 3 +- internal/entity/auth_client_fixtures_test.go | 2 - internal/entity/auth_client_test.go | 5 - internal/entity/auth_session_fixtures_test.go | 2 - internal/entity/auth_session_login_test.go | 4 - internal/entity/auth_user_details_test.go | 1 - internal/entity/auth_user_fixtures_test.go | 2 - .../entity/auth_user_share_fixtures_test.go | 2 - internal/entity/auth_user_test.go | 37 +- internal/entity/details_test.go | 1 - internal/entity/entity_test.go | 4 + internal/entity/face_test.go | 2 - internal/entity/file_share_test.go | 1 - internal/entity/file_sync_test.go | 1 - internal/entity/file_test.go | 2 - internal/entity/folder_test.go | 7 - internal/entity/passcode_test.go | 1 - internal/entity/photo_datetime_test.go | 3 - internal/entity/photo_location_test.go | 3 - internal/entity/photo_merge_test.go | 1 - internal/entity/photo_test.go | 1 - internal/entity/photo_title_test.go | 3 - internal/entity/query/albums_test.go | 5 - internal/entity/query/faces_test.go | 3 - internal/entity/query/files_test.go | 4 - internal/entity/query/folders_test.go | 1 - internal/entity/query/label_test.go | 2 - internal/entity/query/photo_test.go | 4 - internal/entity/search/accounts_test.go | 1 - internal/entity/search/albums_test.go | 3 - internal/entity/search/conditions_test.go | 21 - internal/entity/search/labels_test.go | 5 - .../entity/search/photos_filter_geo_test.go | 1 - internal/entity/search/photos_geo_test.go | 4 - internal/entity/search/photos_results_test.go | 1 - internal/entity/search/photos_test.go | 8 - internal/entity/search/viewer/url_test.go | 1 - .../ffmpeg/extract_image_cmd_negative_test.go | 2 +- internal/ffmpeg/remux_test.go | 6 +- .../ffmpeg/transcode_cmd_negative_test.go | 2 +- internal/form/search_photos_test.go | 1 - internal/form/serialize_test.go | 4 - internal/form/service_search_test.go | 1 - internal/meta/data_test.go | 7 - internal/meta/duration_test.go | 9 - internal/meta/exif_test.go | 29 -- internal/meta/gps_test.go | 6 - internal/meta/json_test.go | 59 --- internal/meta/keywords_test.go | 3 - internal/meta/sanitize_test.go | 19 - internal/meta/xmp_test.go | 5 - internal/photoprism/convert_image_test.go | 2 - .../photoprism/convert_sidecar_json_test.go | 3 - internal/photoprism/convert_video_avc_test.go | 4 - internal/photoprism/dl/cmd.go | 44 ++ internal/photoprism/dl/file.go | 28 +- internal/photoprism/dl/info.go | 13 +- internal/photoprism/filename_test.go | 1 - internal/photoprism/import_test.go | 1 - internal/photoprism/index_mediafile_test.go | 3 - internal/photoprism/index_related_test.go | 1 - internal/photoprism/label_test.go | 9 - .../mediafile_copy_move_force_test.go | 6 +- internal/photoprism/mediafile_meta_test.go | 1 - internal/photoprism/mediafile_related_test.go | 7 - internal/photoprism/mediafile_test.go | 7 - internal/photoprism/mediafile_thumbs_test.go | 3 - internal/photoprism/photoprism_test.go | 4 + internal/photoprism/places_test.go | 1 - internal/photoprism/thumbs_test.go | 3 - internal/server/limiter/request_test.go | 2 - .../service/cluster/{node.go => cluster.go} | 0 .../service/cluster/instance/bootstrap.go | 48 +- .../cluster/instance/bootstrap_test.go | 39 +- internal/service/cluster/instance/instance.go | 38 ++ .../service/cluster/instance/instance_test.go | 15 + .../cluster/provisioner/credentials.go | 132 ++++++ .../cluster/provisioner/credentials_test.go | 113 +++++ .../service/cluster/provisioner/database.go | 127 ++++++ .../cluster/provisioner/database_test.go | 197 ++++++++ internal/service/cluster/provisioner/db.go | 116 ----- .../service/cluster/provisioner/naming.go | 79 ++-- .../cluster/provisioner/naming_test.go | 18 +- .../cluster/provisioner/provisioner.go | 53 +++ internal/service/cluster/registry/client.go | 188 ++++++-- .../cluster/registry/client_more_test.go | 7 +- .../service/cluster/registry/client_test.go | 41 +- internal/service/cluster/registry/node.go | 24 +- internal/service/cluster/registry/registry.go | 47 +- .../registry/registry_clientid_reuse_test.go | 79 ++++ .../registry/registry_clientid_test.go | 145 ++++++ .../registry/registry_list_negative_test.go | 41 ++ .../cluster/registry/registry_rotate_test.go | 49 ++ .../service/cluster/registry/registry_test.go | 219 +++++++++ .../cluster/registry/registry_uuid_test.go | 152 +++++++ internal/service/cluster/registry/response.go | 10 +- internal/service/cluster/response.go | 16 +- internal/service/maps/country_test.go | 2 - internal/thumb/create_test.go | 2 - internal/thumb/frame/collage_test.go | 3 - internal/thumb/frame/image_test.go | 1 - internal/thumb/names_test.go | 1 - internal/workers/backup_test.go | 2 +- internal/workers/index_test.go | 2 +- internal/workers/meta_test.go | 2 +- internal/workers/sync_download_test.go | 4 +- pkg/authn/grants.go | 6 + pkg/clean/ascii.go | 23 +- pkg/clean/dnslabel.go | 64 +++ pkg/clean/dnslabel_test.go | 28 ++ pkg/clean/duration_test.go | 11 - pkg/clean/header.go | 22 +- pkg/clean/hex.go | 47 +- pkg/clean/ip.go | 28 +- pkg/clean/numeric_test.go | 9 - pkg/clean/orientation_test.go | 2 - pkg/clean/search.go | 72 ++- pkg/clean/search_test.go | 26 ++ pkg/clean/state_test.go | 12 - pkg/clean/token.go | 27 +- pkg/clean/type.go | 60 ++- pkg/clean/type_test.go | 58 +++ pkg/fs/cache_test.go | 3 - pkg/fs/const.go | 2 +- pkg/fs/copy_move_test.go | 36 +- pkg/fs/directories_test.go | 5 - pkg/fs/file_ext_test.go | 16 - pkg/fs/file_type_test.go | 2 - pkg/fs/mode.go | 9 +- pkg/fs/purge.go | 71 +++ pkg/fs/purge_test.go | 118 +++++ pkg/fs/resolve_test.go | 2 +- pkg/fs/walk_test.go | 7 +- pkg/fs/write_test.go | 2 +- pkg/fs/yaml_test.go | 10 +- pkg/geo/movement_test.go | 4 - pkg/geo/pluscode/pluscode_test.go | 7 - pkg/geo/s2/s2_test.go | 12 - pkg/geo/s2/token_prefix_test.go | 5 - pkg/i18n/i18n_test.go | 7 - pkg/i18n/response_test.go | 6 - pkg/list/bench_test.go | 65 +++ pkg/list/contains.go | 30 +- pkg/list/join.go | 29 +- pkg/media/colors/colorful_test.go | 1 - pkg/media/colors/lightmap_test.go | 5 - pkg/media/data_url_test.go | 7 - pkg/rnd/uuid.go | 8 + pkg/service/http/header/ip.go | 40 +- pkg/txt/clip/runes.go | 25 +- pkg/txt/clip_test.go | 17 + pkg/txt/contains.go | 28 +- pkg/txt/contains_test.go | 17 + pkg/txt/country_test.go | 25 - pkg/txt/datetime_test.go | 13 - pkg/txt/datetime_year_test.go | 17 - pkg/txt/float_test.go | 30 -- pkg/txt/int_test.go | 35 -- pkg/txt/numeric_test.go | 9 - pkg/txt/report/timestamp_test.go | 3 - pkg/txt/split_test.go | 1 - pkg/txt/states_test.go | 2 - pkg/txt/words.go | 18 +- pkg/txt/words_bench_test.go | 68 +++ pkg/vector/values_test.go | 8 - 242 files changed, 4477 insertions(+), 1789 deletions(-) create mode 100644 internal/api/cluster_nodes_register_url_test.go create mode 100644 internal/config/cluster_defaults.go create mode 100644 internal/config/cluster_defaults_test.go create mode 100644 internal/config/expand.go create mode 100644 internal/config/expand_test.go create mode 100644 internal/photoprism/dl/cmd.go rename internal/service/cluster/{node.go => cluster.go} (100%) create mode 100644 internal/service/cluster/instance/instance.go create mode 100644 internal/service/cluster/instance/instance_test.go create mode 100644 internal/service/cluster/provisioner/credentials.go create mode 100644 internal/service/cluster/provisioner/credentials_test.go create mode 100644 internal/service/cluster/provisioner/database.go create mode 100644 internal/service/cluster/provisioner/database_test.go delete mode 100644 internal/service/cluster/provisioner/db.go create mode 100644 internal/service/cluster/provisioner/provisioner.go create mode 100644 internal/service/cluster/registry/registry_clientid_reuse_test.go create mode 100644 internal/service/cluster/registry/registry_clientid_test.go create mode 100644 internal/service/cluster/registry/registry_list_negative_test.go create mode 100644 internal/service/cluster/registry/registry_rotate_test.go create mode 100644 internal/service/cluster/registry/registry_test.go create mode 100644 internal/service/cluster/registry/registry_uuid_test.go create mode 100644 pkg/clean/dnslabel.go create mode 100644 pkg/clean/dnslabel_test.go create mode 100644 pkg/fs/purge.go create mode 100644 pkg/fs/purge_test.go create mode 100644 pkg/list/bench_test.go create mode 100644 pkg/txt/words_bench_test.go diff --git a/AGENTS.md b/AGENTS.md index 3f01f7da1..885b4284b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,19 @@ 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`. @@ -215,6 +228,13 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t ### Testing - Go tests: When adding tests for sources in `path/to/pkg/.go`, always place them in `path/to/pkg/_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/ -run -count=1` - Commands: `go test ./internal/commands -run -count=1` @@ -338,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 @@ -426,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`, `photoprism_u`. 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. diff --git a/CODEMAP.md b/CODEMAP.md index 546b3abae..7f4357b6c 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -173,7 +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` + - username: `photoprism_u` + 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/.go`, add tests in `path/to/pkg/_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` diff --git a/go.mod b/go.mod index ccd2e55c2..66c043a7c 100644 --- a/go.mod +++ b/go.mod @@ -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,7 +131,6 @@ 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 diff --git a/internal/ai/classify/label_rule_test.go b/internal/ai/classify/label_rule_test.go index cf6647d73..04b5b898a 100644 --- a/internal/ai/classify/label_rule_test.go +++ b/internal/ai/classify/label_rule_test.go @@ -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) diff --git a/internal/ai/classify/label_test.go b/internal/ai/classify/label_test.go index 397d03003..74d5a41e4 100644 --- a/internal/ai/classify/label_test.go +++ b/internal/ai/classify/label_test.go @@ -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()) diff --git a/internal/ai/classify/labels_test.go b/internal/ai/classify/labels_test.go index 26fef7d3f..353501f60 100644 --- a/internal/ai/classify/labels_test.go +++ b/internal/ai/classify/labels_test.go @@ -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} diff --git a/internal/ai/tensorflow/labels.go b/internal/ai/tensorflow/labels.go index 4791e18b2..c5e9f084f 100644 --- a/internal/ai/tensorflow/labels.go +++ b/internal/ai/tensorflow/labels.go @@ -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 } diff --git a/internal/api/albums_test.go b/internal/api/albums_test.go index 571f8f387..241f34f82 100644 --- a/internal/api/albums_test.go +++ b/internal/api/albums_test.go @@ -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) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2b8896a24..58543e072 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) } diff --git a/internal/api/cluster_nodes.go b/internal/api/cluster_nodes.go index e6a6e5140..2d308f17d 100644 --- a/internal/api/cluster_nodes.go +++ b/internal/api/cluster_nodes.go @@ -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"}) }) } diff --git a/internal/api/cluster_nodes_redaction_test.go b/internal/api/cluster_nodes_redaction_test.go index ae297dbf7..a1039ae82 100644 --- a/internal/api/cluster_nodes_redaction_test.go +++ b/internal/api/cluster_nodes_redaction_test.go @@ -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). diff --git a/internal/api/cluster_nodes_register.go b/internal/api/cluster_nodes_register.go index 727b3f8c5..6392c13cf 100644 --- a/internal/api/cluster_nodes_register.go +++ b/internal/api/cluster_nodes_register.go @@ -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) } diff --git a/internal/api/cluster_nodes_register_test.go b/internal/api/cluster_nodes_register_test.go index 61efb1822..148330d8a 100644 --- a/internal/api/cluster_nodes_register_test.go +++ b/internal/api/cluster_nodes_register_test.go @@ -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) + } + }) } diff --git a/internal/api/cluster_nodes_register_url_test.go b/internal/api/cluster_nodes_register_url_test.go new file mode 100644 index 000000000..d03efbe67 --- /dev/null +++ b/internal/api/cluster_nodes_register_url_test.go @@ -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) + } + } +} diff --git a/internal/api/cluster_nodes_test.go b/internal/api/cluster_nodes_test.go index 52f7219f6..938faa438 100644 --- a/internal/api/cluster_nodes_test.go +++ b/internal/api/cluster_nodes_test.go @@ -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. diff --git a/internal/api/cluster_nodes_update_siteurl_test.go b/internal/api/cluster_nodes_update_siteurl_test.go index 01ba6075c..fd9210072 100644 --- a/internal/api/cluster_nodes_update_siteurl_test.go +++ b/internal/api/cluster_nodes_update_siteurl_test.go @@ -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) } diff --git a/internal/api/cluster_permissions_test.go b/internal/api/cluster_permissions_test.go index 8abec2159..a7256b24b 100644 --- a/internal/api/cluster_permissions_test.go +++ b/internal/api/cluster_permissions_test.go @@ -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 diff --git a/internal/api/cluster_theme.go b/internal/api/cluster_theme.go index 914e9a0a2..47986b41e 100644 --- a/internal/api/cluster_theme.go +++ b/internal/api/cluster_theme.go @@ -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) } }) } diff --git a/internal/api/cluster_theme_test.go b/internal/api/cluster_theme_test.go index a88a17949..ac5c8701a 100644 --- a/internal/api/cluster_theme_test.go +++ b/internal/api/cluster_theme_test.go @@ -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)) + }) } diff --git a/internal/api/faces_test.go b/internal/api/faces_test.go index d20310d6a..9cb4e1e08 100644 --- a/internal/api/faces_test.go +++ b/internal/api/faces_test.go @@ -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) diff --git a/internal/api/folders_cover_test.go b/internal/api/folders_cover_test.go index d95496e6c..5021d0604 100644 --- a/internal/api/folders_cover_test.go +++ b/internal/api/folders_cover_test.go @@ -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) diff --git a/internal/api/labels_test.go b/internal/api/labels_test.go index b4e4c7887..64c1f7469 100644 --- a/internal/api/labels_test.go +++ b/internal/api/labels_test.go @@ -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) diff --git a/internal/api/links_test.go b/internal/api/links_test.go index d26fa5304..5d5001fa0 100644 --- a/internal/api/links_test.go +++ b/internal/api/links_test.go @@ -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) diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go index 19cd0458a..c54e176f2 100644 --- a/internal/api/metrics_test.go +++ b/internal/api/metrics_test.go @@ -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() diff --git a/internal/api/photo_unstack_test.go b/internal/api/photo_unstack_test.go index b50fa8819..81853cc1d 100644 --- a/internal/api/photo_unstack_test.go +++ b/internal/api/photo_unstack_test.go @@ -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) diff --git a/internal/api/photos_search_test.go b/internal/api/photos_search_test.go index b8bebe46c..b152dc02b 100644 --- a/internal/api/photos_search_test.go +++ b/internal/api/photos_search_test.go @@ -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) diff --git a/internal/api/photos_test.go b/internal/api/photos_test.go index f88c50d67..b15c81759 100644 --- a/internal/api/photos_test.go +++ b/internal/api/photos_test.go @@ -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) diff --git a/internal/api/services_test.go b/internal/api/services_test.go index 1a690234e..b02ac3504 100644 --- a/internal/api/services_test.go +++ b/internal/api/services_test.go @@ -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) diff --git a/internal/api/subjects_test.go b/internal/api/subjects_test.go index 4273cb37b..7348161b1 100644 --- a/internal/api/subjects_test.go +++ b/internal/api/subjects_test.go @@ -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) diff --git a/internal/api/swagger.json b/internal/api/swagger.json index bee51b1c3..165f9d172 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -329,15 +329,15 @@ "advertiseUrl": { "type": "string" }, + "clientId": { + "type": "string" + }, "createdAt": { "type": "string" }, "database": { "$ref": "#/definitions/cluster.NodeDatabase" }, - "id": { - "type": "string" - }, "labels": { "additionalProperties": { "type": "string" @@ -345,9 +345,11 @@ "type": "object" }, "name": { + "description": "NodeName", "type": "string" }, "role": { + "description": "NodeRole", "type": "string" }, "siteUrl": { @@ -355,12 +357,19 @@ }, "updatedAt": { "type": "string" + }, + "uuid": { + "description": "NodeUUID", + "type": "string" } }, "type": "object" }, "cluster.NodeDatabase": { "properties": { + "driver": { + "type": "string" + }, "name": { "type": "string" }, @@ -375,6 +384,9 @@ }, "cluster.RegisterDatabase": { "properties": { + "driver": { + "type": "string" + }, "dsn": { "type": "string" }, @@ -415,16 +427,20 @@ }, "secrets": { "$ref": "#/definitions/cluster.RegisterSecrets" + }, + "uuid": { + "description": "ClusterUUID", + "type": "string" } }, "type": "object" }, "cluster.RegisterSecrets": { "properties": { - "nodeSecret": { + "clientSecret": { "type": "string" }, - "secretRotatedAt": { + "rotatedAt": { "type": "string" } }, @@ -440,9 +456,6 @@ }, "cluster.SummaryResponse": { "properties": { - "UUID": { - "type": "string" - }, "database": { "$ref": "#/definitions/cluster.DatabaseInfo" }, @@ -451,6 +464,10 @@ }, "time": { "type": "string" + }, + "uuid": { + "description": "ClusterUUID", + "type": "string" } }, "type": "object" @@ -6449,7 +6466,7 @@ "operationId": "ClusterNodesRegister", "parameters": [ { - "description": "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl, rotateDatabase, rotateSecret)", + "description": "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl; to authorize UUID/name changes include clientId+clientSecret; rotation: rotateDatabase, rotateSecret)", "in": "body", "name": "request", "required": true, @@ -6505,20 +6522,20 @@ } } }, - "summary": "registers a node, provisions DB credentials, and issues nodeSecret", + "summary": "registers a node, provisions DB credentials, and issues clientSecret", "tags": [ "Cluster" ] } }, - "/api/v1/cluster/nodes/{id}": { + "/api/v1/cluster/nodes/{uuid}": { "delete": { "operationId": "ClusterDeleteNode", "parameters": [ { - "description": "node id", + "description": "node uuid", "in": "path", - "name": "id", + "name": "uuid", "required": true, "type": "string" } @@ -6558,7 +6575,7 @@ } } }, - "summary": "delete node by id", + "summary": "delete node by uuid", "tags": [ "Cluster" ] @@ -6567,9 +6584,9 @@ "operationId": "ClusterGetNode", "parameters": [ { - "description": "node id", + "description": "node uuid", "in": "path", - "name": "id", + "name": "uuid", "required": true, "type": "string" } @@ -6609,7 +6626,7 @@ } } }, - "summary": "get node by id", + "summary": "get node by uuid", "tags": [ "Cluster" ] @@ -6621,9 +6638,9 @@ "operationId": "ClusterUpdateNode", "parameters": [ { - "description": "node id", + "description": "node uuid", "in": "path", - "name": "id", + "name": "uuid", "required": true, "type": "string" }, diff --git a/internal/api/users_password_test.go b/internal/api/users_password_test.go index b8ea70e0d..5b34edd3b 100644 --- a/internal/api/users_password_test.go +++ b/internal/api/users_password_test.go @@ -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) diff --git a/internal/api/users_update_test.go b/internal/api/users_update_test.go index 7c4a1cdec..f5558dc25 100644 --- a/internal/api/users_update_test.go +++ b/internal/api/users_update_test.go @@ -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) diff --git a/internal/api/video_test.go b/internal/api/video_test.go index feca46566..cec17719f 100644 --- a/internal/api/video_test.go +++ b/internal/api/video_test.go @@ -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) diff --git a/internal/api/websocket_test.go b/internal/api/websocket_test.go index 508ce3c2b..2f4207622 100644 --- a/internal/api/websocket_test.go +++ b/internal/api/websocket_test.go @@ -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) diff --git a/internal/api/zip.go b/internal/api/zip.go index a93452559..664ffbbce 100644 --- a/internal/api/zip.go +++ b/internal/api/zip.go @@ -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)))) diff --git a/internal/auth/acl/roles_test.go b/internal/auth/acl/roles_test.go index 9ebb12d33..7eeba626e 100644 --- a/internal/auth/acl/roles_test.go +++ b/internal/auth/acl/roles_test.go @@ -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]) diff --git a/internal/commands/clients_flags_test.go b/internal/commands/clients_flags_test.go index 696780673..679e1a832 100644 --- a/internal/commands/clients_flags_test.go +++ b/internal/commands/clients_flags_test.go @@ -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 { diff --git a/internal/commands/cluster_helpers.go b/internal/commands/cluster_helpers.go index a5b03402f..d6bbf5288 100644 --- a/internal/commands/cluster_helpers.go +++ b/internal/commands/cluster_helpers.go @@ -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") diff --git a/internal/commands/cluster_nodes_list.go b/internal/commands/cluster_nodes_list.go index f685eab6b..013d88e11 100644 --- a/internal/commands/cluster_nodes_list.go +++ b/internal/commands/cluster_nodes_list.go @@ -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, }) } diff --git a/internal/commands/cluster_nodes_mod.go b/internal/commands/cluster_nodes_mod.go index ee1b740b8..4e6910697 100644 --- a/internal/commands/cluster_nodes_mod.go +++ b/internal/commands/cluster_nodes_mod.go @@ -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) } diff --git a/internal/commands/cluster_nodes_remove.go b/internal/commands/cluster_nodes_remove.go index fa22b3def..b17a3a3cf 100644 --- a/internal/commands/cluster_nodes_remove.go +++ b/internal/commands/cluster_nodes_remove.go @@ -18,6 +18,7 @@ var ClusterNodesRemoveCommand = &cli.Command{ ArgsUsage: "", 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 }) } diff --git a/internal/commands/cluster_nodes_rotate.go b/internal/commands/cluster_nodes_rotate.go index 15571763d..be026dde0 100644 --- a/internal/commands/cluster_nodes_rotate.go +++ b/internal/commands/cluster_nodes_rotate.go @@ -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)) } diff --git a/internal/commands/cluster_nodes_show.go b/internal/commands/cluster_nodes_show.go index a95820b41..02ccd60be 100644 --- a/internal/commands/cluster_nodes_show.go +++ b/internal/commands/cluster_nodes_show.go @@ -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 { diff --git a/internal/commands/cluster_register.go b/internal/commands/cluster_register.go index d1b7f3963..36d0fae78 100644 --- a/internal/commands/cluster_register.go +++ b/internal/commands/cluster_register.go @@ -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) } diff --git a/internal/commands/cluster_register_http_test.go b/internal/commands/cluster_register_http_test.go index 32929fdd1..ff98241e9 100644 --- a/internal/commands/cluster_register_http_test.go +++ b/internal/commands/cluster_register_http_test.go @@ -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()) } diff --git a/internal/commands/cluster_theme_pull.go b/internal/commands/cluster_theme_pull.go index 5d8e3dff4..f05971984 100644 --- a/internal/commands/cluster_theme_pull.go +++ b/internal/commands/cluster_theme_pull.go @@ -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() diff --git a/internal/commands/cluster_theme_pull_oauth_test.go b/internal/commands/cluster_theme_pull_oauth_test.go index 1d6d4c953..39817908a 100644 --- a/internal/commands/cluster_theme_pull_oauth_test.go +++ b/internal/commands/cluster_theme_pull_oauth_test.go @@ -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 } diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index c916a065e..f9101e63f 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -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) } diff --git a/internal/commands/download_e2e_test.go b/internal/commands/download_e2e_test.go index 1ecc37365..3e5c0a5d2 100644 --- a/internal/commands/download_e2e_test.go +++ b/internal/commands/download_e2e_test.go @@ -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 }() diff --git a/internal/commands/users_flags_test.go b/internal/commands/users_flags_test.go index 8f7884d5f..5c6097c72 100644 --- a/internal/commands/users_flags_test.go +++ b/internal/commands/users_flags_test.go @@ -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 { diff --git a/internal/config/client_assets_test.go b/internal/config/client_assets_test.go index 14f52a7d4..aa741496f 100644 --- a/internal/config/client_assets_test.go +++ b/internal/config/client_assets_test.go @@ -32,7 +32,6 @@ func TestClientAssets_Load(t *testing.T) { assert.Equal(t, "splash.test.css", a.SplashCssFile()) assert.NotEmpty(t, a.SplashCssFileContents()) }) - t.Run("Error", func(t *testing.T) { testBuildPath := "testdata/foo" a := NewClientAssets(testBuildPath, c.StaticUri()) diff --git a/internal/config/cluster_defaults.go b/internal/config/cluster_defaults.go new file mode 100644 index 000000000..ad5371173 --- /dev/null +++ b/internal/config/cluster_defaults.go @@ -0,0 +1,101 @@ +package config + +import ( + "net" + "os" + "regexp" + "strings" +) + +// getHostname is a var to allow tests to stub os.Hostname. +var getHostname = os.Hostname + +// NonUniqueHostnames lists hostnames that must never be used as node name or to derive a cluster domain. +// It is a package variable on purpose so operators/tests can adjust in the future without spec changes. +var NonUniqueHostnames = map[string]struct{}{ + "localhost": {}, + "localhost.localdomain": {}, + "localdomain": {}, +} + +// ReservedDomains lists special/reserved domains that must not be used as cluster domains. +var ReservedDomains = map[string]struct{}{ + "example.com": {}, + "example.net": {}, + "example.org": {}, + "invalid": {}, + "test": {}, +} + +var dnsLabelRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$`) + +// isDNSLabel returns true if s is a valid DNS label per our rules: lowercase, [a-z0-9-], 1–32 chars, starts/ends alnum. +func isDNSLabel(s string) bool { + if s == "" || len(s) > 32 { + return false + } + return dnsLabelRe.MatchString(s) +} + +// isLocalSuffix returns true for .local mDNS or similar local-only suffixes we want to ignore. +func isLocalSuffix(suffix string) bool { + return suffix == "local" || strings.HasSuffix(suffix, ".local") +} + +// isDNSDomain validates a DNS domain (FQDN or single label not allowed here). It must have at least one dot. +// Each label must match isDNSLabel (except overall length and hyphen rules already covered by regex logic). +func isDNSDomain(d string) bool { + d = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(d)), ".") + if d == "" || strings.Count(d, ".") < 1 || len(d) > 253 { + return false + } + if _, bad := ReservedDomains[d]; bad { + return false + } + if isLocalSuffix(d) { + return false + } + parts := strings.Split(d, ".") + for _, p := range parts { + if !isDNSLabel(p) { + return false + } + } + return true +} + +// deriveSystemDomain tries to determine a usable cluster domain from system configuration. +// It uses the system hostname and returns the domain (everything after the first dot) when valid and not reserved. +func deriveSystemDomain() string { + hn, _ := getHostname() + hn = strings.ToLower(strings.TrimSpace(hn)) + if hn == "" { + return "" + } + if _, bad := NonUniqueHostnames[hn]; bad { + return "" + } + // If hostname contains a dot, take the domain part. + if i := strings.IndexByte(hn, '.'); i > 0 && i < len(hn)-1 { + dom := hn[i+1:] + if isDNSDomain(dom) { + return dom + } + } + // Try reverse lookup to get FQDN domain, then validate. + if addrs, err := net.LookupAddr(hn); err == nil { + for _, fqdn := range addrs { + fqdn = strings.TrimSuffix(strings.ToLower(fqdn), ".") + if fqdn == "" || fqdn == hn { + continue + } + if i := strings.IndexByte(fqdn, '.'); i > 0 && i < len(fqdn)-1 { + dom := fqdn[i+1:] + if isDNSDomain(dom) { + return dom + } + } + } + } + return "" +} diff --git a/internal/config/cluster_defaults_test.go b/internal/config/cluster_defaults_test.go new file mode 100644 index 000000000..198577c9e --- /dev/null +++ b/internal/config/cluster_defaults_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "testing" +) + +func Test_isDNSLabel(t *testing.T) { + good := []string{"a", "node1", "pp-node-01", "n32", "a234567890123456789012345678901"} + bad := []string{"", "A", "node_1", "-bad", "bad-", stringsRepeat("a", 33)} + for _, s := range good { + if !isDNSLabel(s) { + t.Fatalf("expected valid label: %q", s) + } + } + for _, s := range bad { + if isDNSLabel(s) { + t.Fatalf("expected invalid label: %q", s) + } + } +} + +func Test_isDNSDomain(t *testing.T) { + good := []string{"example.dev", "sub.domain.dev", "a.b"} + bad := []string{"localdomain", "localhost", "a", "EXAMPLE.com", "example.com", "invalid", "test", "x.local"} + for _, s := range good { + if !isDNSDomain(s) { + t.Fatalf("expected valid domain: %q", s) + } + } + for _, s := range bad { + if isDNSDomain(s) { + t.Fatalf("expected invalid domain: %q", s) + } + } +} + +// helper: fast string repeat without importing strings just for tests +func stringsRepeat(s string, n int) string { + b := make([]byte, 0, len(s)*n) + for i := 0; i < n; i++ { + b = append(b, s...) + } + return string(b) +} diff --git a/internal/config/config.go b/internal/config/config.go index f2fe63507..dd51c0de3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -239,7 +239,6 @@ func (c *Config) Init() error { // Initialize early extensions before connecting to the database so they can // influence DB settings (e.g., cluster bootstrap providing MariaDB creds). - log.Debugf("config: initializing early extensions") EarlyExt().InitEarly(c) // Connect to database. diff --git a/internal/config/config_backup_test.go b/internal/config/config_backup_test.go index f784bc4b9..5fc8d5fa8 100644 --- a/internal/config/config_backup_test.go +++ b/internal/config/config_backup_test.go @@ -58,7 +58,7 @@ func TestConfig_BackupDatabasePath(t *testing.T) { c := NewConfig(CliTestContext()) // Ensure DB defaults (SQLite) so path resolves to sqlite backup path c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Contains(t, c.BackupDatabasePath(), "/storage/testdata/backup/sqlite") } diff --git a/internal/config/config_cluster.go b/internal/config/config_cluster.go index 78cc3b305..e745839bb 100644 --- a/internal/config/config_cluster.go +++ b/internal/config/config_cluster.go @@ -1,6 +1,7 @@ package config import ( + "errors" "os" "path/filepath" "strings" @@ -11,10 +12,72 @@ import ( "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/service/http/header" ) +// DefaultPortalUrl specifies the default portal URL with variable cluster domain. +var DefaultPortalUrl = "https://portal.${PHOTOPRISM_CLUSTER_DOMAIN}" +var DefaultNodeRole = cluster.RoleInstance + +// ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 1–63 chars). +func (c *Config) ClusterDomain() string { + if c.options.ClusterDomain != "" { + return strings.ToLower(c.options.ClusterDomain) + } + + if _, d, found := c.deriveNodeNameAndDomainFromHttpHost(); found && d != "" { + return d + } + + // Attempt to derive from system configuration when not explicitly set. + if d := deriveSystemDomain(); d != "" { + return d + } + + return "" +} + +// ClusterCIDR returns the configured cluster CIDR used for IP-based allowances. +func (c *Config) ClusterCIDR() string { + return strings.TrimSpace(c.options.ClusterCIDR) +} + +// ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal. +// Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist. +func (c *Config) ClusterUUID() string { + // Return if the configured cluster UUID is not in the expected format. + if !rnd.IsUUID(c.options.ClusterUUID) { + return "" + } + + // Respect explicit CLI value if provided. + if c.cliCtx != nil && c.cliCtx.IsSet("cluster-uuid") { + return c.options.ClusterUUID + } + + return c.options.ClusterUUID +} + // PortalUrl returns the URL of the cluster management portal server, if configured. func (c *Config) PortalUrl() string { + if c.options.PortalUrl == "" { + return "" + } + + d := c.ClusterDomain() + + // Return empty string if default and there's no cluster domain configured. + if d == "" && c.options.PortalUrl == DefaultPortalUrl { + return "" + } + + // Replace variables with the configured cluster domain. + c.options.PortalUrl = ExpandVars(c.options.PortalUrl, map[string]string{ + "cluster-domain": d, + "CLUSTER_DOMAIN": d, + "PHOTOPRISM_CLUSTER_DOMAIN": d, + }) + return c.options.PortalUrl } @@ -25,7 +88,7 @@ func (c *Config) IsPortal() bool { // PortalConfigPath returns the path to the default configuration for cluster nodes. func (c *Config) PortalConfigPath() string { - return filepath.Join(c.ConfigPath(), fs.ClusterDir) + return filepath.Join(c.ConfigPath(), fs.PortalDir) } // PortalThemePath returns the path to the theme files for cluster nodes to use. @@ -53,66 +116,94 @@ func (c *Config) JoinToken() string { } } -// ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal. -// Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist. -func (c *Config) ClusterUUID() string { - // Use value loaded into options only if it is persisted in the current options.yml. - // This avoids tests (or defaults) loading a UUID from an unrelated file path. - if c.options.ClusterUUID != "" { - // Respect explicit CLI value if provided. - if c.cliCtx != nil && c.cliCtx.IsSet("cluster-uuid") { - return c.options.ClusterUUID - } - // Otherwise, only trust a persisted value from the current options.yml. - if fs.FileExists(c.OptionsYaml()) { - return c.options.ClusterUUID +// deriveNodeNameAndDomainFromHttpHost attempts to derive cluster host and domain name from the site URL. +func (c *Config) deriveNodeNameAndDomainFromHttpHost() (hostName, domainName string, found bool) { + if fqdn := c.SiteDomain(); fqdn != "" && !header.IsIP(fqdn) { + hostName, domainName, found = strings.Cut(fqdn, ".") + if hostName = clean.DNSLabel(hostName); found && isDNSLabel(hostName) && isDNSDomain(domainName) { + c.options.NodeName = hostName + if c.options.ClusterDomain == "" { + c.options.ClusterDomain = strings.ToLower(domainName) + } + return c.options.NodeName, c.options.ClusterDomain, found } } - // Generate, persist, and cache in memory if still empty. - id := rnd.UUID() - c.options.ClusterUUID = id - - if err := c.saveClusterUUID(id); err != nil { - log.Warnf("config: failed to persist ClusterUUID to %s (%s)", c.OptionsYaml(), err) - } - - return id -} - -// ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 1–63 chars). -func (c *Config) ClusterDomain() string { - return c.options.ClusterDomain + return "", "", false } // NodeName returns the cluster node NAME (unique in cluster domain; [a-z0-9-]{1,32}). func (c *Config) NodeName() string { - return clean.TypeLowerDash(c.options.NodeName) + if n := clean.DNSLabel(c.options.NodeName); n != "" { + return n + } + + if h, _, found := c.deriveNodeNameAndDomainFromHttpHost(); found && h != "" { + return h + } + + // Default: portal nodes → "portal". + if c.IsPortal() { + return "portal" + } + + // Instances/services: derive from hostname via DNSLabel normalization. + if hn, _ := getHostname(); hn != "" { + if cand := clean.DNSLabel(hn); cand != "" { + return cand + } + } + + // Fallback to a stable short identifier + s := c.SerialChecksum() + return "node-" + s } // NodeRole returns the cluster node ROLE (portal, instance, or service). func (c *Config) NodeRole() string { + if c.Edition() == Portal { + c.options.NodeRole = cluster.RolePortal + return c.options.NodeRole + } + switch c.options.NodeRole { case cluster.RolePortal, cluster.RoleInstance, cluster.RoleService: return c.options.NodeRole default: - return cluster.RoleInstance + return DefaultNodeRole } } -// NodeID returns the client ID registered with the portal (auto-assigned via join token). -func (c *Config) NodeID() string { - return clean.ID(c.options.NodeID) +// NodeUUID returns the UUID (v7) that identifies this node. +func (c *Config) NodeUUID() string { + if c.options.NodeUUID != "" { + return c.options.NodeUUID + } + + // Generate, persist, and cache a UUIDv7 if still empty. + uuid := rnd.UUIDv7() + c.options.NodeUUID = uuid + + if err := c.SaveNodeUUID(uuid); err != nil { + log.Warnf("config: could not save node UUID to %s (%s)", c.OptionsYaml(), err) + } + + return uuid } -// NodeSecret returns client SECRET registered with the portal (auto-assigned via join token). -func (c *Config) NodeSecret() string { - if c.options.NodeSecret != "" { - return c.options.NodeSecret - } else if fileName := FlagFilePath("NODE_SECRET"); fileName == "" { +// NodeClientID returns the OAuth client ID registered with the portal (auto-assigned via join token). +func (c *Config) NodeClientID() string { + return clean.ID(c.options.NodeClientID) +} + +// NodeClientSecret returns the OAuth client SECRET registered with the portal (auto-assigned via join token). +func (c *Config) NodeClientSecret() string { + if c.options.NodeClientSecret != "" { + return c.options.NodeClientSecret + } else if fileName := FlagFilePath("NODE_CLIENT_SECRET"); fileName == "" { return "" } else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { - log.Warnf("config: failed to read node secret from %s (%s)", fileName, err) + log.Warnf("config: failed to read node client secret from %s (%s)", fileName, err) return "" } else { return string(b) @@ -121,23 +212,33 @@ func (c *Config) NodeSecret() string { // AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]). func (c *Config) AdvertiseUrl() string { - if c.options.AdvertiseUrl == "" { - return c.SiteUrl() + if c.options.AdvertiseUrl != "" { + return strings.TrimRight(c.options.AdvertiseUrl, "/") + "/" } - - return strings.TrimRight(c.options.AdvertiseUrl, "/") + "/" + // Derive from cluster domain and node name if available; otherwise fall back to SiteUrl(). + if d := c.ClusterDomain(); d != "" { + if n := c.NodeName(); n != "" && isDNSLabel(n) { + return "https://" + n + "." + d + "/" + } + } + return c.SiteUrl() } -// saveClusterUUID writes or updates the ClusterUUID key in options.yml without +// SaveClusterUUID writes or updates the ClusterUUID key in options.yml without // touching unrelated keys. Creates the file and directories if needed. -func (c *Config) saveClusterUUID(id string) error { +func (c *Config) SaveClusterUUID(uuid string) error { + if !rnd.IsUUID(uuid) { + return errors.New("invalid cluster UUID") + } + // Always resolve against the current ConfigPath and remember it explicitly // so subsequent calls don't accidentally point to a previous default. cfgDir := c.ConfigPath() if err := fs.MkdirAll(cfgDir); err != nil { return err } - fileName := filepath.Join(cfgDir, "options.yml") + + fileName := c.OptionsYaml() var m map[string]interface{} @@ -151,19 +252,55 @@ func (c *Config) saveClusterUUID(id string) error { m = map[string]interface{}{} } - m["ClusterUUID"] = id + m["ClusterUUID"] = uuid if b, err := yaml.Marshal(m); err != nil { return err - } else if err = os.WriteFile(fileName, b, 0o644); err != nil { + } else if err = os.WriteFile(fileName, b, fs.ModeFile); err != nil { return err } + c.options.ClusterUUID = uuid + // Remember options.yml path for subsequent loads and ensure in-memory options see the value. if c.options != nil { - c.options.OptionsYaml = fileName _ = c.options.Load(fileName) } return nil } + +// SaveNodeUUID writes or updates the NodeUUID key in options.yml without touching unrelated keys. +func (c *Config) SaveNodeUUID(uuid string) error { + if !rnd.IsUUID(uuid) { + return errors.New("invalid node UUID") + } + + cfgDir := c.ConfigPath() + + if err := fs.MkdirAll(cfgDir); err != nil { + return err + } + + fileName := c.OptionsYaml() + + var m map[string]interface{} + if fs.FileExists(fileName) { + if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { + _ = yaml.Unmarshal(b, &m) + } + } + if m == nil { + m = map[string]interface{}{} + } + m["NodeUUID"] = uuid + if b, err := yaml.Marshal(m); err != nil { + return err + } else if err = os.WriteFile(fileName, b, fs.ModeFile); err != nil { + return err + } + + c.options.NodeUUID = uuid + + return nil +} diff --git a/internal/config/config_cluster_test.go b/internal/config/config_cluster_test.go index 2707f63aa..534c8b9f9 100644 --- a/internal/config/config_cluster_test.go +++ b/internal/config/config_cluster_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -13,6 +14,52 @@ import ( "github.com/photoprism/photoprism/pkg/rnd" ) +func TestConfig_PortalUrl(t *testing.T) { + t.Run("Unset", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.PortalUrl = "" + c.options.ClusterDomain = "example.dev" + assert.Equal(t, "", c.PortalUrl()) + c.options.PortalUrl = DefaultPortalUrl + }) + t.Run("Default", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.PortalUrl = DefaultPortalUrl + c.options.ClusterDomain = "foo.bar.baz" + assert.Equal(t, "https://portal.foo.bar.baz", c.PortalUrl()) + }) + t.Run("Substitute_PHOTOPRISM_CLUSTER_DOMAIN", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.ClusterDomain = "example.dev" + // Use curly braces style as found in repo fixtures; resolver normalizes to ${...}. + c.options.PortalUrl = "https://portal.${PHOTOPRISM_CLUSTER_DOMAIN}" + assert.Equal(t, "https://portal.example.dev", c.PortalUrl()) + c.options.PortalUrl = DefaultPortalUrl + }) + t.Run("Substitute_CLUSTER_DOMAIN", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.ClusterDomain = "example.dev" + c.options.PortalUrl = "https://portal.${CLUSTER_DOMAIN}" + assert.Equal(t, "https://portal.example.dev", c.PortalUrl()) + c.options.PortalUrl = DefaultPortalUrl + }) + t.Run("Substitute_cluster_dash_domain_Curly", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.ClusterDomain = "example.dev" + // Curly brace variant {cluster-domain} is normalized by ExpandVars. + c.options.PortalUrl = "https://portal.${cluster-domain}" + assert.Equal(t, "https://portal.example.dev", c.PortalUrl()) + c.options.PortalUrl = DefaultPortalUrl + }) + t.Run("LiteralPreserved", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.PortalUrl = "https://portal.example.test" + c.options.ClusterDomain = "ignored.dev" + assert.Equal(t, "https://portal.example.test", c.PortalUrl()) + c.options.PortalUrl = DefaultPortalUrl + }) +} + func TestConfig_Cluster(t *testing.T) { t.Run("Flags", func(t *testing.T) { c := NewConfig(CliTestContext()) @@ -25,31 +72,30 @@ func TestConfig_Cluster(t *testing.T) { assert.True(t, c.IsPortal()) c.Options().NodeRole = "" }) - t.Run("Paths", func(t *testing.T) { c := NewConfig(CliTestContext()) // Use an isolated config path so we don't affect repo storage fixtures. tempCfg := t.TempDir() c.options.ConfigPath = tempCfg - c.options.NodeSecret = "" + c.options.NodeClientSecret = "" c.options.PortalUrl = "" c.options.JoinToken = "" c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml") // Clear values potentially loaded at NewConfig creation. - c.options.NodeSecret = "" + c.options.NodeClientSecret = "" c.options.PortalUrl = "" c.options.JoinToken = "" c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml") // Clear values that may have been loaded from repo fixtures before we // isolated the config path. - c.options.NodeSecret = "" + c.options.NodeClientSecret = "" c.options.PortalUrl = "" c.options.JoinToken = "" c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml") // PortalConfigPath always points to a "cluster" subfolder under ConfigPath. - expectedCluster := filepath.Join(c.ConfigPath(), fs.ClusterDir) + expectedCluster := filepath.Join(c.ConfigPath(), fs.PortalDir) assert.Equal(t, expectedCluster, c.PortalConfigPath()) // PortalThemePath falls back to ThemePath if cluster dir does not exist. @@ -57,15 +103,14 @@ func TestConfig_Cluster(t *testing.T) { assert.Equal(t, expectedTheme, c.PortalThemePath()) // When only the cluster directory exists (without a theme subfolder), it still falls back to ThemePath. - assert.NoError(t, os.MkdirAll(expectedCluster, 0o755)) + assert.NoError(t, os.MkdirAll(expectedCluster, fs.ModeDir)) assert.Equal(t, expectedTheme, c.PortalThemePath()) // When the cluster theme directory exists, PortalThemePath returns it. expectedClusterTheme := filepath.Join(expectedCluster, fs.ThemeDir) - assert.NoError(t, os.MkdirAll(expectedClusterTheme, 0o755)) + assert.NoError(t, os.MkdirAll(expectedClusterTheme, fs.ModeDir)) assert.Equal(t, expectedClusterTheme, c.PortalThemePath()) }) - t.Run("PortalAndSecrets", func(t *testing.T) { // Isolate config so defaults aren't overridden by repo fixtures: set config-path // before creating the Config so NewConfig does not load repository options.yml. @@ -74,21 +119,22 @@ func TestConfig_Cluster(t *testing.T) { assert.NoError(t, ctx.Set("config-path", tempCfg)) c := NewConfig(ctx) - // Defaults (no options.yml present) + // Defaults (no options.yml present). Clear the flag default for portal-url + // so we can assert the derived (unset) behavior. + c.options.PortalUrl = "" assert.Equal(t, "", c.PortalUrl()) assert.Equal(t, "", c.JoinToken()) - assert.Equal(t, "", c.NodeSecret()) + assert.Equal(t, "", c.NodeClientSecret()) // Set and read back values c.options.PortalUrl = "https://portal.example.test" c.options.JoinToken = "join-token" - c.options.NodeSecret = "node-secret" + c.options.NodeClientSecret = "node-secret" assert.Equal(t, "https://portal.example.test", c.PortalUrl()) assert.Equal(t, "join-token", c.JoinToken()) - assert.Equal(t, "node-secret", c.NodeSecret()) + assert.Equal(t, "node-secret", c.NodeClientSecret()) }) - t.Run("AbsolutePaths", func(t *testing.T) { c := NewConfig(CliTestContext()) tempCfg := t.TempDir() @@ -102,18 +148,51 @@ func TestConfig_Cluster(t *testing.T) { // Create cluster theme directory and verify again. clusterTheme := filepath.Join(c.PortalConfigPath(), fs.ThemeDir) - assert.NoError(t, os.MkdirAll(clusterTheme, 0o755)) + assert.NoError(t, os.MkdirAll(clusterTheme, fs.ModeDir)) assert.True(t, filepath.IsAbs(c.PortalThemePath())) }) - t.Run("NodeName", func(t *testing.T) { c := NewConfig(CliTestContext()) + c.options.SiteUrl = "https://app.localssl.dev" + h, d, found := c.deriveNodeNameAndDomainFromHttpHost() + assert.Equal(t, "app", h) + assert.Equal(t, "localssl.dev", d) + assert.True(t, found) c.options.NodeName = " Client Credentials幸" assert.Equal(t, "client-credentials", c.NodeName()) c.options.NodeName = "" - assert.Equal(t, "", c.NodeName()) + // With defaults, NodeName derives from hostname or falls back to a stable identifier. + got := c.NodeName() + assert.NotEmpty(t, got) + assert.Equal(t, "app", h) + assert.Equal(t, "localssl.dev", d) + // Must be DNS label compatible (lowercase [a-z0-9-], 1–32, start/end alnum). + assert.Regexp(t, `^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$`, got) }) + t.Run("NodeNameNormalization", func(t *testing.T) { + orig := getHostname + getHostname = func() (string, error) { return "", nil } + t.Cleanup(func() { getHostname = orig }) + c := NewConfig(CliTestContext()) + c.options.NodeName = " My.Host/Name:Prod " + assert.Equal(t, "my-host-name-prod", c.NodeName()) + + c.options.NodeName = "-._a--" + assert.Equal(t, "a", c.NodeName()) + + c.options.NodeName = strings.Repeat("a", 40) + assert.Equal(t, strings.Repeat("a", 32), c.NodeName()) + }) + t.Run("NodeNameFromHostname", func(t *testing.T) { + orig := getHostname + getHostname = func() (string, error) { return "My.Host/Name:Prod", nil } + t.Cleanup(func() { getHostname = orig }) + + c := NewConfig(CliTestContext()) + c.options.NodeName = "" + assert.Equal(t, "my-host-name-prod", c.NodeName()) + }) t.Run("NodeRoleValues", func(t *testing.T) { c := NewConfig(CliTestContext()) @@ -131,31 +210,30 @@ func TestConfig_Cluster(t *testing.T) { c.options.NodeRole = string(cluster.RoleService) assert.Equal(t, string(cluster.RoleService), c.NodeRole()) }) - t.Run("SecretsFromFiles", func(t *testing.T) { c := NewConfig(CliTestContext()) // Create temp secret/token files. dir := t.TempDir() - nsFile := filepath.Join(dir, "node_secret") + nsFile := filepath.Join(dir, "node_client_secret") tkFile := filepath.Join(dir, "portal_token") assert.NoError(t, os.WriteFile(nsFile, []byte("s3cr3t"), 0o600)) assert.NoError(t, os.WriteFile(tkFile, []byte("t0k3n"), 0o600)) // Clear inline values so file-based lookup is used. - c.options.NodeSecret = "" + c.options.NodeClientSecret = "" c.options.JoinToken = "" // Point env vars at the files and verify. - t.Setenv("PHOTOPRISM_NODE_SECRET_FILE", nsFile) + t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", nsFile) t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", tkFile) - assert.Equal(t, "s3cr3t", c.NodeSecret()) + assert.Equal(t, "s3cr3t", c.NodeClientSecret()) assert.Equal(t, "t0k3n", c.JoinToken()) // Empty / missing should yield empty strings. - t.Setenv("PHOTOPRISM_NODE_SECRET_FILE", filepath.Join(dir, "missing")) + t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", filepath.Join(dir, "missing")) t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", filepath.Join(dir, "missing")) - assert.Equal(t, "", c.NodeSecret()) + assert.Equal(t, "", c.NodeClientSecret()) assert.Equal(t, "", c.JoinToken()) }) } @@ -170,7 +248,7 @@ func TestConfig_ClusterUUID_FileOverridesEnv(t *testing.T) { // Prepare options.yml with a UUID; file should override env/CLI. opts := map[string]any{"ClusterUUID": "11111111-1111-4111-8111-111111111111"} b, _ := yaml.Marshal(opts) - assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, fs.ModeFile)) // Set env; file value must win for consistency with other options. t.Setenv("PHOTOPRISM_CLUSTER_UUID", "22222222-2222-4222-8222-222222222222") @@ -182,21 +260,30 @@ func TestConfig_ClusterUUID_FileOverridesEnv(t *testing.T) { func TestConfig_ClusterUUID_FromOptions(t *testing.T) { c := NewConfig(CliTestContext()) + optionsOriginal := c.OptionsYaml() tempCfg := t.TempDir() + + if err := fs.MkdirAll(tempCfg); err != nil { + t.Fatal(err) + } + c.options.ConfigPath = tempCfg + optionsYaml := filepath.Join(tempCfg, "options.yml") + c.options.OptionsYaml = optionsYaml opts := map[string]any{"ClusterUUID": "33333333-3333-4333-8333-333333333333"} b, _ := yaml.Marshal(opts) - assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644)) + assert.NoError(t, os.WriteFile(optionsYaml, b, fs.ModeFile)) // Ensure env is not set. t.Setenv("PHOTOPRISM_CLUSTER_UUID", "") // Load options.yml into options struct (we updated ConfigPath after creation). - assert.NoError(t, c.options.Load(c.OptionsYaml())) + assert.NoError(t, c.options.Load(optionsYaml)) // Access the value via getter. got := c.ClusterUUID() assert.Equal(t, "33333333-3333-4333-8333-333333333333", got) + c.options.OptionsYaml = optionsOriginal } func TestConfig_ClusterUUID_FromCLIFlag(t *testing.T) { @@ -218,19 +305,32 @@ func TestConfig_ClusterUUID_FromCLIFlag(t *testing.T) { func TestConfig_ClusterUUID_GenerateAndPersist(t *testing.T) { c := NewConfig(CliTestContext()) + optionsOriginal := c.OptionsYaml() + tempCfg := t.TempDir() + + if err := fs.MkdirAll(tempCfg); err != nil { + t.Fatal(err) + } + c.options.ConfigPath = tempCfg + optionsYaml := filepath.Join(tempCfg, "options.yml") + c.options.OptionsYaml = optionsYaml // No env, no options.yml → should generate and persist. t.Setenv("PHOTOPRISM_CLUSTER_UUID", "") + if err := c.SaveClusterUUID(rnd.UUID()); err != nil { + t.Fatal(err) + } + got := c.ClusterUUID() if !rnd.IsUUID(got) { t.Fatalf("expected a UUIDv4, got %q", got) } // Verify content persisted to options.yml. - b, err := os.ReadFile(filepath.Join(tempCfg, "options.yml")) + b, err := os.ReadFile(optionsYaml) assert.NoError(t, err) var m map[string]any assert.NoError(t, yaml.Unmarshal(b, &m)) @@ -239,4 +339,6 @@ func TestConfig_ClusterUUID_GenerateAndPersist(t *testing.T) { // Second call returns the same value (from options in-memory / file). got2 := c.ClusterUUID() assert.Equal(t, got, got2) + + c.options.OptionsYaml = optionsOriginal } diff --git a/internal/config/config_const.go b/internal/config/config_const.go index 9c58f526b..83521dd39 100644 --- a/internal/config/config_const.go +++ b/internal/config/config_const.go @@ -69,6 +69,7 @@ const DefaultSessionCache = unix.Minute * 15 // Product feature tags used to automatically generate documentation. const ( Pro = "pro" + Portal = "portal" Plus = "plus" Essentials = "essentials" Community = "ce" diff --git a/internal/config/config_db.go b/internal/config/config_db.go index 6fce5a9d7..be148501b 100644 --- a/internal/config/config_db.go +++ b/internal/config/config_db.go @@ -24,6 +24,7 @@ import ( // SQL Databases. // TODO: PostgreSQL support requires upgrading GORM, so generic column data types can be used. const ( + Auto = "auto" MySQL = "mysql" MariaDB = "mariadb" Postgres = "postgres" @@ -46,11 +47,11 @@ func (c *Config) DatabaseDriver() string { case "tidb": log.Warnf("config: database driver 'tidb' is deprecated, using sqlite") c.options.DatabaseDriver = SQLite3 - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" default: log.Warnf("config: unsupported database driver %s, using sqlite", c.options.DatabaseDriver) c.options.DatabaseDriver = SQLite3 - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" } return c.options.DatabaseDriver @@ -99,9 +100,9 @@ func (c *Config) DatabaseSsl() bool { } } -// DatabaseDsn returns the database data source name (DSN). -func (c *Config) DatabaseDsn() string { - if c.options.DatabaseDsn == "" { +// DatabaseDSN returns the database data source name (DSN). +func (c *Config) DatabaseDSN() string { + if c.options.DatabaseDSN == "" { switch c.DatabaseDriver() { case MySQL, MariaDB: databaseServer := c.DatabaseServer() @@ -140,22 +141,22 @@ func (c *Config) DatabaseDsn() string { } } - return c.options.DatabaseDsn + return c.options.DatabaseDSN } // DatabaseFile returns the filename part of a sqlite database DSN. func (c *Config) DatabaseFile() string { - fileName, _, _ := strings.Cut(strings.TrimPrefix(c.DatabaseDsn(), "file:"), "?") + fileName, _, _ := strings.Cut(strings.TrimPrefix(c.DatabaseDSN(), "file:"), "?") return fileName } -// ParseDatabaseDsn parses the database dsn and extracts user, password, database server, and name. -func (c *Config) ParseDatabaseDsn() { - if c.options.DatabaseDsn == "" || c.options.DatabaseServer != "" { +// ParseDatabaseDSN parses the database dsn and extracts user, password, database server, and name. +func (c *Config) ParseDatabaseDSN() { + if c.options.DatabaseDSN == "" || c.options.DatabaseServer != "" { return } - d := NewDSN(c.options.DatabaseDsn) + d := NewDSN(c.options.DatabaseDSN) c.options.DatabaseName = d.Name c.options.DatabaseServer = d.Server @@ -165,7 +166,7 @@ func (c *Config) ParseDatabaseDsn() { // DatabaseServer the database server. func (c *Config) DatabaseServer() string { - c.ParseDatabaseDsn() + c.ParseDatabaseDSN() if c.DatabaseDriver() == SQLite3 { return "" @@ -217,10 +218,10 @@ func (c *Config) DatabasePortString() string { // DatabaseName the database schema name. func (c *Config) DatabaseName() string { - c.ParseDatabaseDsn() + c.ParseDatabaseDSN() if c.DatabaseDriver() == SQLite3 { - return c.DatabaseDsn() + return c.DatabaseDSN() } else if c.options.DatabaseName == "" { return "photoprism" } @@ -234,7 +235,7 @@ func (c *Config) DatabaseUser() string { return "" } - c.ParseDatabaseDsn() + c.ParseDatabaseDSN() if c.options.DatabaseUser == "" { return "photoprism" @@ -249,7 +250,7 @@ func (c *Config) DatabasePassword() string { return "" } - c.ParseDatabaseDsn() + c.ParseDatabaseDSN() // Try to read password from file if c.options.DatabasePassword is not set. if c.options.DatabasePassword != "" { @@ -457,7 +458,7 @@ func (c *Config) connectDb() error { // Get database driver and data source name. dbDriver := c.DatabaseDriver() - dbDsn := c.DatabaseDsn() + dbDsn := c.DatabaseDSN() if dbDriver == "" { return errors.New("config: database driver not specified") diff --git a/internal/config/config_db_test.go b/internal/config/config_db_test.go index 6c8cb2373..0680c1e50 100644 --- a/internal/config/config_db_test.go +++ b/internal/config/config_db_test.go @@ -11,7 +11,7 @@ func TestConfig_DatabaseDriver(t *testing.T) { c := NewConfig(CliTestContext()) // Ensure defaults not overridden by repo fixtures. c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" c.options.DatabaseServer = "" c.options.DatabaseName = "" c.options.DatabaseUser = "" @@ -23,7 +23,7 @@ func TestConfig_DatabaseDriver(t *testing.T) { func TestConfig_DatabaseDriverName(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" driver := c.DatabaseDriverName() assert.Equal(t, "SQLite", driver) } @@ -41,10 +41,10 @@ func TestConfig_DatabaseSsl(t *testing.T) { assert.False(t, c.DatabaseSsl()) } -func TestConfig_ParseDatabaseDsn(t *testing.T) { +func TestConfig_ParseDatabaseDSN(t *testing.T) { c := NewConfig(CliTestContext()) - c.options.DatabaseDsn = "foo:b@r@tcp(honeypot:1234)/baz?charset=utf8mb4,utf8&parseTime=true" + c.options.DatabaseDSN = "foo:b@r@tcp(honeypot:1234)/baz?charset=utf8mb4,utf8&parseTime=true" c.options.DatabaseDriver = SQLite3 assert.Equal(t, "", c.DatabaseServer()) @@ -76,7 +76,7 @@ func TestConfig_ParseDatabaseDsn(t *testing.T) { func TestConfig_DatabaseServer(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "", c.DatabaseServer()) c.options.DatabaseServer = "test" assert.Equal(t, "", c.DatabaseServer()) @@ -85,42 +85,42 @@ func TestConfig_DatabaseServer(t *testing.T) { func TestConfig_DatabaseHost(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "", c.DatabaseHost()) } func TestConfig_DatabasePort(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, 0, c.DatabasePort()) } func TestConfig_DatabasePortString(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "", c.DatabasePortString()) } func TestConfig_DatabaseName(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseName()) } func TestConfig_DatabaseUser(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "", c.DatabaseUser()) } func TestConfig_DatabasePassword(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "", c.DatabasePassword()) // Test setting the password via secret file. @@ -134,39 +134,39 @@ func TestConfig_DatabasePassword(t *testing.T) { assert.Equal(t, "", c.DatabasePassword()) } -func TestConfig_DatabaseDsn(t *testing.T) { +func TestConfig_DatabaseDSN(t *testing.T) { c := NewConfig(CliTestContext()) c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" driver := c.DatabaseDriver() assert.Equal(t, SQLite3, driver) - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" c.options.DatabaseDriver = "MariaDB" - assert.Equal(t, "photoprism:@tcp(localhost)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true&timeout=15s", c.DatabaseDsn()) + assert.Equal(t, "photoprism:@tcp(localhost)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true&timeout=15s", c.DatabaseDSN()) c.options.DatabaseDriver = "tidb" - assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDsn()) + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDSN()) c.options.DatabaseDriver = "Postgres" - assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDsn()) + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDSN()) c.options.DatabaseDriver = "SQLite" - assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDsn()) + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDSN()) c.options.DatabaseDriver = "" - assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDsn()) + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDSN()) } func TestConfig_DatabaseFile(t *testing.T) { c := NewConfig(CliTestContext()) // Ensure SQLite defaults c.options.DatabaseDriver = "" - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" c.options.DatabaseServer = "" c.options.DatabaseName = "" c.options.DatabaseUser = "" c.options.DatabasePassword = "" driver := c.DatabaseDriver() assert.Equal(t, SQLite3, driver) - c.options.DatabaseDsn = "" + c.options.DatabaseDSN = "" assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db", c.DatabaseFile()) - assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDsn()) + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/index.db?_busy_timeout=5000", c.DatabaseDSN()) } func TestConfig_DatabaseTimeout(t *testing.T) { diff --git a/internal/config/config_storage_test.go b/internal/config/config_storage_test.go index d0b1beb62..a1bc2df0c 100644 --- a/internal/config/config_storage_test.go +++ b/internal/config/config_storage_test.go @@ -282,7 +282,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("storage path error", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() @@ -299,7 +298,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("originals path not found", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() @@ -324,7 +322,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("import path not found", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() @@ -349,7 +346,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("sidecar path error", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() @@ -366,7 +362,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("cache path error", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() @@ -383,7 +378,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("config path error", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() @@ -400,7 +394,6 @@ func TestConfig_CreateDirectories2(t *testing.T) { } assert.Contains(t, err2.Error(), "check config and permissions") }) - t.Run("temp path error", func(t *testing.T) { testConfigMutex.Lock() defer testConfigMutex.Unlock() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5864db2af..21e282e14 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -31,6 +31,9 @@ func TestMain(m *testing.M) { code := m.Run() + // Purge local SQLite test artifacts created during this package's tests. + fs.PurgeTestDbFiles(".", false) + os.Exit(code) } @@ -123,7 +126,6 @@ func TestConfig_OptionsYaml(t *testing.T) { c := NewConfig(CliTestContext()) assert.Contains(t, c.OptionsYaml(), "options.yml") }) - t.Run("ChangePath", func(t *testing.T) { c := NewConfig(CliTestContext()) assert.Contains(t, c.OptionsYaml(), "options.yml") diff --git a/internal/config/config_thumb_test.go b/internal/config/config_thumb_test.go index 823f801cb..0db6bf2f7 100644 --- a/internal/config/config_thumb_test.go +++ b/internal/config/config_thumb_test.go @@ -48,7 +48,7 @@ func TestConfig_ThumbFilter(t *testing.T) { assert.Equal(t, thumb.ResampleLanczos, c.ThumbFilter()) c.options.ThumbFilter = "linear" assert.Equal(t, thumb.ResampleLinear, c.ThumbFilter()) - c.options.ThumbFilter = "auto" + c.options.ThumbFilter = Auto assert.Equal(t, thumb.ResampleLanczos, c.ThumbFilter()) c.options.ThumbFilter = "" assert.Equal(t, thumb.ResampleLanczos, c.ThumbFilter()) @@ -92,7 +92,7 @@ func TestConfig_PngSize(t *testing.T) { func TestConfig_ThumbLibrary(t *testing.T) { c := NewConfig(CliTestContext()) assert.False(t, c.DisableVips()) - c.options.ThumbLibrary = "auto" + c.options.ThumbLibrary = Auto assert.Equal(t, "vips", c.ThumbLibrary()) c.options.DisableVips = true assert.Equal(t, "imaging", c.ThumbLibrary()) diff --git a/internal/config/customize/acl_test.go b/internal/config/customize/acl_test.go index fac4be8a2..c96797bac 100644 --- a/internal/config/customize/acl_test.go +++ b/internal/config/customize/acl_test.go @@ -51,7 +51,6 @@ func TestSettings_ApplyACL(t *testing.T) { t.Logf("RoleAdmin: %#v", r) assert.Equal(t, expected, r.Features) }) - t.Run("RoleVisitor", func(t *testing.T) { s := NewDefaultSettings() diff --git a/internal/config/customize/scope_test.go b/internal/config/customize/scope_test.go index ee849bc24..563dc06a1 100644 --- a/internal/config/customize/scope_test.go +++ b/internal/config/customize/scope_test.go @@ -55,7 +55,6 @@ func TestSettings_ApplyScope(t *testing.T) { t.Logf("AdminUnscoped: %#v", result) assert.Equal(t, expected, result.Features) }) - t.Run("ClientScoped", func(t *testing.T) { s := NewDefaultSettings() @@ -96,7 +95,6 @@ func TestSettings_ApplyScope(t *testing.T) { t.Logf("ClientScoped: %#v", result) assert.Equal(t, expected, result.Features) }) - t.Run("GuestSettings", func(t *testing.T) { s := NewDefaultSettings() @@ -136,7 +134,6 @@ func TestSettings_ApplyScope(t *testing.T) { t.Logf("GuestSettings: %#v", result) assert.Equal(t, expected, result.Features) }) - t.Run("VisitorSettings", func(t *testing.T) { s := NewDefaultSettings() @@ -176,7 +173,6 @@ func TestSettings_ApplyScope(t *testing.T) { t.Logf("VisitorSettings: %#v", result) assert.Equal(t, expected, result.Features) }) - t.Run("VisitorMetrics", func(t *testing.T) { s := NewDefaultSettings() diff --git a/internal/config/expand.go b/internal/config/expand.go new file mode 100644 index 000000000..416c82bcb --- /dev/null +++ b/internal/config/expand.go @@ -0,0 +1,17 @@ +package config + +import ( + "os" +) + +// Vars represents a map of variable names to values. +type Vars = map[string]string + +// ExpandVars replaces variables in the format ${NAME} with their corresponding values. +func ExpandVars(s string, vars Vars) string { + if s == "" { + return s + } + + return os.Expand(s, func(key string) string { return vars[key] }) +} diff --git a/internal/config/expand_test.go b/internal/config/expand_test.go new file mode 100644 index 000000000..2f0ac6615 --- /dev/null +++ b/internal/config/expand_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandVars(t *testing.T) { + t.Run("Unset", func(t *testing.T) { + assert.Equal(t, "", ExpandVars("", nil)) + }) + t.Run("DefaultPortalUrl", func(t *testing.T) { + assert.Equal(t, + "https://portal.foo.bar.baz", + ExpandVars(DefaultPortalUrl, Vars{"PHOTOPRISM_CLUSTER_DOMAIN": "foo.bar.baz"})) + }) + t.Run("UnbracedUppercase", func(t *testing.T) { + in := "https://portal.$CLUSTER_DOMAIN" + out := ExpandVars(in, Vars{"CLUSTER_DOMAIN": "example.com"}) + assert.Equal(t, "https://portal.example.com", out) + }) + t.Run("HyphenKeyWithBraces", func(t *testing.T) { + in := "https://portal.${cluster-domain}" + out := ExpandVars(in, Vars{"cluster-domain": "foo.bar"}) + assert.Equal(t, "https://portal.foo.bar", out) + }) + t.Run("MultipleVariablesMixedForms", func(t *testing.T) { + in := "https://${cluster-domain}/$CLUSTER_DOMAIN" + out := ExpandVars(in, Vars{ + "cluster-domain": "foo.bar", + "CLUSTER_DOMAIN": "baz.qux", + }) + assert.Equal(t, "https://foo.bar/baz.qux", out) + }) + t.Run("UnknownVarBecomesEmpty", func(t *testing.T) { + in := "pre $UNKNOWN post" + out := ExpandVars(in, nil) + // $UNKNOWN maps to empty -> double space remains between words. + assert.Equal(t, "pre post", out) + }) + t.Run("TrailingDollarIsLiteral", func(t *testing.T) { + in := "end$" + out := ExpandVars(in, nil) + // A trailing '$' is not followed by a name, so it remains. + assert.Equal(t, "end$", out) + }) + t.Run("BadSyntaxMissingRightBrace", func(t *testing.T) { + in := "pre ${foo" + out := ExpandVars(in, Vars{"foo": "X"}) + // os.Expand eats the invalid "${" sequence; remaining text stays. + assert.Equal(t, "pre foo", out) + }) + t.Run("EmptyBracesAreEaten", func(t *testing.T) { + in := "a ${} b" + out := ExpandVars(in, nil) + // os.Expand treats ${} as bad syntax and removes it entirely. + assert.Equal(t, "a b", out) + }) + t.Run("SpecialVarDollar", func(t *testing.T) { + in := "cost $$100" + out := ExpandVars(in, nil) + // In os.Expand, '$$' is parsed as special var "$" and maps to empty. + assert.Equal(t, "cost 100", out) + }) +} diff --git a/internal/config/flags.go b/internal/config/flags.go index 0eac5401b..d04e36ca9 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -664,9 +664,27 @@ var Flags = CliFlags{ EnvVars: EnvVars("CORS_METHODS"), Value: header.DefaultAccessControlAllowMethods, }}, { + Flag: &cli.StringFlag{ + Name: "cluster-domain", + Usage: "cluster `DOMAIN` (lowercase DNS name; 1–63 chars)", + EnvVars: EnvVars("CLUSTER_DOMAIN"), + }}, { + Flag: &cli.StringFlag{ + Name: "cluster-uuid", + Usage: "cluster `UUID` (v4) to scope node credentials", + EnvVars: EnvVars("CLUSTER_UUID"), + Hidden: true, + }}, { + Flag: &cli.StringFlag{ + Name: "cluster-cidr", + Usage: "cluster `CIDR` (e.g., 10.0.0.0/8) for IP-based authorization", + EnvVars: EnvVars("CLUSTER_CIDR"), + Hidden: true, + }}, { Flag: &cli.StringFlag{ Name: "portal-url", - Usage: "base `URL` of the cluster management portal (e.g. https://portal.example.com)", + Usage: "base `URL` of the cluster management portal", + Value: DefaultPortalUrl, EnvVars: EnvVars("PORTAL_URL"), }}, { Flag: &cli.StringFlag{ @@ -674,16 +692,6 @@ var Flags = CliFlags{ Usage: "secret `TOKEN` required to join the cluster", EnvVars: EnvVars("JOIN_TOKEN"), }}, { - Flag: &cli.StringFlag{ - Name: "cluster-uuid", - Usage: "cluster `UUID` (v4) to scope node credentials", - EnvVars: EnvVars("CLUSTER_UUID"), - }}, { - Flag: &cli.StringFlag{ - Name: "cluster-domain", - Usage: "cluster `DOMAIN` (lowercase DNS name; 1–63 chars)", - EnvVars: EnvVars("CLUSTER_DOMAIN"), - }}, { Flag: &cli.StringFlag{ Name: "node-name", Usage: "node `NAME` (unique in cluster domain; [a-z0-9-]{1,32})", @@ -691,20 +699,25 @@ var Flags = CliFlags{ }}, { Flag: &cli.StringFlag{ Name: "node-role", - Usage: "node `ROLE` (portal, instance, or service)", + Usage: "node `ROLE` (instance or service)", EnvVars: EnvVars("NODE_ROLE"), + }}, { + Flag: &cli.StringFlag{ + Name: "node-uuid", + Usage: "node `UUID` (v7) that uniquely identifies this instance", + EnvVars: EnvVars("NODE_UUID"), Hidden: true, }}, { Flag: &cli.StringFlag{ - Name: "node-id", - Usage: "client `ID` registered with the portal (auto-assigned via join token)", - EnvVars: EnvVars("NODE_ID"), + Name: "node-client-id", + Usage: "node OAuth client `ID` (auto-assigned via join token)", + EnvVars: EnvVars("NODE_CLIENT_ID"), Hidden: true, }}, { Flag: &cli.StringFlag{ - Name: "node-secret", - Usage: "client `SECRET` registered with the portal (auto-assigned via join token)", - EnvVars: EnvVars("NODE_SECRET"), + Name: "node-client-secret", + Usage: "node OAuth client `SECRET` (auto-assigned via join token)", + EnvVars: EnvVars("NODE_CLIENT_SECRET"), Hidden: true, }}, { Flag: &cli.StringFlag{ @@ -877,6 +890,19 @@ var Flags = CliFlags{ Usage: "maximum `NUMBER` of idle database connections", EnvVars: EnvVars("DATABASE_CONNS_IDLE"), }}, { + Flag: &cli.StringFlag{ + Name: "database-provision-driver", + Usage: "auto-provisioning `DRIVER` (auto, mysql)", + Value: Auto, + EnvVars: EnvVars("DATABASE_PROVISION_DRIVER"), + Hidden: true, + }}, { + Flag: &cli.StringFlag{ + Name: "database-provision-dsn", + Usage: "auto-provisioning `DSN`", + EnvVars: EnvVars("DATABASE_PROVISION_DSN"), + Hidden: true, + }}, { Flag: &cli.StringFlag{ Name: "ffmpeg-bin", Usage: "FFmpeg `COMMAND` for video transcoding and thumbnail extraction", @@ -1026,7 +1052,7 @@ var Flags = CliFlags{ Name: "thumb-library", Aliases: []string{"thumbs"}, Usage: "image processing `LIBRARY` to be used for generating thumbnails (auto, imaging, vips)", - Value: "auto", + Value: Auto, EnvVars: EnvVars("THUMB_LIBRARY"), }}, { Flag: &cli.StringFlag{ diff --git a/internal/config/options.go b/internal/config/options.go index f74a9fc3b..fcdb3575c 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -18,218 +18,222 @@ import ( // Application code should retrieve option values via getter functions since they provide // validation and return defaults if a value is empty. type Options struct { - Name string `json:"-"` - About string `json:"-"` - Edition string `json:"-"` - Version string `json:"-"` - Copyright string `json:"-"` - PartnerID string `yaml:"-" json:"-" flag:"partner-id"` - AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"` - AuthSecret string `yaml:"AuthSecret" json:"-" flag:"auth-secret"` - Public bool `yaml:"Public" json:"-" flag:"public"` - NoHub bool `yaml:"-" json:"-" flag:"no-hub"` - AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"` - AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"` - PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"` - PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri" tags:"plus,pro"` - RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri" tags:"pro"` - LoginUri string `yaml:"-" json:"-" flag:"login-uri"` - LoginInfo string `yaml:"LoginInfo" json:"-" flag:"login-info" tags:"plus,pro"` - OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"` - OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"` - OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"` - OIDCScopes string `yaml:"OIDCScopes" json:"-" flag:"oidc-scopes"` - OIDCProvider string `yaml:"OIDCProvider" json:"OIDCProvider" flag:"oidc-provider"` - OIDCIcon string `yaml:"OIDCIcon" json:"OIDCIcon" flag:"oidc-icon"` - OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"` - OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"` - OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"` - OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain" tags:"pro"` - OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"pro"` - OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"` - DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"` - SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"` - SessionTimeout int64 `yaml:"SessionTimeout" json:"-" flag:"session-timeout"` - SessionCache int64 `yaml:"SessionCache" json:"-" flag:"session-cache"` - LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"` - Prod bool `yaml:"Prod" json:"Prod" flag:"prod"` - Debug bool `yaml:"Debug" json:"Debug" flag:"debug"` - Trace bool `yaml:"Trace" json:"Trace" flag:"trace"` - Test bool `yaml:"-" json:"Test,omitempty" flag:"test"` - Unsafe bool `yaml:"-" json:"-" flag:"unsafe"` - Demo bool `yaml:"-" json:"-" flag:"demo"` - Sponsor bool `yaml:"-" json:"-" flag:"sponsor"` - ConfigPath string `yaml:"ConfigPath" json:"-" flag:"config-path"` - OptionsYaml string `json:"-" yaml:"-" flag:"-"` - DefaultsYaml string `json:"-" yaml:"-" flag:"defaults-yaml"` - OriginalsPath string `yaml:"OriginalsPath" json:"-" flag:"originals-path"` - OriginalsLimit int `yaml:"OriginalsLimit" json:"OriginalsLimit" flag:"originals-limit"` - ResolutionLimit int `yaml:"ResolutionLimit" json:"ResolutionLimit" flag:"resolution-limit"` - UsersPath string `yaml:"UsersPath" json:"-" flag:"users-path"` - StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"` - ImportPath string `yaml:"ImportPath" json:"-" flag:"import-path"` - ImportDest string `yaml:"ImportDest" json:"-" flag:"import-dest"` - ImportAllow string `yaml:"ImportAllow" json:"ImportAllow" flag:"import-allow"` - UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"` - UploadAllow string `yaml:"UploadAllow" json:"-" flag:"upload-allow"` - UploadArchives bool `yaml:"UploadArchives" json:"-" flag:"upload-archives"` - UploadLimit int `yaml:"UploadLimit" json:"-" flag:"upload-limit"` - CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"` - TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"` - AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"` - CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path" tags:"plus,pro"` - CustomThemePath string `yaml:"-" json:"-" flag:"theme-path"` - ModelsPath string `yaml:"ModelsPath" json:"-" flag:"models-path"` - SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"` - SidecarYaml bool `yaml:"SidecarYaml" json:"SidecarYaml" flag:"sidecar-yaml" default:"true"` - UsageInfo bool `yaml:"UsageInfo" json:"UsageInfo" flag:"usage-info"` - FilesQuota uint64 `yaml:"FilesQuota" json:"-" flag:"files-quota"` - UsersQuota int `yaml:"UsersQuota" json:"-" flag:"users-quota" tags:"pro"` - BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"` - BackupSchedule string `yaml:"BackupSchedule" json:"BackupSchedule" flag:"backup-schedule"` - BackupRetain int `yaml:"BackupRetain" json:"BackupRetain" flag:"backup-retain"` - BackupDatabase bool `yaml:"BackupDatabase" json:"BackupDatabase" flag:"backup-database" default:"true"` - BackupAlbums bool `yaml:"BackupAlbums" json:"BackupAlbums" flag:"backup-albums" default:"true"` - IndexWorkers int `yaml:"IndexWorkers" json:"IndexWorkers" flag:"index-workers"` - IndexSchedule string `yaml:"IndexSchedule" json:"IndexSchedule" flag:"index-schedule"` - WakeupInterval time.Duration `yaml:"WakeupInterval" json:"WakeupInterval" flag:"wakeup-interval"` - AutoIndex int `yaml:"AutoIndex" json:"AutoIndex" flag:"auto-index"` - AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"` - ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"` - Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"` - DisableFrontend bool `yaml:"DisableFrontend" json:"-" flag:"disable-frontend"` - DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"` - DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"` - DisableRestart bool `yaml:"DisableRestart" json:"-" flag:"disable-restart"` - DisableWebDAV bool `yaml:"DisableWebDAV" json:"DisableWebDAV" flag:"disable-webdav"` - DisablePlaces bool `yaml:"DisablePlaces" json:"DisablePlaces" flag:"disable-places"` - DisableTensorFlow bool `yaml:"DisableTensorFlow" json:"DisableTensorFlow" flag:"disable-tensorflow"` - DisableFaces bool `yaml:"DisableFaces" json:"DisableFaces" flag:"disable-faces"` - DisableClassification bool `yaml:"DisableClassification" json:"DisableClassification" flag:"disable-classification"` - DisableFFmpeg bool `yaml:"DisableFFmpeg" json:"DisableFFmpeg" flag:"disable-ffmpeg"` - DisableExifTool bool `yaml:"DisableExifTool" json:"DisableExifTool" flag:"disable-exiftool"` - DisableVips bool `yaml:"DisableVips" json:"DisableVips" flag:"disable-vips"` - DisableSips bool `yaml:"DisableSips" json:"DisableSips" flag:"disable-sips"` - DisableDarktable bool `yaml:"DisableDarktable" json:"DisableDarktable" flag:"disable-darktable"` - DisableRawTherapee bool `yaml:"DisableRawTherapee" json:"DisableRawTherapee" flag:"disable-rawtherapee"` - DisableImageMagick bool `yaml:"DisableImageMagick" json:"DisableImageMagick" flag:"disable-imagemagick"` - DisableHeifConvert bool `yaml:"DisableHeifConvert" json:"DisableHeifConvert" flag:"disable-heifconvert"` - DisableVectors bool `yaml:"DisableVectors" json:"DisableVectors" flag:"disable-vectors"` - DisableJpegXL bool `yaml:"DisableJpegXL" json:"DisableJpegXL" flag:"disable-jpegxl"` - DisableRaw bool `yaml:"DisableRaw" json:"DisableRaw" flag:"disable-raw"` - RawPresets bool `yaml:"RawPresets" json:"RawPresets" flag:"raw-presets"` - ExifBruteForce bool `yaml:"ExifBruteForce" json:"ExifBruteForce" flag:"exif-bruteforce"` - DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"` - DefaultTimezone string `yaml:"DefaultTimezone" json:"DefaultTimezone" flag:"default-timezone"` - DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"` - PlacesLocale string `yaml:"PlacesLocale" json:"PlacesLocale" flag:"places-locale"` - AppName string `yaml:"AppName" json:"AppName" flag:"app-name"` - AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"` - AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"` - AppColor string `yaml:"AppColor" json:"AppColor" flag:"app-color"` - LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"` - LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"` - WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"` - SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"` - SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"` - SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"` - SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"` - SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"` - SiteFavicon string `yaml:"SiteFavicon" json:"SiteFavicon" flag:"site-favicon"` - SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"` - CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"` - CdnVideo bool `yaml:"CdnVideo" json:"CdnVideo" flag:"cdn-video"` - CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"` - CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"` - CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"` - PortalUrl string `yaml:"PortalUrl" json:"-" flag:"portal-url"` - JoinToken string `yaml:"JoinToken" json:"-" flag:"join-token"` - ClusterUUID string `yaml:"ClusterUUID" json:"-" flag:"cluster-uuid"` - ClusterDomain string `yaml:"ClusterDomain" json:"-" flag:"cluster-domain"` - NodeName string `yaml:"NodeName" json:"-" flag:"node-name"` - NodeRole string `yaml:"NodeRole" json:"-" flag:"node-role"` - NodeID string `yaml:"NodeID" json:"-" flag:"node-id"` - NodeSecret string `yaml:"NodeSecret" json:"-" flag:"node-secret"` - AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"` - HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"` - HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"` - TrustedPlatform string `yaml:"TrustedPlatform" json:"-" flag:"trusted-platform"` - TrustedProxies []string `yaml:"TrustedProxies" json:"-" flag:"trusted-proxy"` - ProxyClientHeaders []string `yaml:"ProxyClientHeaders" json:"-" flag:"proxy-client-header"` - ProxyProtoHeaders []string `yaml:"ProxyProtoHeaders" json:"-" flag:"proxy-proto-header"` - ProxyProtoHttps []string `yaml:"ProxyProtoHttps" json:"-" flag:"proxy-proto-https"` - DisableTLS bool `yaml:"DisableTLS" json:"DisableTLS" flag:"disable-tls"` - DefaultTLS bool `yaml:"DefaultTLS" json:"DefaultTLS" flag:"default-tls"` - TLSEmail string `yaml:"TLSEmail" json:"TLSEmail" flag:"tls-email"` - TLSCert string `yaml:"TLSCert" json:"TLSCert" flag:"tls-cert"` - TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"` - HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"` - HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"` - HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"` - HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"` - HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"` - HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"` - HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"` - HttpSocket *url.URL `yaml:"-" json:"-" flag:"-"` - DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"` - DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"` - DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"` - DatabaseServer string `yaml:"DatabaseServer" json:"-" flag:"database-server"` - DatabaseUser string `yaml:"DatabaseUser" json:"-" flag:"database-user"` - DatabasePassword string `yaml:"DatabasePassword" json:"-" flag:"database-password"` - DatabaseTimeout int `yaml:"DatabaseTimeout" json:"-" flag:"database-timeout"` - DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"` - DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"` - FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"` - FFmpegEncoder string `yaml:"FFmpegEncoder" json:"FFmpegEncoder" flag:"ffmpeg-encoder"` - FFmpegSize int `yaml:"FFmpegSize" json:"FFmpegSize" flag:"ffmpeg-size"` - FFmpegQuality int `yaml:"FFmpegQuality" json:"FFmpegQuality" flag:"ffmpeg-quality"` - FFmpegBitrate int `yaml:"FFmpegBitrate" json:"FFmpegBitrate" flag:"ffmpeg-bitrate"` - FFmpegPreset string `yaml:"FFmpegPreset" json:"FFmpegPreset" flag:"ffmpeg-preset"` - FFmpegDevice string `yaml:"FFmpegDevice" json:"-" flag:"ffmpeg-device"` - FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"` - FFmpegMapAudio string `yaml:"FFmpegMapAudio" json:"FFmpegMapAudio" flag:"ffmpeg-map-audio"` - ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"` - SipsBin string `yaml:"SipsBin" json:"-" flag:"sips-bin"` - SipsExclude string `yaml:"SipsExclude" json:"-" flag:"sips-exclude"` - DarktableBin string `yaml:"DarktableBin" json:"-" flag:"darktable-bin"` - DarktableCachePath string `yaml:"DarktableCachePath" json:"-" flag:"darktable-cache-path"` - DarktableConfigPath string `yaml:"DarktableConfigPath" json:"-" flag:"darktable-config-path"` - DarktableExclude string `yaml:"DarktableExclude" json:"-" flag:"darktable-exclude"` - RawTherapeeBin string `yaml:"RawTherapeeBin" json:"-" flag:"rawtherapee-bin"` - RawTherapeeExclude string `yaml:"RawTherapeeExclude" json:"-" flag:"rawtherapee-exclude"` - ImageMagickBin string `yaml:"ImageMagickBin" json:"-" flag:"imagemagick-bin"` - ImageMagickExclude string `yaml:"ImageMagickExclude" json:"-" flag:"imagemagick-exclude"` - HeifConvertBin string `yaml:"HeifConvertBin" json:"-" flag:"heifconvert-bin"` - HeifConvertOrientation string `yaml:"HeifConvertOrientation" json:"-" flag:"heifconvert-orientation"` - RsvgConvertBin string `yaml:"RsvgConvertBin" json:"-" flag:"rsvgconvert-bin"` - DownloadToken string `yaml:"DownloadToken" json:"-" flag:"download-token"` - PreviewToken string `yaml:"PreviewToken" json:"-" flag:"preview-token"` - ThumbLibrary string `yaml:"ThumbLibrary" json:"ThumbLibrary" flag:"thumb-library"` - ThumbColor string `yaml:"ThumbColor" json:"ThumbColor" flag:"thumb-color"` - ThumbFilter string `yaml:"ThumbFilter" json:"ThumbFilter" flag:"thumb-filter"` - ThumbSize int `yaml:"ThumbSize" json:"ThumbSize" flag:"thumb-size"` - ThumbSizeUncached int `yaml:"ThumbSizeUncached" json:"ThumbSizeUncached" flag:"thumb-size-uncached"` - ThumbUncached bool `yaml:"ThumbUncached" json:"ThumbUncached" flag:"thumb-uncached"` - JpegQuality int `yaml:"JpegQuality" json:"JpegQuality" flag:"jpeg-quality"` - JpegSize int `yaml:"JpegSize" json:"JpegSize" flag:"jpeg-size"` - PngSize int `yaml:"PngSize" json:"PngSize" flag:"png-size"` - VisionYaml string `yaml:"VisionYaml" json:"-" flag:"vision-yaml"` - VisionApi bool `yaml:"VisionApi" json:"-" flag:"vision-api"` - VisionUri string `yaml:"VisionUri" json:"-" flag:"vision-uri"` - VisionKey string `yaml:"VisionKey" json:"-" flag:"vision-key"` - DetectNSFW bool `yaml:"DetectNSFW" json:"DetectNSFW" flag:"detect-nsfw"` - FaceSize int `yaml:"-" json:"-" flag:"face-size"` - FaceScore float64 `yaml:"-" json:"-" flag:"face-score"` - FaceOverlap int `yaml:"-" json:"-" flag:"face-overlap"` - FaceClusterSize int `yaml:"-" json:"-" flag:"face-cluster-size"` - FaceClusterScore int `yaml:"-" json:"-" flag:"face-cluster-score"` - FaceClusterCore int `yaml:"-" json:"-" flag:"face-cluster-core"` - FaceClusterDist float64 `yaml:"-" json:"-" flag:"face-cluster-dist"` - FaceMatchDist float64 `yaml:"-" json:"-" flag:"face-match-dist"` - PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"` - LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"` - DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"` + Name string `json:"-"` + About string `json:"-"` + Edition string `json:"-"` + Version string `json:"-"` + Copyright string `json:"-"` + PartnerID string `yaml:"-" json:"-" flag:"partner-id"` + AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"` + AuthSecret string `yaml:"AuthSecret" json:"-" flag:"auth-secret"` + Public bool `yaml:"Public" json:"-" flag:"public"` + NoHub bool `yaml:"-" json:"-" flag:"no-hub"` + AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"` + AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"` + PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"` + PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri" tags:"plus,pro"` + RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri" tags:"pro"` + LoginUri string `yaml:"-" json:"-" flag:"login-uri"` + LoginInfo string `yaml:"LoginInfo" json:"-" flag:"login-info" tags:"plus,pro"` + OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"` + OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"` + OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"` + OIDCScopes string `yaml:"OIDCScopes" json:"-" flag:"oidc-scopes"` + OIDCProvider string `yaml:"OIDCProvider" json:"OIDCProvider" flag:"oidc-provider"` + OIDCIcon string `yaml:"OIDCIcon" json:"OIDCIcon" flag:"oidc-icon"` + OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"` + OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"` + OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"` + OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain" tags:"pro"` + OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"pro"` + OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"` + DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"` + SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"` + SessionTimeout int64 `yaml:"SessionTimeout" json:"-" flag:"session-timeout"` + SessionCache int64 `yaml:"SessionCache" json:"-" flag:"session-cache"` + LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"` + Prod bool `yaml:"Prod" json:"Prod" flag:"prod"` + Debug bool `yaml:"Debug" json:"Debug" flag:"debug"` + Trace bool `yaml:"Trace" json:"Trace" flag:"trace"` + Test bool `yaml:"-" json:"Test,omitempty" flag:"test"` + Unsafe bool `yaml:"-" json:"-" flag:"unsafe"` + Demo bool `yaml:"-" json:"-" flag:"demo"` + Sponsor bool `yaml:"-" json:"-" flag:"sponsor"` + ConfigPath string `yaml:"ConfigPath" json:"-" flag:"config-path"` + OptionsYaml string `json:"-" yaml:"-" flag:"-"` + DefaultsYaml string `json:"-" yaml:"-" flag:"defaults-yaml"` + OriginalsPath string `yaml:"OriginalsPath" json:"-" flag:"originals-path"` + OriginalsLimit int `yaml:"OriginalsLimit" json:"OriginalsLimit" flag:"originals-limit"` + ResolutionLimit int `yaml:"ResolutionLimit" json:"ResolutionLimit" flag:"resolution-limit"` + UsersPath string `yaml:"UsersPath" json:"-" flag:"users-path"` + StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"` + ImportPath string `yaml:"ImportPath" json:"-" flag:"import-path"` + ImportDest string `yaml:"ImportDest" json:"-" flag:"import-dest"` + ImportAllow string `yaml:"ImportAllow" json:"ImportAllow" flag:"import-allow"` + UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"` + UploadAllow string `yaml:"UploadAllow" json:"-" flag:"upload-allow"` + UploadArchives bool `yaml:"UploadArchives" json:"-" flag:"upload-archives"` + UploadLimit int `yaml:"UploadLimit" json:"-" flag:"upload-limit"` + CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"` + TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"` + AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"` + CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path" tags:"plus,pro"` + CustomThemePath string `yaml:"-" json:"-" flag:"theme-path"` + ModelsPath string `yaml:"ModelsPath" json:"-" flag:"models-path"` + SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"` + SidecarYaml bool `yaml:"SidecarYaml" json:"SidecarYaml" flag:"sidecar-yaml" default:"true"` + UsageInfo bool `yaml:"UsageInfo" json:"UsageInfo" flag:"usage-info"` + FilesQuota uint64 `yaml:"FilesQuota" json:"-" flag:"files-quota"` + UsersQuota int `yaml:"UsersQuota" json:"-" flag:"users-quota" tags:"pro"` + BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"` + BackupSchedule string `yaml:"BackupSchedule" json:"BackupSchedule" flag:"backup-schedule"` + BackupRetain int `yaml:"BackupRetain" json:"BackupRetain" flag:"backup-retain"` + BackupDatabase bool `yaml:"BackupDatabase" json:"BackupDatabase" flag:"backup-database" default:"true"` + BackupAlbums bool `yaml:"BackupAlbums" json:"BackupAlbums" flag:"backup-albums" default:"true"` + IndexWorkers int `yaml:"IndexWorkers" json:"IndexWorkers" flag:"index-workers"` + IndexSchedule string `yaml:"IndexSchedule" json:"IndexSchedule" flag:"index-schedule"` + WakeupInterval time.Duration `yaml:"WakeupInterval" json:"WakeupInterval" flag:"wakeup-interval"` + AutoIndex int `yaml:"AutoIndex" json:"AutoIndex" flag:"auto-index"` + AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"` + ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"` + Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"` + DisableFrontend bool `yaml:"DisableFrontend" json:"-" flag:"disable-frontend"` + DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"` + DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"` + DisableRestart bool `yaml:"DisableRestart" json:"-" flag:"disable-restart"` + DisableWebDAV bool `yaml:"DisableWebDAV" json:"DisableWebDAV" flag:"disable-webdav"` + DisablePlaces bool `yaml:"DisablePlaces" json:"DisablePlaces" flag:"disable-places"` + DisableTensorFlow bool `yaml:"DisableTensorFlow" json:"DisableTensorFlow" flag:"disable-tensorflow"` + DisableFaces bool `yaml:"DisableFaces" json:"DisableFaces" flag:"disable-faces"` + DisableClassification bool `yaml:"DisableClassification" json:"DisableClassification" flag:"disable-classification"` + DisableFFmpeg bool `yaml:"DisableFFmpeg" json:"DisableFFmpeg" flag:"disable-ffmpeg"` + DisableExifTool bool `yaml:"DisableExifTool" json:"DisableExifTool" flag:"disable-exiftool"` + DisableVips bool `yaml:"DisableVips" json:"DisableVips" flag:"disable-vips"` + DisableSips bool `yaml:"DisableSips" json:"DisableSips" flag:"disable-sips"` + DisableDarktable bool `yaml:"DisableDarktable" json:"DisableDarktable" flag:"disable-darktable"` + DisableRawTherapee bool `yaml:"DisableRawTherapee" json:"DisableRawTherapee" flag:"disable-rawtherapee"` + DisableImageMagick bool `yaml:"DisableImageMagick" json:"DisableImageMagick" flag:"disable-imagemagick"` + DisableHeifConvert bool `yaml:"DisableHeifConvert" json:"DisableHeifConvert" flag:"disable-heifconvert"` + DisableVectors bool `yaml:"DisableVectors" json:"DisableVectors" flag:"disable-vectors"` + DisableJpegXL bool `yaml:"DisableJpegXL" json:"DisableJpegXL" flag:"disable-jpegxl"` + DisableRaw bool `yaml:"DisableRaw" json:"DisableRaw" flag:"disable-raw"` + RawPresets bool `yaml:"RawPresets" json:"RawPresets" flag:"raw-presets"` + ExifBruteForce bool `yaml:"ExifBruteForce" json:"ExifBruteForce" flag:"exif-bruteforce"` + DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"` + DefaultTimezone string `yaml:"DefaultTimezone" json:"DefaultTimezone" flag:"default-timezone"` + DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"` + PlacesLocale string `yaml:"PlacesLocale" json:"PlacesLocale" flag:"places-locale"` + AppName string `yaml:"AppName" json:"AppName" flag:"app-name"` + AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"` + AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"` + AppColor string `yaml:"AppColor" json:"AppColor" flag:"app-color"` + LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"` + LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"` + WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"` + SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"` + SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"` + SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"` + SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"` + SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"` + SiteFavicon string `yaml:"SiteFavicon" json:"SiteFavicon" flag:"site-favicon"` + SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"` + CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"` + CdnVideo bool `yaml:"CdnVideo" json:"CdnVideo" flag:"cdn-video"` + CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"` + CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"` + CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"` + ClusterDomain string `yaml:"ClusterDomain" json:"-" flag:"cluster-domain"` + ClusterCIDR string `yaml:"ClusterCIDR" json:"-" flag:"cluster-cidr"` + ClusterUUID string `yaml:"ClusterUUID" json:"-" flag:"cluster-uuid"` + PortalUrl string `yaml:"PortalUrl" json:"-" flag:"portal-url"` + JoinToken string `yaml:"JoinToken" json:"-" flag:"join-token"` + NodeName string `yaml:"NodeName" json:"-" flag:"node-name"` + NodeUUID string `yaml:"NodeUUID" json:"-" flag:"node-uuid"` + NodeRole string `yaml:"-" json:"-" flag:"node-role"` + NodeClientID string `yaml:"NodeClientID" json:"-" flag:"node-client-id"` + NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"` + AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"` + HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"` + HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"` + TrustedPlatform string `yaml:"TrustedPlatform" json:"-" flag:"trusted-platform"` + TrustedProxies []string `yaml:"TrustedProxies" json:"-" flag:"trusted-proxy"` + ProxyClientHeaders []string `yaml:"ProxyClientHeaders" json:"-" flag:"proxy-client-header"` + ProxyProtoHeaders []string `yaml:"ProxyProtoHeaders" json:"-" flag:"proxy-proto-header"` + ProxyProtoHttps []string `yaml:"ProxyProtoHttps" json:"-" flag:"proxy-proto-https"` + DisableTLS bool `yaml:"DisableTLS" json:"DisableTLS" flag:"disable-tls"` + DefaultTLS bool `yaml:"DefaultTLS" json:"DefaultTLS" flag:"default-tls"` + TLSEmail string `yaml:"TLSEmail" json:"TLSEmail" flag:"tls-email"` + TLSCert string `yaml:"TLSCert" json:"TLSCert" flag:"tls-cert"` + TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"` + HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"` + HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"` + HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"` + HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"` + HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"` + HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"` + HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"` + HttpSocket *url.URL `yaml:"-" json:"-" flag:"-"` + DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"` + DatabaseDSN string `yaml:"DatabaseDSN" json:"-" flag:"database-dsn"` + DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"` + DatabaseServer string `yaml:"DatabaseServer" json:"-" flag:"database-server"` + DatabaseUser string `yaml:"DatabaseUser" json:"-" flag:"database-user"` + DatabasePassword string `yaml:"DatabasePassword" json:"-" flag:"database-password"` + DatabaseTimeout int `yaml:"DatabaseTimeout" json:"-" flag:"database-timeout"` + DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"` + DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"` + DatabaseProvisionDriver string `yaml:"DatabaseProvisionDriver" json:"-" flag:"database-provision-driver"` + DatabaseProvisionDSN string `yaml:"DatabaseProvisionDSN" json:"-" flag:"database-provision-dsn"` + FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"` + FFmpegEncoder string `yaml:"FFmpegEncoder" json:"FFmpegEncoder" flag:"ffmpeg-encoder"` + FFmpegSize int `yaml:"FFmpegSize" json:"FFmpegSize" flag:"ffmpeg-size"` + FFmpegQuality int `yaml:"FFmpegQuality" json:"FFmpegQuality" flag:"ffmpeg-quality"` + FFmpegBitrate int `yaml:"FFmpegBitrate" json:"FFmpegBitrate" flag:"ffmpeg-bitrate"` + FFmpegPreset string `yaml:"FFmpegPreset" json:"FFmpegPreset" flag:"ffmpeg-preset"` + FFmpegDevice string `yaml:"FFmpegDevice" json:"-" flag:"ffmpeg-device"` + FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"` + FFmpegMapAudio string `yaml:"FFmpegMapAudio" json:"FFmpegMapAudio" flag:"ffmpeg-map-audio"` + ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"` + SipsBin string `yaml:"SipsBin" json:"-" flag:"sips-bin"` + SipsExclude string `yaml:"SipsExclude" json:"-" flag:"sips-exclude"` + DarktableBin string `yaml:"DarktableBin" json:"-" flag:"darktable-bin"` + DarktableCachePath string `yaml:"DarktableCachePath" json:"-" flag:"darktable-cache-path"` + DarktableConfigPath string `yaml:"DarktableConfigPath" json:"-" flag:"darktable-config-path"` + DarktableExclude string `yaml:"DarktableExclude" json:"-" flag:"darktable-exclude"` + RawTherapeeBin string `yaml:"RawTherapeeBin" json:"-" flag:"rawtherapee-bin"` + RawTherapeeExclude string `yaml:"RawTherapeeExclude" json:"-" flag:"rawtherapee-exclude"` + ImageMagickBin string `yaml:"ImageMagickBin" json:"-" flag:"imagemagick-bin"` + ImageMagickExclude string `yaml:"ImageMagickExclude" json:"-" flag:"imagemagick-exclude"` + HeifConvertBin string `yaml:"HeifConvertBin" json:"-" flag:"heifconvert-bin"` + HeifConvertOrientation string `yaml:"HeifConvertOrientation" json:"-" flag:"heifconvert-orientation"` + RsvgConvertBin string `yaml:"RsvgConvertBin" json:"-" flag:"rsvgconvert-bin"` + DownloadToken string `yaml:"DownloadToken" json:"-" flag:"download-token"` + PreviewToken string `yaml:"PreviewToken" json:"-" flag:"preview-token"` + ThumbLibrary string `yaml:"ThumbLibrary" json:"ThumbLibrary" flag:"thumb-library"` + ThumbColor string `yaml:"ThumbColor" json:"ThumbColor" flag:"thumb-color"` + ThumbFilter string `yaml:"ThumbFilter" json:"ThumbFilter" flag:"thumb-filter"` + ThumbSize int `yaml:"ThumbSize" json:"ThumbSize" flag:"thumb-size"` + ThumbSizeUncached int `yaml:"ThumbSizeUncached" json:"ThumbSizeUncached" flag:"thumb-size-uncached"` + ThumbUncached bool `yaml:"ThumbUncached" json:"ThumbUncached" flag:"thumb-uncached"` + JpegQuality int `yaml:"JpegQuality" json:"JpegQuality" flag:"jpeg-quality"` + JpegSize int `yaml:"JpegSize" json:"JpegSize" flag:"jpeg-size"` + PngSize int `yaml:"PngSize" json:"PngSize" flag:"png-size"` + VisionYaml string `yaml:"VisionYaml" json:"-" flag:"vision-yaml"` + VisionApi bool `yaml:"VisionApi" json:"-" flag:"vision-api"` + VisionUri string `yaml:"VisionUri" json:"-" flag:"vision-uri"` + VisionKey string `yaml:"VisionKey" json:"-" flag:"vision-key"` + DetectNSFW bool `yaml:"DetectNSFW" json:"DetectNSFW" flag:"detect-nsfw"` + FaceSize int `yaml:"-" json:"-" flag:"face-size"` + FaceScore float64 `yaml:"-" json:"-" flag:"face-score"` + FaceOverlap int `yaml:"-" json:"-" flag:"face-overlap"` + FaceClusterSize int `yaml:"-" json:"-" flag:"face-cluster-size"` + FaceClusterScore int `yaml:"-" json:"-" flag:"face-cluster-score"` + FaceClusterCore int `yaml:"-" json:"-" flag:"face-cluster-core"` + FaceClusterDist float64 `yaml:"-" json:"-" flag:"face-cluster-dist"` + FaceMatchDist float64 `yaml:"-" json:"-" flag:"face-match-dist"` + PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"` + LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"` + DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"` } // NewOptions creates a new configuration entity by using two methods: diff --git a/internal/config/options_test.go b/internal/config/options_test.go index dc7d54411..047c4b231 100644 --- a/internal/config/options_test.go +++ b/internal/config/options_test.go @@ -40,7 +40,7 @@ func TestOptions_SetOptionsFromFile(t *testing.T) { assert.Equal(t, "/srv/photoprism/temp", c.TempPath) assert.Equal(t, "1h34m9s", c.WakeupInterval.String()) assert.NotEmpty(t, c.DatabaseDriver) - assert.NotEmpty(t, c.DatabaseDsn) + assert.NotEmpty(t, c.DatabaseDSN) assert.Equal(t, 81, c.HttpPort) } diff --git a/internal/config/report.go b/internal/config/report.go index 38bb049b0..c3a8bb4d6 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -161,19 +161,6 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"site-favicon", c.SiteFavicon()}, {"site-preview", c.SitePreview()}, - // Cluster Configuration. - {"portal-url", c.PortalUrl()}, - {"portal-config-path", c.PortalConfigPath()}, - {"portal-theme-path", c.PortalThemePath()}, - {"join-token", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.JoinToken())))}, - {"cluster-uuid", c.ClusterUUID()}, - {"cluster-domain", c.ClusterDomain()}, - {"node-name", c.NodeName()}, - {"node-role", c.NodeRole()}, - {"node-id", c.NodeID()}, - {"node-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeSecret())))}, - {"advertise-url", c.AdvertiseUrl()}, - // CDN and Cross-Origin Resource Sharing (CORS). {"cdn-url", c.CdnUrl("/")}, {"cdn-video", fmt.Sprintf("%t", c.CdnVideo())}, @@ -188,6 +175,21 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"content-uri", c.ContentUri()}, {"video-uri", c.VideoUri()}, + // Cluster Configuration. + {"cluster-domain", c.ClusterDomain()}, + {"cluster-cidr", c.ClusterCIDR()}, + {"cluster-uuid", c.ClusterUUID()}, + {"portal-url", c.PortalUrl()}, + {"portal-config-path", c.PortalConfigPath()}, + {"portal-theme-path", c.PortalThemePath()}, + {"join-token", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.JoinToken())))}, + {"node-name", c.NodeName()}, + {"node-role", c.NodeRole()}, + {"node-uuid", c.NodeUUID()}, + {"node-client-id", c.NodeClientID()}, + {"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))}, + {"advertise-url", c.AdvertiseUrl()}, + // Proxy Servers. {"https-proxy", c.HttpsProxy()}, {"https-proxy-insecure", fmt.Sprintf("%t", c.HttpsProxyInsecure())}, diff --git a/internal/config/report_sections.go b/internal/config/report_sections.go index e2a8c5bc8..6b199123c 100644 --- a/internal/config/report_sections.go +++ b/internal/config/report_sections.go @@ -25,7 +25,7 @@ var OptionsReportSections = []ReportSection{ {Start: "PHOTOPRISM_READONLY", Title: "Feature Flags"}, {Start: "PHOTOPRISM_DEFAULT_LOCALE", Title: "Customization"}, {Start: "PHOTOPRISM_SITE_URL", Title: "Site Information"}, - {Start: "PHOTOPRISM_PORTAL_URL", Title: "Cluster Configuration"}, + {Start: "PHOTOPRISM_CLUSTER_DOMAIN", Title: "Cluster Configuration"}, {Start: "PHOTOPRISM_HTTPS_PROXY", Title: "Proxy Server"}, {Start: "PHOTOPRISM_DISABLE_TLS", Title: "Web Server"}, {Start: "PHOTOPRISM_DATABASE_DRIVER", Title: "Database Connection"}, @@ -52,7 +52,7 @@ var YamlReportSections = []ReportSection{ {Start: "ReadOnly", Title: "Feature Flags"}, {Start: "DefaultLocale", Title: "Customization"}, {Start: "SiteUrl", Title: "Site Information"}, - {Start: "PortalUrl", Title: "Cluster Configuration"}, + {Start: "ClusterDomain", Title: "Cluster Configuration"}, {Start: "HttpsProxy", Title: "Proxy Server"}, {Start: "DisableTLS", Title: "Web Server"}, {Start: "DatabaseDriver", Title: "Database Connection"}, diff --git a/internal/config/test.go b/internal/config/test.go index 302f1577d..edc96568f 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -120,7 +120,7 @@ func NewTestOptions(pkg string) *Options { BackupRetain: DefaultBackupRetain, BackupSchedule: DefaultBackupSchedule, DatabaseDriver: driver, - DatabaseDsn: dsn, + DatabaseDSN: dsn, AdminPassword: "photoprism", OriginalsLimit: 66, ResolutionLimit: 33, @@ -145,7 +145,7 @@ func NewTestOptionsError() *Options { ImportPath: dataPath + "/import", TempPath: dataPath + "/temp", DatabaseDriver: SQLite3, - DatabaseDsn: ".test-error.db", + DatabaseDSN: ".test-error.db", } return c diff --git a/internal/config/testdata/config.yml b/internal/config/testdata/config.yml index 4baefef99..72b4658e2 100644 --- a/internal/config/testdata/config.yml +++ b/internal/config/testdata/config.yml @@ -10,7 +10,7 @@ HttpMode: release HttpPort: 81 HttpPassword: DatabaseDriver: sqlite -DatabaseDsn: .photoprism.db +DatabaseDSN: .photoprism.db Theme: lavendel Language: english JpegQuality: 87 \ No newline at end of file diff --git a/internal/entity/album_test.go b/internal/entity/album_test.go index ebe0e35ed..5cba3011b 100644 --- a/internal/entity/album_test.go +++ b/internal/entity/album_test.go @@ -69,11 +69,9 @@ func TestAddPhotoToAlbums(t *testing.T) { t.Fatal(err) } }) - t.Run("InvalidPhotoUid", func(t *testing.T) { assert.Error(t, AddPhotoToAlbums("xxx", []string{"as6sg6bitoga0004"})) }) - t.Run("SuccessTwoAlbums", func(t *testing.T) { err := AddPhotoToAlbums("ps6sg6bexxvl0yh0", []string{"as6sg6bitoga0004", ""}) diff --git a/internal/entity/auth_client.go b/internal/entity/auth_client.go index d9b3800a1..ca5fe29ff 100644 --- a/internal/entity/auth_client.go +++ b/internal/entity/auth_client.go @@ -31,6 +31,7 @@ type Clients []Client // Client represents a client application. type Client struct { ClientUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"ClientUID"` + NodeUUID string `gorm:"type:VARBINARY(64);index;default:'';" json:"NodeUUID,omitempty" yaml:"NodeUUID,omitempty"` UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"` UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"` user *User `gorm:"-" yaml:"-"` @@ -105,6 +106,18 @@ func FindClientByUID(uid string) *Client { return m } +// FindClientByNodeUUID returns the client with the given NodeUUID or nil if not found. +func FindClientByNodeUUID(nodeUUID string) *Client { + if nodeUUID == "" { + return nil + } + m := &Client{} + if err := UnscopedDb().Where("node_uuid = ?", nodeUUID).First(m).Error; err != nil { + return nil + } + return m +} + // GetUID returns the client uid string. func (m *Client) GetUID() string { return m.ClientUID diff --git a/internal/entity/auth_client_add_test.go b/internal/entity/auth_client_add_test.go index e63de57e8..48552db07 100644 --- a/internal/entity/auth_client_add_test.go +++ b/internal/entity/auth_client_add_test.go @@ -83,7 +83,6 @@ func Test_AddClient_WithRole(t *testing.T) { } assert.Equal(t, "admin", persisted.ClientRole) }) - t.Run("InvalidRoleDefaultsToClient", func(t *testing.T) { frm := form.Client{ ClientID: "cs5cpu17n6gj9r11", diff --git a/internal/entity/auth_client_data.go b/internal/entity/auth_client_data.go index aaa17d809..a0f40de5a 100644 --- a/internal/entity/auth_client_data.go +++ b/internal/entity/auth_client_data.go @@ -8,6 +8,7 @@ import ( type ClientDatabase struct { Name string `json:"name,omitempty"` User string `json:"user,omitempty"` + Driver string `json:"driver,omitempty"` RotatedAt string `json:"rotatedAt,omitempty"` } @@ -15,7 +16,7 @@ type ClientDatabase struct { type ClientData struct { Labels map[string]string `json:"labels,omitempty"` Database *ClientDatabase `json:"database,omitempty"` - SecretRotatedAt string `json:"secretRotatedAt,omitempty"` + RotatedAt string `json:"rotatedAt,omitempty"` SiteURL string `json:"siteUrl,omitempty"` ClusterUUID string `json:"clusterUUID,omitempty"` ServiceKind string `json:"serviceKind,omitempty"` diff --git a/internal/entity/auth_client_fixtures_test.go b/internal/entity/auth_client_fixtures_test.go index 3a8bd2855..66ff80aec 100644 --- a/internal/entity/auth_client_fixtures_test.go +++ b/internal/entity/auth_client_fixtures_test.go @@ -13,7 +13,6 @@ func TestClientMap_Get(t *testing.T) { assert.Equal(t, "cs5gfen1bgxz7s9i", r.ClientUID) assert.IsType(t, Client{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := ClientFixtures.Get("xxx") assert.Equal(t, "", r.ClientName) @@ -29,7 +28,6 @@ func TestClientMap_Pointer(t *testing.T) { assert.Equal(t, "Alice", r.ClientName) assert.IsType(t, &Client{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := ClientFixtures.Pointer("xxx") assert.Equal(t, "", r.ClientName) diff --git a/internal/entity/auth_client_test.go b/internal/entity/auth_client_test.go index 12470cf31..48fb338d6 100644 --- a/internal/entity/auth_client_test.go +++ b/internal/entity/auth_client_test.go @@ -636,7 +636,6 @@ func TestClient_SetFormValues_Role(t *testing.T) { assert.True(t, c.HasRole(acl.RolePortal)) assert.False(t, c.HasRole(acl.RoleClient)) }) - t.Run("InvalidRoleFromFormDefaultsToClient", func(t *testing.T) { m := Client{ClientName: "InvalidRole", ClientUID: "cs5cpu17n6gj9r02"} if err := m.Save(); err != nil { @@ -649,7 +648,6 @@ func TestClient_SetFormValues_Role(t *testing.T) { assert.Equal(t, "client", c.ClientRole) assert.True(t, c.HasRole(acl.RoleClient)) }) - t.Run("ChangeRoleFromClientToAdmin", func(t *testing.T) { m := NewClient() m.ClientName = "ChangeRole" @@ -702,7 +700,6 @@ func TestClient_SetFormValues_SetUser(t *testing.T) { assert.Equal(t, uid, c.UserUID) assert.Equal(t, uid, c.User().UserUID) }) - t.Run("ByUserName", func(t *testing.T) { m := NewClient() m.ClientName = "SetUserByName" @@ -717,7 +714,6 @@ func TestClient_SetFormValues_SetUser(t *testing.T) { assert.Equal(t, "alice", c.UserName) assert.Equal(t, "alice", c.User().UserName) }) - t.Run("UnknownUserNoChange", func(t *testing.T) { // Seed with a known user, then attempt to change to an unknown one. m := NewClient() @@ -741,7 +737,6 @@ func TestClient_AclRole_Resolution(t *testing.T) { m := &Client{ClientRole: ""} assert.Equal(t, acl.RoleNone, m.AclRole()) }) - t.Run("ClientIsClient", func(t *testing.T) { m := &Client{ClientRole: "client"} assert.Equal(t, acl.RoleClient, m.AclRole()) diff --git a/internal/entity/auth_session_fixtures_test.go b/internal/entity/auth_session_fixtures_test.go index 5313d1da7..ef8601844 100644 --- a/internal/entity/auth_session_fixtures_test.go +++ b/internal/entity/auth_session_fixtures_test.go @@ -14,7 +14,6 @@ func TestSessionMap_Get(t *testing.T) { assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", r.ID) assert.IsType(t, Session{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := SessionFixtures.Get("xxx") assert.Equal(t, "", r.UserName) @@ -31,7 +30,6 @@ func TestSessionMap_Pointer(t *testing.T) { assert.Equal(t, "alice", r.UserName) assert.IsType(t, &Session{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := SessionFixtures.Pointer("xxx") assert.Equal(t, "", r.UserName) diff --git a/internal/entity/auth_session_login_test.go b/internal/entity/auth_session_login_test.go index 496bc079d..b216cf729 100644 --- a/internal/entity/auth_session_login_test.go +++ b/internal/entity/auth_session_login_test.go @@ -514,7 +514,6 @@ func TestSessionLogIn(t *testing.T) { t.Fatal(err) } }) - t.Run("UnknownUserWithInvalidToken", func(t *testing.T) { m := NewSession(unix.Day, unix.Hour*6) m.SetClientIP(clientIp) @@ -534,7 +533,6 @@ func TestSessionLogIn(t *testing.T) { t.Fatal("login should fail") } }) - t.Run("UnknownUserWithoutToken", func(t *testing.T) { m := NewSession(unix.Day, unix.Hour*6) m.SetClientIP(clientIp) @@ -552,7 +550,6 @@ func TestSessionLogIn(t *testing.T) { t.Fatal("login should fail") } }) - t.Run("KnownUserWithToken", func(t *testing.T) { m := FindSessionByRefID("sessxkkcabch") m.SetClientIP(clientIp) @@ -572,7 +569,6 @@ func TestSessionLogIn(t *testing.T) { t.Fatal(err) } }) - t.Run("KnownUserWithInvalidToken", func(t *testing.T) { m := FindSessionByRefID("sessxkkcabch") m.SetClientIP(clientIp) diff --git a/internal/entity/auth_user_details_test.go b/internal/entity/auth_user_details_test.go index 4e27975e6..57c5992b8 100644 --- a/internal/entity/auth_user_details_test.go +++ b/internal/entity/auth_user_details_test.go @@ -60,7 +60,6 @@ func TestUserDetails_DisplayName(t *testing.T) { assert.Equal(t, "Dr. John Doe", m.UserDetails.DisplayName()) }) - t.Run("Empty", func(t *testing.T) { m := &User{} assert.Equal(t, "", m.UserDetails.DisplayName()) diff --git a/internal/entity/auth_user_fixtures_test.go b/internal/entity/auth_user_fixtures_test.go index f583c1806..b3b346697 100644 --- a/internal/entity/auth_user_fixtures_test.go +++ b/internal/entity/auth_user_fixtures_test.go @@ -15,7 +15,6 @@ func TestUserMap_Get(t *testing.T) { assert.Equal(t, "alice", r.Username()) assert.IsType(t, User{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := UserFixtures.Get("monstera") assert.Equal(t, "", r.UserName) @@ -34,7 +33,6 @@ func TestUserMap_Pointer(t *testing.T) { assert.Equal(t, acl.RoleAdmin, r.AclRole()) assert.IsType(t, &User{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := UserFixtures.Pointer("monstera") assert.Equal(t, "", r.UserName) diff --git a/internal/entity/auth_user_share_fixtures_test.go b/internal/entity/auth_user_share_fixtures_test.go index 5de050613..2ed984da4 100644 --- a/internal/entity/auth_user_share_fixtures_test.go +++ b/internal/entity/auth_user_share_fixtures_test.go @@ -13,7 +13,6 @@ func TestUserShareMap_Get(t *testing.T) { assert.Equal(t, "as6sg6bxpogaaba9", r.ShareUID) assert.IsType(t, UserShare{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := UserShareFixtures.Get("monstera") assert.Equal(t, "", r.Comment) @@ -30,7 +29,6 @@ func TestUserShareMap_Pointer(t *testing.T) { assert.IsType(t, &UserShare{}, r) }) - t.Run("Invalid", func(t *testing.T) { r := UserShareFixtures.Pointer("monstera") assert.Equal(t, "", r.Comment) diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index 63b04faa7..11099c99d 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -112,7 +112,6 @@ func TestFindLocalUser(t *testing.T) { assert.NotEmpty(t, m.CreatedAt) assert.NotEmpty(t, m.UpdatedAt) }) - t.Run("Alice", func(t *testing.T) { m := FindLocalUser("alice") @@ -135,7 +134,6 @@ func TestFindLocalUser(t *testing.T) { assert.NotEmpty(t, m.CreatedAt) assert.NotEmpty(t, m.UpdatedAt) }) - t.Run("Bob", func(t *testing.T) { m := FindLocalUser("bob") @@ -156,7 +154,6 @@ func TestFindLocalUser(t *testing.T) { assert.NotEmpty(t, m.CreatedAt) assert.NotEmpty(t, m.UpdatedAt) }) - t.Run("Unknown", func(t *testing.T) { m := FindLocalUser("") @@ -164,7 +161,6 @@ func TestFindLocalUser(t *testing.T) { t.Fatal("result should be nil") } }) - t.Run("NotFound", func(t *testing.T) { m := FindLocalUser("xxx") @@ -199,7 +195,6 @@ func TestFindUserByName(t *testing.T) { assert.NotEmpty(t, m.CreatedAt) assert.NotEmpty(t, m.UpdatedAt) }) - t.Run("Alice", func(t *testing.T) { m := FindUserByName("alice") @@ -220,7 +215,6 @@ func TestFindUserByName(t *testing.T) { assert.NotEmpty(t, m.CreatedAt) assert.NotEmpty(t, m.UpdatedAt) }) - t.Run("Bob", func(t *testing.T) { m := FindUserByName("bob") @@ -239,7 +233,6 @@ func TestFindUserByName(t *testing.T) { assert.NotEmpty(t, m.CreatedAt) assert.NotEmpty(t, m.UpdatedAt) }) - t.Run("Unknown", func(t *testing.T) { m := FindUserByName("") @@ -247,7 +240,6 @@ func TestFindUserByName(t *testing.T) { t.Fatal("result should be nil") } }) - t.Run("NotFound", func(t *testing.T) { m := FindUserByName("xxx") @@ -408,7 +400,6 @@ func TestUser_Save(t *testing.T) { t.Fatal(err) } }) - t.Run("NewUser", func(t *testing.T) { if err := NewUser().Save(); err != nil { t.Fatal(err) @@ -1754,26 +1745,26 @@ func TestUser_SetMethod(t *testing.T) { } func TestUser_SetAuthID(t *testing.T) { - id := rnd.UUID() + uuid := rnd.UUID() issuer := "http://dummy-oidc:9998" t.Run("UUID", func(t *testing.T) { m := UserFixtures.Get("guest") - m.SetAuthID(id, issuer) - assert.Equal(t, id, m.AuthID) + m.SetAuthID(uuid, issuer) + assert.Equal(t, uuid, m.AuthID) assert.Equal(t, issuer, m.AuthIssuer) - m.SetAuthID(id, "") - assert.Equal(t, id, m.AuthID) + m.SetAuthID(uuid, "") + assert.Equal(t, uuid, m.AuthID) assert.Equal(t, "", m.AuthIssuer) m.SetAuthID("", issuer) - assert.Equal(t, id, m.AuthID) + assert.Equal(t, uuid, m.AuthID) assert.Equal(t, "", m.AuthIssuer) }) } func TestUser_UpdateAuthID(t *testing.T) { - id := rnd.UUID() + uuid := rnd.UUID() issuer := "http://dummy-oidc:9998" t.Run("UUID", func(t *testing.T) { @@ -1782,22 +1773,22 @@ func TestUser_UpdateAuthID(t *testing.T) { m.SetAuthID("", issuer) assert.Equal(t, "", m.AuthID) assert.Equal(t, "", m.AuthIssuer) - m.SetAuthID(id, issuer) - assert.Equal(t, id, m.AuthID) + m.SetAuthID(uuid, issuer) + assert.Equal(t, uuid, m.AuthID) assert.Equal(t, issuer, m.AuthIssuer) - err := m.UpdateAuthID(id, "") + err := m.UpdateAuthID(uuid, "") assert.NoError(t, err) - assert.Equal(t, id, m.AuthID) + assert.Equal(t, uuid, m.AuthID) assert.Equal(t, "", m.AuthIssuer) }) t.Run("InvalidUUID", func(t *testing.T) { m := User{UserUID: "123"} assert.Equal(t, "", m.AuthIssuer) - m.SetAuthID(id, issuer) - assert.Equal(t, id, m.AuthID) + m.SetAuthID(uuid, issuer) + assert.Equal(t, uuid, m.AuthID) assert.Equal(t, issuer, m.AuthIssuer) - err := m.UpdateAuthID(id, "") + err := m.UpdateAuthID(uuid, "") assert.Error(t, err) }) } diff --git a/internal/entity/details_test.go b/internal/entity/details_test.go index 73bdafd42..d5d8b41da 100644 --- a/internal/entity/details_test.go +++ b/internal/entity/details_test.go @@ -176,7 +176,6 @@ func TestDetails_Save(t *testing.T) { assert.True(t, afterDate.After(initialDate)) }) - t.Run("Error", func(t *testing.T) { details := Details{PhotoID: 0} diff --git a/internal/entity/entity_test.go b/internal/entity/entity_test.go index a6a4dd6a9..7d965a63d 100644 --- a/internal/entity/entity_test.go +++ b/internal/entity/entity_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/pkg/fs" ) func TestMain(m *testing.M) { @@ -23,6 +24,9 @@ func TestMain(m *testing.M) { code := m.Run() + // Purge local SQLite test artifacts created during this package's tests. + fs.PurgeTestDbFiles(".", false) + os.Exit(code) } diff --git a/internal/entity/face_test.go b/internal/entity/face_test.go index 22358886f..bbd67d74d 100644 --- a/internal/entity/face_test.go +++ b/internal/entity/face_test.go @@ -23,7 +23,6 @@ func TestFace_Match(t *testing.T) { assert.Greater(t, dist, 1.31) assert.Less(t, dist, 1.32) }) - t.Run("1000003-6", func(t *testing.T) { m := FaceFixtures.Get("joe-biden") match, dist := m.Match(MarkerFixtures.Pointer("1000003-6").Embeddings()) @@ -32,7 +31,6 @@ func TestFace_Match(t *testing.T) { assert.Greater(t, dist, 1.27) assert.Less(t, dist, 1.28) }) - t.Run("len(embeddings) == 0", func(t *testing.T) { m := FaceFixtures.Get("joe-biden") match, dist := m.Match(face.Embeddings{}) diff --git a/internal/entity/file_share_test.go b/internal/entity/file_share_test.go index 2f7d8a501..7a59c280d 100644 --- a/internal/entity/file_share_test.go +++ b/internal/entity/file_share_test.go @@ -37,7 +37,6 @@ func TestFirstOrCreateFileShare(t *testing.T) { t.Errorf("ServiceID should be the same: %d %d", result.ServiceID, fileShare.ServiceID) } }) - t.Run("existing", func(t *testing.T) { fileShare := NewFileShare(778, 999, "NameForRemote") result := FirstOrCreateFileShare(fileShare) diff --git a/internal/entity/file_sync_test.go b/internal/entity/file_sync_test.go index 10f5415fe..c301e5b60 100644 --- a/internal/entity/file_sync_test.go +++ b/internal/entity/file_sync_test.go @@ -36,7 +36,6 @@ func TestFirstOrCreateFileSync(t *testing.T) { t.Errorf("ServiceID should be the same: %d %d", result.ServiceID, fileSync.ServiceID) } }) - t.Run("existing", func(t *testing.T) { fileSync := NewFileSync(778, "NameForRemote") result := FirstOrCreateFileSync(fileSync) diff --git a/internal/entity/file_test.go b/internal/entity/file_test.go index c9302f436..5626bb387 100644 --- a/internal/entity/file_test.go +++ b/internal/entity/file_test.go @@ -146,12 +146,10 @@ func TestFile_Missing(t *testing.T) { file := &File{FileMissing: false, Photo: nil, FileType: "jpg", FileSize: 500, ModTime: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC).Unix(), DeletedAt: &deletedAt} assert.Equal(t, true, file.Missing()) }) - t.Run("missing", func(t *testing.T) { file := &File{FileMissing: true, Photo: nil, FileType: "jpg", FileSize: 500, ModTime: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC).Unix(), DeletedAt: nil} assert.Equal(t, true, file.Missing()) }) - t.Run("not_missing", func(t *testing.T) { file := &File{FileMissing: false, Photo: nil, FileType: "jpg", FileSize: 500, ModTime: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC).Unix(), DeletedAt: nil} assert.Equal(t, false, file.Missing()) diff --git a/internal/entity/folder_test.go b/internal/entity/folder_test.go index 2efc4ac40..e4680f91f 100644 --- a/internal/entity/folder_test.go +++ b/internal/entity/folder_test.go @@ -27,7 +27,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 5, folder.FolderMonth) assert.Equal(t, UnknownID, folder.FolderCountry) }) - t.Run("/2020/05/01/", func(t *testing.T) { folder := NewFolder(RootOriginals, "/2020/05/01/", time.Now().UTC()) assert.Equal(t, "2020/05/01", folder.Path) @@ -36,7 +35,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 5, folder.FolderMonth) assert.Equal(t, UnknownID, folder.FolderCountry) }) - t.Run("/2020/05/23/", func(t *testing.T) { folder := NewFolder(RootImport, "/2020/05/23/", time.Now().UTC()) assert.Equal(t, "2020/05/23", folder.Path) @@ -45,7 +43,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 5, folder.FolderMonth) assert.Equal(t, UnknownID, folder.FolderCountry) }) - t.Run("/2020/05/23/Iceland 2020", func(t *testing.T) { folder := NewFolder(RootOriginals, "/2020/05/23/Iceland 2020", time.Now().UTC()) assert.Equal(t, "2020/05/23/Iceland 2020", folder.Path) @@ -54,7 +51,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 5, folder.FolderMonth) assert.Equal(t, "is", folder.FolderCountry) }) - t.Run("/London/2020/05/23", func(t *testing.T) { folder := NewFolder(RootOriginals, "/London/2020/05/23", time.Now().UTC()) assert.Equal(t, "London/2020/05/23", folder.Path) @@ -63,7 +59,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 5, folder.FolderMonth) assert.Equal(t, "gb", folder.FolderCountry) }) - t.Run("RootOriginalsNoDir", func(t *testing.T) { folder := NewFolder(RootOriginals, "", time.Time{}) assert.Equal(t, "", folder.Path) @@ -72,7 +67,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 0, folder.FolderMonth) assert.Equal(t, UnknownID, folder.FolderCountry) }) - t.Run("RootOriginalsRootDir", func(t *testing.T) { folder := NewFolder(RootOriginals, RootPath, time.Time{}) assert.Equal(t, "", folder.Path) @@ -81,7 +75,6 @@ func TestNewFolder(t *testing.T) { assert.Equal(t, 0, folder.FolderMonth) assert.Equal(t, UnknownID, folder.FolderCountry) }) - t.Run("NoRootWithRootDir", func(t *testing.T) { folder := NewFolder("", RootPath, time.Now().UTC()) assert.Equal(t, "", folder.Path) diff --git a/internal/entity/passcode_test.go b/internal/entity/passcode_test.go index 1e39db47f..69b5b8c71 100644 --- a/internal/entity/passcode_test.go +++ b/internal/entity/passcode_test.go @@ -149,7 +149,6 @@ func TestPasscode_SetUID(t *testing.T) { assert.False(t, passcode.InvalidUID()) }) - t.Run("Invalid", func(t *testing.T) { m := &Passcode{ UID: "uqxc08w3d0ej2283", diff --git a/internal/entity/photo_datetime_test.go b/internal/entity/photo_datetime_test.go index 0f18239d8..da3dfe39a 100644 --- a/internal/entity/photo_datetime_test.go +++ b/internal/entity/photo_datetime_test.go @@ -351,7 +351,6 @@ func TestPhoto_UpdateTimeZone(t *testing.T) { assert.Equal(t, takenAt, m.TakenAt) assert.Equal(t, m.GetTakenAtLocal(), m.TakenAtLocal) }) - t.Run("Europe/Berlin", func(t *testing.T) { m := PhotoFixtures.Get("Photo12") @@ -370,7 +369,6 @@ func TestPhoto_UpdateTimeZone(t *testing.T) { assert.Equal(t, takenAtLocal, m.TakenAtLocal) assert.Equal(t, m.GetTakenAtLocal(), m.TakenAtLocal) }) - t.Run("America/New_York", func(t *testing.T) { m := PhotoFixtures.Get("Photo12") m.TimeZone = "Europe/Berlin" @@ -390,7 +388,6 @@ func TestPhoto_UpdateTimeZone(t *testing.T) { assert.Equal(t, m.GetTakenAt(), m.TakenAt) assert.Equal(t, takenAtLocal, m.TakenAtLocal) }) - t.Run("manual", func(t *testing.T) { m := PhotoFixtures.Get("Photo12") m.TimeZone = "Europe/Berlin" diff --git a/internal/entity/photo_location_test.go b/internal/entity/photo_location_test.go index 8b12db65d..7607cec82 100644 --- a/internal/entity/photo_location_test.go +++ b/internal/entity/photo_location_test.go @@ -245,7 +245,6 @@ func TestPhoto_UnknownLocation(t *testing.T) { m := PhotoFixtures.Get("19800101_000002_D640C559") assert.True(t, m.UnknownLocation()) }) - t.Run("no_lat_lng", func(t *testing.T) { m := PhotoFixtures.Get("Photo08") m.PhotoLat = 0.0 @@ -254,7 +253,6 @@ func TestPhoto_UnknownLocation(t *testing.T) { assert.False(t, m.HasLocation()) assert.True(t, m.UnknownLocation()) }) - t.Run("lat_lng_cell_id", func(t *testing.T) { m := PhotoFixtures.Get("Photo08") // t.Logf("MODEL: %+v", m) @@ -413,7 +411,6 @@ func TestUpdateLocation(t *testing.T) { assert.Equal(t, "mx:VvfNBpFegSCr", m.PlaceID) assert.Equal(t, SrcEstimate, m.PlaceSrc) }) - t.Run("change_estimate", func(t *testing.T) { m := Photo{ PhotoName: "test_photo_1", diff --git a/internal/entity/photo_merge_test.go b/internal/entity/photo_merge_test.go index 1fc622e43..d273ac4f7 100644 --- a/internal/entity/photo_merge_test.go +++ b/internal/entity/photo_merge_test.go @@ -47,7 +47,6 @@ func TestPhoto_IdenticalIdentical(t *testing.T) { t.Logf("result: %#v", result) assert.Equal(t, 1, len(result)) }) - t.Run("unstacked photo", func(t *testing.T) { photo := &Photo{PhotoStack: IsUnstacked, PhotoName: "testName"} diff --git a/internal/entity/photo_test.go b/internal/entity/photo_test.go index 7a5a3c368..bef0d2649 100644 --- a/internal/entity/photo_test.go +++ b/internal/entity/photo_test.go @@ -215,7 +215,6 @@ func TestPhoto_SaveLabels(t *testing.T) { assert.EqualError(t, err, "photo: cannot save to database, id is empty") }) - t.Run("ExistingPhoto", func(t *testing.T) { m := PhotoFixtures.Get("19800101_000002_D640C559") err := m.SaveLabels() diff --git a/internal/entity/photo_title_test.go b/internal/entity/photo_title_test.go index 3f1bec375..2f424ab94 100644 --- a/internal/entity/photo_title_test.go +++ b/internal/entity/photo_title_test.go @@ -116,7 +116,6 @@ func TestPhoto_GenerateTitle(t *testing.T) { } assert.Equal(t, "longlonglonglonglonglongName / 2018", m.PhotoTitle) }) - t.Run("photo with location and short city", func(t *testing.T) { m := PhotoFixtures.Get("Photo09") classifyLabels := &classify.Labels{} @@ -143,7 +142,6 @@ func TestPhoto_GenerateTitle(t *testing.T) { assert.Equal(t, "Holiday Park / Germany / 2016", m.PhotoTitle) } }) - t.Run("photo with location without loc name and long city", func(t *testing.T) { m := PhotoFixtures.Get("Photo11") classifyLabels := &classify.Labels{} @@ -184,7 +182,6 @@ func TestPhoto_GenerateTitle(t *testing.T) { } assert.Equal(t, "Classify / Germany / 2006", m.PhotoTitle) }) - t.Run("no location no labels", func(t *testing.T) { m := PhotoFixtures.Get("Photo02") classifyLabels := &classify.Labels{} diff --git a/internal/entity/query/albums_test.go b/internal/entity/query/albums_test.go index 00b0f9715..eba80c669 100644 --- a/internal/entity/query/albums_test.go +++ b/internal/entity/query/albums_test.go @@ -42,7 +42,6 @@ func TestAlbumCoverByUID(t *testing.T) { assert.Equal(t, "1990/04/bridge2.jpg", file.FileName) }) - t.Run("existing uid folder album", func(t *testing.T) { file, err := AlbumCoverByUID("as6sg6bipogaaba1", true) @@ -52,20 +51,17 @@ func TestAlbumCoverByUID(t *testing.T) { assert.Equal(t, "1990/04/bridge2.jpg", file.FileName) }) - t.Run("existing uid empty moment album", func(t *testing.T) { file, err := AlbumCoverByUID("as6sg6bitoga0005", true) assert.EqualError(t, err, "no cover found", err) assert.Equal(t, "", file.FileName) }) - t.Run("not existing uid", func(t *testing.T) { file, err := AlbumCoverByUID("3765", true) assert.Error(t, err, "record not found") t.Log(file) }) - t.Run("existing uid empty month album", func(t *testing.T) { file, err := AlbumCoverByUID("as6sg6bipogaabj9", true) @@ -120,7 +116,6 @@ func TestAlbumsByUID(t *testing.T) { assert.Len(t, results, 2) }) - t.Run("IncludeDeleted", func(t *testing.T) { results, err := AlbumsByUID([]string{"as6sg6bxpogaaba7", "as6sg6bxpogaaba8"}, true) diff --git a/internal/entity/query/faces_test.go b/internal/entity/query/faces_test.go index 39b06088e..0aa7e0a30 100644 --- a/internal/entity/query/faces_test.go +++ b/internal/entity/query/faces_test.go @@ -23,7 +23,6 @@ func TestFaces(t *testing.T) { assert.IsType(t, entity.Face{}, val) } }) - t.Run("Hidden", func(t *testing.T) { results, err := Faces(false, false, true, false) @@ -33,7 +32,6 @@ func TestFaces(t *testing.T) { assert.GreaterOrEqual(t, len(results), 1) }) - t.Run("Ignored", func(t *testing.T) { results, err := Faces(false, false, true, true) @@ -43,7 +41,6 @@ func TestFaces(t *testing.T) { assert.GreaterOrEqual(t, len(results), 1) }) - t.Run("Unmatched", func(t *testing.T) { results, err := Faces(false, true, false, false) diff --git a/internal/entity/query/files_test.go b/internal/entity/query/files_test.go index d9f14de12..7cb5895e0 100644 --- a/internal/entity/query/files_test.go +++ b/internal/entity/query/files_test.go @@ -126,7 +126,6 @@ func TestFileByPhotoUID(t *testing.T) { } assert.Equal(t, "Germany/bridge.jpg", file.FileName) }) - t.Run("no files found", func(t *testing.T) { file, err := FileByPhotoUID("111") @@ -144,7 +143,6 @@ func TestVideoByPhotoUID(t *testing.T) { } assert.Equal(t, "1990/04/bridge2.mp4", file.FileName) }) - t.Run("no files found", func(t *testing.T) { file, err := VideoByPhotoUID("111") @@ -163,7 +161,6 @@ func TestFileByUID(t *testing.T) { assert.Equal(t, "2790/07/27900704_070228_D6D51B6C.jpg", file.FileName) }) - t.Run("no files found", func(t *testing.T) { file, err := FileByUID("111") @@ -185,7 +182,6 @@ func TestFileByHash(t *testing.T) { } assert.Equal(t, "2790/07/27900704_070228_D6D51B6C.jpg", file.FileName) }) - t.Run("no files found", func(t *testing.T) { file, err := FileByHash("111") diff --git a/internal/entity/query/folders_test.go b/internal/entity/query/folders_test.go index b619c715d..310f0ff4b 100644 --- a/internal/entity/query/folders_test.go +++ b/internal/entity/query/folders_test.go @@ -41,7 +41,6 @@ func TestFoldersByPath(t *testing.T) { assert.Len(t, folders, 1) }) - t.Run("subdirectory", func(t *testing.T) { folders, err := FoldersByPath(entity.RootOriginals, "testdata", "directory", false) diff --git a/internal/entity/query/label_test.go b/internal/entity/query/label_test.go index fa2c31265..97df44d40 100644 --- a/internal/entity/query/label_test.go +++ b/internal/entity/query/label_test.go @@ -19,7 +19,6 @@ func TestLabelBySlug(t *testing.T) { assert.IsType(t, &entity.Label{}, result) assert.Equal(t, "Flower", result.LabelName) }) - t.Run("NotFound", func(t *testing.T) { label, err := LabelBySlug("111") @@ -40,7 +39,6 @@ func TestLabelByUID(t *testing.T) { assert.IsType(t, &entity.Label{}, result) assert.Equal(t, "COW", result.LabelName) }) - t.Run("NotFound", func(t *testing.T) { result, err := LabelByUID("111") diff --git a/internal/entity/query/photo_test.go b/internal/entity/query/photo_test.go index 6f7071950..f8dc217fc 100644 --- a/internal/entity/query/photo_test.go +++ b/internal/entity/query/photo_test.go @@ -18,7 +18,6 @@ func TestPhotoByID(t *testing.T) { } assert.Equal(t, 2790, result.PhotoYear) }) - t.Run("no photo found", func(t *testing.T) { result, err := PhotoByID(99999) assert.Error(t, err, "record not found") @@ -34,7 +33,6 @@ func TestPhotoByUID(t *testing.T) { } assert.Equal(t, "Reunion", result.PhotoTitle) }) - t.Run("no photo found", func(t *testing.T) { result, err := PhotoByUID("99999") assert.Error(t, err, "record not found") @@ -50,7 +48,6 @@ func TestPreloadPhotoByUID(t *testing.T) { } assert.Equal(t, "Reunion", result.PhotoTitle) }) - t.Run("no photo found", func(t *testing.T) { result, err := PhotoPreloadByUID("99999") assert.Error(t, err, "record not found") @@ -122,7 +119,6 @@ func TestFlagHiddenPhotos(t *testing.T) { t.Fatal(err) } }) - t.Run("SuccessWith1000", func(t *testing.T) { var checkedTime = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) // Load 1000 photos that need to be hidden diff --git a/internal/entity/search/accounts_test.go b/internal/entity/search/accounts_test.go index f63cadfc8..36cb0759e 100644 --- a/internal/entity/search/accounts_test.go +++ b/internal/entity/search/accounts_test.go @@ -34,7 +34,6 @@ func TestAccounts(t *testing.T) { assert.IsType(t, entity.Service{}, r) } }) - t.Run("find accounts count 1001", func(t *testing.T) { f := form.SearchServices{ Query: "", diff --git a/internal/entity/search/albums_test.go b/internal/entity/search/albums_test.go index 82154930a..daf3e579f 100644 --- a/internal/entity/search/albums_test.go +++ b/internal/entity/search/albums_test.go @@ -67,7 +67,6 @@ func TestAlbums(t *testing.T) { assert.Equal(t, "Christmas 2030", result[0].AlbumTitle) }) - t.Run("SearchWithSlug", func(t *testing.T) { query := form.NewAlbumSearch("slug:holiday") query.Type = entity.AlbumManual @@ -79,7 +78,6 @@ func TestAlbums(t *testing.T) { assert.Equal(t, "Holiday 2030", result[0].AlbumTitle) }) - t.Run("SearchWithCountry", func(t *testing.T) { query := form.NewAlbumSearch("country:ca") result, err := Albums(query) @@ -90,7 +88,6 @@ func TestAlbums(t *testing.T) { assert.Equal(t, "April 1990", result[0].AlbumTitle) }) - t.Run("FavoritesTrue", func(t *testing.T) { query := form.NewAlbumSearch("favorite:true") query.Count = 100000 diff --git a/internal/entity/search/conditions_test.go b/internal/entity/search/conditions_test.go index 0a88c1b89..b94f67dbb 100644 --- a/internal/entity/search/conditions_test.go +++ b/internal/entity/search/conditions_test.go @@ -61,7 +61,6 @@ func TestLikeAny(t *testing.T) { assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%' OR k.keyword LIKE 'usa'", w[0]) } }) - t.Run("cat dog", func(t *testing.T) { if w := LikeAny("k.keyword", "cat dog", true, false); len(w) != 1 { t.Fatal("one where condition expected") @@ -69,7 +68,6 @@ func TestLikeAny(t *testing.T) { assert.Equal(t, "k.keyword LIKE 'cat' OR k.keyword LIKE 'dog'", w[0]) } }) - t.Run("cats dogs", func(t *testing.T) { if w := LikeAny("k.keyword", "cats dogs", true, false); len(w) != 1 { t.Fatal("one where condition expected") @@ -77,7 +75,6 @@ func TestLikeAny(t *testing.T) { assert.Equal(t, "k.keyword LIKE 'cats%' OR k.keyword LIKE 'cat' OR k.keyword LIKE 'dogs%' OR k.keyword LIKE 'dog'", w[0]) } }) - t.Run("spoon", func(t *testing.T) { if w := LikeAny("k.keyword", "spoon", true, false); len(w) != 1 { t.Fatal("one where condition expected") @@ -85,13 +82,11 @@ func TestLikeAny(t *testing.T) { assert.Equal(t, "k.keyword LIKE 'spoon%'", w[0]) } }) - t.Run("img", func(t *testing.T) { if w := LikeAny("k.keyword", "img", true, false); len(w) > 0 { t.Fatal("no where condition expected") } }) - t.Run("Empty", func(t *testing.T) { if w := LikeAny("k.keyword", "", true, false); len(w) > 0 { t.Fatal("no where condition expected") @@ -261,47 +256,38 @@ func TestAnySlug(t *testing.T) { where := AnySlug("custom_slug", "table spoon usa img json", " ") assert.Equal(t, "custom_slug = 'table' OR custom_slug = 'spoon' OR custom_slug = 'usa' OR custom_slug = 'img' OR custom_slug = 'json'", where) }) - t.Run("CatDog", func(t *testing.T) { where := AnySlug("custom_slug", "cat dog", " ") assert.Equal(t, "custom_slug = 'cat' OR custom_slug = 'dog'", where) }) - t.Run("CatsDogs", func(t *testing.T) { where := AnySlug("custom_slug", "cats dogs", " ") assert.Equal(t, "custom_slug = 'cats' OR custom_slug = 'cat' OR custom_slug = 'dogs' OR custom_slug = 'dog'", where) }) - t.Run("Spoon", func(t *testing.T) { where := AnySlug("custom_slug", "spoon", " ") assert.Equal(t, "custom_slug = 'spoon'", where) }) - t.Run("Img", func(t *testing.T) { where := AnySlug("custom_slug", "img", " ") assert.Equal(t, "custom_slug = 'img'", where) }) - t.Run("Space", func(t *testing.T) { where := AnySlug("custom_slug", " ", "") assert.Equal(t, "custom_slug = '' OR custom_slug = ''", where) }) - t.Run("Empty", func(t *testing.T) { where := AnySlug("custom_slug", "", " ") assert.Equal(t, "", where) }) - t.Run("CommaSeparated", func(t *testing.T) { where := AnySlug("custom_slug", "botanical-garden|landscape|bay", txt.Or) assert.Equal(t, "custom_slug = 'botanical-garden' OR custom_slug = 'landscape' OR custom_slug = 'bay'", where) }) - t.Run("Emoji", func(t *testing.T) { where := AnySlug("custom_slug", "💐", "|") assert.Equal(t, "custom_slug = '_5cpzfea'", where) }) - t.Run("EmojiSlug", func(t *testing.T) { where := AnySlug("custom_slug", "_5cpzfea", "|") assert.Equal(t, "custom_slug = '_5cpzfea'", where) @@ -313,22 +299,18 @@ func TestAnyInt(t *testing.T) { where := AnyInt("photos.photo_month", "", txt.Or, entity.UnknownMonth, txt.MonthMax) assert.Equal(t, "", where) }) - t.Run("Range", func(t *testing.T) { where := AnyInt("photos.photo_month", "-3|0|10|9|11|12|13", txt.Or, entity.UnknownMonth, txt.MonthMax) assert.Equal(t, "photos.photo_month = 10 OR photos.photo_month = 9 OR photos.photo_month = 11 OR photos.photo_month = 12", where) }) - t.Run("Chars", func(t *testing.T) { where := AnyInt("photos.photo_month", "a|b|c", txt.Or, entity.UnknownMonth, txt.MonthMax) assert.Equal(t, "", where) }) - t.Run("CommaSeparated", func(t *testing.T) { where := AnyInt("photos.photo_month", "-3,10,9,11,12,13", ",", entity.UnknownMonth, txt.MonthMax) assert.Equal(t, "photos.photo_month = 10 OR photos.photo_month = 9 OR photos.photo_month = 11 OR photos.photo_month = 12", where) }) - t.Run("Invalid", func(t *testing.T) { where := AnyInt("photos.photo_month", " , | ", ",", entity.UnknownMonth, txt.MonthMax) assert.Equal(t, "", where) @@ -459,19 +441,16 @@ func TestSplitAnd(t *testing.T) { assert.Equal(t, []string{}, values) }) - t.Run("FooOrBar", func(t *testing.T) { values := SplitAnd(" foo | Bar ") assert.Equal(t, []string{" foo | Bar "}, values) }) - t.Run("FooAndBar", func(t *testing.T) { values := SplitAnd(" foo & Bar ") assert.Equal(t, []string{"foo", "Bar"}, values) }) - t.Run("FooAndBarAndBaz", func(t *testing.T) { values := SplitAnd(" foo & Bar&BAZ ") diff --git a/internal/entity/search/labels_test.go b/internal/entity/search/labels_test.go index 4c9fde20a..2e192e091 100644 --- a/internal/entity/search/labels_test.go +++ b/internal/entity/search/labels_test.go @@ -38,7 +38,6 @@ func TestLabels(t *testing.T) { } } }) - t.Run("search for cow", func(t *testing.T) { query := form.NewLabelSearch("Q:cow") query.Count = 1005 @@ -93,7 +92,6 @@ func TestLabels(t *testing.T) { } } }) - t.Run("search with empty query", func(t *testing.T) { query := form.NewLabelSearch("") result, err := Labels(query) @@ -105,7 +103,6 @@ func TestLabels(t *testing.T) { t.Log(result) assert.LessOrEqual(t, 3, len(result)) }) - t.Run("search with invalid query string", func(t *testing.T) { query := form.NewLabelSearch("xxx:bla") result, err := Labels(query) @@ -113,7 +110,6 @@ func TestLabels(t *testing.T) { assert.Error(t, err, "unknown filter") assert.Empty(t, result) }) - t.Run("search for ID", func(t *testing.T) { f := form.SearchLabels{ Query: "", @@ -135,7 +131,6 @@ func TestLabels(t *testing.T) { assert.Equal(t, "cake", result[0].LabelSlug) }) - t.Run("search for label landscape", func(t *testing.T) { f := form.SearchLabels{ Query: "landscape", diff --git a/internal/entity/search/photos_filter_geo_test.go b/internal/entity/search/photos_filter_geo_test.go index c90e52c4e..adadac4d4 100644 --- a/internal/entity/search/photos_filter_geo_test.go +++ b/internal/entity/search/photos_filter_geo_test.go @@ -51,7 +51,6 @@ func TestPhotosQueryGeo(t *testing.T) { assert.Equal(t, "zz", r.CellID) } }) - t.Run("StartsWithPercent", func(t *testing.T) { var f form.SearchPhotos diff --git a/internal/entity/search/photos_geo_test.go b/internal/entity/search/photos_geo_test.go index 9e0320565..50c4f354a 100644 --- a/internal/entity/search/photos_geo_test.go +++ b/internal/entity/search/photos_geo_test.go @@ -82,7 +82,6 @@ func TestGeo(t *testing.T) { assert.LessOrEqual(t, 4, len(result)) } }) - t.Run("search for bridge", func(t *testing.T) { query := form.NewSearchPhotosGeo("q:bridge Before:3006-01-02") @@ -101,7 +100,6 @@ func TestGeo(t *testing.T) { assert.LessOrEqual(t, 1, len(result)) }) - t.Run("search for date range", func(t *testing.T) { query := form.NewSearchPhotosGeo("After:2014-12-02 Before:3006-01-02") @@ -120,7 +118,6 @@ func TestGeo(t *testing.T) { assert.Equal(t, "Reunion", result[0].PhotoTitle) }) - t.Run("search for review true, quality 0", func(t *testing.T) { f := form.SearchPhotosGeo{ Query: "", @@ -148,7 +145,6 @@ func TestGeo(t *testing.T) { assert.Equal(t, "1000017", result[0].ID) } }) - t.Run("search for review false, quality > 0", func(t *testing.T) { f := form.SearchPhotosGeo{ Query: "", diff --git a/internal/entity/search/photos_results_test.go b/internal/entity/search/photos_results_test.go index 8f3bf57e3..aecdc9800 100644 --- a/internal/entity/search/photos_results_test.go +++ b/internal/entity/search/photos_results_test.go @@ -781,7 +781,6 @@ func TestPhotosResult_ShareFileName(t *testing.T) { r := result1.ShareBase(0) assert.Contains(t, r, "20151111-090718-uid123") }) - t.Run("SeqGreater0", func(t *testing.T) { result1 := Photo{ ID: 111111, diff --git a/internal/entity/search/photos_test.go b/internal/entity/search/photos_test.go index 625cc4f74..34224c388 100644 --- a/internal/entity/search/photos_test.go +++ b/internal/entity/search/photos_test.go @@ -556,7 +556,6 @@ func TestPhotos(t *testing.T) { //t.Logf("results: %+v", photos) assert.Equal(t, 1, len(photos)) }) - t.Run("form.portrait", func(t *testing.T) { var f form.SearchPhotos f.Query = "portrait:true" @@ -572,7 +571,6 @@ func TestPhotos(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) - t.Run("form.mono", func(t *testing.T) { var f form.SearchPhotos f.Query = "mono:true" @@ -739,7 +737,6 @@ func TestPhotos(t *testing.T) { } assert.LessOrEqual(t, 2, len(photos)) }) - t.Run("search for diff", func(t *testing.T) { var f form.SearchPhotos f.Query = "Diff:800" @@ -889,7 +886,6 @@ func TestPhotos(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) - t.Run("search for labels", func(t *testing.T) { var f form.SearchPhotos f.Label = "botanical-garden|nature|landscape|park" @@ -902,7 +898,6 @@ func TestPhotos(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) - t.Run("search for primary files", func(t *testing.T) { var f form.SearchPhotos f.Primary = true @@ -915,7 +910,6 @@ func TestPhotos(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) - t.Run("search for landscape", func(t *testing.T) { var f form.SearchPhotos f.Query = "landscape" @@ -928,7 +922,6 @@ func TestPhotos(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) - t.Run("search with multiple parameters", func(t *testing.T) { var f form.SearchPhotos f.Hidden = true @@ -1849,7 +1842,6 @@ func TestPhotos(t *testing.T) { assert.Equal(t, len(photos3), len(photos4)) assert.Equal(t, len(photos), len(photos4)) }) - t.Run("Search in Title", func(t *testing.T) { var f form.SearchPhotos f.Query = "N" diff --git a/internal/entity/search/viewer/url_test.go b/internal/entity/search/viewer/url_test.go index 3a78c718b..9c179bda9 100644 --- a/internal/entity/search/viewer/url_test.go +++ b/internal/entity/search/viewer/url_test.go @@ -14,7 +14,6 @@ func TestDownloadUrl(t *testing.T) { result := DownloadUrl("d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2", apiUri, dlToken) assert.Equal(t, "/api/v1/dl/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2?t=3tcsggxy", result) }) - t.Run("NoToken", func(t *testing.T) { dlToken := "" result := DownloadUrl("653cd9e5754e98d899e9ba30c9075da4ebb16141", apiUri, dlToken) diff --git a/internal/ffmpeg/extract_image_cmd_negative_test.go b/internal/ffmpeg/extract_image_cmd_negative_test.go index 8f2d8a3ab..773bc95bf 100644 --- a/internal/ffmpeg/extract_image_cmd_negative_test.go +++ b/internal/ffmpeg/extract_image_cmd_negative_test.go @@ -21,7 +21,7 @@ func TestExtractImageCmd_UnwritableDest(t *testing.T) { if err := os.MkdirAll(unwritable, 0o555); err != nil { t.Fatal(err) } - defer os.Chmod(unwritable, 0o755) + defer os.Chmod(unwritable, fs.ModeDir) destName := filepath.Join(unwritable, "frame.jpg") cmd := ExtractImageCmd(srcName, destName, opt) diff --git a/internal/ffmpeg/remux_test.go b/internal/ffmpeg/remux_test.go index db87c4440..15a55b15b 100644 --- a/internal/ffmpeg/remux_test.go +++ b/internal/ffmpeg/remux_test.go @@ -21,7 +21,6 @@ func TestRemuxFile(t *testing.T) { assert.Equal(t, "invalid video file path", err.Error()) }) - t.Run("Mp4", func(t *testing.T) { opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false) @@ -64,7 +63,6 @@ func TestRemuxCmd(t *testing.T) { assert.Equal(t, "empty source filename", err.Error()) }) - t.Run("Mp4", func(t *testing.T) { opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false) @@ -107,7 +105,7 @@ func TestRemuxFile_DestExists_NoForce_NoOp(t *testing.T) { dest := filepath.Join(dir, "already-there.mp4") // Create a tiny placeholder dest file _ = os.Remove(dest) - if err := os.WriteFile(dest, []byte("x"), 0o644); err != nil { + if err := os.WriteFile(dest, []byte("x"), fs.ModeFile); err != nil { t.Fatal(err) } defer os.Remove(dest) @@ -134,7 +132,7 @@ func TestRemuxFile_TempExists_NoForce_Error(t *testing.T) { if err := fs.Copy(orig, src, true); err != nil { t.Fatal(err) } - if err := os.WriteFile(temp, []byte("x"), 0o644); err != nil { + if err := os.WriteFile(temp, []byte("x"), fs.ModeFile); err != nil { t.Fatal(err) } err := RemuxFile(src, dest, opt) diff --git a/internal/ffmpeg/transcode_cmd_negative_test.go b/internal/ffmpeg/transcode_cmd_negative_test.go index 5d1acb62d..0650e74ff 100644 --- a/internal/ffmpeg/transcode_cmd_negative_test.go +++ b/internal/ffmpeg/transcode_cmd_negative_test.go @@ -21,7 +21,7 @@ func TestTranscodeCmd_UnwritableDest(t *testing.T) { if err := os.MkdirAll(unwritable, 0o555); err != nil { t.Fatal(err) } - defer os.Chmod(unwritable, 0o755) + defer os.Chmod(unwritable, fs.ModeDir) destName := filepath.Join(unwritable, "out.mp4") cmd, _, err := TranscodeCmd(srcName, destName, opt) diff --git a/internal/form/search_photos_test.go b/internal/form/search_photos_test.go index a967cde48..7a9d59d42 100644 --- a/internal/form/search_photos_test.go +++ b/internal/form/search_photos_test.go @@ -97,7 +97,6 @@ func TestParseQueryString(t *testing.T) { assert.Equal(t, "123abc/,EFG", form.Path) }) - t.Run("folder", func(t *testing.T) { form := &SearchPhotos{Query: "folder:123abc/,EFG"} diff --git a/internal/form/serialize_test.go b/internal/form/serialize_test.go index 2b4bc0f35..7f96a18e4 100644 --- a/internal/form/serialize_test.go +++ b/internal/form/serialize_test.go @@ -82,25 +82,21 @@ func TestSerialize(t *testing.T) { assert.IsType(t, expected, result) assert.Equal(t, expected, result) }) - t.Run("pointer", func(t *testing.T) { result := Serialize(&form, false) assert.IsType(t, expected, result) assert.Equal(t, expected, result) }) - t.Run("all value", func(t *testing.T) { result := Serialize(form, true) assert.IsType(t, expectedAll, result) assert.Equal(t, expectedAll, result) }) - t.Run("all pointer", func(t *testing.T) { result := Serialize(&form, true) assert.IsType(t, expectedAll, result) assert.Equal(t, expectedAll, result) }) - t.Run("invalid argument", func(t *testing.T) { result := Serialize("string", true) assert.Equal(t, "", result) diff --git a/internal/form/service_search_test.go b/internal/form/service_search_test.go index 8266f0cc1..17f36d139 100644 --- a/internal/form/service_search_test.go +++ b/internal/form/service_search_test.go @@ -44,7 +44,6 @@ func TestSearchServices_ParseQueryString(t *testing.T) { assert.Equal(t, false, form.Sync) assert.Equal(t, 0, form.Count) }) - t.Run("query for invalid filter", func(t *testing.T) { form := &SearchServices{Query: "xxx:false"} diff --git a/internal/meta/data_test.go b/internal/meta/data_test.go index 6f2f28e98..e58e074f6 100644 --- a/internal/meta/data_test.go +++ b/internal/meta/data_test.go @@ -27,7 +27,6 @@ func TestData_AspectRatio(t *testing.T) { assert.Equal(t, float32(0.83), data.AspectRatio()) }) - t.Run("invalid", func(t *testing.T) { data := Data{ DocumentID: "123", @@ -58,7 +57,6 @@ func TestData_Portrait(t *testing.T) { assert.Equal(t, true, data.Portrait()) }) - t.Run("false", func(t *testing.T) { data := Data{ Width: 800, @@ -88,7 +86,6 @@ func TestData_HasDocumentID(t *testing.T) { assert.Equal(t, true, data.HasDocumentID()) }) - t.Run("asdfg12345hjyt6", func(t *testing.T) { data := Data{ DocumentID: "asdfg12345hjyt6", @@ -96,7 +93,6 @@ func TestData_HasDocumentID(t *testing.T) { assert.Equal(t, false, data.HasDocumentID()) }) - t.Run("asdfg12345hj", func(t *testing.T) { data := Data{ DocumentID: "asdfg12345hj", @@ -114,7 +110,6 @@ func TestData_HasInstanceID(t *testing.T) { assert.Equal(t, true, data.HasInstanceID()) }) - t.Run("false", func(t *testing.T) { data := Data{ InstanceID: "asdfg12345hj", @@ -134,7 +129,6 @@ func TestData_HasTimeAndPlace(t *testing.T) { assert.Equal(t, true, data.HasTimeAndPlace()) }) - t.Run("false", func(t *testing.T) { data := Data{ Lat: 1.334, @@ -153,7 +147,6 @@ func TestData_HasTimeAndPlace(t *testing.T) { assert.Equal(t, false, data.HasTimeAndPlace()) }) - t.Run("false", func(t *testing.T) { data := Data{ Lat: 1.334, diff --git a/internal/meta/duration_test.go b/internal/meta/duration_test.go index bff7dbffe..54e5a9858 100644 --- a/internal/meta/duration_test.go +++ b/internal/meta/duration_test.go @@ -11,47 +11,38 @@ func TestDuration(t *testing.T) { d := Duration("") assert.Equal(t, "0s", d.String()) }) - t.Run("0", func(t *testing.T) { d := Duration("0") assert.Equal(t, "0s", d.String()) }) - t.Run("0.5", func(t *testing.T) { d := Duration("0.5") assert.Equal(t, "500ms", d.String()) }) - t.Run("2.41 s", func(t *testing.T) { d := Duration("2.41 s") assert.Equal(t, "2.41s", d.String()) }) - t.Run("0.41 s", func(t *testing.T) { d := Duration("0.41 s") assert.Equal(t, "410ms", d.String()) }) - t.Run("41 s", func(t *testing.T) { d := Duration("41 s") assert.Equal(t, "41s", d.String()) }) - t.Run("0:0:1", func(t *testing.T) { d := Duration("0:0:1") assert.Equal(t, "1s", d.String()) }) - t.Run("0:04:25", func(t *testing.T) { d := Duration("0:04:25") assert.Equal(t, "4m25s", d.String()) }) - t.Run("0001:04:25", func(t *testing.T) { d := Duration("0001:04:25") assert.Equal(t, "1h4m25s", d.String()) }) - t.Run("invalid", func(t *testing.T) { d := Duration("01:04:25:67") assert.Equal(t, "0s", d.String()) diff --git a/internal/meta/exif_test.go b/internal/meta/exif_test.go index e2f84f8ce..41b8178bd 100644 --- a/internal/meta/exif_test.go +++ b/internal/meta/exif_test.go @@ -37,7 +37,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 0, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("iptc-2016.jpg", func(t *testing.T) { data, err := Exif("testdata/iptc-2016.jpg", fs.ImageJpeg, true) @@ -65,7 +64,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 0, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("photoshop.jpg", func(t *testing.T) { data, err := Exif("testdata/photoshop.jpg", fs.ImageJpeg, true) @@ -98,7 +96,6 @@ func TestExif(t *testing.T) { // TODO: Values are empty - why? // assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel) }) - t.Run("ladybug.jpg", func(t *testing.T) { data, err := Exif("testdata/ladybug.jpg", fs.ImageJpeg, true) @@ -131,7 +128,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 100, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("gopro_hd2.jpg", func(t *testing.T) { data, err := Exif("testdata/gopro_hd2.jpg", fs.ImageJpeg, true) @@ -161,7 +157,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 16, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("tweethog.png", func(t *testing.T) { _, err := Exif("testdata/tweethog.png", fs.ImagePng, true) @@ -171,7 +166,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "found no exif header", err.Error()) }) - t.Run("iphone_7.heic", func(t *testing.T) { data, err := Exif("testdata/iphone_7.heic", fs.ImageHeic, true) if err != nil { @@ -192,7 +186,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel) assert.Equal(t, "", data.ColorProfile) }) - t.Run("gps-2000.jpg", func(t *testing.T) { data, err := Exif("testdata/gps-2000.jpg", fs.ImageJpeg, true) @@ -220,7 +213,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 0, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("image-2011.jpg", func(t *testing.T) { data, err := Exif("testdata/image-2011.jpg", fs.ImageJpeg, true) @@ -257,7 +249,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.LensMake) assert.Equal(t, "", data.LensModel) }) - t.Run("ship.jpg", func(t *testing.T) { data, err := Exif("testdata/ship.jpg", fs.ImageJpeg, true) @@ -279,7 +270,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.LensMake) assert.Equal(t, "", data.LensModel) }) - t.Run("no-exif-data.jpg", func(t *testing.T) { _, err := Exif("testdata/no-exif-data.jpg", fs.ImageJpeg, false) @@ -289,7 +279,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "found no exif header", err.Error()) }) - t.Run("no-exif-data.jpg/BruteForce", func(t *testing.T) { _, err := Exif("testdata/no-exif-data.jpg", fs.ImageJpeg, true) @@ -299,7 +288,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "found no exif data", err.Error()) }) - t.Run("screenshot.png", func(t *testing.T) { data, err := Exif("testdata/screenshot.png", fs.ImagePng, true) @@ -310,7 +298,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "721", data.exif["PixelXDimension"]) assert.Equal(t, "332", data.exif["PixelYDimension"]) }) - t.Run("orientation.jpg", func(t *testing.T) { data, err := Exif("testdata/orientation.jpg", fs.ImageJpeg, true) @@ -338,19 +325,16 @@ func TestExif(t *testing.T) { t.Error("error expected when providing wrong original name") } }) - t.Run("gopher-preview.jpg", func(t *testing.T) { _, err := Exif("testdata/gopher-preview.jpg", fs.ImageJpeg, false) assert.EqualError(t, err, "found no exif header") }) - t.Run("gopher-preview.jpg/BruteForce", func(t *testing.T) { _, err := Exif("testdata/gopher-preview.jpg", fs.ImageJpeg, true) assert.EqualError(t, err, "found no exif data") }) - t.Run("huawei-gps-error.jpg", func(t *testing.T) { data, err := Exif("testdata/huawei-gps-error.jpg", fs.ImageJpeg, true) @@ -372,7 +356,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.LensMake) assert.Equal(t, "", data.LensModel) }) - t.Run("panorama360.jpg", func(t *testing.T) { data, err := Exif("testdata/panorama360.jpg", fs.ImageJpeg, true) @@ -404,7 +387,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) - t.Run("exif-example.tiff", func(t *testing.T) { data, err := Exif("testdata/exif-example.tiff", fs.ImageTiff, true) @@ -436,7 +418,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) - t.Run("out-of-range-500.jpg", func(t *testing.T) { data, err := Exif("testdata/out-of-range-500.jpg", fs.ImageJpeg, true) @@ -468,7 +449,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) - t.Run("digikam.jpg", func(t *testing.T) { data, err := Exif("testdata/digikam.jpg", fs.ImageJpeg, true) @@ -503,7 +483,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 0, data.Orientation) assert.Equal(t, "", data.ColorProfile) }) - t.Run("notebook.jpg", func(t *testing.T) { data, err := Exif("testdata/notebook.jpg", fs.ImageJpeg, true) @@ -524,7 +503,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 26, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("snow.jpg", func(t *testing.T) { data, err := Exif("testdata/snow.jpg", fs.ImageJpeg, true) @@ -545,7 +523,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 28, data.FocalLength) assert.Equal(t, 1, data.Orientation) }) - t.Run("keywords.jpg", func(t *testing.T) { data, err := Exif("testdata/keywords.jpg", fs.ImageJpeg, true) @@ -565,7 +542,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "EF70-200mm f/4L IS USM", data.LensModel) assert.Equal(t, 1, data.Orientation) }) - t.Run("Iceland-P3.jpg", func(t *testing.T) { data, err := Exif("testdata/Iceland-P3.jpg", fs.ImageJpeg, true) @@ -597,7 +573,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) - t.Run("Iceland-sRGB.jpg", func(t *testing.T) { data, err := Exif("testdata/Iceland-sRGB.jpg", fs.ImageJpeg, true) @@ -629,7 +604,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) - t.Run("animated.gif", func(t *testing.T) { _, err := Exif("testdata/animated.gif", fs.ImageGif, true) @@ -639,7 +613,6 @@ func TestExif(t *testing.T) { assert.Equal(t, "found no exif data", err.Error()) } }) - t.Run("aurora.jpg", func(t *testing.T) { data, err := Exif("testdata/aurora.jpg", fs.ImageJpeg, false) @@ -654,7 +627,6 @@ func TestExif(t *testing.T) { assert.Equal(t, 0.0, data.Lat) assert.Equal(t, 0.0, data.Lng) }) - t.Run("buggy_panorama.jpg", func(t *testing.T) { data, err := Exif("testdata/buggy_panorama.jpg", fs.ImageJpeg, false) @@ -670,7 +642,6 @@ func TestExif(t *testing.T) { assert.InEpsilon(t, 103.48, data.Lng, 0.00001) assert.Equal(t, 0.0, data.Altitude) }) - t.Run("altitude.jpg", func(t *testing.T) { data, err := Exif("testdata/altitude.jpg", fs.ImageJpeg, false) diff --git a/internal/meta/gps_test.go b/internal/meta/gps_test.go index 1bc90ce02..d3806c5f3 100644 --- a/internal/meta/gps_test.go +++ b/internal/meta/gps_test.go @@ -28,13 +28,11 @@ func TestGpsToLatLng(t *testing.T) { assert.InEpsilon(t, lat, expLat, 0.1) assert.InEpsilon(t, lng, expLng, 0.1) }) - t.Run("empty string", func(t *testing.T) { lat, lng := GpsToLatLng("") assert.Equal(t, float64(0), lat) assert.Equal(t, float64(0), lng) }) - t.Run("invalid string", func(t *testing.T) { lat, lng := GpsToLatLng("abc bdf") assert.Equal(t, float64(0), lat) @@ -47,12 +45,10 @@ func TestGpsToDecimal(t *testing.T) { r := GpsToDecimal("51 deg 15' 17.47\" N") assert.InEpsilon(t, 51.25485277777778, r, 0.01) }) - t.Run("empty string", func(t *testing.T) { r := GpsToDecimal("") assert.Equal(t, float64(0), r) }) - t.Run("invalid string", func(t *testing.T) { r := GpsToDecimal("abc") assert.Equal(t, float64(0), r) @@ -64,12 +60,10 @@ func TestGpsCoord(t *testing.T) { r := ParseFloat("51") assert.Equal(t, float64(51), r) }) - t.Run("empty string", func(t *testing.T) { r := ParseFloat("") assert.Equal(t, float64(0), r) }) - t.Run("invalid string", func(t *testing.T) { r := ParseFloat("abc") assert.Equal(t, float64(0), r) diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go index 93949202d..27cd93b7c 100644 --- a/internal/meta/json_test.go +++ b/internal/meta/json_test.go @@ -41,7 +41,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 12 mini", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("iphone-mov.json", func(t *testing.T) { data, err := JSON("testdata/iphone-mov.json", "") @@ -71,7 +70,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone SE", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("yoga-av1.webm.json", func(t *testing.T) { data, err := JSON("testdata/yoga-av1.webm.json", "") @@ -87,7 +85,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 854, data.ActualWidth()) assert.Equal(t, 480, data.ActualHeight()) }) - t.Run("stream.webm.json", func(t *testing.T) { data, err := JSON("testdata/stream.webm.json", "") @@ -103,7 +100,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1280, data.ActualWidth()) assert.Equal(t, 720, data.ActualHeight()) }) - t.Run("earth.ogv.json", func(t *testing.T) { data, err := JSON("testdata/earth.ogv.json", "") @@ -119,7 +115,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1280, data.ActualWidth()) assert.Equal(t, 720, data.ActualHeight()) }) - t.Run("webm-vp8.json", func(t *testing.T) { data, err := JSON("testdata/webm-vp8.json", "") @@ -135,7 +130,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1920, data.ActualWidth()) assert.Equal(t, 1080, data.ActualHeight()) }) - t.Run("webm-vp9.json", func(t *testing.T) { data, err := JSON("testdata/webm-vp9.json", "") @@ -151,7 +145,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1280, data.ActualWidth()) assert.Equal(t, 720, data.ActualHeight()) }) - t.Run("gopher-telegram.json", func(t *testing.T) { data, err := JSON("testdata/gopher-telegram.json", "") @@ -179,7 +172,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("gopher-original.json", func(t *testing.T) { data, err := JSON("testdata/gopher-original.json", "") @@ -206,7 +198,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("berlin-landscape.json", func(t *testing.T) { data, err := JSON("testdata/berlin-landscape.json", "") @@ -231,7 +222,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("mp4.json", func(t *testing.T) { data, err := JSON("testdata/mp4.json", "") @@ -252,7 +242,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("photoshop.json", func(t *testing.T) { data, err := JSON("testdata/photoshop.json", "") @@ -279,7 +268,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel) assert.Equal(t, 1, data.Orientation) }) - t.Run("canon_eos_6d.json", func(t *testing.T) { data, err := JSON("testdata/canon_eos_6d.json", "") @@ -300,7 +288,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0, data.TakenNs) assert.Equal(t, 1, data.Orientation) }) - t.Run("gps-2000.json", func(t *testing.T) { data, err := JSON("testdata/gps-2000.json", "") @@ -321,7 +308,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.LensModel) assert.Equal(t, 1, data.Orientation) }) - t.Run("ladybug.json", func(t *testing.T) { data, err := JSON("testdata/ladybug.json", "") @@ -342,7 +328,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.LensModel) assert.Equal(t, 1, data.Orientation) }) - t.Run("iphone_7.json", func(t *testing.T) { data, err := JSON("testdata/iphone_7.json", "") @@ -363,7 +348,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "Apple", data.LensMake) assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel) }) - t.Run("uuid-original.json", func(t *testing.T) { data, err := JSON("testdata/uuid-original.json", "") @@ -389,7 +373,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone SE", data.CameraModel) assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel) }) - t.Run("uuid-copy.json", func(t *testing.T) { data, err := JSON("testdata/uuid-copy.json", "") @@ -415,7 +398,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone SE", data.CameraModel) assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel) }) - t.Run("uuid-imagemagick.json", func(t *testing.T) { data, err := JSON("testdata/uuid-imagemagick.json", "") @@ -441,7 +423,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone SE", data.CameraModel) assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel) }) - t.Run("orientation.json", func(t *testing.T) { data, err := JSON("testdata/orientation.json", "") @@ -453,7 +434,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 184, data.Height) assert.Equal(t, 1, data.Orientation) }) - t.Run("gphotos-1.json", func(t *testing.T) { data, err := JSON("testdata/gphotos-1.json", "") @@ -482,7 +462,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("gphotos-2.json", func(t *testing.T) { data, err := JSON("testdata/gphotos-2.json", "") @@ -500,7 +479,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0.0, data.Altitude) assert.Equal(t, 1118, data.Views) }) - t.Run("gphotos-3.json", func(t *testing.T) { data, err := JSON("testdata/gphotos-3.json", "") @@ -518,7 +496,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0.0, data.Altitude) assert.Equal(t, 177, data.Views) }) - t.Run("gphotos-4.json", func(t *testing.T) { data, err := JSON("testdata/gphotos-4.json", "") @@ -536,7 +513,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0.0, data.Altitude) assert.Equal(t, 0, data.Views) }) - t.Run("gphotos-album.json", func(t *testing.T) { data, err := JSON("testdata/gphotos-album.json", "") @@ -554,7 +530,6 @@ func TestJSON(t *testing.T) { assert.Len(t, data.Albums, 1) } }) - t.Run("panorama360.json", func(t *testing.T) { data, err := JSON("testdata/panorama360.json", "panorama360.jpg") @@ -585,7 +560,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1, data.Orientation) assert.Equal(t, projection.Equirectangular.String(), data.Projection) }) - t.Run("P7250006.json", func(t *testing.T) { data, err := JSON("testdata/P7250006.json", "P7250006.MOV") @@ -616,7 +590,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1, data.Orientation) assert.Equal(t, "", data.Projection) }) - t.Run("P9150300.json", func(t *testing.T) { data, err := JSON("testdata/P9150300.json", "P9150300.MOV") @@ -629,7 +602,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "OLYMPUS DIGITAL CAMERA", data.CameraMake) assert.Equal(t, "E-M10MarkII", data.CameraModel) }) - t.Run("GOPR0533.json", func(t *testing.T) { data, err := JSON("testdata/GOPR0533.json", "GOPR0533.MP4") @@ -641,7 +613,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraMake) assert.Equal(t, "", data.CameraModel) }) - t.Run("digikam.json", func(t *testing.T) { data, err := JSON("testdata/digikam.json", "") @@ -673,7 +644,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 5, data.FocalLength) assert.Equal(t, 1, int(data.Orientation)) }) - t.Run("date.mov.json", func(t *testing.T) { data, err := JSON("testdata/date.mov.json", "") @@ -697,7 +667,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 6 Plus", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("date-creation.mov.json", func(t *testing.T) { data, err := JSON("testdata/date-creation.mov.json", "") @@ -722,7 +691,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 6 Plus", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("date-iphone8.mov.json", func(t *testing.T) { data, err := JSON("testdata/date-iphone8.mov.json", "") @@ -746,7 +714,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 8", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("date-iphonex.mov.json", func(t *testing.T) { data, err := JSON("testdata/date-iphonex.mov.json", "") @@ -767,7 +734,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.LensModel) assert.Equal(t, "ca20385d-6106-49c9-acf5-2f8098f4b390", data.DocumentID) }) - t.Run("aurora.jpg.json", func(t *testing.T) { data, err := JSON("testdata/aurora.jpg.json", "") @@ -784,7 +750,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0.0, data.Lat) assert.Equal(t, 0.0, data.Lng) }) - t.Run("MVI_1724.MOV.json", func(t *testing.T) { data, err := JSON("testdata/MVI_1724.MOV.json", "") @@ -804,7 +769,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "Canon PowerShot G15", data.CameraModel) assert.Equal(t, "6.1", data.LensModel) }) - t.Run("snow.json", func(t *testing.T) { data, err := JSON("testdata/snow.json", "") @@ -830,7 +794,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.LensModel) assert.Equal(t, "Bad PrintIM data", data.Warning) }) - t.Run("datetime-zero.json", func(t *testing.T) { data, err := JSON("testdata/datetime-zero.json", "") @@ -855,7 +818,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "TG-830", data.CameraModel) assert.Equal(t, "", data.LensModel) }) - t.Run("subject-1.json", func(t *testing.T) { data, err := JSON("testdata/subject-1.json", "") @@ -882,7 +844,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "holiday", data.Subject) assert.Equal(t, "holiday", data.Keywords.String()) }) - t.Run("subject-2.json", func(t *testing.T) { data, err := JSON("testdata/subject-2.json", "") @@ -908,7 +869,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "holiday, greetings", data.Subject) assert.Equal(t, "greetings, holiday", data.Keywords.String()) }) - t.Run("newline.json", func(t *testing.T) { data, err := JSON("testdata/newline.json", "newline.jpg") @@ -941,7 +901,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1, data.Orientation) assert.Equal(t, "", data.Projection) }) - t.Run("keywords.json", func(t *testing.T) { data, err := JSON("testdata/keywords.json", "") @@ -961,7 +920,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "EF70-200mm f/4L IS USM", data.LensModel) assert.Equal(t, 1, data.Orientation) }) - t.Run("quicktimeutc_on.json", func(t *testing.T) { data, err := JSON("testdata/quicktimeutc_on.json", "") @@ -978,7 +936,6 @@ func TestJSON(t *testing.T) { assert.InDelta(t, 43.5683, data.Lat, 0.00001) assert.InDelta(t, 4.5645, data.Lng, 0.00001) }) - t.Run("quicktimeutc_off.json", func(t *testing.T) { data, err := JSON("testdata/quicktimeutc_off.json", "") @@ -995,7 +952,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, float32(43.5683), float32(data.Lat)) assert.Equal(t, float32(4.5645), float32(data.Lng)) }) - t.Run("video_num_on.json", func(t *testing.T) { data, err := JSON("testdata/video_num_on.json", "") @@ -1012,7 +968,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, float32(43.5683), float32(data.Lat)) assert.Equal(t, float32(4.5645), float32(data.Lng)) }) - t.Run("cr2_num_off.json", func(t *testing.T) { data, err := JSON("testdata/cr2_num_off.json", "") @@ -1038,7 +993,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1, data.Orientation) assert.Equal(t, "", data.Projection) }) - t.Run("cr2_num_on.json", func(t *testing.T) { data, err := JSON("testdata/cr2_num_on.json", "") @@ -1062,7 +1016,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 1, data.Orientation) assert.Equal(t, "", data.Projection) }) - t.Run("pxl-mp4.json", func(t *testing.T) { data, err := JSON("testdata/pxl-mp4.json", "") @@ -1080,7 +1033,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0.0, data.Altitude) assert.Equal(t, 1, data.Orientation) }) - t.Run("sony_mp4_exiftool.json", func(t *testing.T) { data, err := JSON("testdata/sony_mp4_exiftool.json", "") @@ -1098,7 +1050,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, 0.0, data.Altitude) assert.Equal(t, 1, data.Orientation) }) - t.Run("Iceland-P3.jpg", func(t *testing.T) { data, err := JSON("testdata/Iceland-P3.json", "") @@ -1131,7 +1082,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "Display P3", data.ColorProfile) }) - t.Run("Iceland-P3-n.jpg", func(t *testing.T) { data, err := JSON("testdata/Iceland-P3-n.json", "") @@ -1164,7 +1114,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "Display P3", data.ColorProfile) }) - t.Run("Iceland-sRGB.jpg", func(t *testing.T) { data, err := JSON("testdata/Iceland-sRGB.json", "") @@ -1196,7 +1145,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "Display P3", data.ColorProfile) }) - t.Run("gif.json", func(t *testing.T) { data, err := JSON("testdata/gif.json", "") @@ -1230,7 +1178,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.Projection) assert.Equal(t, "", data.ColorProfile) }) - t.Run("iptc-fields-500", func(t *testing.T) { data, err := JSON("testdata/iptc-fields-500.json", "") @@ -1246,7 +1193,6 @@ func TestJSON(t *testing.T) { //TODO //assert.Equal(t, "zqdtcxt1q9wrxnur", data.DocumentID) }) - t.Run("iPhone_6s.json", func(t *testing.T) { data, err := JSON("testdata/iPhone_6s.json", "") @@ -1271,7 +1217,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 6s", data.CameraModel) assert.Equal(t, "iPhone 6s back camera 4.15mm f/2.2", data.LensModel) }) - t.Run("iPhone_14_Pro.json", func(t *testing.T) { data, err := JSON("testdata/iPhone_14_Pro.json", "") @@ -1297,7 +1242,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 14 Pro Max back triple camera 9mm f/2.8", data.LensModel) assert.Equal(t, "e5f10d35-06c3-4f75-a00c-50b793a6c325", data.DocumentID) }) - t.Run("buggy_panorama.json", func(t *testing.T) { data, err := JSON("testdata/buggy_panorama.json", "") @@ -1316,7 +1260,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, float32(103.48), float32(data.Lng)) assert.Equal(t, 0.0, data.Altitude) }) - t.Run("altitude.json", func(t *testing.T) { data, err := JSON("testdata/altitude.json", "") @@ -1330,7 +1273,6 @@ func TestJSON(t *testing.T) { assert.InDelta(t, 4294967284, data.Altitude, 1000) assert.Equal(t, 0, clean.Altitude(data.Altitude)) }) - t.Run("timeoffset.json", func(t *testing.T) { data, err := JSON("testdata/timeoffset.json", "") @@ -1352,7 +1294,6 @@ func TestJSON(t *testing.T) { assert.Equal(t, "iPhone 13", data.CameraModel) assert.Equal(t, "iPhone 13 back dual wide camera 5.1mm f/1.6", data.LensModel) }) - t.Run("kodak-slide-n-scan.json", func(t *testing.T) { data, err := JSON("testdata/kodak-slide-n-scan.json", "") diff --git a/internal/meta/keywords_test.go b/internal/meta/keywords_test.go index ba5b4f148..315624c7d 100644 --- a/internal/meta/keywords_test.go +++ b/internal/meta/keywords_test.go @@ -20,7 +20,6 @@ func TestData_AddKeywords(t *testing.T) { assert.Equal(t, "baz, foobar, pro", data.Keywords.String()) }) - t.Run("ignore", func(t *testing.T) { data := NewData() @@ -42,7 +41,6 @@ func TestData_AutoAddKeywords(t *testing.T) { assert.Equal(t, "burst", data.Keywords.String()) }) - t.Run("ignore", func(t *testing.T) { data := NewData() @@ -52,7 +50,6 @@ func TestData_AutoAddKeywords(t *testing.T) { assert.Equal(t, "", data.Keywords.String()) }) - t.Run("ignore because too short", func(t *testing.T) { data := NewData() diff --git a/internal/meta/sanitize_test.go b/internal/meta/sanitize_test.go index b11d8c344..cbf0414a6 100644 --- a/internal/meta/sanitize_test.go +++ b/internal/meta/sanitize_test.go @@ -10,11 +10,9 @@ func TestSanitizeUnicode(t *testing.T) { t.Run("Ascii", func(t *testing.T) { assert.Equal(t, "IMG_0599", SanitizeUnicode("IMG_0599")) }) - t.Run("Unicode", func(t *testing.T) { assert.Equal(t, "Naïve bonds and futures surge as inflation eases 🚀🚀🚀", SanitizeUnicode(" Naïve bonds and futures surge as inflation eases 🚀🚀🚀 ")) }) - t.Run("Empty", func(t *testing.T) { assert.Equal(t, "", SanitizeUnicode("")) }) @@ -28,7 +26,6 @@ func TestSanitizeTitle(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("IMG_0599.JPG", func(t *testing.T) { result := SanitizeTitle("IMG_0599.JPG") @@ -36,7 +33,6 @@ func TestSanitizeTitle(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("IMG_0599 ABC", func(t *testing.T) { result := SanitizeTitle("IMG_0599 ABC") @@ -44,7 +40,6 @@ func TestSanitizeTitle(t *testing.T) { t.Fatal("result should be IMG_0599 ABC") } }) - t.Run("DSC10599", func(t *testing.T) { result := SanitizeTitle("DSC10599") @@ -52,55 +47,46 @@ func TestSanitizeTitle(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("titanic_cloud_computing.jpg", func(t *testing.T) { result := SanitizeTitle("titanic_cloud_computing.jpg") assert.Equal(t, "Titanic Cloud Computing", result) }) - t.Run("naomi-watts--ewan-mcgregor--the-impossible--tiff-2012_7999540939_o.jpg", func(t *testing.T) { result := SanitizeTitle("naomi-watts--ewan-mcgregor--the-impossible--tiff-2012_7999540939_o.jpg") assert.Equal(t, "Naomi Watts / Ewan McGregor / The Impossible / TIFF", result) }) - t.Run("Bei den Landungsbrücken.png", func(t *testing.T) { result := SanitizeTitle("Bei den Landungsbrücken.png") assert.Equal(t, "Bei den Landungsbrücken", result) }) - t.Run("Bei den Landungsbrücken.foo", func(t *testing.T) { result := SanitizeTitle("Bei den Landungsbrücken.foo") assert.Equal(t, "Bei den Landungsbrücken.foo", result) }) - t.Run("let_it_snow", func(t *testing.T) { result := SanitizeTitle("let_it_snow") assert.Equal(t, "let_it_snow", result) }) - t.Run("let_it_snow.jpg", func(t *testing.T) { result := SanitizeTitle("let_it_snow.jpg") assert.Equal(t, "Let It Snow", result) }) - t.Run("Niklaus_Wirth.jpg", func(t *testing.T) { result := SanitizeTitle("Niklaus_Wirth.jpg") assert.Equal(t, "Niklaus Wirth", result) }) - t.Run("Niklaus_Wirth", func(t *testing.T) { result := SanitizeTitle("Niklaus_Wirth") assert.Equal(t, "Niklaus_Wirth", result) }) - t.Run("string with binary data", func(t *testing.T) { result := SanitizeTitle("string with binary data blablabla") @@ -116,7 +102,6 @@ func TestSanitizeCaption(t *testing.T) { t.Fatal("result should not be empty") } }) - t.Run("OLYMPUS DIGITAL CAMERA", func(t *testing.T) { result := SanitizeCaption("OLYMPUS DIGITAL CAMERA") @@ -124,7 +109,6 @@ func TestSanitizeCaption(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("GoPro", func(t *testing.T) { result := SanitizeCaption("DCIM\\108GOPRO\\GOPR2137.JPG") @@ -132,7 +116,6 @@ func TestSanitizeCaption(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("hdrpl", func(t *testing.T) { result := SanitizeCaption("hdrpl") @@ -140,7 +123,6 @@ func TestSanitizeCaption(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("btf", func(t *testing.T) { result := SanitizeCaption("btf") @@ -148,7 +130,6 @@ func TestSanitizeCaption(t *testing.T) { t.Fatal("result should be empty") } }) - t.Run("wtf", func(t *testing.T) { result := SanitizeCaption("wtf") diff --git a/internal/meta/xmp_test.go b/internal/meta/xmp_test.go index 8218dd925..d1fa59c1a 100644 --- a/internal/meta/xmp_test.go +++ b/internal/meta/xmp_test.go @@ -20,7 +20,6 @@ func TestXMP(t *testing.T) { assert.Equal(t, "Tulpen am See", data.Caption) assert.Equal(t, Keywords{"blume", "krokus", "schöne", "wiese"}, data.Keywords) }) - t.Run("photoshop", func(t *testing.T) { data, err := XMP("testdata/photoshop.xmp") @@ -37,7 +36,6 @@ func TestXMP(t *testing.T) { assert.Equal(t, "ELE-L29", data.CameraModel) assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel) }) - t.Run("canon_eos_6d", func(t *testing.T) { data, err := XMP("testdata/canon_eos_6d.xmp") @@ -53,7 +51,6 @@ func TestXMP(t *testing.T) { assert.Equal(t, "Canon EOS 6D", data.CameraModel) assert.Equal(t, "EF24-105mm f/4L IS USM", data.LensModel) }) - t.Run("iphone_7", func(t *testing.T) { data, err := XMP("testdata/iphone_7.xmp") @@ -70,7 +67,6 @@ func TestXMP(t *testing.T) { assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel) assert.Equal(t, false, data.Favorite) }) - t.Run("fstop", func(t *testing.T) { data, err := XMP("testdata/fstop-favorite.xmp") @@ -80,7 +76,6 @@ func TestXMP(t *testing.T) { assert.Equal(t, true, data.Favorite) }) - t.Run("DateHeic", func(t *testing.T) { data, err := XMP("testdata/date.heic.xmp") diff --git a/internal/photoprism/convert_image_test.go b/internal/photoprism/convert_image_test.go index 2b1ea1ef8..08b51a872 100644 --- a/internal/photoprism/convert_image_test.go +++ b/internal/photoprism/convert_image_test.go @@ -49,7 +49,6 @@ func TestConvert_ToImage(t *testing.T) { _ = os.Remove(outputName) }) - t.Run("Raw", func(t *testing.T) { jpegFilename := filepath.Join(cnf.ImportPath(), "fern_green.jpg") @@ -106,7 +105,6 @@ func TestConvert_ToImage(t *testing.T) { _ = os.Remove(jpgFilename) }) - t.Run("Svg", func(t *testing.T) { svgFile := fs.Abs("./testdata/agpl.svg") diff --git a/internal/photoprism/convert_sidecar_json_test.go b/internal/photoprism/convert_sidecar_json_test.go index 65d1cc2b9..875249c20 100644 --- a/internal/photoprism/convert_sidecar_json_test.go +++ b/internal/photoprism/convert_sidecar_json_test.go @@ -40,7 +40,6 @@ func TestConvert_ToJson(t *testing.T) { _ = os.Remove(jsonName) }) - t.Run("IMG_4120.JPG", func(t *testing.T) { fileName := filepath.Join(c.ExamplesPath(), "IMG_4120.JPG") assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName) @@ -65,7 +64,6 @@ func TestConvert_ToJson(t *testing.T) { _ = os.Remove(jsonName) }) - t.Run("iphone_7.heic", func(t *testing.T) { fileName := c.ExamplesPath() + "/iphone_7.heic" @@ -91,7 +89,6 @@ func TestConvert_ToJson(t *testing.T) { _ = os.Remove(jsonName) }) - t.Run("iphone_15_pro.heic", func(t *testing.T) { fileName := c.ExamplesPath() + "/iphone_15_pro.heic" diff --git a/internal/photoprism/convert_video_avc_test.go b/internal/photoprism/convert_video_avc_test.go index ae5512a81..a9b5e971f 100644 --- a/internal/photoprism/convert_video_avc_test.go +++ b/internal/photoprism/convert_video_avc_test.go @@ -43,7 +43,6 @@ func TestConvert_ToAvc(t *testing.T) { _ = os.Remove(outputName) }) - t.Run("jpg", func(t *testing.T) { conf := config.TestConfig() convert := NewConvert(conf) @@ -84,7 +83,6 @@ func TestConvert_AvcBitrate(t *testing.T) { assert.Equal(t, "1M", convert.AvcBitrate(mf)) }) - t.Run("Medium", func(t *testing.T) { fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4") @@ -101,7 +99,6 @@ func TestConvert_AvcBitrate(t *testing.T) { assert.Equal(t, "16M", convert.AvcBitrate(mf)) }) - t.Run("High", func(t *testing.T) { fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4") @@ -118,7 +115,6 @@ func TestConvert_AvcBitrate(t *testing.T) { assert.Equal(t, "25M", convert.AvcBitrate(mf)) }) - t.Run("VeryHigh", func(t *testing.T) { fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4") diff --git a/internal/photoprism/dl/cmd.go b/internal/photoprism/dl/cmd.go new file mode 100644 index 000000000..5a4b40905 --- /dev/null +++ b/internal/photoprism/dl/cmd.go @@ -0,0 +1,44 @@ +package dl + +import ( + "bufio" + "context" + "os" + "os/exec" +) + +// ytDlpCommand builds the exec.Cmd for invoking yt-dlp. +// If the configured binary looks like a shell script (shebang), +// we invoke it via a shell to work around noexec mounts in CI. +func ytDlpCommand(ctx context.Context, args []string) *exec.Cmd { + bin := FindYtDlpBin() + + // Optional override to force shell invocation. + force := os.Getenv("YTDLP_FORCE_SHELL") == "1" + + if force || isShellScript(bin) { + sh := os.Getenv("YTDLP_SHELL") + if sh == "" { + sh = "bash" + } + return exec.CommandContext(ctx, sh, append([]string{bin}, args...)...) + } + + return exec.CommandContext(ctx, bin, args...) +} + +// isShellScript tries to detect if a file starts with a shebang (#!). +func isShellScript(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + r := bufio.NewReader(f) + b, err := r.Peek(2) + if err != nil { + return false + } + return len(b) >= 2 && b[0] == '#' && b[1] == '!' +} diff --git a/internal/photoprism/dl/file.go b/internal/photoprism/dl/file.go index 14bb9a17a..d4a36c6c0 100644 --- a/internal/photoprism/dl/file.go +++ b/internal/photoprism/dl/file.go @@ -6,7 +6,6 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -18,6 +17,27 @@ func (result Metadata) DownloadToFileWithOptions( ctx context.Context, options DownloadOptions, ) ([]string, error) { + // Test stub: allow bypassing external yt-dlp via env, useful on noexec mounts. + if os.Getenv("YTDLP_FAKE") == "1" { + outTpl := options.Output + if outTpl == "" { + return nil, fmt.Errorf("missing output template in fake mode") + } + out := outTpl + out = strings.ReplaceAll(out, "%(id)s", "abc") + out = strings.ReplaceAll(out, "%(ext)s", "mp4") + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return nil, err + } + content := os.Getenv("YTDLP_DUMMY_CONTENT") + if content == "" { + content = "dummy" + } + if err := os.WriteFile(out, []byte(content), 0o644); err != nil { + return nil, err + } + return []string{out}, nil + } if !result.Options.noInfoDownload { if (result.Info.Type == "playlist" || result.Info.Type == "multi_video" || @@ -44,9 +64,7 @@ func (result Metadata) DownloadToFileWithOptions( } } - cmd := exec.CommandContext( - ctx, - FindYtDlpBin(), + cmd := ytDlpCommand(ctx, []string{ // see comment below about ignoring errors for playlists "--ignore-errors", // TODO: deprecated in yt-dlp? @@ -55,7 +73,7 @@ func (result Metadata) DownloadToFileWithOptions( "--newline", // safer filenames "--restrict-filenames", - ) + }) // Output template: caller may provide one; otherwise use a deterministic fallback in CWD // Note: caller should set a template rooted in the session temp dir. diff --git a/internal/photoprism/dl/info.go b/internal/photoprism/dl/info.go index df63e3f6c..c6b759204 100644 --- a/internal/photoprism/dl/info.go +++ b/internal/photoprism/dl/info.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "os" - "os/exec" "strconv" "strings" ) @@ -111,9 +110,13 @@ func infoFromURL( rawURL string, options Options, ) (info Info, rawJSON []byte, err error) { - cmd := exec.CommandContext( - ctx, - FindYtDlpBin(), + // Test stub: allow bypassing external yt-dlp via env, useful on noexec mounts. + if os.Getenv("YTDLP_FAKE") == "1" { + info = Info{ID: "abc", Title: "Test", URL: rawURL, Type: "video"} + rawJSON = info.JSON() + return info, rawJSON, nil + } + cmd := ytDlpCommand(ctx, []string{ // see comment below about ignoring errors for playlists "--ignore-errors", // TODO: deprecated in yt-dlp? @@ -127,7 +130,7 @@ func infoFromURL( "--batch-file", "-", // dump info json "--dump-single-json", - ) + }) if options.ProxyUrl != "" { cmd.Args = append(cmd.Args, "--proxy", options.ProxyUrl) diff --git a/internal/photoprism/filename_test.go b/internal/photoprism/filename_test.go index e179be264..bb2e7a824 100644 --- a/internal/photoprism/filename_test.go +++ b/internal/photoprism/filename_test.go @@ -29,7 +29,6 @@ func TestCacheName(t *testing.T) { assert.Error(t, err) assert.Empty(t, r) }) - t.Run("Success", func(t *testing.T) { r, err := CacheName("abcdghoj", "test", "juh") if err != nil { diff --git a/internal/photoprism/import_test.go b/internal/photoprism/import_test.go index a8d32dfb0..8a868d4c1 100644 --- a/internal/photoprism/import_test.go +++ b/internal/photoprism/import_test.go @@ -46,7 +46,6 @@ func TestImport_DestinationFilename(t *testing.T) { assert.Equal(t, cfg.OriginalsPath()+"/2019/07/20190705_153230_C167C6FD.cr2", fileName) }) - t.Run("WithBasePath", func(t *testing.T) { fileName, err := imp.DestinationFilename(rawFile, rawFile, "users/guest") diff --git a/internal/photoprism/index_mediafile_test.go b/internal/photoprism/index_mediafile_test.go index 3c04f5e46..d72c313ea 100644 --- a/internal/photoprism/index_mediafile_test.go +++ b/internal/photoprism/index_mediafile_test.go @@ -48,7 +48,6 @@ func TestIndex_MediaFile(t *testing.T) { assert.Equal(t, "Animal with green eyes on table burst", mediaFile.metaData.Caption) assert.Equal(t, IndexStatus("added"), result.Status) }) - t.Run("blue-go-video.mp4", func(t *testing.T) { cfg := config.TestConfig() @@ -91,7 +90,6 @@ func TestIndexResult_Archived(t *testing.T) { r := &IndexResult{IndexArchived, nil, 5, "", 5, ""} assert.True(t, r.Archived()) }) - t.Run("false", func(t *testing.T) { r := &IndexResult{IndexAdded, nil, 5, "", 5, ""} assert.False(t, r.Archived()) @@ -103,7 +101,6 @@ func TestIndexResult_Skipped(t *testing.T) { r := &IndexResult{IndexSkipped, nil, 5, "", 5, ""} assert.True(t, r.Skipped()) }) - t.Run("false", func(t *testing.T) { r := &IndexResult{IndexAdded, nil, 5, "", 5, ""} assert.False(t, r.Skipped()) diff --git a/internal/photoprism/index_related_test.go b/internal/photoprism/index_related_test.go index bd7842197..ea90aec53 100644 --- a/internal/photoprism/index_related_test.go +++ b/internal/photoprism/index_related_test.go @@ -75,7 +75,6 @@ func TestIndexRelated(t *testing.T) { assert.Equal(t, "name", photo.TakenSrc) } }) - t.Run("apple-test-2.jpg", func(t *testing.T) { cfg := config.TestConfig() diff --git a/internal/photoprism/label_test.go b/internal/photoprism/label_test.go index ded6c02d0..4ec546c4a 100644 --- a/internal/photoprism/label_test.go +++ b/internal/photoprism/label_test.go @@ -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 := NewLocationLabel("locationtest - minus", 80, -2) t.Log(LocLabel) @@ -44,7 +43,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} @@ -63,7 +61,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} @@ -71,7 +68,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} @@ -79,7 +75,6 @@ func TestLabels_Title(t *testing.T) { assert.Equal(t, "fallback", labels.Title("fallback")) }) - t.Run("empty 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} @@ -87,13 +82,11 @@ func TestLabels_Title(t *testing.T) { assert.Equal(t, "", labels.Title("")) }) - t.Run("empty labels", func(t *testing.T) { labels := Labels{} assert.Equal(t, "fallback", labels.Title("fallback")) }) - t.Run("priority < 0", func(t *testing.T) { cat := Label{Name: "cat", Source: "location", Uncertainty: 61, Priority: -5} dog := Label{Name: "dog", Source: "location", Uncertainty: 10, Priority: -4} @@ -101,7 +94,6 @@ func TestLabels_Title(t *testing.T) { assert.Equal(t, "fallback", labels.Title("fallback")) }) - t.Run("priority == 0", func(t *testing.T) { cat := Label{Name: "cat", Source: "location", Uncertainty: 60, Priority: 0} dog := Label{Name: "dog", Source: "location", Uncertainty: 51, Priority: 0} @@ -140,7 +132,6 @@ func TestLabels_Less(t *testing.T) { assert.True(t, labels.Less(0, 1)) assert.False(t, labels.Less(1, 0)) }) - t.Run("equal priorities", func(t *testing.T) { cat := Label{Name: "cat", Source: "location", Uncertainty: 59, Priority: 5} dog := Label{Name: "dog", Source: "location", Uncertainty: 10, Priority: 5} diff --git a/internal/photoprism/mediafile_copy_move_force_test.go b/internal/photoprism/mediafile_copy_move_force_test.go index 98c9a77b5..c4a3b973f 100644 --- a/internal/photoprism/mediafile_copy_move_force_test.go +++ b/internal/photoprism/mediafile_copy_move_force_test.go @@ -7,14 +7,16 @@ import ( "time" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/fs" ) func writeFile(t *testing.T, p string, data []byte) { t.Helper() - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(p), fs.ModeDir); err != nil { t.Fatal(err) } - if err := os.WriteFile(p, data, 0o644); err != nil { + if err := os.WriteFile(p, data, fs.ModeFile); err != nil { t.Fatal(err) } } diff --git a/internal/photoprism/mediafile_meta_test.go b/internal/photoprism/mediafile_meta_test.go index cc28b1bf3..dcdcf1dda 100644 --- a/internal/photoprism/mediafile_meta_test.go +++ b/internal/photoprism/mediafile_meta_test.go @@ -297,7 +297,6 @@ func TestMediaFile_Exif_Jpeg(t *testing.T) { t.Logf("UTC: %s", data.TakenAt.String()) t.Logf("Local: %s", data.TakenAtLocal.String()) }) - t.Run("fern_green.jpg", func(t *testing.T) { img, err := NewMediaFile(c.ExamplesPath() + "/fern_green.jpg") diff --git a/internal/photoprism/mediafile_related_test.go b/internal/photoprism/mediafile_related_test.go index 89cf9a419..c7c95cf51 100644 --- a/internal/photoprism/mediafile_related_test.go +++ b/internal/photoprism/mediafile_related_test.go @@ -49,7 +49,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { } } }) - t.Run("canon_eos_6d.dng", func(t *testing.T) { mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng") @@ -80,7 +79,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { assert.Equal(t, expectedBaseFilename, baseFilename) } }) - t.Run("iphone_7.heic", func(t *testing.T) { mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic") @@ -112,7 +110,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { } } }) - t.Run("iphone_15_pro.heic", func(t *testing.T) { mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_15_pro.heic") @@ -144,7 +141,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { } } }) - t.Run("2015-02-04.jpg", func(t *testing.T) { mediaFile, err := NewMediaFile("testdata/2015-02-04.jpg") @@ -175,7 +171,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { assert.Equal(t, "2015-02-04.jpg.json", related.Files[2].BaseName()) assert.Equal(t, "2015-02-04.jpg(1).json", related.Files[3].BaseName()) }) - t.Run("2015-02-04(1).jpg", func(t *testing.T) { mediaFile, err := NewMediaFile("testdata/2015-02-04(1).jpg") @@ -201,7 +196,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { assert.Equal(t, "2015-02-04(1).jpg", related.Files[0].BaseName()) }) - t.Run("2015-02-04(1).jpg stacked", func(t *testing.T) { mediaFile, err := NewMediaFile("testdata/2015-02-04(1).jpg") @@ -230,7 +224,6 @@ func TestMediaFile_RelatedFiles(t *testing.T) { assert.Equal(t, "2015-02-04.jpg.json", related.Files[2].BaseName()) assert.Equal(t, "2015-02-04.jpg(1).json", related.Files[3].BaseName()) }) - t.Run("Ordering", func(t *testing.T) { mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.JPG") diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 03e2be367..80001cee1 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -531,7 +531,6 @@ func TestMediaFile_RelName(t *testing.T) { filename := mediaFile.RelName(conf.AssetsPath()) assert.Equal(t, "examples/tree_white.jpg", filename) }) - t.Run("directory without end slash", func(t *testing.T) { filename := mediaFile.RelName(conf.AssetsPath()) assert.Equal(t, "examples/tree_white.jpg", filename) @@ -1781,7 +1780,6 @@ func TestMediaFile_decodeDimension(t *testing.T) { assert.EqualError(t, decodeErr, ".docx is not a valid media file") }) - t.Run("clock_purple.jpg", func(t *testing.T) { cfg := config.TestConfig() @@ -1795,7 +1793,6 @@ func TestMediaFile_decodeDimension(t *testing.T) { t.Fatal(err) } }) - t.Run("iphone_7.heic", func(t *testing.T) { cfg := config.TestConfig() @@ -1809,7 +1806,6 @@ func TestMediaFile_decodeDimension(t *testing.T) { t.Fatal(err) } }) - t.Run("example.png", func(t *testing.T) { cfg := config.TestConfig() @@ -1826,7 +1822,6 @@ func TestMediaFile_decodeDimension(t *testing.T) { assert.Equal(t, 100, mediaFile.Width()) assert.Equal(t, 67, mediaFile.Height()) }) - t.Run("example.gif", func(t *testing.T) { cfg := config.TestConfig() @@ -1843,7 +1838,6 @@ func TestMediaFile_decodeDimension(t *testing.T) { assert.Equal(t, 100, mediaFile.Width()) assert.Equal(t, 67, mediaFile.Height()) }) - t.Run("blue-go-video.mp4", func(t *testing.T) { cfg := config.TestConfig() @@ -2418,7 +2412,6 @@ func TestMediaFile_PathNameInfo(t *testing.T) { assert.Equal(t, "beach_sand.jpg", name) mediaFile.SetFileName(initialName) }) - t.Run("beach_sand unknown root", func(t *testing.T) { mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_sand.jpg") diff --git a/internal/photoprism/mediafile_thumbs_test.go b/internal/photoprism/mediafile_thumbs_test.go index 57f9601b9..29a6a0955 100644 --- a/internal/photoprism/mediafile_thumbs_test.go +++ b/internal/photoprism/mediafile_thumbs_test.go @@ -172,7 +172,6 @@ func TestMediaFile_GenerateThumbnails(t *testing.T) { assert.FileExists(t, thumbFilename) assert.NoError(t, m.GenerateThumbnails(thumbsPath, false)) }) - t.Run("animated-earth.jpg", func(t *testing.T) { m, err := NewMediaFile("testdata/animated-earth.jpg") @@ -195,7 +194,6 @@ func TestMediaFile_GenerateThumbnails(t *testing.T) { assert.FileExists(t, thumbFilename) assert.NoError(t, m.GenerateThumbnails(thumbsPath, false)) }) - t.Run("photoprism.png", func(t *testing.T) { m, err := NewMediaFile("testdata/photoprism.png") @@ -218,7 +216,6 @@ func TestMediaFile_GenerateThumbnails(t *testing.T) { assert.FileExists(t, thumbFilename) assert.NoError(t, m.GenerateThumbnails(thumbsPath, false)) }) - t.Run("broken/animated-earth.jpg", func(t *testing.T) { m, err := NewMediaFile("testdata/broken/animated-earth.jpg") diff --git a/internal/photoprism/photoprism_test.go b/internal/photoprism/photoprism_test.go index eb5591aeb..c4d601a3a 100644 --- a/internal/photoprism/photoprism_test.go +++ b/internal/photoprism/photoprism_test.go @@ -7,6 +7,7 @@ import ( "github.com/sirupsen/logrus" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/pkg/fs" ) func TestMain(m *testing.M) { @@ -19,5 +20,8 @@ func TestMain(m *testing.M) { code := m.Run() + // Purge local SQLite test artifacts created during this package's tests. + fs.PurgeTestDbFiles(".", false) + os.Exit(code) } diff --git a/internal/photoprism/places_test.go b/internal/photoprism/places_test.go index b636f02fd..a3f59c74a 100644 --- a/internal/photoprism/places_test.go +++ b/internal/photoprism/places_test.go @@ -32,7 +32,6 @@ func TestPlaces(t *testing.T) { t.Fatal("affected must not be negative") } }) - t.Run("Force", func(t *testing.T) { updated, err := w.Start(true) diff --git a/internal/photoprism/thumbs_test.go b/internal/photoprism/thumbs_test.go index 62ebd0fc3..6cfd63128 100644 --- a/internal/photoprism/thumbs_test.go +++ b/internal/photoprism/thumbs_test.go @@ -119,7 +119,6 @@ func TestThumb_FromFile(t *testing.T) { assert.Nil(t, err) assert.FileExists(t, thumbnail) }) - t.Run("hash too short", func(t *testing.T) { file := &entity.File{ FileName: c.ExamplesPath() + "/elephants.jpg", @@ -146,7 +145,6 @@ func TestThumb_FromFile(t *testing.T) { t.Error("error is nil") } }) - t.Run("rotate-6.tiff", func(t *testing.T) { fileName := "testdata/rotate/6.tiff" @@ -239,7 +237,6 @@ func TestThumb_Create(t *testing.T) { bounds := thumbnail.Bounds() assert.NotEqual(t, 150, bounds.Dx()) }) - t.Run("invalid height", func(t *testing.T) { expectedFilename, err := thumb.FileName("12345", thumbsPath, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) diff --git a/internal/server/limiter/request_test.go b/internal/server/limiter/request_test.go index 2e5083463..b1f621d2f 100644 --- a/internal/server/limiter/request_test.go +++ b/internal/server/limiter/request_test.go @@ -31,13 +31,11 @@ func TestRequest(t *testing.T) { assert.True(t, r.Allow()) assert.False(t, r.Reject()) }) - t.Run("Reject", func(t *testing.T) { r := Request{allow: false} assert.False(t, r.Allow()) assert.True(t, r.Reject()) }) - t.Run("Success", func(t *testing.T) { l := NewLimit(0.166, 10).IP(clientIp) r1 := NewRequest(l, 10) diff --git a/internal/service/cluster/node.go b/internal/service/cluster/cluster.go similarity index 100% rename from internal/service/cluster/node.go rename to internal/service/cluster/cluster.go diff --git a/internal/service/cluster/instance/bootstrap.go b/internal/service/cluster/instance/bootstrap.go index e077b9ee3..525e6d0e0 100644 --- a/internal/service/cluster/instance/bootstrap.go +++ b/internal/service/cluster/instance/bootstrap.go @@ -22,6 +22,7 @@ import ( "github.com/photoprism/photoprism/internal/service/cluster" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/rnd" ) var log = event.Log @@ -113,18 +114,27 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error { opts := c.Options() driver := c.DatabaseDriver() wantRotateDatabase := (driver == config.MySQL || driver == config.MariaDB) && - opts.DatabaseDsn == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" && + opts.DatabaseDSN == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" && c.DatabasePassword() == "" payload := map[string]interface{}{ "nodeName": c.NodeName(), + "nodeUUID": c.NodeUUID(), "nodeRole": cluster.RoleInstance, // JSON wire format is string "advertiseUrl": c.AdvertiseUrl(), } + // Include client credentials when present so the Portal can verify re-registration + // and authorize UUID/name changes. + if id, secret := strings.TrimSpace(c.NodeClientID()), strings.TrimSpace(c.NodeClientSecret()); id != "" && secret != "" { + payload["clientId"] = id + payload["clientSecret"] = secret + } + // Include siteUrl when it differs from advertiseUrl; server will validate/normalize. if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() { payload["siteUrl"] = su } + if wantRotateDatabase { // Align with API: request database rotation/creation on (re)register. payload["rotateDatabase"] = true @@ -201,28 +211,38 @@ func isTemporary(err error) bool { func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDatabase bool) error { updates := map[string]interface{}{} - // Always persist NodeID (client UID) from response for future OAuth token requests. - if r.Node.ID != "" { - updates["NodeID"] = r.Node.ID + // Persist ClusterUUID from portal response if provided. + if rnd.IsUUID(r.UUID) { + updates["ClusterUUID"] = r.UUID } - // Persist node secret only if missing locally and provided by server. - if r.Secrets != nil && r.Secrets.NodeSecret != "" && c.NodeSecret() == "" { - updates["NodeSecret"] = r.Secrets.NodeSecret + // Always persist NodeClientID (client UID) from response for future OAuth token requests. + if r.Node.ClientID != "" { + updates["NodeClientID"] = r.Node.ClientID + } + + // Persist node client secret only if missing locally and provided by server. + if r.Secrets != nil && r.Secrets.ClientSecret != "" && c.NodeClientSecret() == "" { + updates["NodeClientSecret"] = r.Secrets.ClientSecret + } + + // Persist NodeUUID from portal response if provided and not set locally. + if r.Node.UUID != "" && c.Options().NodeUUID == "" { + updates["NodeUUID"] = r.Node.UUID } // Persist DB settings only if rotation was requested and driver is MySQL/MariaDB // and local DB not configured (as checked before calling). if wantRotateDatabase { if r.Database.DSN != "" { - updates["DatabaseDriver"] = config.MySQL - updates["DatabaseDsn"] = r.Database.DSN + updates["DatabaseDriver"] = r.Database.Driver + updates["DatabaseDSN"] = r.Database.DSN } else if r.Database.Name != "" && r.Database.User != "" && r.Database.Password != "" { server := r.Database.Host if r.Database.Port > 0 { server = net.JoinHostPort(r.Database.Host, strconv.Itoa(r.Database.Port)) } - updates["DatabaseDriver"] = config.MySQL + updates["DatabaseDriver"] = r.Database.Driver updates["DatabaseServer"] = server updates["DatabaseName"] = r.Database.Name updates["DatabaseUser"] = r.Database.User @@ -248,7 +268,7 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota } func hasDBUpdate(m map[string]interface{}) bool { - if _, ok := m["DatabaseDsn"]; ok { + if _, ok := m["DatabaseDSN"]; ok { return true } if _, ok := m["DatabaseName"]; ok { @@ -289,7 +309,7 @@ func mergeOptionsYaml(c *config.Config, updates map[string]interface{}) error { if err != nil { return err } - return os.WriteFile(fileName, b, 0o644) + return os.WriteFile(fileName, b, fs.ModeFile) } // installThemeIfMissing downloads and installs the Portal-provided theme if the @@ -304,9 +324,9 @@ func installThemeIfMissing(c *config.Config, portal *url.URL, token string) erro endpoint := *portal endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/cluster/theme" - // Prefer OAuth client-credentials using NodeID/NodeSecret if available; fallback to join token. + // Prefer OAuth client-credentials using NodeClientID/NodeClientSecret if available; fallback to join token. bearer := "" - if id, secret := strings.TrimSpace(c.NodeID()), strings.TrimSpace(c.NodeSecret()); id != "" && secret != "" { + if id, secret := strings.TrimSpace(c.NodeClientID()), strings.TrimSpace(c.NodeClientSecret()); id != "" && secret != "" { if t, err := oauthAccessToken(c, portal, id, secret); err != nil { log.Debugf("cluster: oauth token request failed (%s)", clean.Error(err)) } else { diff --git a/internal/service/cluster/instance/bootstrap_test.go b/internal/service/cluster/instance/bootstrap_test.go index b5c17453f..bc5a01d7b 100644 --- a/internal/service/cluster/instance/bootstrap_test.go +++ b/internal/service/cluster/instance/bootstrap_test.go @@ -14,6 +14,8 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service/cluster" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/rnd" ) func TestInitConfig_NoPortal_NoOp(t *testing.T) { @@ -34,9 +36,18 @@ func TestRegister_PersistSecretAndDB(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) resp := cluster.RegisterResponse{ - Node: cluster.Node{Name: "pp-node-01"}, - Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"}, - Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"}, + Node: cluster.Node{Name: "pp-node-01"}, + UUID: rnd.UUID(), + Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"}, + Database: cluster.RegisterDatabase{ + Driver: config.MySQL, + Host: "db.local", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pp_pw", + DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true", + }, } _ = json.NewEncoder(w).Encode(resp) case "/api/v1/cluster/theme": @@ -54,7 +65,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) { c.Options().JoinToken = "t0k3n" // Gate rotate=true: driver mysql and no DSN/fields. c.Options().DatabaseDriver = config.MySQL - c.Options().DatabaseDsn = "" + c.Options().DatabaseDSN = "" c.Options().DatabaseName = "" c.Options().DatabaseUser = "" c.Options().DatabasePassword = "" @@ -63,9 +74,9 @@ func TestRegister_PersistSecretAndDB(t *testing.T) { assert.NoError(t, InitConfig(c)) // Options should be reloaded; check values. - assert.Equal(t, "SECRET", c.NodeSecret()) + assert.Equal(t, "SECRET", c.NodeClientSecret()) // DSN branch should be preferred and persisted. - assert.Contains(t, c.Options().DatabaseDsn, "@tcp(db.local:3306)/pp_db") + assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db") assert.Equal(t, config.MySQL, c.Options().DatabaseDriver) } @@ -83,8 +94,8 @@ func TestThemeInstall_Missing(t *testing.T) { switch r.URL.Path { case "/api/v1/cluster/nodes/register": w.Header().Set("Content-Type", "application/json") - // Return NodeID + NodeSecret so bootstrap can request OAuth token - _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{Node: cluster.Node{ID: "cid123", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"}}) + // Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token + _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}}) case "/api/v1/oauth/token": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"}) @@ -129,7 +140,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) { w.WriteHeader(http.StatusCreated) resp := cluster.RegisterResponse{ Node: cluster.Node{Name: "pp-node-01"}, - Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"}, + Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"}, Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"}, } _ = json.NewEncoder(w).Encode(resp) @@ -144,16 +155,16 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) { c.Options().PortalUrl = srv.URL c.Options().JoinToken = "t0k3n" // Remember original DSN so we can ensure it is not changed. - origDSN := c.Options().DatabaseDsn + origDSN := c.Options().DatabaseDSN t.Cleanup(func() { _ = os.Remove(origDSN) }) // Run bootstrap. assert.NoError(t, InitConfig(c)) - // NodeSecret should persist, but DB should remain SQLite (no DSN update). - assert.Equal(t, "SECRET", c.NodeSecret()) + // NodeClientSecret should persist, but DB should remain SQLite (no DSN update). + assert.Equal(t, "SECRET", c.NodeClientSecret()) assert.Equal(t, config.SQLite3, c.DatabaseDriver()) - assert.Equal(t, origDSN, c.Options().DatabaseDsn) + assert.Equal(t, origDSN, c.Options().DatabaseDSN) } func TestRegister_404_NoRetry(t *testing.T) { @@ -206,7 +217,7 @@ func TestThemeInstall_SkipWhenAppJsExists(t *testing.T) { assert.NoError(t, err) defer func() { _ = os.RemoveAll(tempTheme) }() c.SetThemePath(tempTheme) - assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("// app\n"), 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("// app\n"), fs.ModeFile)) assert.NoError(t, InitConfig(c)) // Should have skipped request because app.js already exists. diff --git a/internal/service/cluster/instance/instance.go b/internal/service/cluster/instance/instance.go new file mode 100644 index 000000000..ccc7cd503 --- /dev/null +++ b/internal/service/cluster/instance/instance.go @@ -0,0 +1,38 @@ +/* +Package instance bootstraps a PhotoPrism node that joins a cluster Portal. + +Responsibilities include: + + - Initializing runtime configuration derived from options and environment. + - Registering the node with the Portal over HTTP(S), handling non-transient + errors (401/403/404) as terminal and bounding retries on transient failures. + - Persisting returned registration data such as the Node secret and, when + appropriate, database settings (never for SQLite), by merging into options.yml. + - Installing a theme from the Portal if the local theme is missing, without + overwriting existing installations. + +The package deliberately avoids importing Portal internals and communicates +with the Portal using HTTP-only APIs. + +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"): + + + 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: + + +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: + +*/ +package instance diff --git a/internal/service/cluster/instance/instance_test.go b/internal/service/cluster/instance/instance_test.go new file mode 100644 index 000000000..04bd8eb0a --- /dev/null +++ b/internal/service/cluster/instance/instance_test.go @@ -0,0 +1,15 @@ +package instance + +import ( + "os" + "testing" + + "github.com/photoprism/photoprism/pkg/fs" +) + +// TestMain ensures SQLite test DB artifacts are purged after the suite runs. +func TestMain(m *testing.M) { + code := m.Run() + fs.PurgeTestDbFiles(".", false) + os.Exit(code) +} diff --git a/internal/service/cluster/provisioner/credentials.go b/internal/service/cluster/provisioner/credentials.go new file mode 100644 index 000000000..1b83e1800 --- /dev/null +++ b/internal/service/cluster/provisioner/credentials.go @@ -0,0 +1,132 @@ +package provisioner + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/photoprism/photoprism/internal/config" +) + +// Credentials contains the connection details returned when ensuring a node database. +type Credentials struct { + Driver string + Host string + Port int + Name string + User string + Password string + DSN string + RotatedAt string +} + +// GetCredentials ensures a per-node database and user exist with minimal grants. +// - Requires a MySQL/MariaDB admin connection (this package maintains it). +// - Returns created=true if the database schema did not exist before. +// - If rotate is true or created, rotates the user password and includes it (and DSN) in the result. +func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName string, rotate bool) (Credentials, bool, error) { + out := Credentials{} + + // Normalize provisioner driver to lower-case to accept variants like "MySQL"/"MariaDB". + DatabaseDriver = strings.ToLower(DatabaseDriver) + + switch DatabaseDriver { + case config.MySQL, config.MariaDB: + // ok + case config.SQLite3, config.Postgres: + return out, false, errors.New("database must be MySQL/MariaDB for auto-provisioning") + default: + // Driver is configured externally for the provisioner (decoupled from app config). + return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", DatabaseDriver) + } + + // Compute deterministic names and a candidate password. + dbName, dbUser, dbPass := GenerateCreds(conf, nodeUUID, nodeName) + + // Extra safety: enforce allowed identifier charset. + if !identRe.MatchString(dbName) || !identRe.MatchString(dbUser) { + return out, false, errors.New("invalid generated database identifiers") + } + + // Get (or open) admin DB handle. + db, err := GetDB(ctx) + if err != nil { + return out, false, err + } + + // 1) Determine if database already exists (values can be parameterized). + var count int + { + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := db.QueryRowContext( + c, + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", + dbName, + ).Scan(&count); err != nil { + return out, false, err + } + } + created := count == 0 + + // 2) Create database schema if needed (identifier must be quoted). + qDB, err := quoteIdent(dbName) + if err != nil { + return out, created, err + } + createDB := "CREATE DATABASE IF NOT EXISTS " + qDB + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + if err := execTimeout(ctx, db, 15*time.Second, createDB); err != nil { + return out, created, err + } + + // 3) Ensure user exists. + acc, err := quoteAccount("%", dbUser) // user@'%' + if err != nil { + return out, created, err + } + pass, err := quoteString(dbPass) + if err != nil { + return out, created, err + } + + createUser := "CREATE USER IF NOT EXISTS " + acc + " IDENTIFIED BY " + pass + if err := execTimeout(ctx, db, 10*time.Second, createUser); err != nil { + return out, created, err + } + + // 4) Rotate or set password explicitly on first creation. + if rotate || created { + alterUser := "ALTER USER " + acc + " IDENTIFIED BY " + pass + if err := execTimeout(ctx, db, 10*time.Second, alterUser); err != nil { + return out, created, err + } + out.Password = dbPass + out.RotatedAt = time.Now().UTC().Format(time.RFC3339) + } + + // 5) Grant privileges on schema. + grant := "GRANT ALL PRIVILEGES ON " + qDB + ".* TO " + acc + if err := execTimeout(ctx, db, 10*time.Second, grant); err != nil { + return out, created, err + } + + // 6) Optional on modern MariaDB/MySQL; harmless if included. + if err := execTimeout(ctx, db, 5*time.Second, "FLUSH PRIVILEGES"); err != nil { + return out, created, err + } + + // Compose credentials. + out.Host = DatabaseHost + out.Port = DatabasePort + out.Name = dbName + out.User = dbUser + out.Driver = DatabaseDriver + + if out.Password != "" { + out.DSN = BuildDSN(DatabaseDriver, out.Host, out.Port, out.User, out.Password, out.Name) + } + + return out, created, nil +} diff --git a/internal/service/cluster/provisioner/credentials_test.go b/internal/service/cluster/provisioner/credentials_test.go new file mode 100644 index 000000000..c943ea1aa --- /dev/null +++ b/internal/service/cluster/provisioner/credentials_test.go @@ -0,0 +1,113 @@ +package provisioner + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/config" +) + +// TestGetCredentials_MariaDB exercises the direct mysql driver path using the +// ProvisionDSN. It skips if MariaDB is not reachable or when not explicitly enabled +// via environment (PHOTOPRISM_TEST_DRIVER=mysql). +func TestGetCredentials_MariaDB(t *testing.T) { + ctx := context.Background() + + // Quick liveness probe for AdminDsn; skip fast if not reachable. + if db, err := sql.Open("mysql", ProvisionDSN); err != nil { + t.Skipf("admin DSN not openable: %v", err) + } else { + c, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if err := db.PingContext(c); err != nil { + _ = db.Close() + t.Skipf("admin DSN not reachable: %v", err) + } + _ = db.Close() + } + + // Unique-ish ClusterUUID to avoid collisions across runs; format is not strictly validated. + c := config.NewConfig(config.CliTestContext()) + c.Options().ClusterUUID = time.Now().UTC().Format("20060102-150405.000000000") + + nodeName := "pp-itest-node" + + // 1st call: rotate=true so we receive a password + DSN. + creds, created, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", nodeName, true) + if err != nil { + t.Fatalf("GetCredentials(rotate=true) error: %v", err) + } + if creds.Name == "" || creds.User == "" { + t.Fatalf("missing db name/user in creds: %+v", creds) + } + if creds.Password == "" || creds.DSN == "" { + t.Fatalf("expected password and DSN on rotate/create; got: %+v (created=%v)", creds, created) + } + + // DSN should be usable by the node user (at least ping). + udb, err := sql.Open("mysql", creds.DSN) + if err != nil { + t.Fatalf("open node DSN: %v", err) + } + c2, cancel := context.WithTimeout(ctx, 5*time.Second) + if err := udb.PingContext(c2); err != nil { + cancel() + _ = udb.Close() + t.Fatalf("ping node DSN: %v", err) + } + cancel() + _ = udb.Close() + + // 2nd call: rotate=false should not return a password (idempotent ensure). + creds2, _, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", nodeName, false) + if err != nil { + t.Fatalf("GetCredentials(rotate=false) error: %v", err) + } + if creds2.Password != "" || creds2.DSN != "" { + t.Fatalf("expected no password/DSN without rotation; got: %+v", creds2) + } + + // Cleanup: drop user and database to keep the dev DB tidy. + adb, err := GetDB(ctx) + if err != nil { + t.Fatalf("GetDB: %v", err) + } + qdb, err := quoteIdent(creds.Name) + if err != nil { + t.Fatalf("quoteIdent: %v", err) + } + acc, err := quoteAccount("%", creds.User) + if err != nil { + t.Fatalf("quoteAccount: %v", err) + } + // Best-effort cleanup; ignore individual errors to avoid masking earlier failures. + _ = execTimeout(ctx, adb, 10*time.Second, "REVOKE ALL PRIVILEGES, GRANT OPTION FROM "+acc) + _ = execTimeout(ctx, adb, 10*time.Second, "DROP USER IF EXISTS "+acc) + _ = execTimeout(ctx, adb, 15*time.Second, "DROP DATABASE IF EXISTS "+qdb) +} + +// Verifies that GetCredentials normalizes DatabaseDriver case and rejects +// non-MySQL/MariaDB drivers early without attempting a DB connection. +func TestGetCredentials_DriverNormalization(t *testing.T) { + orig := DatabaseDriver + t.Cleanup(func() { DatabaseDriver = orig }) + + c := config.NewConfig(config.CliTestContext()) + ctx := context.Background() + + // Postgres in weird case should hit the explicit rejection path. + DatabaseDriver = "PostGreS" + _, _, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", "pp-node", false) + assert.Error(t, err) + + // Unknown driver should return the unsupported error including normalized name. + DatabaseDriver = "TiDB" + _, _, err = GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", "pp-node", false) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "unsupported auto-provisioning database driver: tidb") + } +} diff --git a/internal/service/cluster/provisioner/database.go b/internal/service/cluster/provisioner/database.go new file mode 100644 index 000000000..ffeb64bc3 --- /dev/null +++ b/internal/service/cluster/provisioner/database.go @@ -0,0 +1,127 @@ +package provisioner + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "sync" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// ProvisionDSN specifies database auto-provisioning DSN, for example: +// root:insecure@tcp(127.0.0.1:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true +var ProvisionDSN = "root:photoprism@tcp(mariadb:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" +var DatabaseHost = "mariadb" +var DatabasePort = 4001 +var DatabaseDriver = "mysql" + +// ----------------------------------------------------------------------------- +// Persistent auto-provisioning *sql.DB connection with liveness checks +// ----------------------------------------------------------------------------- + +var ( + dbConn *sql.DB + dbMutex sync.RWMutex +) + +// GetDB returns a pooled auto-provisioning connection, opening (or reopening) if needed. +// It pings with a short timeout before returning to ensure liveness. +func GetDB(ctx context.Context) (*sql.DB, error) { + // Fast path with read lock. + dbMutex.RLock() + db := dbConn + dbMutex.RUnlock() + + if db != nil { + if err := pingWithTimeout(ctx, db, 3*time.Second); err == nil { + return db, nil + } + // Ping failed -> close & rebuild. + _ = db.Close() + setDB(nil) + } + + var err error + + db, err = sql.Open("mysql", ProvisionDSN) + if err != nil { + return nil, err + } + + // Reasonable pool settings; adjust for your environment. + db.SetConnMaxLifetime(30 * time.Minute) + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + + // Verify connection. + if pingErr := pingWithTimeout(ctx, db, 5*time.Second); pingErr != nil { + _ = db.Close() + return nil, pingErr + } + + setDB(db) + return db, nil +} + +func setDB(db *sql.DB) { + dbMutex.Lock() + defer dbMutex.Unlock() + dbConn = db +} + +func pingWithTimeout(ctx context.Context, db *sql.DB, d time.Duration) error { + c, cancel := context.WithTimeout(ctx, d) + defer cancel() + return db.PingContext(c) +} + +// ----------------------------------------------------------------------------- +// Quoting & validation helpers +// ----------------------------------------------------------------------------- + +// Allow only safe characters in generated identifiers (you can tighten/loosen this). +var identRe = regexp.MustCompile(`^[a-z0-9\-_.]+$`) + +func quoteIdent(s string) (string, error) { + if s == "" { + return "", errors.New("empty identifier") + } + if !identRe.MatchString(s) { + return "", fmt.Errorf("invalid identifier %q", s) + } + // Backtick-escape any accidental backticks (shouldn't happen with identRe). + return "`" + strings.ReplaceAll(s, "`", "``") + "`", nil +} + +func quoteString(s string) (string, error) { + if strings.ContainsRune(s, '\x00') { + return "", errors.New("string contains NUL") + } + // SQL-92 string literal quoting: single quotes doubled. + return "'" + strings.ReplaceAll(s, "'", "''") + "'", nil +} + +func quoteAccount(host, user string) (string, error) { + u, err := quoteString(user) + if err != nil { + return "", fmt.Errorf("invalid user: %w", err) + } + h, err := quoteString(host) + if err != nil { + return "", fmt.Errorf("invalid host: %w", err) + } + return u + "@" + h, nil +} + +// Exec with a timeout. +func execTimeout(ctx context.Context, db *sql.DB, d time.Duration, stmt string) error { + c, cancel := context.WithTimeout(ctx, d) + defer cancel() + _, err := db.ExecContext(c, stmt) + return err +} diff --git a/internal/service/cluster/provisioner/database_test.go b/internal/service/cluster/provisioner/database_test.go new file mode 100644 index 000000000..4bba7f4cc --- /dev/null +++ b/internal/service/cluster/provisioner/database_test.go @@ -0,0 +1,197 @@ +package provisioner + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --- Quoting helpers --------------------------------------------------------- + +func TestQuoteIdent_Valid(t *testing.T) { + cases := []string{ + "photoprism_db", + "my.db-1_2", + "a", + "z9-._", + } + for _, c := range cases { + got, err := quoteIdent(c) + assert.NoError(t, err, c) + assert.Equal(t, "`"+c+"`", got, c) + } +} + +func TestQuoteIdent_Invalid(t *testing.T) { + cases := []string{"", "UPPER", "bad name", "semi;colon", "ä", "`tick`"} + for _, c := range cases { + _, err := quoteIdent(c) + assert.Error(t, err, c) + } +} + +func TestQuoteString_BasicEscapes(t *testing.T) { + // Single quotes are doubled, others are left as-is. + in := "O'Reilly & partners" + got, err := quoteString(in) + assert.NoError(t, err) + assert.Equal(t, "'O''Reilly & partners'", got) + + in2 := "back`tick and normal" + got2, err := quoteString(in2) + assert.NoError(t, err) + assert.Equal(t, "'back`tick and normal'", got2) +} + +func TestQuoteString_RejectsNUL(t *testing.T) { + _, err := quoteString("nul\x00here") + assert.Error(t, err) +} + +func TestQuoteAccount(t *testing.T) { + got, err := quoteAccount("%", "photoprism_user") + assert.NoError(t, err) + assert.Equal(t, "'photoprism_user'@'%'", got) +} + +func TestQuoteAccount_Errors(t *testing.T) { + _, err := quoteAccount("%", "bad\x00user") + assert.Error(t, err) + _, err = quoteAccount("bad\x00host", "ok") + assert.Error(t, err) +} + +// --- Timeout helpers (using test drivers) ----------------------------------- + +// sleepyDriver implements ExecContext that waits for ctx.Done(), allowing us to +// verify deadline enforcement in execTimeout without a real DB. +type sleepyDriver struct{} + +func (sleepyDriver) Open(name string) (driver.Conn, error) { return sleepyConn{}, nil } + +type sleepyConn struct{} + +func (sleepyConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} +func (sleepyConn) Close() error { return nil } +func (sleepyConn) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") } + +// Implement ExecerContext so database/sql uses Exec directly. +func (sleepyConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func TestExecTimeout_DeadlineExceeded(t *testing.T) { + drv := "sleepy_exec_deadline" + sql.Register(drv, sleepyDriver{}) + db, err := sql.Open(drv, "") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + + ctx := context.Background() + start := time.Now() + err = execTimeout(ctx, db, 50*time.Millisecond, "DO 1") + dur := time.Since(start) + assert.Error(t, err) + // Should be close to the deadline, not seconds. + assert.Less(t, dur, 500*time.Millisecond) +} + +// fastDriver returns immediately and captures the last query to ensure that +// execTimeout forwards the statement correctly. +type fastDriver struct{ last *string } + +func (f fastDriver) Open(name string) (driver.Conn, error) { return fastConn{last: f.last}, nil } + +type fastConn struct{ last *string } + +func (c fastConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} +func (c fastConn) Close() error { return nil } +func (c fastConn) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") } +func (c fastConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + if c.last != nil { + *c.last = query + } + return driver.RowsAffected(1), nil +} + +func TestExecTimeout_ForwardsStatement(t *testing.T) { + var last string + drv := "fast_exec" + sql.Register(drv, fastDriver{last: &last}) + db, err := sql.Open(drv, "") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + + ctx := context.Background() + stmt := "DO 1" + err = execTimeout(ctx, db, 200*time.Millisecond, stmt) + assert.NoError(t, err) + assert.Equal(t, stmt, last) +} + +// pingyDriver implements driver.Pinger so PingContext respects context; one +// variant waits for cancellation, the other returns quickly. +type pingyDriver struct{ wait bool } + +func (p pingyDriver) Open(name string) (driver.Conn, error) { return pingyConn{wait: p.wait}, nil } + +type pingyConn struct{ wait bool } + +func (c pingyConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} +func (c pingyConn) Close() error { return nil } +func (c pingyConn) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") } + +func (c pingyConn) Ping(ctx context.Context) error { + if c.wait { + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func TestPingWithTimeout_DeadlineExceeded(t *testing.T) { + drv := "pingy_wait" + sql.Register(drv, pingyDriver{wait: true}) + db, err := sql.Open(drv, "") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + + ctx := context.Background() + start := time.Now() + err = pingWithTimeout(ctx, db, 50*time.Millisecond) + dur := time.Since(start) + assert.Error(t, err) + assert.Less(t, dur, 500*time.Millisecond) +} + +func TestPingWithTimeout_Succeeds(t *testing.T) { + drv := "pingy_fast" + sql.Register(drv, pingyDriver{wait: false}) + db, err := sql.Open(drv, "") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + + ctx := context.Background() + err = pingWithTimeout(ctx, db, 100*time.Millisecond) + assert.NoError(t, err) +} diff --git a/internal/service/cluster/provisioner/db.go b/internal/service/cluster/provisioner/db.go deleted file mode 100644 index 5d1e54e53..000000000 --- a/internal/service/cluster/provisioner/db.go +++ /dev/null @@ -1,116 +0,0 @@ -package provisioner - -import ( - "context" - "errors" - "fmt" - "regexp" - "strings" - "time" - - "github.com/jinzhu/gorm" - - "github.com/photoprism/photoprism/internal/config" -) - -// Creds contains the connection details returned when ensuring a node database. -type Creds struct { - Host string - Port int - Name string - User string - Password string - DSN string - LastRotatedAt string -} - -var identRe = regexp.MustCompile(`^[a-z0-9\-_.]+$`) - -func quoteIdent(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" } - -// EnsureNodeDatabase ensures a per-node database and user exist with minimal grants. -// - Requires MySQL/MariaDB driver on the portal. -// - Returns created=true if the database schema did not exist before. -// - If rotate is true or created, rotates the user password and includes it (and DSN) in the result. -func EnsureNodeDatabase(ctx context.Context, conf *config.Config, nodeName string, rotate bool) (Creds, bool, error) { - out := Creds{} - - switch conf.DatabaseDriver() { - case config.MySQL, config.MariaDB: - // ok - case config.SQLite3, config.Postgres: - return out, false, errors.New("portal database must be MySQL/MariaDB for registration") - default: - return out, false, fmt.Errorf("unsupported portal database driver: %s", conf.DatabaseDriver()) - } - - // Compute deterministic names and a candidate password. - dbName, dbUser, dbPass := GenerateCreds(conf, nodeName) - - // Extra safety: enforce allowed identifier charset. - if !identRe.MatchString(dbName) || !identRe.MatchString(dbUser) { - return out, false, errors.New("invalid generated database identifiers") - } - - // Determine if database already exists. - type res struct{ C int } - var r res - - q := conf.Db().Unscoped() - - if err := q.Raw("SELECT COUNT(*) AS C FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", dbName).Scan(&r).Error; err != nil { - return out, false, err - } - - created := r.C == 0 - - // Create database schema if needed. - if err := exec(q, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", quoteIdent(dbName))); err != nil { - return out, created, err - } - - // Create user if needed (host wildcard '%'). - if err := exec(q, fmt.Sprintf("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'", dbUser, dbPass)); err != nil { - return out, created, err - } - - // Rotate or set password explicitly on first creation. - if rotate || created { - if err := exec(q, fmt.Sprintf("ALTER USER '%s'@'%%' IDENTIFIED BY '%s'", dbUser, dbPass)); err != nil { - return out, created, err - } - out.Password = dbPass - out.LastRotatedAt = time.Now().UTC().Format(time.RFC3339) - } - - // Grant privileges on schema. - if err := exec(q, fmt.Sprintf("GRANT ALL PRIVILEGES ON %s.* TO '%s'@'%%'", quoteIdent(dbName), dbUser)); err != nil { - return out, created, err - } - - // Optional on modern MariaDB, harmless if included. - if err := exec(q, "FLUSH PRIVILEGES"); err != nil { - return out, created, err - } - - out.Host = conf.DatabaseHost() - out.Port = conf.DatabasePort() - out.Name = dbName - out.User = dbUser - - if out.Password != "" { - out.DSN = BuildDSN(out.Host, out.Port, out.User, out.Password, out.Name) - } - - return out, created, nil -} - -func exec(db *gorm.DB, stmt string) error { - if stmt == "" { - return nil - } - - // Use a no-op scan into a struct to execute raw SQL with gorm v1. - var nop struct{} - return db.Raw(stmt).Scan(&nop).Error -} diff --git a/internal/service/cluster/provisioner/naming.go b/internal/service/cluster/provisioner/naming.go index 17819f4cb..eed58cebd 100644 --- a/internal/service/cluster/provisioner/naming.go +++ b/internal/service/cluster/provisioner/naming.go @@ -8,50 +8,55 @@ import ( "strings" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" ) const ( - dbPrefix = "pp_" - userPrefix = "pp_" - dbSuffix = 8 - userSuffix = 6 - userMax = 32 - dbMax = 64 + // Name prefix for generated DB objects. + // Final pattern without slugs (UUID-based): + // database: photoprism_d + // username: photoprism_u + prefix = "photoprism_" + dbSuffix = 11 + userSuffix = 11 + // Budgets: keep user conservative for MySQL compatibility; MariaDB allows more. + userMax = 32 + dbMax = 64 ) // GenerateCreds computes deterministic database name and user for a node under the given portal -// plus a random password. Naming is stable for a given (clusterUUID, nodeName) pair and changes -// if the cluster UUID changes. The returned password is random and independent. -func GenerateCreds(conf *config.Config, nodeName string) (dbName, dbUser, dbPass string) { +// plus a random password. Naming is stable for a given (clusterUUID, nodeUUID) pair and changes +// if the cluster UUID or node UUID changes. +func GenerateCreds(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) { clusterUUID := conf.ClusterUUID() - slug := clean.TypeLowerDash(nodeName) - // Compute base32 (no padding) HMAC suffixes scoped by cluster UUID. - sName := hmacBase32("db-name:"+clusterUUID, slug) - sUser := hmacBase32("db-user:"+clusterUUID, slug) + // Compute base32 (no padding) HMAC suffixes scoped by cluster UUID and node UUID. + sName := hmacBase32("db-name:"+clusterUUID, nodeUUID) + sUser := hmacBase32("db-user:"+clusterUUID, nodeUUID) - // Budgets: user ≤32, db ≤64 - // Patterns: pp__ - // Compute max slug lengths to honor budgets. - userSlugMax := userMax - len(userPrefix) - 1 - userSuffix // 32 - 3 - 1 - 6 = 22 - dbSlugMax := dbMax - len(dbPrefix) - 1 - dbSuffix // 64 - 3 - 1 - 8 = 52 - - slugUser := trimRunes(slug, userSlugMax) - slugDb := trimRunes(slug, dbSlugMax) - - dbName = fmt.Sprintf("%s%s_%s", dbPrefix, slugDb, sName[:dbSuffix]) - dbUser = fmt.Sprintf("%s%s_%s", userPrefix, slugUser, sUser[:userSuffix]) + // Budgets: user ≤32, db ≤64. Suffixes are fixed length and derived from UUID. + dbName = fmt.Sprintf("%sd%s", prefix, sName[:dbSuffix]) + dbUser = fmt.Sprintf("%su%s", prefix, sUser[:userSuffix]) dbPass = rnd.Base62(32) + return } -// BuildDSN returns a MySQL/MariaDB DSN suitable for PhotoPrism nodes. -func BuildDSN(host string, port int, user, pass, name string) string { - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=true", - user, pass, host, port, name, - ) +// BuildDSN returns a DSN suitable for PhotoPrism nodes given a database driver. +// Currently, "mysql"/"mariadb" are supported; other drivers log a warning and fall back to MySQL format. +func BuildDSN(driver, host string, port int, user, pass, name string) string { + d := strings.ToLower(driver) + switch d { + case config.MySQL, config.MariaDB: + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=true", + user, pass, host, port, name, + ) + default: + log.Warnf("provisioner: unsupported driver %q, falling back to mysql DSN format", driver) + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=true", + user, pass, host, port, name, + ) + } } func hmacBase32(key, data string) string { @@ -62,17 +67,3 @@ func hmacBase32(key, data string) string { return strings.ToLower(enc) } - -func trimRunes(s string, max int) string { - if max <= 0 || len(s) <= max { - return s - } - - // Trim by runes to avoid mid-rune cut, though s should be ASCII by cleaning. - r := []rune(s) - if len(r) <= max { - return s - } - - return string(r[:max]) -} diff --git a/internal/service/cluster/provisioner/naming_test.go b/internal/service/cluster/provisioner/naming_test.go index e52a97e1f..281e8de36 100644 --- a/internal/service/cluster/provisioner/naming_test.go +++ b/internal/service/cluster/provisioner/naming_test.go @@ -13,8 +13,8 @@ func TestGenerateCreds_StabilityAndBudgets(t *testing.T) { // Fix the cluster UUID via options to ensure determinism. c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111" - db1, user1, pass1 := GenerateCreds(c, "pp-node-01") - db2, user2, pass2 := GenerateCreds(c, "pp-node-01") + db1, user1, pass1 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01") + db2, user2, pass2 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01") // Names stable; password random. assert.Equal(t, db1, db2) @@ -24,8 +24,8 @@ func TestGenerateCreds_StabilityAndBudgets(t *testing.T) { // Budgets and patterns. assert.LessOrEqual(t, len(user1), 32) assert.LessOrEqual(t, len(db1), 64) - assert.Contains(t, db1, "pp-") - assert.Contains(t, user1, "pp-") + assert.Contains(t, db1, "photoprism_") + assert.Contains(t, user1, "photoprism_") } func TestGenerateCreds_DifferentPortal(t *testing.T) { @@ -34,8 +34,8 @@ func TestGenerateCreds_DifferentPortal(t *testing.T) { c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111" c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222" - db1, user1, _ := GenerateCreds(c1, "pp-node-01") - db2, user2, _ := GenerateCreds(c2, "pp-node-01") + db1, user1, _ := GenerateCreds(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01") + db2, user2, _ := GenerateCreds(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01") assert.NotEqual(t, db1, db2) assert.NotEqual(t, user1, user2) @@ -45,14 +45,14 @@ func TestGenerateCreds_Truncation(t *testing.T) { c := config.NewConfig(config.CliTestContext()) c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111" longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets" - db, user, _ := GenerateCreds(c, longName) + db, user, _ := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", longName) assert.LessOrEqual(t, len(user), 32) assert.LessOrEqual(t, len(db), 64) } func TestBuildDSN(t *testing.T) { - dsn := BuildDSN("mariadb", 3306, "user", "pass", "dbname") + dsn := BuildDSN("mysql", "mariadb", 3306, "user", "pass", "dbname") assert.Contains(t, dsn, "user:pass@tcp(mariadb:3306)/dbname") assert.Contains(t, dsn, "charset=utf8mb4") assert.Contains(t, dsn, "parseTime=true") @@ -64,6 +64,6 @@ func TestEnsureNodeDatabase_SqliteRejected(t *testing.T) { if c.DatabaseDriver() != config.SQLite3 { t.Skip("test requires SQLite driver in test config") } - _, _, err := EnsureNodeDatabase(nil, c, "pp-node-01", false) + _, _, err := GetCredentials(nil, c, "11111111-1111-4111-8111-111111111111", "pp-node-01", false) assert.Error(t, err) } diff --git a/internal/service/cluster/provisioner/provisioner.go b/internal/service/cluster/provisioner/provisioner.go new file mode 100644 index 000000000..5f43e56e9 --- /dev/null +++ b/internal/service/cluster/provisioner/provisioner.go @@ -0,0 +1,53 @@ +/* +Package provisioner manages per-node database provisioning for cluster setups. + +It runs on the Portal and is responsible for: + + - Generating deterministic database and user names for nodes based on the + portal's Cluster UUID and the node name, while keeping lengths within + engine limits. + - Creating the database schema if missing and granting minimal privileges + to the node's database user. + - Creating the user if needed and rotating its password on demand, returning + credentials (and a ready-to-use DSN) to the caller. + +The implementation uses GORM v1 for execution and binds parameters safely +instead of string formatting for values, while quoting identifiers where +necessary for portability and security. + +Admin DSN: Provisioning connects via a dedicated admin DSN (mysql driver), +independent of the application's main database/driver. This follows least +privilege best practices and allows the app DB to be SQLite while provisioning +against MariaDB/MySQL. + +Testing notes: Package includes a lightweight MariaDB integration test that +uses the Admin DSN and skips automatically if the DSN cannot be opened/pinged. +Historically, behavior was also validated via Docker Compose and broader repo +targets such as "make run-test-mariadb". + +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"): + + + 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: + + +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: + +*/ +package provisioner + +import "github.com/photoprism/photoprism/internal/event" + +var log = event.Log diff --git a/internal/service/cluster/registry/client.go b/internal/service/cluster/registry/client.go index 5be711786..7f5a638d8 100644 --- a/internal/service/cluster/registry/client.go +++ b/internal/service/cluster/registry/client.go @@ -6,6 +6,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/service/cluster" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -26,13 +27,14 @@ func toNode(c *entity.Client) *Node { return nil } n := &Node{ - ID: c.ClientUID, + UUID: c.NodeUUID, Name: c.ClientName, Role: c.ClientRole, - CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), - UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339), + ClientID: c.ClientUID, AdvertiseUrl: c.ClientURL, Labels: map[string]string{}, + CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339), } data := c.GetData() if data != nil { @@ -41,45 +43,60 @@ func toNode(c *entity.Client) *Node { } n.SiteUrl = data.SiteURL if db := data.Database; db != nil { - n.DB.Name = db.Name - n.DB.User = db.User - n.DB.RotAt = db.RotatedAt + n.Database.Name = db.Name + n.Database.User = db.User + n.Database.Driver = db.Driver + n.Database.RotatedAt = db.RotatedAt } - n.SecretRot = data.SecretRotatedAt + n.RotatedAt = data.RotatedAt } return n } func (r *ClientRegistry) Put(n *Node) error { - // Upsert client by UID if provided, else by name. + // Upsert client preferring NodeUUID (primary), then ClientID, then Name. var m *entity.Client - if rnd.IsUID(n.ID, entity.ClientUID) { - if existing := entity.FindClientByUID(n.ID); existing != nil { + + // 1) Try NodeUUID first, if provided. + if n.UUID != "" { + var existing entity.Client + if err := entity.UnscopedDb().Where("node_uuid = ?", n.UUID).First(&existing).Error; err == nil && existing.ClientUID != "" { + m = &existing + } + } + + // 2) Fall back to ClientID if not found by UUID and ClientID is valid. + if m == nil && rnd.IsUID(n.ClientID, entity.ClientUID) { + if existing := entity.FindClientByUID(n.ClientID); existing != nil { m = existing } } + + // 3) Finally, try by Name (latest by UpdatedAt). Avoid mismatching when a UUID is provided but name belongs to another node. if m == nil && n.Name != "" { - // Try by name (latest updated wins if multiple); scan minimal for now. var list []entity.Client - if err := entity.UnscopedDb().Where("client_name = ?", n.Name).Find(&list).Error; err == nil { - var latest *entity.Client - for i := range list { - if latest == nil || list[i].UpdatedAt.After(latest.UpdatedAt) { + if err := entity.UnscopedDb().Where("client_name = ?", n.Name).Find(&list).Error; err == nil && len(list) > 0 { + // pick latest + latest := &list[0] + for i := 1; i < len(list); i++ { + if list[i].UpdatedAt.After(latest.UpdatedAt) { latest = &list[i] } } - if latest != nil { + // If caller provided a UUID, do not attach to a different UUID. + if n.UUID == "" || latest.NodeUUID == n.UUID || latest.NodeUUID == "" { m = latest } } } + if m == nil { m = entity.NewClient() } // Apply fields. if n.Name != "" { - m.ClientName = clean.TypeLowerDash(n.Name) + m.ClientName = clean.DNSLabel(n.Name) } if n.Role != "" { m.SetRole(n.Role) @@ -105,14 +122,18 @@ func (r *ClientRegistry) Put(n *Node) error { if n.SiteUrl != "" { data.SiteURL = n.SiteUrl } - data.SecretRotatedAt = n.SecretRot - if n.DB.Name != "" || n.DB.User != "" || n.DB.RotAt != "" { + if n.UUID != "" { + m.NodeUUID = n.UUID + } + data.RotatedAt = n.RotatedAt + if n.Database.Name != "" || n.Database.User != "" || n.Database.RotatedAt != "" { if data.Database == nil { data.Database = &entity.ClientDatabase{} } - data.Database.Name = n.DB.Name - data.Database.User = n.DB.User - data.Database.RotatedAt = n.DB.RotAt + data.Database.Name = n.Database.Name + data.Database.User = n.Database.User + data.Database.Driver = n.Database.Driver + data.Database.RotatedAt = n.Database.RotatedAt } m.SetData(data) @@ -128,9 +149,9 @@ func (r *ClientRegistry) Put(n *Node) error { } // Reflect persisted values back into the provided node pointer so callers - // (e.g., API handlers) can return the actual ID and timestamps. + // (e.g., API handlers) can return the actual ClientID and timestamps. // Note: Do not overwrite sensitive in-memory fields like Secret. - n.ID = m.ClientUID + n.ClientID = m.ClientUID n.Name = m.ClientName n.Role = m.ClientRole n.AdvertiseUrl = m.ClientURL @@ -144,15 +165,15 @@ func (r *ClientRegistry) Put(n *Node) error { } n.SiteUrl = data.SiteURL if db := data.Database; db != nil { - n.DB.Name = db.Name - n.DB.User = db.User - n.DB.RotAt = db.RotatedAt + n.Database.Name = db.Name + n.Database.User = db.User + n.Database.RotatedAt = db.RotatedAt } - n.SecretRot = data.SecretRotatedAt + n.RotatedAt = data.RotatedAt } // Set initial secret if provided on create/update. - if n.Secret != "" { - if err := m.SetSecret(n.Secret); err != nil { + if n.ClientSecret != "" { + if err := m.SetSecret(n.ClientSecret); err != nil { return err } } @@ -160,15 +181,19 @@ func (r *ClientRegistry) Put(n *Node) error { } func (r *ClientRegistry) Get(id string) (*Node, error) { - c := entity.FindClientByUID(id) - if c == nil { + // Get by NodeUUID (UUID is primary identifier) + if id == "" { return nil, ErrNotFound } - return toNode(c), nil + var c entity.Client + if err := entity.UnscopedDb().Where("node_uuid = ?", id).First(&c).Error; err != nil || c.ClientUID == "" { + return nil, ErrNotFound + } + return toNode(&c), nil } func (r *ClientRegistry) FindByName(name string) (*Node, error) { - name = clean.TypeLowerDash(name) + name = clean.DNSLabel(name) if name == "" { return nil, ErrNotFound } @@ -188,9 +213,53 @@ func (r *ClientRegistry) FindByName(name string) (*Node, error) { return toNode(latest), nil } +// FindByNodeUUID looks up a node by its NodeUUID and returns the latest record. +func (r *ClientRegistry) FindByNodeUUID(nodeUUID string) (*Node, error) { + if nodeUUID == "" { + return nil, ErrNotFound + } + var list []entity.Client + if err := entity.UnscopedDb().Where("node_uuid = ?", nodeUUID).Find(&list).Error; err != nil { + return nil, err + } + if len(list) == 0 { + return nil, ErrNotFound + } + latest := &list[0] + for i := 1; i < len(list); i++ { + if list[i].UpdatedAt.After(latest.UpdatedAt) { + latest = &list[i] + } + } + return toNode(latest), nil +} + +// FindByClientID looks up a node by its OAuth client identifier. +func (r *ClientRegistry) FindByClientID(id string) (*Node, error) { + if !rnd.IsUID(id, entity.ClientUID) { + return nil, ErrNotFound + } + c := entity.FindClientByUID(id) + if c == nil { + return nil, ErrNotFound + } + return toNode(c), nil +} + +// GetClusterNodeByUUID returns a redacted cluster.Node DTO for a given NodeUUID. +// Use NodeOptsForSession to control exposure when wiring to HTTP handlers. +func (r *ClientRegistry) GetClusterNodeByUUID(nodeUUID string, opts NodeOpts) (cluster.Node, error) { + n, err := r.FindByNodeUUID(nodeUUID) + if err != nil || n == nil { + return cluster.Node{}, err + } + return BuildClusterNode(*n, opts), nil +} + func (r *ClientRegistry) List() ([]Node, error) { var list []entity.Client - if err := entity.UnscopedDb().Where("client_role IN (?)", []string{"instance", "service", "portal"}).Find(&list).Error; err != nil { + // Identify cluster nodes primarily by presence of NodeUUID. + if err := entity.UnscopedDb().Where("node_uuid <> ''").Find(&list).Error; err != nil { return nil, err } sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.After(list[j].UpdatedAt) }) @@ -203,16 +272,51 @@ func (r *ClientRegistry) List() ([]Node, error) { return out, nil } -func (r *ClientRegistry) Delete(id string) error { - c := entity.FindClientByUID(id) +func (r *ClientRegistry) Delete(uuid string) error { + if uuid == "" { + return ErrNotFound + } + // Delete the latest record for this UUID (typical case: only one). + n, err := r.FindByNodeUUID(uuid) + if err != nil || n == nil || n.ClientID == "" { + return ErrNotFound + } + c := entity.FindClientByUID(n.ClientID) if c == nil { return ErrNotFound } return c.Delete() } -func (r *ClientRegistry) RotateSecret(id string) (*Node, error) { - c := entity.FindClientByUID(id) +// DeleteAllByUUID removes all client rows that match the given NodeUUID. +func (r *ClientRegistry) DeleteAllByUUID(uuid string) error { + if uuid == "" { + return ErrNotFound + } + var list []entity.Client + if err := entity.UnscopedDb().Where("node_uuid = ?", uuid).Find(&list).Error; err != nil { + return err + } + if len(list) == 0 { + return ErrNotFound + } + for i := range list { + if err := list[i].Delete(); err != nil { + return err + } + } + return nil +} + +func (r *ClientRegistry) RotateSecret(uuid string) (*Node, error) { + if uuid == "" { + return nil, ErrNotFound + } + n, err := r.FindByNodeUUID(uuid) + if err != nil || n == nil || n.ClientID == "" { + return nil, ErrNotFound + } + c := entity.FindClientByUID(n.ClientID) if c == nil { return nil, ErrNotFound } @@ -223,12 +327,12 @@ func (r *ClientRegistry) RotateSecret(id string) (*Node, error) { } // Update rotation timestamp in data. data := c.GetData() - data.SecretRotatedAt = time.Now().UTC().Format(time.RFC3339) + data.RotatedAt = time.Now().UTC().Format(time.RFC3339) c.SetData(data) if err := c.Save(); err != nil { return nil, err } - n := toNode(c) - n.Secret = secret // plaintext only in-memory for response composition + n = toNode(c) + n.ClientSecret = secret // plaintext only in-memory for response composition return n, nil } diff --git a/internal/service/cluster/registry/client_more_test.go b/internal/service/cluster/registry/client_more_test.go index 41cd0d2ed..9e9871d9f 100644 --- a/internal/service/cluster/registry/client_more_test.go +++ b/internal/service/cluster/registry/client_more_test.go @@ -8,6 +8,7 @@ import ( cfg "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" ) // Duplicate names: FindByName should return the most recently updated. @@ -29,8 +30,10 @@ func TestClientRegistry_DuplicateNamePrefersLatest(t *testing.T) { assert.NoError(t, err) if assert.NotNil(t, n) { // Latest should be c2 - assert.Equal(t, c2.ClientUID, n.ID) + assert.Equal(t, c2.ClientUID, n.ClientID) assert.Equal(t, "service", n.Role) + // IDs have expected format + assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID)) } } @@ -49,7 +52,7 @@ func TestClientRegistry_RoleChange(t *testing.T) { assert.Equal(t, "service", got.Role) } // Change to instance - upd := &Node{ID: got.ID, Name: got.Name, Role: "instance"} + upd := &Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"} assert.NoError(t, r.Put(upd)) got2, err := r.FindByName("pp-role") assert.NoError(t, err) diff --git a/internal/service/cluster/registry/client_test.go b/internal/service/cluster/registry/client_test.go index 1f568fc2b..b8a54e9e0 100644 --- a/internal/service/cluster/registry/client_test.go +++ b/internal/service/cluster/registry/client_test.go @@ -23,17 +23,18 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) { // Create new node n := &Node{ + UUID: rnd.UUIDv7(), Name: "pp-node-a", Role: "instance", - Labels: map[string]string{"env": "test"}, - AdvertiseUrl: "http://pp-node-a:2342", SiteUrl: "https://photos.example.com", + AdvertiseUrl: "http://pp-node-a:2342", + Labels: map[string]string{"env": "test"}, } - n.DB.Name = "pp_db" - n.DB.User = "pp_user" - n.DB.RotAt = time.Now().UTC().Format(time.RFC3339) - n.SecretRot = time.Now().UTC().Format(time.RFC3339) - n.Secret = rnd.ClientSecret() + n.Database.Name = "pp_db" + n.Database.User = "pp_user" + n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339) + n.RotatedAt = time.Now().UTC().Format(time.RFC3339) + n.ClientSecret = rnd.ClientSecret() assert.NoError(t, r.Put(n)) @@ -41,22 +42,24 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) { got, err := r.FindByName("pp-node-a") assert.NoError(t, err) if assert.NotNil(t, got) { - assert.NotEmpty(t, got.ID) + assert.NotEmpty(t, got.ClientID) + assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID)) + assert.True(t, rnd.IsUUID(got.UUID)) assert.Equal(t, "pp-node-a", got.Name) assert.Equal(t, "instance", got.Role) assert.Equal(t, "http://pp-node-a:2342", got.AdvertiseUrl) assert.Equal(t, "https://photos.example.com", got.SiteUrl) - assert.Equal(t, "pp_db", got.DB.Name) - assert.Equal(t, "pp_user", got.DB.User) + assert.Equal(t, "pp_db", got.Database.Name) + assert.Equal(t, "pp_user", got.Database.User) assert.NotEmpty(t, got.CreatedAt) assert.NotEmpty(t, got.UpdatedAt) // Secret is not persisted in plaintext - assert.Equal(t, "", got.Secret) - assert.NotEmpty(t, got.SecretRot) + assert.Equal(t, "", got.ClientSecret) + assert.NotEmpty(t, got.RotatedAt) // Password row exists and validates the initial secret - pw := entity.FindPassword(got.ID) + pw := entity.FindPassword(got.ClientID) if assert.NotNil(t, pw) { - assert.True(t, pw.Valid(n.Secret)) + assert.True(t, pw.Valid(n.ClientSecret)) } } @@ -73,19 +76,19 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) { assert.True(t, found) // Rotate secret - rotated, err := r.RotateSecret(got.ID) + rotated, err := r.RotateSecret(got.UUID) assert.NoError(t, err) if assert.NotNil(t, rotated) { - assert.NotEmpty(t, rotated.Secret) + assert.NotEmpty(t, rotated.ClientSecret) // Validate new secret - pw := entity.FindPassword(got.ID) + pw := entity.FindPassword(got.ClientID) if assert.NotNil(t, pw) { - assert.True(t, pw.Valid(rotated.Secret)) + assert.True(t, pw.Valid(rotated.ClientSecret)) } } // Update labels and site URL via Put (upsert by id) - upd := &Node{ID: got.ID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"} + upd := &Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"} assert.NoError(t, r.Put(upd)) got2, err := r.FindByName("pp-node-a") assert.NoError(t, err) diff --git a/internal/service/cluster/registry/node.go b/internal/service/cluster/registry/node.go index 46e1959cb..8b4000d41 100644 --- a/internal/service/cluster/registry/node.go +++ b/internal/service/cluster/registry/node.go @@ -3,19 +3,21 @@ package registry // Node represents a registered cluster node (transport DTO inside registry package). // It is used by both client-backed and (legacy) file-backed registries. type Node struct { - ID string `json:"id"` + UUID string `json:"uuid"` // primary identifier (UUID v7) Name string `json:"name"` Role string `json:"role"` - Labels map[string]string `json:"labels"` - SiteUrl string `json:"siteUrl"` - AdvertiseUrl string `json:"advertiseUrl"` + ClientID string `json:"clientId,omitempty"` // OAuth client identifier (legacy) + ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory + SiteUrl string `json:"siteUrl,omitempty"` + AdvertiseUrl string `json:"advertiseUrl,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + RotatedAt string `json:"rotatedAt,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` - Secret string `json:"-"` // plaintext only when newly created/rotated in-memory - SecretRot string `json:"secretRotatedAt"` - DB struct { - Name string `json:"name"` - User string `json:"user"` - RotAt string `json:"rotatedAt"` - } `json:"db"` + Database struct { + Name string `json:"name"` + User string `json:"user"` + Driver string `json:"driver,omitempty"` + RotatedAt string `json:"rotatedAt,omitempty"` + } `json:"database,omitempty"` } diff --git a/internal/service/cluster/registry/registry.go b/internal/service/cluster/registry/registry.go index 489902d8d..e83ca242d 100644 --- a/internal/service/cluster/registry/registry.go +++ b/internal/service/cluster/registry/registry.go @@ -1,16 +1,53 @@ +/* +Package registry defines and implements the cluster node registry abstraction. + +The default implementation stores nodes in the Portal's OAuth client table +and exposes CRUD-style operations to create, lookup, list, delete, and rotate +secrets for nodes. Helper mappers convert internal Node records to API/CLI +response DTOs while redacting sensitive fields. + +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"): + + + 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: + + +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: + +*/ package registry -import "os" +import ( + "os" + + "github.com/photoprism/photoprism/internal/service/cluster" +) // Registry abstracts cluster node persistence so we can back it with auth_clients. // Implementations should be Portal-local and enforce no cross-process locking here. type Registry interface { - Put(n *Node) error - Get(id string) (*Node, error) + Put(n *Node) error // Core CRUD. + Get(uuid string) (*Node, error) FindByName(name string) (*Node, error) List() ([]Node, error) - Delete(id string) error - RotateSecret(id string) (*Node, error) + Delete(uuid string) error // Typical case: a single record to delete or update. + DeleteAllByUUID(uuid string) error // DeleteAllByUUID removes all client records that share the given UUID. + RotateSecret(uuid string) (*Node, error) // Secret rotation by UUID (primary identifier). + FindByNodeUUID(uuid string) (*Node, error) // UUID-first helpers (primary identifier for nodes). + FindByClientID(clientID string) (*Node, error) + GetClusterNodeByUUID(uuid string, opts NodeOpts) (cluster.Node, error) } // ErrNotFound is returned when a node cannot be found. diff --git a/internal/service/cluster/registry/registry_clientid_reuse_test.go b/internal/service/cluster/registry/registry_clientid_reuse_test.go new file mode 100644 index 000000000..33548223b --- /dev/null +++ b/internal/service/cluster/registry/registry_clientid_reuse_test.go @@ -0,0 +1,79 @@ +package registry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + cfg "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// When both a conflicting UUID and an existing ClientID are provided, the UUID-first +// rule prevents hijacking: the update applies to the UUID's row and does not move +// the ClientID from its original node. +func TestClientRegistry_ClientIDReuse_CannotHijackExistingUUID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-cid-hijack") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + // Seed two independent nodes + a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"} + b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"} + assert.NoError(t, r.Put(a)) + assert.NoError(t, r.Put(b)) + + // Attempt to update UUID=b while passing ClientID of a + assert.NoError(t, r.Put(&Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"})) + + // a stays attached to its original UUID and ClientID + gotA, err := r.FindByNodeUUID(a.UUID) + assert.NoError(t, err) + if assert.NotNil(t, gotA) { + assert.Equal(t, a.ClientID, gotA.ClientID) + assert.True(t, rnd.IsUUID(gotA.UUID)) + assert.True(t, rnd.IsUID(gotA.ClientID, entity.ClientUID)) + } + // b remains the same client row (not replaced by a) + gotB, err := r.FindByNodeUUID(b.UUID) + assert.NoError(t, err) + if assert.NotNil(t, gotB) { + assert.Equal(t, b.ClientID, gotB.ClientID) + assert.True(t, rnd.IsUUID(gotB.UUID)) + assert.True(t, rnd.IsUID(gotB.ClientID, entity.ClientUID)) + } +} + +// If a target UUID does not exist yet, providing an existing ClientID with a new UUID +// migrates the row to the new UUID. This mirrors restore flows where a node's ClientID +// is reused for a regenerated or reassigned UUID. +func TestClientRegistry_ClientIDReuse_ChangesUUIDWhenTargetMissing(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-cid-move") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + // Seed one node + a := &Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"} + assert.NoError(t, r.Put(a)) + + // Move the row to a new UUID by referencing the same ClientID and a new UUID + newUUID := rnd.UUIDv7() + assert.NoError(t, r.Put(&Node{UUID: newUUID, ClientID: a.ClientID})) + + // Old UUID no longer resolves + _, err := r.FindByNodeUUID(a.UUID) + assert.Error(t, err) + + // New UUID points to the same client row (ClientID unchanged) + got, err := r.FindByNodeUUID(newUUID) + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, a.ClientID, got.ClientID) + assert.Equal(t, newUUID, got.UUID) + assert.True(t, rnd.IsUUID(got.UUID)) + assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID)) + } +} diff --git a/internal/service/cluster/registry/registry_clientid_test.go b/internal/service/cluster/registry/registry_clientid_test.go new file mode 100644 index 000000000..01d431f78 --- /dev/null +++ b/internal/service/cluster/registry/registry_clientid_test.go @@ -0,0 +1,145 @@ +package registry + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + cfg "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// Basic FindByClientID flow with Put and DTO mapping. +func TestClientRegistry_FindByClientID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-find-clientid") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + n := &Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()} + assert.NoError(t, r.Put(n)) + + got, err := r.FindByClientID(n.ClientID) + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, n.ClientID, got.ClientID) + assert.Equal(t, n.UUID, got.UUID) + assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID)) + assert.True(t, rnd.IsUUID(got.UUID)) + } +} + +// Simulate client ID changing after a restore: old row removed, new row created with same NodeUUID. +func TestClientRegistry_ClientIDChangedAfterRestore(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-clientid-restore") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + uuid := rnd.UUIDv7() + // Original row + a := entity.NewClient().SetName("pp-restore").SetRole("instance") + a.NodeUUID = uuid + assert.NoError(t, a.Create()) + oldID := a.ClientUID + + // Simulate restore: remove old row, create new row for same node UUID with new UID + assert.NoError(t, a.Delete()) + time.Sleep(1100 * time.Millisecond) + b := entity.NewClient().SetName("pp-restore").SetRole("instance") + b.NodeUUID = uuid + assert.NoError(t, b.Create()) + + r, _ := NewClientRegistryWithConfig(c) + + // Old ClientID no longer valid + _, err := r.FindByClientID(oldID) + assert.Error(t, err) + + // UUID lookup still works and returns the new row + got, err := r.FindByNodeUUID(uuid) + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, b.ClientUID, got.ClientID) + assert.Equal(t, uuid, got.UUID) + assert.True(t, rnd.IsUUID(got.UUID)) + assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID)) + } +} + +// Names swapped between two nodes: UUIDs must remain authoritative. +func TestClientRegistry_SwapNames_UUIDAuthoritative(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-swap-names") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"} + b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"} + assert.NoError(t, r.Put(a)) + assert.NoError(t, r.Put(b)) + + // Swap names via UUID-targeted updates + assert.NoError(t, r.Put(&Node{UUID: a.UUID, Name: "pp-b"})) + time.Sleep(1100 * time.Millisecond) + assert.NoError(t, r.Put(&Node{UUID: b.UUID, Name: "pp-a"})) + + // UUID lookups map to the correct updated names + gotA, err := r.FindByNodeUUID(a.UUID) + assert.NoError(t, err) + if assert.NotNil(t, gotA) { + assert.Equal(t, "pp-b", gotA.Name) + assert.True(t, rnd.IsUUID(gotA.UUID)) + } + gotB, err := r.FindByNodeUUID(b.UUID) + assert.NoError(t, err) + if assert.NotNil(t, gotB) { + assert.Equal(t, "pp-a", gotB.Name) + assert.True(t, rnd.IsUUID(gotB.UUID)) + } + + // Name-based lookup chooses latest update for each name; both exist and are valid + byNameA, err := r.FindByName("pp-a") + assert.NoError(t, err) + if assert.NotNil(t, byNameA) { + assert.Equal(t, b.UUID, byNameA.UUID) + assert.True(t, rnd.IsUUID(byNameA.UUID)) + } + byNameB, err := r.FindByName("pp-b") + assert.NoError(t, err) + if assert.NotNil(t, byNameB) { + assert.Equal(t, a.UUID, byNameB.UUID) + assert.True(t, rnd.IsUUID(byNameB.UUID)) + } +} + +// Ensure DB driver and fields round-trip through Put → toNode → BuildClusterNode. +func TestClientRegistry_DBDriverAndFields(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-dbdriver") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + n := &Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"} + n.Database.Name = "photoprism_d123" + n.Database.User = "photoprism_u123" + n.Database.Driver = "mysql" + n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339) + assert.NoError(t, r.Put(n)) + + got, err := r.FindByNodeUUID(n.UUID) + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, "photoprism_d123", got.Database.Name) + assert.Equal(t, "photoprism_u123", got.Database.User) + assert.Equal(t, "mysql", got.Database.Driver) + } + + // Build DTO with DB included + dto := BuildClusterNode(*got, NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}) + if assert.NotNil(t, dto.Database) { + assert.Equal(t, "mysql", dto.Database.Driver) + assert.Equal(t, "photoprism_d123", dto.Database.Name) + } +} diff --git a/internal/service/cluster/registry/registry_list_negative_test.go b/internal/service/cluster/registry/registry_list_negative_test.go new file mode 100644 index 000000000..6ab358974 --- /dev/null +++ b/internal/service/cluster/registry/registry_list_negative_test.go @@ -0,0 +1,41 @@ +package registry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + cfg "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// Ensure List() excludes clients that look like nodes by role but have no NodeUUID. +func TestClientRegistry_ListExcludesNodeRoleWithoutUUID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-list-exclude-node-role") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + // Bad records: node-like roles but empty NodeUUID + bad1 := entity.NewClient().SetName("pp-bad1").SetRole("instance") + assert.NoError(t, bad1.Create()) + bad2 := entity.NewClient().SetName("pp-bad2").SetRole("service") + assert.NoError(t, bad2.Create()) + + // Good record: proper NodeUUID + good := entity.NewClient().SetName("pp-good").SetRole("instance") + good.NodeUUID = rnd.UUIDv7() + assert.NoError(t, good.Create()) + + r, _ := NewClientRegistryWithConfig(c) + list, err := r.List() + assert.NoError(t, err) + + // Only the UUID-backed record should be present + if assert.Equal(t, 1, len(list)) { + assert.Equal(t, "pp-good", list[0].Name) + assert.NotEmpty(t, list[0].UUID) + assert.NotEqual(t, "pp-bad1", list[0].Name) + assert.NotEqual(t, "pp-bad2", list[0].Name) + } +} diff --git a/internal/service/cluster/registry/registry_rotate_test.go b/internal/service/cluster/registry/registry_rotate_test.go new file mode 100644 index 000000000..d3a56a179 --- /dev/null +++ b/internal/service/cluster/registry/registry_rotate_test.go @@ -0,0 +1,49 @@ +package registry + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + cfg "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// Rotating secret selects the latest row for a UUID and persists rotation timestamp and password. +func TestClientRegistry_RotateSecretByUUID_LatestRow(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-rotate-latest") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + uuid := rnd.UUIDv7() + + // Create two entries for same NodeUUID; c2 will be latest + n1 := &Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"} + assert.NoError(t, r.Put(n1)) + time.Sleep(1100 * time.Millisecond) + n2 := &Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"} + assert.NoError(t, r.Put(n2)) + + // Rotate by UUID + rotated, err := r.RotateSecret(uuid) + assert.NoError(t, err) + if assert.NotNil(t, rotated) { + assert.NotEmpty(t, rotated.ClientSecret) + assert.Equal(t, uuid, rotated.UUID) + // Password row updated for latest ClientID + pw := entity.FindPassword(rotated.ClientID) + if assert.NotNil(t, pw) { + assert.True(t, pw.Valid(rotated.ClientSecret)) + } + } + + // Rotation timestamp persisted in client data + got, err := r.FindByNodeUUID(uuid) + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.NotEmpty(t, got.RotatedAt) + } +} diff --git a/internal/service/cluster/registry/registry_test.go b/internal/service/cluster/registry/registry_test.go new file mode 100644 index 000000000..9284fd8a0 --- /dev/null +++ b/internal/service/cluster/registry/registry_test.go @@ -0,0 +1,219 @@ +package registry + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + cfg "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// TestMain ensures SQLite test DB artifacts are purged after the suite runs. +func TestMain(m *testing.M) { + code := m.Run() + fs.PurgeTestDbFiles(".", false) + os.Exit(code) +} + +func TestClientRegistry_GetAndDelete(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-delete") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + + // Missing / invalid uuid + if _, err := r.Get("not-a-uuid"); err == nil { + t.Fatalf("expected error for invalid uuid") + } + + // Create node + n := &Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()} + assert.NoError(t, r.Put(n)) + assert.NotEmpty(t, n.ClientID) + assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID)) + assert.True(t, rnd.IsUUID(n.UUID)) + + // Get by UUID + got, err := r.Get(n.UUID) + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, n.UUID, got.UUID) + assert.Equal(t, "pp-del", got.Name) + assert.True(t, rnd.IsUUID(got.UUID)) + assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID)) + } + + // Delete by UUID + assert.NoError(t, r.Delete(n.UUID)) + + // Now missing + _, err = r.Get(n.UUID) + assert.Error(t, err) + _, err = r.FindByName("pp-del") + assert.Error(t, err) + + // Deleting again yields not found + assert.Error(t, r.Delete(n.UUID)) +} + +func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-order") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + + a := &Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()} + b := &Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()} + assert.NoError(t, r.Put(a)) + // Ensure distinct UpdatedAt values (DBs often have second precision) + time.Sleep(1100 * time.Millisecond) + assert.NoError(t, r.Put(b)) + + // Update a to make it most recent + time.Sleep(1100 * time.Millisecond) + assert.NoError(t, r.Put(&Node{ClientID: a.ClientID, Name: a.Name})) + + list, err := r.List() + assert.NoError(t, err) + if assert.GreaterOrEqual(t, len(list), 2) { + // First should be the most recently updated (a) + assert.Equal(t, "pp-a", list[0].Name) + // Basic ID shape checks + assert.True(t, rnd.IsUUID(list[0].UUID)) + assert.True(t, rnd.IsUID(list[0].ClientID, entity.ClientUID)) + } +} + +func TestResponseBuilders_RedactionAndOpts(t *testing.T) { + // Base node with all fields + n := Node{ + ClientID: "cs5gfen1bgxz7s9i", + Name: "pp-node", + Role: "instance", + SiteUrl: "https://photos.example.com", + AdvertiseUrl: "http://node:2342", + Labels: map[string]string{"env": "prod"}, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + n.Database.Name = "dbn" + n.Database.User = "dbu" + n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339) + + // Non-admin (default opts): redact advertise/database + out := BuildClusterNode(n, NodeOpts{}) + assert.Equal(t, "", out.AdvertiseUrl) + assert.Nil(t, out.Database) + + // Include advertise only + out2 := BuildClusterNode(n, NodeOpts{IncludeAdvertiseUrl: true}) + assert.Equal(t, "http://node:2342", out2.AdvertiseUrl) + assert.Nil(t, out2.Database) + + // Include advertise + database + out3 := BuildClusterNode(n, NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}) + if assert.NotNil(t, out3.Database) { + assert.Equal(t, "dbn", out3.Database.Name) + assert.Equal(t, "dbu", out3.Database.User) + } + + // BuildClusterNodes on empty input returns empty slice (not nil) + list := BuildClusterNodes(nil, NodeOpts{}) + assert.NotNil(t, list) + assert.Equal(t, 0, len(list)) +} + +func TestNodeOptsForSession_AdminVsNonAdmin(t *testing.T) { + // Admin: SuperAdmin=true suffices for IsAdmin() + admin := &entity.User{SuperAdmin: true} + sAdmin, _ := entity.NewSession(0, 0), (&entity.User{}) + sAdmin.SetUser(admin) + optsA := NodeOptsForSession(sAdmin) + assert.True(t, optsA.IncludeAdvertiseUrl) + assert.True(t, optsA.IncludeDatabase) + + // Non-admin: empty session/user + s := &entity.Session{} + opts := NodeOptsForSession(s) + assert.False(t, opts.IncludeAdvertiseUrl) + assert.False(t, opts.IncludeDatabase) + + // Nil session defaults to redacted + optsNil := NodeOptsForSession(nil) + assert.False(t, optsNil.IncludeAdvertiseUrl) + assert.False(t, optsNil.IncludeDatabase) +} + +func TestToNode_Mapping(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-map") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + m := entity.NewClient().SetName("pp-map").SetRole("instance") + m.NodeUUID = rnd.UUIDv7() + m.ClientURL = "http://pp-map:2342" + data := m.GetData() + data.Labels = map[string]string{"tier": "gold"} + data.SiteURL = "https://photos.example.com" + data.Database = &entity.ClientDatabase{Name: "dbn", User: "dbu", RotatedAt: time.Now().UTC().Format(time.RFC3339)} + m.SetData(data) + assert.NoError(t, m.Create()) + + n := toNode(m) + if assert.NotNil(t, n) { + assert.Equal(t, "pp-map", n.Name) + assert.Equal(t, "instance", n.Role) + assert.Equal(t, "http://pp-map:2342", n.AdvertiseUrl) + assert.Equal(t, "gold", n.Labels["tier"]) + assert.Equal(t, "https://photos.example.com", n.SiteUrl) + assert.Equal(t, "dbn", n.Database.Name) + assert.Equal(t, "dbu", n.Database.User) + _, err := time.Parse(time.RFC3339, n.CreatedAt) + assert.NoError(t, err) + _, err = time.Parse(time.RFC3339, n.UpdatedAt) + assert.NoError(t, err) + } +} + +func TestClientRegistry_GetClusterNodeByUUID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-getbyuuid") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + // Insert a node with NodeUUID + nu := rnd.UUIDv7() + n := &Node{Name: "pp-getuuid", Role: "instance", UUID: nu} + assert.NoError(t, r.Put(n)) + + // Fetch DTO by NodeUUID + dto, err := r.GetClusterNodeByUUID(nu, NodeOpts{}) + assert.NoError(t, err) + assert.Equal(t, "pp-getuuid", dto.Name) + assert.Equal(t, nu, dto.UUID) + assert.True(t, rnd.IsUUID(dto.UUID)) +} + +func TestClientRegistry_FindByName_NormalizesDNSLabel(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-findname") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + // Create canonical node name + n := &Node{Name: "my-node-prod", Role: "instance"} + assert.NoError(t, r.Put(n)) + // Lookup using mixed separators and case + got, err := r.FindByName("My.Node/Prod") + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, "my-node-prod", got.Name) + } +} diff --git a/internal/service/cluster/registry/registry_uuid_test.go b/internal/service/cluster/registry/registry_uuid_test.go new file mode 100644 index 000000000..e38db1a41 --- /dev/null +++ b/internal/service/cluster/registry/registry_uuid_test.go @@ -0,0 +1,152 @@ +package registry + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + cfg "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// UUID-first upsert: Put finds existing row by UUID and updates fields. +func TestClientRegistry_PutUpdateByUUID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-put-uuid") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + uuid := rnd.UUIDv7() + + // Create via UUID + n := &Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}} + assert.NoError(t, r.Put(n)) + assert.NotEmpty(t, n.ClientID) + assert.True(t, rnd.IsUUID(n.UUID)) + assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID)) + + // Update same record by UUID only; change name and labels + upd := &Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}} + assert.NoError(t, r.Put(upd)) + + got, err := r.FindByNodeUUID(uuid) + assert.NoError(t, err) + if assert.NotNil(t, got) { + // Still the same underlying client row + assert.Equal(t, n.ClientID, got.ClientID) + assert.Equal(t, "pp-uuid-new", got.Name) + assert.Equal(t, "2", got.Labels["a"]) + assert.Equal(t, "x", got.Labels["b"]) + assert.True(t, rnd.IsUUID(got.UUID)) + assert.True(t, rnd.IsUID(got.ClientID, entity.ClientUID)) + } +} + +// Latest-by-UpdatedAt when multiple rows share the same NodeUUID (historical duplicates). +func TestClientRegistry_FindByNodeUUID_PrefersLatest(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-find-uuid-latest") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + uuid := rnd.UUIDv7() + // Create two raw client rows with the same NodeUUID and different UpdatedAt + c1 := entity.NewClient().SetName("pp-dup-1").SetRole("instance") + c1.NodeUUID = uuid + assert.NoError(t, c1.Create()) + time.Sleep(1100 * time.Millisecond) + c2 := entity.NewClient().SetName("pp-dup-2").SetRole("service") + c2.NodeUUID = uuid + assert.NoError(t, c2.Create()) + + r, _ := NewClientRegistryWithConfig(c) + got, err := r.FindByNodeUUID(uuid) + assert.NoError(t, err) + if assert.NotNil(t, got) { + // Should return the most recently updated row (c2) + assert.Equal(t, c2.ClientUID, got.ClientID) + assert.Equal(t, "service", got.Role) + assert.Equal(t, "pp-dup-2", got.Name) + } +} + +// DeleteAllByUUID removes all rows that share a NodeUUID. +func TestClientRegistry_DeleteAllByUUID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-delete-all") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + uuid := rnd.UUIDv7() + // Two rows with same UUID + a := entity.NewClient().SetName("pp-del-a").SetRole("instance") + a.NodeUUID = uuid + assert.NoError(t, a.Create()) + b := entity.NewClient().SetName("pp-del-b").SetRole("service") + b.NodeUUID = uuid + assert.NoError(t, b.Create()) + + r, _ := NewClientRegistryWithConfig(c) + assert.NoError(t, r.DeleteAllByUUID(uuid)) + + // Ensure no rows remain for this UUID + var list []entity.Client + err := entity.UnscopedDb().Where("node_uuid = ?", uuid).Find(&list).Error + assert.NoError(t, err) + assert.Equal(t, 0, len(list)) +} + +// List() should only include clients that represent cluster nodes (i.e., have a NodeUUID). +func TestClientRegistry_ListOnlyUUID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-list-only-uuid") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + // Create one client with empty NodeUUID (non-node), and one proper node + nonNode := entity.NewClient().SetName("webapp").SetRole("client") + assert.NoError(t, nonNode.Create()) + node := entity.NewClient().SetName("pp-node").SetRole("instance") + node.NodeUUID = rnd.UUIDv7() + assert.NoError(t, node.Create()) + + r, _ := NewClientRegistryWithConfig(c) + list, err := r.List() + assert.NoError(t, err) + // Only the NodeUUID-backed record should be present + if assert.Equal(t, 1, len(list)) { + assert.Equal(t, "pp-node", list[0].Name) + assert.NotEmpty(t, list[0].UUID) + } +} + +// Put should prefer UUID over ClientID when both are provided, avoiding cross-attachment. +func TestClientRegistry_PutPrefersUUIDOverClientID(t *testing.T) { + c := cfg.NewTestConfig("cluster-registry-put-prefers-uuid") + defer c.CloseDb() + assert.NoError(t, c.Init()) + + r, _ := NewClientRegistryWithConfig(c) + // Seed two separate records + n1 := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"} + assert.NoError(t, r.Put(n1)) + n2 := &Node{Name: "pp-b", Role: "service"} + assert.NoError(t, r.Put(n2)) + + // Now attempt to update by UUID of n1 while also passing n2.ClientID: + // implementation must use UUID and not attach to n2. + upd := &Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"} + assert.NoError(t, r.Put(upd)) + + got1, err := r.FindByNodeUUID(n1.UUID) + assert.NoError(t, err) + if assert.NotNil(t, got1) { + assert.Equal(t, "service", got1.Role) + assert.Equal(t, n1.ClientID, got1.ClientID) + } + // n2 should remain unchanged + got2 := entity.FindClientByUID(n2.ClientID) + if assert.NotNil(t, got2) { + assert.Equal(t, "service", got2.ClientRole) + assert.NotEqual(t, got2.ClientUID, got1.ClientID) + } +} diff --git a/internal/service/cluster/registry/response.go b/internal/service/cluster/registry/response.go index 4f3742265..b9fddf79d 100644 --- a/internal/service/cluster/registry/response.go +++ b/internal/service/cluster/registry/response.go @@ -24,9 +24,10 @@ func NodeOptsForSession(s *entity.Session) NodeOpts { // BuildClusterNode builds a cluster.Node DTO from a registry.Node with redaction according to opts. func BuildClusterNode(n Node, opts NodeOpts) cluster.Node { out := cluster.Node{ - ID: n.ID, + UUID: n.UUID, Name: n.Name, Role: n.Role, + ClientID: n.ClientID, SiteUrl: n.SiteUrl, Labels: n.Labels, CreatedAt: n.CreatedAt, @@ -39,9 +40,10 @@ func BuildClusterNode(n Node, opts NodeOpts) cluster.Node { if opts.IncludeDatabase { out.Database = &cluster.NodeDatabase{ - Name: n.DB.Name, - User: n.DB.User, - RotatedAt: n.DB.RotAt, + Name: n.Database.Name, + User: n.Database.User, + Driver: n.Database.Driver, + RotatedAt: n.Database.RotatedAt, } } diff --git a/internal/service/cluster/response.go b/internal/service/cluster/response.go index 8ad482c63..df3c15d7a 100644 --- a/internal/service/cluster/response.go +++ b/internal/service/cluster/response.go @@ -5,15 +5,17 @@ package cluster type NodeDatabase struct { Name string `json:"name"` User string `json:"user"` + Driver string `json:"driver,omitempty"` RotatedAt string `json:"rotatedAt"` } // Node is the API response DTO for a cluster node. // swagger:model Node type Node struct { - ID string `json:"id"` - Name string `json:"name"` - Role string `json:"role"` + UUID string `json:"uuid"` // NodeUUID + Name string `json:"name"` // NodeName + Role string `json:"role"` // NodeRole + ClientID string `json:"clientId,omitempty"` SiteUrl string `json:"siteUrl,omitempty"` AdvertiseUrl string `json:"advertiseUrl,omitempty"` Labels map[string]string `json:"labels,omitempty"` @@ -33,7 +35,7 @@ type DatabaseInfo struct { // SummaryResponse is the response type for GET /api/v1/cluster. // swagger:model SummaryResponse type SummaryResponse struct { - UUID string `json:"UUID"` + UUID string `json:"uuid"` // ClusterUUID Nodes int `json:"nodes"` Database DatabaseInfo `json:"database"` Time string `json:"time"` @@ -42,13 +44,14 @@ type SummaryResponse struct { // RegisterSecrets contains newly issued or rotated node secrets. // swagger:model RegisterSecrets type RegisterSecrets struct { - NodeSecret string `json:"nodeSecret,omitempty"` - SecretRotatedAt string `json:"secretRotatedAt,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` + RotatedAt string `json:"rotatedAt,omitempty"` } // RegisterDatabase describes database credentials returned during registration/rotation. // swagger:model RegisterDatabase type RegisterDatabase struct { + Driver string `json:"driver"` Host string `json:"host"` Port int `json:"port"` Name string `json:"name"` @@ -61,6 +64,7 @@ type RegisterDatabase struct { // RegisterResponse is the response body for POST /api/v1/cluster/nodes/register. // swagger:model RegisterResponse type RegisterResponse struct { + UUID string `json:"uuid"` // ClusterUUID Node Node `json:"node"` Database RegisterDatabase `json:"database"` Secrets *RegisterSecrets `json:"secrets,omitempty"` diff --git a/internal/service/maps/country_test.go b/internal/service/maps/country_test.go index 9eea42352..4343f36ad 100644 --- a/internal/service/maps/country_test.go +++ b/internal/service/maps/country_test.go @@ -11,12 +11,10 @@ func TestCountryName(t *testing.T) { result := CountryName("gb") assert.Equal(t, "United Kingdom", result) }) - t.Run("us", func(t *testing.T) { result := CountryName("us") assert.Equal(t, "United States", result) }) - t.Run("Empty", func(t *testing.T) { result := CountryName("") assert.Equal(t, "Unknown", result) diff --git a/internal/thumb/create_test.go b/internal/thumb/create_test.go index 06ad1af29..6c2d85359 100644 --- a/internal/thumb/create_test.go +++ b/internal/thumb/create_test.go @@ -159,7 +159,6 @@ func TestFileName(t *testing.T) { assert.Equal(t, "testdata/1/2/3/123456789098765432_3x3_resize.png", result) }) - t.Run("fit_720", func(t *testing.T) { fit720 := Sizes[Fit720] @@ -229,7 +228,6 @@ func TestResolvedName(t *testing.T) { assert.Error(t, err) assert.Equal(t, "", result) }) - t.Run("fit_720", func(t *testing.T) { fit720 := Sizes[Fit720] diff --git a/internal/thumb/frame/collage_test.go b/internal/thumb/frame/collage_test.go index 862c6e32f..8a164e64b 100644 --- a/internal/thumb/frame/collage_test.go +++ b/internal/thumb/frame/collage_test.go @@ -37,7 +37,6 @@ func TestCollage(t *testing.T) { _ = os.Remove(saveName) }) - t.Run("Two", func(t *testing.T) { var images []image.Image @@ -61,7 +60,6 @@ func TestCollage(t *testing.T) { _ = os.Remove(saveName) }) - t.Run("NoImages", func(t *testing.T) { var images []image.Image @@ -78,7 +76,6 @@ func TestCollage(t *testing.T) { _ = os.Remove(saveName) }) - t.Run("UnknownCollageType", func(t *testing.T) { var images []image.Image diff --git a/internal/thumb/frame/image_test.go b/internal/thumb/frame/image_test.go index 339e3c644..86029b3d0 100644 --- a/internal/thumb/frame/image_test.go +++ b/internal/thumb/frame/image_test.go @@ -31,7 +31,6 @@ func TestImage(t *testing.T) { _ = os.Remove(saveName) }) - t.Run("TypeUnknown", func(t *testing.T) { img, err := imaging.Open("testdata/500x500.jpg") assert.NoError(t, err) diff --git a/internal/thumb/names_test.go b/internal/thumb/names_test.go index da763e802..375209cee 100644 --- a/internal/thumb/names_test.go +++ b/internal/thumb/names_test.go @@ -19,7 +19,6 @@ func TestFind(t *testing.T) { assert.Equal(t, 1920, size.Width) assert.Equal(t, 1200, size.Height) }) - t.Run("1900", func(t *testing.T) { name, size := Find(1900) assert.Equal(t, Fit1280, name) diff --git a/internal/workers/backup_test.go b/internal/workers/backup_test.go index a802ffe69..b51433319 100644 --- a/internal/workers/backup_test.go +++ b/internal/workers/backup_test.go @@ -12,7 +12,7 @@ import ( func TestBackup_Start(t *testing.T) { conf := config.TestConfig() - t.Logf("database-dsn: %s", conf.DatabaseDsn()) + t.Logf("database-dsn: %s", conf.DatabaseDSN()) worker := NewBackup(conf) diff --git a/internal/workers/index_test.go b/internal/workers/index_test.go index 1ffe6414b..b9ea519cd 100644 --- a/internal/workers/index_test.go +++ b/internal/workers/index_test.go @@ -11,7 +11,7 @@ import ( func TestIndex_Start(t *testing.T) { conf := config.TestConfig() - t.Logf("database-dsn: %s", conf.DatabaseDsn()) + t.Logf("database-dsn: %s", conf.DatabaseDSN()) worker := NewIndex(conf) diff --git a/internal/workers/meta_test.go b/internal/workers/meta_test.go index 70214c7d2..6c8651120 100644 --- a/internal/workers/meta_test.go +++ b/internal/workers/meta_test.go @@ -14,7 +14,7 @@ import ( func TestMeta_Start(t *testing.T) { conf := config.TestConfig() - t.Logf("database-dsn: %s", conf.DatabaseDsn()) + t.Logf("database-dsn: %s", conf.DatabaseDSN()) worker := NewMeta(conf) diff --git a/internal/workers/sync_download_test.go b/internal/workers/sync_download_test.go index 2941b3ad0..cba8bb02b 100644 --- a/internal/workers/sync_download_test.go +++ b/internal/workers/sync_download_test.go @@ -15,7 +15,7 @@ func TestSync_download(t *testing.T) { t.Run("NotFound", func(t *testing.T) { conf := config.TestConfig() - t.Logf("database-dsn: %s", conf.DatabaseDsn()) + t.Logf("database-dsn: %s", conf.DatabaseDSN()) worker := NewSync(conf) @@ -33,7 +33,7 @@ func TestSync_download(t *testing.T) { conf := config.TestConfig() conf.Options().FilesQuota = 1 - t.Logf("database-dsn: %s", conf.DatabaseDsn()) + t.Logf("database-dsn: %s", conf.DatabaseDSN()) worker := NewSync(conf) diff --git a/pkg/authn/grants.go b/pkg/authn/grants.go index a72bd70db..45e7c111c 100644 --- a/pkg/authn/grants.go +++ b/pkg/authn/grants.go @@ -1,6 +1,8 @@ package authn import ( + "strings" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -89,6 +91,10 @@ func (t GrantType) Pretty() string { // String returns the grant type as a string. func (t GrantType) String() string { + if strings.HasPrefix(string(t), "urn:") { + return clean.TypeLowerDash(string(t)) + } + return clean.TypeLowerUnderscore(string(t)) } diff --git a/pkg/clean/ascii.go b/pkg/clean/ascii.go index e7fe44841..867079044 100644 --- a/pkg/clean/ascii.go +++ b/pkg/clean/ascii.go @@ -1,18 +1,25 @@ package clean -// ASCII removes all non-ascii characters from a string and returns it. +// ASCII removes all non-ASCII bytes from a string. +// Fast path: return the original string when it already contains only ASCII. func ASCII(s string) string { if s == "" { return "" } - result := make([]rune, 0, len(s)) - - for _, r := range s { - if r <= 127 { - result = append(result, r) + // Fast path: all bytes < 128 → no allocation. + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { // non-ASCII + // Slow path: filter into a new byte slice. + dst := make([]byte, 0, len(s)) + for j := 0; j < len(s); j++ { + b := s[j] + if b < 0x80 { + dst = append(dst, b) + } + } + return string(dst) } } - - return string(result) + return s } diff --git a/pkg/clean/dnslabel.go b/pkg/clean/dnslabel.go new file mode 100644 index 000000000..e4085bfc7 --- /dev/null +++ b/pkg/clean/dnslabel.go @@ -0,0 +1,64 @@ +package clean + +import ( + "strings" + "unicode" +) + +// DNSLabel normalizes a string to a DNS label per our rules: +// - lowercase +// - allowed chars: [a-z0-9-] +// - other runes (including separators like space, '_', '.', '/', ':') map to '-' +// - collapses multiple '-' and trims leading/trailing '-' +// - maximum length 32 characters; trimming preserves start/end as alnum when possible +// Returns an empty string if no valid characters remain after normalization. +func DNSLabel(s string) string { + if s == "" { + return "" + } + + const maxLen = 32 + s = strings.ToLower(s) + + var b strings.Builder + b.Grow(len(s)) + prevDash := false + + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + if r == '-' { + if prevDash { + continue + } + prevDash = true + } else { + prevDash = false + } + b.WriteRune(r) + default: + // Treat any separator/invalid as a single '-'. + // Includes space, underscore, dot, slash, colon, and others. + if unicode.IsSpace(r) || r != 0 { + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + } + } + + out := strings.Trim(b.String(), "-") + if out == "" { + return "" + } + + if len(out) > maxLen { + out = out[:maxLen] + out = strings.Trim(out, "-") + if out == "" { + return "" + } + } + return out +} diff --git a/pkg/clean/dnslabel_test.go b/pkg/clean/dnslabel_test.go new file mode 100644 index 000000000..6c883b2c8 --- /dev/null +++ b/pkg/clean/dnslabel_test.go @@ -0,0 +1,28 @@ +package clean + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDNSLabel(t *testing.T) { + cases := []struct { + in string + want string + name string + }{ + {" Client Credentials幸", "client-credentials", "basic normalization"}, + {" My.Host/Name:Prod ", "my-host-name-prod", "separators to dash"}, + {"a---b___c d", "a-b-c-d", "collapse dashes"}, + {"-._a--", "a", "trim leading trailing"}, + {strings.Repeat("a", 40), strings.Repeat("a", 32), "clip length"}, + {"!!!", "", "all invalid"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, DNSLabel(tc.in)) + }) + } +} diff --git a/pkg/clean/duration_test.go b/pkg/clean/duration_test.go index 69112a8ca..d760bdfa9 100644 --- a/pkg/clean/duration_test.go +++ b/pkg/clean/duration_test.go @@ -11,57 +11,46 @@ func TestDuration(t *testing.T) { result := Duration("") assert.Equal(t, "", result) }) - t.Run("NonNumeric", func(t *testing.T) { result := Duration(" Screenshot ") assert.Equal(t, "", result) }) - t.Run("Zero", func(t *testing.T) { result := Duration("0") assert.Equal(t, "0", result) }) - t.Run("Float", func(t *testing.T) { result := Duration("0.5") assert.Equal(t, "0.5", result) }) - t.Run("Seconds", func(t *testing.T) { result := Duration("0.5 s") assert.Equal(t, "0.5s", result) }) - t.Run("MinutesSeconds", func(t *testing.T) { result := Duration("1.0 m0.01 s ") assert.Equal(t, "1.0m0.01s", result) }) - t.Run("01:00", func(t *testing.T) { result := Duration("01:00") assert.Equal(t, "01:00", result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Duration(" 000123") assert.Equal(t, "000123", result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := Duration(" 123,556\t ") assert.Equal(t, "123.556", result) }) - t.Run("PositiveFloat", func(t *testing.T) { result := Duration("123,000.45245 ") assert.Equal(t, "123.00045245", result) }) - t.Run("NegativeFloat", func(t *testing.T) { result := Duration(" - 123,000.45245 ") assert.Equal(t, "-123.00045245", result) }) - t.Run("MultipleDots", func(t *testing.T) { result := Duration("123.000.45245.44 m") assert.Equal(t, "123.0004524544m", result) diff --git a/pkg/clean/header.go b/pkg/clean/header.go index 4236ce29d..0d6fba36b 100644 --- a/pkg/clean/header.go +++ b/pkg/clean/header.go @@ -1,18 +1,26 @@ package clean // Header sanitizes a string for use in request or response headers. +// Keeps printable ASCII (32..126). Fast path avoids allocation if unchanged. func Header(s string) string { if s == "" || len(s) > LengthLimit { return "" } - result := make([]rune, 0, len(s)) - - for _, r := range s { - if r > 31 && r < 127 { - result = append(result, r) + // Fast path: check if all bytes are already header-safe ASCII. + for i := 0; i < len(s); i++ { + b := s[i] + if b < 32 || b >= 127 { + // Slow path: filter into a new byte slice. + dst := make([]byte, 0, len(s)) + for j := 0; j < len(s); j++ { + c := s[j] + if c > 31 && c < 127 { + dst = append(dst, c) + } + } + return string(dst) } } - - return string(result) + return s } diff --git a/pkg/clean/hex.go b/pkg/clean/hex.go index 995e8192f..5e63b34b3 100644 --- a/pkg/clean/hex.go +++ b/pkg/clean/hex.go @@ -4,22 +4,47 @@ import ( "strings" ) -// Hex removes invalid character from a hex string and makes it lowercase. +// Hex removes invalid characters from a hex string and lowercases A-F. func Hex(s string) string { if s == "" || reject(s, 1024) { return "" } - s = strings.ToLower(strings.TrimSpace(s)) + s = strings.TrimSpace(s) + if s == "" { + return "" + } - // Remove all invalid characters. - s = strings.Map(func(r rune) rune { - if (r < '0' || r > '9') && (r < 'a' || r > 'f') { - return -1 + // Scan once; lower-case A-F on the fly; drop non-hex. + // Allocate only if needed. + var out []byte + for i := 0; i < len(s); i++ { + b := s[i] + switch { + case b >= '0' && b <= '9': + if out != nil { + out = append(out, b) + } + case b >= 'a' && b <= 'f': + if out != nil { + out = append(out, b) + } + case b >= 'A' && b <= 'F': + if out == nil { + out = make([]byte, 0, len(s)) + out = append(out, s[:i]...) + } + out = append(out, b+32) // to lower + default: + if out == nil { + out = make([]byte, 0, len(s)) + out = append(out, s[:i]...) + } + // skip } - - return r - }, s) - - return s + } + if out == nil { + return s + } + return string(out) } diff --git a/pkg/clean/ip.go b/pkg/clean/ip.go index 34f0a6e6a..0b28f2db0 100644 --- a/pkg/clean/ip.go +++ b/pkg/clean/ip.go @@ -1,34 +1,10 @@ package clean import ( - "net" - "regexp" + "github.com/photoprism/photoprism/pkg/service/http/header" ) -// IpRegExp matches characters allowed in IPv4 or IPv6 network addresses. -var IpRegExp = regexp.MustCompile(`[^a-zA-Z0-9:.]`) - // IP returns the sanitized and normalized network address if it is valid, or the default otherwise. func IP(s, defaultIp string) string { - // Return default if invalid. - if s == "" || len(s) > LengthLimit || s == defaultIp { - return defaultIp - } - - // Remove invalid characters, including whitespace. - if s = IpRegExp.ReplaceAllString(s, ""); s == "" { - return defaultIp - } - - // Limit string length to 39 characters. - if len(s) > LengthIPv6 { - s = s[:LengthIPv6] - } - - // Parse IP address and return it as string. - if ip := net.ParseIP(s); ip == nil { - return defaultIp - } else { - return ip.String() - } + return header.IP(s, defaultIp) } diff --git a/pkg/clean/numeric_test.go b/pkg/clean/numeric_test.go index d72d7e2dc..866fac9b7 100644 --- a/pkg/clean/numeric_test.go +++ b/pkg/clean/numeric_test.go @@ -11,47 +11,38 @@ func TestNumeric(t *testing.T) { result := Numeric("") assert.Equal(t, "", result) }) - t.Run("NonNumeric", func(t *testing.T) { result := Numeric(" Screenshot ") assert.Equal(t, "", result) }) - t.Run("Zero", func(t *testing.T) { result := Numeric("0") assert.Equal(t, "0", result) }) - t.Run("0.5", func(t *testing.T) { result := Numeric("0.5") assert.Equal(t, "0.5", result) }) - t.Run("01:00", func(t *testing.T) { result := Numeric("01:00") assert.Equal(t, "0100", result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Numeric(" 000123") assert.Equal(t, "000123", result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := Numeric(" 123,556\t ") assert.Equal(t, "123.556", result) }) - t.Run("PositiveFloat", func(t *testing.T) { result := Numeric("123,000.45245 ") assert.Equal(t, "123000.45245", result) }) - t.Run("NegativeFloat", func(t *testing.T) { result := Numeric(" - 123,000.45245 ") assert.Equal(t, "-123000.45245", result) }) - t.Run("MultipleDots", func(t *testing.T) { result := Numeric("123.000.45245.44 m") assert.Equal(t, "1230004524544", result) diff --git a/pkg/clean/orientation_test.go b/pkg/clean/orientation_test.go index 8454e209b..4b4128a83 100644 --- a/pkg/clean/orientation_test.go +++ b/pkg/clean/orientation_test.go @@ -10,7 +10,6 @@ func TestOrientation(t *testing.T) { t.Run("Empty", func(t *testing.T) { assert.Equal(t, 0, Orientation(0)) }) - t.Run("Valid", func(t *testing.T) { assert.Equal(t, 1, Orientation(1)) assert.Equal(t, 3, Orientation(3)) @@ -18,7 +17,6 @@ func TestOrientation(t *testing.T) { assert.Equal(t, 7, Orientation(7)) assert.Equal(t, 8, Orientation(8)) }) - t.Run("Invalid", func(t *testing.T) { assert.Equal(t, 0, Orientation(-1)) assert.Equal(t, 0, Orientation(9)) diff --git a/pkg/clean/search.go b/pkg/clean/search.go index 5a5cb1fe1..a5dbce252 100644 --- a/pkg/clean/search.go +++ b/pkg/clean/search.go @@ -1,7 +1,6 @@ package clean import ( - "regexp" "strings" ) @@ -11,8 +10,63 @@ func spaced(s string) string { } // replace performs a case-insensitive string replacement. -func replace(subject string, search string, replace string) string { - return regexp.MustCompile("(?i)"+search).ReplaceAllString(subject, replace) +// replaceFoldASCII replaces all case-insensitive ASCII matches of needle +// in s with repl. It avoids regex compilation and extra allocations. +func replaceFoldASCII(s, needle, repl string) string { + if s == "" || needle == "" { + return s + } + + // Quick check to see if there's any possible match using a lowercased scan. + // We implement a simple ASCII case-insensitive search. + toLower := func(b byte) byte { + if b >= 'A' && b <= 'Z' { + return b + 32 + } + return b + } + + nl := len(needle) + // Precompute lower-case needle bytes. + nb := make([]byte, nl) + for i := 0; i < nl; i++ { + nb[i] = toLower(needle[i]) + } + + // First pass: find if any match exists; if not, return s unchanged. + // Second pass: build result with replacements. + // Implement both in one pass by building only when the first match is seen. + var out []byte + i := 0 + last := 0 + for i <= len(s)-nl { + // Compare at position i. + j := 0 + for ; j < nl; j++ { + if toLower(s[i+j]) != nb[j] { + break + } + } + if j == nl { + // Match found. + if out == nil { + // Allocate with an estimate: original len. + out = make([]byte, 0, len(s)) + } + out = append(out, s[last:i]...) + out = append(out, repl...) + i += nl + last = i + continue + } + i++ + } + if out == nil { + return s + } + // Append the tail. + out = append(out, s[last:]...) + return string(out) } // SearchString replaces search operator with default symbols. @@ -37,12 +91,12 @@ func SearchQuery(s string) string { } // Normalize. - s = replace(s, spaced(EnOr), Or) - s = replace(s, spaced(EnOr), Or) - s = replace(s, spaced(EnAnd), And) - s = replace(s, spaced(EnWith), And) - s = replace(s, spaced(EnIn), And) - s = replace(s, spaced(EnAt), And) + s = replaceFoldASCII(s, spaced(EnOr), Or) + s = replaceFoldASCII(s, spaced(EnOr), Or) + s = replaceFoldASCII(s, spaced(EnAnd), And) + s = replaceFoldASCII(s, spaced(EnWith), And) + s = replaceFoldASCII(s, spaced(EnIn), And) + s = replaceFoldASCII(s, spaced(EnAt), And) s = strings.ReplaceAll(s, SpacedPlus, And) s = strings.ReplaceAll(s, "%%", "%") s = strings.ReplaceAll(s, "%", "*") diff --git a/pkg/clean/search_test.go b/pkg/clean/search_test.go index e118b9f6a..3c6ea4efe 100644 --- a/pkg/clean/search_test.go +++ b/pkg/clean/search_test.go @@ -1,6 +1,7 @@ package clean import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -43,3 +44,28 @@ func TestSearchQuery(t *testing.T) { assert.Equal(t, "", q) }) } + +func BenchmarkSearchQuery_Complex(b *testing.B) { + s := "Jens AND Mander and me Or Kitty WITH flowers IN the park AT noon | img% json OR BILL!\n" + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = SearchQuery(s) + } +} + +func BenchmarkSearchQuery_Short(b *testing.B) { + s := "cat and dog" + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = SearchQuery(s) + } +} + +func BenchmarkSearchQuery_LongNoOps(b *testing.B) { + // No tokens to replace, primarily tests normalization + trim. + s := strings.Repeat("alpha beta gamma ", 50) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = SearchQuery(s) + } +} diff --git a/pkg/clean/state_test.go b/pkg/clean/state_test.go index c412ceef5..8e06e1395 100644 --- a/pkg/clean/state_test.go +++ b/pkg/clean/state_test.go @@ -11,62 +11,50 @@ func TestState(t *testing.T) { result := State("Berlin", "de") assert.Equal(t, "Berlin", result) }) - t.Run("WA", func(t *testing.T) { result := State("WA", "us") assert.Equal(t, "Washington", result) }) - t.Run("QCUnknownCountry", func(t *testing.T) { result := State("QC", "") assert.Equal(t, "QC", result) }) - t.Run("QCCanada", func(t *testing.T) { result := State("QC", "ca") assert.Equal(t, "Quebec", result) }) - t.Run("QCUnitedStates", func(t *testing.T) { result := State("QC", "us") assert.Equal(t, "QC", result) }) - t.Run("Wa", func(t *testing.T) { result := State("Wa", "us") assert.Equal(t, "Wa", result) }) - t.Run("Washington", func(t *testing.T) { result := State("Washington", "us") assert.Equal(t, "Washington", result) }) - t.Run("Never mind Nirvana", func(t *testing.T) { result := State("Never mind Nirvana.", "us") assert.Equal(t, "Never mind Nirvana.", result) }) - t.Run("Empty", func(t *testing.T) { result := State("", "us") assert.Equal(t, "", result) }) - t.Run("Unknown", func(t *testing.T) { result := State("zz", "us") assert.Equal(t, "", result) }) - t.Run("Space", func(t *testing.T) { result := State(" ", "us") assert.Equal(t, "", result) }) - t.Run("Control Character", func(t *testing.T) { result := State("Washington"+string(rune(127)), "us") assert.Equal(t, "Washington", result) }) - t.Run("Special Chars", func(t *testing.T) { result := State("Wa?shing*ton"+string(rune(127)), "us") assert.Equal(t, "Washington", result) diff --git a/pkg/clean/token.go b/pkg/clean/token.go index 097ac3585..18d21b79f 100644 --- a/pkg/clean/token.go +++ b/pkg/clean/token.go @@ -1,24 +1,27 @@ package clean -import ( - "strings" -) - // Token returns the sanitized token string with a length of up to 4096 characters. +// Allowed: [0-9a-zA-Z-_:] only. Fast path: return original when already valid ASCII. func Token(s string) string { if s == "" || reject(s, LengthLimit) { return "" } - // Remove all invalid characters. - s = strings.Map(func(r rune) rune { - if (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && r != '-' && r != '_' && r != ':' { - return -1 + // Fast path: check if all bytes are allowed ASCII. + for i := 0; i < len(s); i++ { + b := s[i] + if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '-' || b == '_' || b == ':') { + // Slow path: filter into a new byte slice. + dst := make([]byte, 0, len(s)) + for j := 0; j < len(s); j++ { + c := s[j] + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-' || c == '_' || c == ':' { + dst = append(dst, c) + } + } + return string(dst) } - - return r - }, s) - + } return s } diff --git a/pkg/clean/type.go b/pkg/clean/type.go index 65316cbaa..32ecbacb2 100644 --- a/pkg/clean/type.go +++ b/pkg/clean/type.go @@ -2,6 +2,7 @@ package clean import ( "strings" + "unicode" "github.com/photoprism/photoprism/pkg/txt/clip" ) @@ -15,6 +16,50 @@ func Type(s string) string { return clip.Chars(ASCII(s), LengthType) } +// TypeUnderscore replaces whitespace, dividers, quotes, brackets, and other special characters with an underscore. +func TypeUnderscore(s string) string { + if s == "" { + return s + } + + s = strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return '_' + } + + switch r { + case '-', '`', '~', '\\', '|', '"', '\'', '?', '*', '<', '>', '{', '}': + return '_' + default: + return r + } + }, s) + + return s +} + +// TypeDash replaces whitespace, dividers, quotes, brackets, and other special characters with a dash. +func TypeDash(s string) string { + if s == "" { + return s + } + + s = strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return '-' + } + + switch r { + case '_', '`', '~', '\\', '|', '"', '\'', '?', '*', '<', '>', '{', '}': + return '-' + default: + return r + } + }, s) + + return s +} + // TypeLower converts a type string to lowercase, omits invalid runes, and shortens it if needed. func TypeLower(s string) string { if s == "" { @@ -30,7 +75,7 @@ func TypeLowerUnderscore(s string) string { return s } - return strings.ReplaceAll(TypeLower(s), " ", "_") + return TypeUnderscore(TypeLower(s)) } // TypeLowerDash converts a string to a lowercase type string and replaces spaces with dashes. @@ -39,7 +84,7 @@ func TypeLowerDash(s string) string { return s } - return strings.ReplaceAll(TypeLower(s), " ", "-") + return TypeDash(TypeLower(s)) } // ShortType omits invalid runes, ensures a maximum length of 8 characters, and returns the result. @@ -66,5 +111,14 @@ func ShortTypeLowerUnderscore(s string) string { return s } - return strings.ReplaceAll(ShortTypeLower(s), " ", "_") + return TypeUnderscore(ShortTypeLower(s)) +} + +// ShortTypeLowerDash converts a string to a short lowercase type string and replaces spaces with dashes. +func ShortTypeLowerDash(s string) string { + if s == "" { + return s + } + + return TypeDash(ShortTypeLower(s)) } diff --git a/pkg/clean/type_test.go b/pkg/clean/type_test.go index 1e62d085a..e955851f7 100644 --- a/pkg/clean/type_test.go +++ b/pkg/clean/type_test.go @@ -95,11 +95,69 @@ func TestTypeLowerDash(t *testing.T) { "ollama-model:7b", TypeLowerDash("Ollama Model:7b")) }) + t.Run("OllamaModelWithSlash", func(t *testing.T) { + assert.Equal( + t, + "ollama-model/7b", + TypeLowerDash("Ollama Model/7b")) + }) t.Run("Empty", func(t *testing.T) { assert.Equal(t, "", TypeLowerDash("")) }) } +func TestTypeUnderscore(t *testing.T) { + t.Run("WhitespaceToUnderscore", func(t *testing.T) { + in := "a b\tc\nd" + out := TypeUnderscore(in) + assert.Equal(t, "a_b_c_d", out) + }) + t.Run("SpecialsToUnderscore", func(t *testing.T) { + // Maps (colon and slash allowed): '-', '`', '~', '\\', '|', '"', '\'', '?', '*', '<', '>', '{', '}' + in := "a-`~/\\:|\"'?*<>{}b" + out := TypeUnderscore(in) + assert.Equal(t, "a___/_:_________b", out) + }) + t.Run("NonASCIIPreserved", func(t *testing.T) { + assert.Equal(t, "äöü", TypeUnderscore("äöü")) + }) +} + +func TestTypeDash(t *testing.T) { + t.Run("WhitespaceToDash", func(t *testing.T) { + in := "a b\tc\nd" + out := TypeDash(in) + assert.Equal(t, "a-b-c-d", out) + }) + t.Run("SpecialsToDash", func(t *testing.T) { + // Maps (colon and slash allowed): '_', '`', '~', '\\', '|', '"', '\'', '?', '*', '<', '>', '{', '}' + in := "a_`~/\\:|\"'?*<>{}b" + out := TypeDash(in) + // 13 mapped; slash and colon preserved → 3 dashes, '/', '-', ':', then 9 dashes + assert.Equal(t, "a---/-:---------b", out) + }) + t.Run("NonASCIIPreserved", func(t *testing.T) { + assert.Equal(t, "äöü", TypeDash("äöü")) + }) +} + +func TestShortTypeLowerDash(t *testing.T) { + t.Run("Undefined", func(t *testing.T) { + assert.Equal(t, "", ShortTypeLowerDash(" \t ")) + }) + t.Run("ClientCredentials", func(t *testing.T) { + assert.Equal(t, "client-c", ShortTypeLowerDash(" Client Credentials幸")) + }) + t.Run("Clip", func(t *testing.T) { + assert.Equal(t, + "hanzi-ar", + ShortTypeLowerDash(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!")) + }) + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, "", ShortTypeLowerDash("")) + }) +} + func TestShortType(t *testing.T) { t.Run("Clip", func(t *testing.T) { result := ShortType(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") diff --git a/pkg/fs/cache_test.go b/pkg/fs/cache_test.go index cace5ad87..3b9d1390e 100644 --- a/pkg/fs/cache_test.go +++ b/pkg/fs/cache_test.go @@ -15,14 +15,12 @@ func TestCachePath(t *testing.T) { assert.Equal(t, "", result) assert.EqualError(t, err, "cache: hash '123' is too short") }) - t.Run("namespace empty", func(t *testing.T) { result, err := CachePath("/foo/bar", "123hjfju567695", "", false) assert.Equal(t, "", result) assert.EqualError(t, err, "cache: namespace for hash '123hjfju567695' is empty") }) - t.Run("1234567890abcdef", func(t *testing.T) { result, err := CachePath("/foo/bar", "1234567890abcdef", "baz", false) @@ -32,7 +30,6 @@ func TestCachePath(t *testing.T) { assert.Equal(t, "/foo/bar/baz/1/2/3", result) }) - t.Run("create", func(t *testing.T) { ns := "pkg_fs_test" result, err := CachePath(os.TempDir(), "1234567890abcdef", ns, true) diff --git a/pkg/fs/const.go b/pkg/fs/const.go index 0bedd87bf..2c1f56496 100644 --- a/pkg/fs/const.go +++ b/pkg/fs/const.go @@ -16,7 +16,7 @@ const ( BuildDir = "build" CacheDir = "cache" CertificatesDir = "certificates" - ClusterDir = "cluster" + PortalDir = "portal" CmdDir = "cmd" ConfigDir = "config" ExamplesDir = "examples" diff --git a/pkg/fs/copy_move_test.go b/pkg/fs/copy_move_test.go index a70479b85..d9bfb17c4 100644 --- a/pkg/fs/copy_move_test.go +++ b/pkg/fs/copy_move_test.go @@ -13,7 +13,7 @@ func TestCopy_NewDestination_Succeeds(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "sub", "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("hello"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("hello"), ModeFile)) err := Copy(src, dst, false) assert.NoError(t, err) @@ -26,8 +26,8 @@ func TestCopy_ExistingNonEmpty_NoForce_Error(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("short"), 0o644)) - assert.NoError(t, os.WriteFile(dst, []byte("existing"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("short"), ModeFile)) + assert.NoError(t, os.WriteFile(dst, []byte("existing"), ModeFile)) err := Copy(src, dst, false) assert.Error(t, err) @@ -40,9 +40,9 @@ func TestCopy_ExistingNonEmpty_Force_TruncatesAndOverwrites(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("short"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("short"), ModeFile)) // Destination contains longer content which must be truncated when force=true - assert.NoError(t, os.WriteFile(dst, []byte("existing-long"), 0o644)) + assert.NoError(t, os.WriteFile(dst, []byte("existing-long"), ModeFile)) err := Copy(src, dst, true) assert.NoError(t, err) @@ -55,8 +55,8 @@ func TestCopy_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("data"), 0o644)) - assert.NoError(t, os.WriteFile(dst, []byte{}, 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("data"), ModeFile)) + assert.NoError(t, os.WriteFile(dst, []byte{}, ModeFile)) err := Copy(src, dst, false) assert.NoError(t, err) @@ -67,7 +67,7 @@ func TestCopy_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) { func TestCopy_SamePath_Error(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "file.txt") - assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("x"), ModeFile)) err := Copy(src, src, true) assert.Error(t, err) } @@ -75,7 +75,7 @@ func TestCopy_SamePath_Error(t *testing.T) { func TestCopy_InvalidPaths_Error(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "file.txt") - assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("x"), ModeFile)) assert.Error(t, Copy("", filepath.Join(dir, "a.txt"), false)) assert.Error(t, Copy(src, "", false)) assert.Error(t, Copy(src, ".", false)) @@ -86,7 +86,7 @@ func TestMove_NewDestination_Succeeds(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "sub", "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("hello"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("hello"), ModeFile)) err := Move(src, dst, false) assert.NoError(t, err) @@ -102,8 +102,8 @@ func TestMove_ExistingNonEmpty_NoForce_Error(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("src"), 0o644)) - assert.NoError(t, os.WriteFile(dst, []byte("dst"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("src"), ModeFile)) + assert.NoError(t, os.WriteFile(dst, []byte("dst"), ModeFile)) err := Move(src, dst, false) assert.Error(t, err) @@ -119,8 +119,8 @@ func TestMove_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("src"), 0o644)) - assert.NoError(t, os.WriteFile(dst, []byte{}, 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("src"), ModeFile)) + assert.NoError(t, os.WriteFile(dst, []byte{}, ModeFile)) err := Move(src, dst, false) assert.NoError(t, err) @@ -135,8 +135,8 @@ func TestMove_ExistingNonEmpty_Force_Succeeds(t *testing.T) { src := filepath.Join(dir, "src.txt") dst := filepath.Join(dir, "dst.txt") - assert.NoError(t, os.WriteFile(src, []byte("AAA"), 0o644)) - assert.NoError(t, os.WriteFile(dst, []byte("BBBBB"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("AAA"), ModeFile)) + assert.NoError(t, os.WriteFile(dst, []byte("BBBBB"), ModeFile)) err := Move(src, dst, true) assert.NoError(t, err) @@ -149,7 +149,7 @@ func TestMove_ExistingNonEmpty_Force_Succeeds(t *testing.T) { func TestMove_SamePath_Error(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "file.txt") - assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("x"), ModeFile)) err := Move(src, src, true) assert.Error(t, err) } @@ -157,7 +157,7 @@ func TestMove_SamePath_Error(t *testing.T) { func TestMove_InvalidPaths_Error(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "file.txt") - assert.NoError(t, os.WriteFile(src, []byte("x"), 0o644)) + assert.NoError(t, os.WriteFile(src, []byte("x"), ModeFile)) assert.Error(t, Move("", filepath.Join(dir, "a.txt"), false)) assert.Error(t, Move(src, "", false)) assert.Error(t, Move(src, ".", false)) diff --git a/pkg/fs/directories_test.go b/pkg/fs/directories_test.go index 5f4e19495..3ff2a1359 100644 --- a/pkg/fs/directories_test.go +++ b/pkg/fs/directories_test.go @@ -23,7 +23,6 @@ func TestDirs(t *testing.T) { assert.NotContains(t, result, "/originals/storage") assert.Contains(t, result, "/linked") }) - t.Run("recursive no-symlinks", func(t *testing.T) { result, err := Dirs("testdata", true, false) @@ -36,7 +35,6 @@ func TestDirs(t *testing.T) { assert.Contains(t, result, "/directory/subdirectory/animals") assert.Contains(t, result, "/linked") }) - t.Run("non-recursive", func(t *testing.T) { result, err := Dirs("testdata", false, true) @@ -47,7 +45,6 @@ func TestDirs(t *testing.T) { assert.Contains(t, result, "/directory") assert.Contains(t, result, "/linked") }) - t.Run("non-recursive no-symlinks", func(t *testing.T) { result, err := Dirs("testdata/directory/subdirectory", false, false) @@ -58,7 +55,6 @@ func TestDirs(t *testing.T) { assert.Len(t, result, 1) assert.Contains(t, result, "/animals") }) - t.Run("non-recursive symlinks", func(t *testing.T) { result, err := Dirs("testdata/linked", false, true) @@ -69,7 +65,6 @@ func TestDirs(t *testing.T) { assert.Contains(t, result, "/photoprism") assert.Contains(t, result, "/self") }) - t.Run("no-result", func(t *testing.T) { result, err := Dirs("testdata/linked", false, false) diff --git a/pkg/fs/file_ext_test.go b/pkg/fs/file_ext_test.go index 74829b939..76bff3380 100644 --- a/pkg/fs/file_ext_test.go +++ b/pkg/fs/file_ext_test.go @@ -11,37 +11,30 @@ func TestNormalizeExt(t *testing.T) { result := NormalizedExt("testdata/test") assert.Equal(t, "", result) }) - t.Run("dot", func(t *testing.T) { result := NormalizedExt("testdata/test.") assert.Equal(t, "", result) }) - t.Run("test.z", func(t *testing.T) { result := NormalizedExt("testdata/test.z") assert.Equal(t, "z", result) }) - t.Run("test.jpg", func(t *testing.T) { result := NormalizedExt("testdata/test.jpg") assert.Equal(t, "jpg", result) }) - t.Run("test.PNG", func(t *testing.T) { result := NormalizedExt("testdata/test.PNG") assert.Equal(t, "png", result) }) - t.Run("test.MOV", func(t *testing.T) { result := NormalizedExt("testdata/test.MOV") assert.Equal(t, "mov", result) }) - t.Run("test.xmp", func(t *testing.T) { result := NormalizedExt("testdata/test.xMp") assert.Equal(t, "xmp", result) }) - t.Run("test.MP", func(t *testing.T) { result := NormalizedExt("testdata/test.mp") assert.Equal(t, "mp", result) @@ -74,12 +67,10 @@ func TestStripExt(t *testing.T) { result := StripExt("/testdata/Test.jpg") assert.Equal(t, "/testdata/Test", result) }) - t.Run("Test.jpg.json", func(t *testing.T) { result := StripExt("/testdata/Test.jpg.json") assert.Equal(t, "/testdata/Test.jpg", result) }) - t.Run("Test copy 3.foo", func(t *testing.T) { result := StripExt("/testdata/Test copy 3.foo") assert.Equal(t, "/testdata/Test copy 3", result) @@ -91,22 +82,18 @@ func TestStripKnownExt(t *testing.T) { result := StripKnownExt("/testdata/Test.jpg") assert.Equal(t, "/testdata/Test", result) }) - t.Run("Test.jpg.json", func(t *testing.T) { result := StripKnownExt("/testdata/Test.jpg.json") assert.Equal(t, "/testdata/Test", result) }) - t.Run("Test copy 3.foo", func(t *testing.T) { result := StripKnownExt("/testdata/Test copy 3.foo") assert.Equal(t, "/testdata/Test copy 3.foo", result) }) - t.Run("my/file.jpg.json.xmp", func(t *testing.T) { result := StripKnownExt("my/file.jpg.json.xmp") assert.Equal(t, "my/file", result) }) - t.Run("my/jpg/avi.foo.bar.baz", func(t *testing.T) { result := StripKnownExt("my/jpg/avi.foo.bar.baz") assert.Equal(t, "my/jpg/avi.foo.bar.baz", result) @@ -134,17 +121,14 @@ func TestExt(t *testing.T) { result := Ext("/testdata/Test.jpg") assert.Equal(t, ".jpg", result) }) - t.Run("Test.jpg.json", func(t *testing.T) { result := Ext("/testdata/Test.jpg.json") assert.Equal(t, ".jpg.json", result) }) - t.Run("Test copy 3.foo", func(t *testing.T) { result := Ext("/testdata/Test copy 3.foo") assert.Equal(t, ".foo", result) }) - t.Run("Test", func(t *testing.T) { result := Ext("/testdata/Test") assert.Equal(t, "", result) diff --git a/pkg/fs/file_type_test.go b/pkg/fs/file_type_test.go index 4da46206a..0a89e7c3c 100644 --- a/pkg/fs/file_type_test.go +++ b/pkg/fs/file_type_test.go @@ -82,12 +82,10 @@ func TestType_Find(t *testing.T) { result := ImageJpeg.Find("testdata/test (2).xmp", true) assert.Equal(t, "testdata/test.jpg", result) }) - t.Run("name upper", func(t *testing.T) { result := ImageJpeg.Find("testdata/CATYELLOW.xmp", true) assert.Equal(t, "testdata/CATYELLOW.jpg", result) }) - t.Run("name lower", func(t *testing.T) { result := ImageJpeg.Find("testdata/chameleon_lime.xmp", true) assert.Equal(t, "testdata/chameleon_lime.jpg", result) diff --git a/pkg/fs/mode.go b/pkg/fs/mode.go index df5cabaf8..70cce3a04 100644 --- a/pkg/fs/mode.go +++ b/pkg/fs/mode.go @@ -5,12 +5,14 @@ import ( "strconv" ) -// File and directory permissions. +// File and directory permissions. Umask restricts +// further; these are not the effective permissions. var ( - ModeDir os.FileMode = 0o777 + ModeDir os.FileMode = 0o777 // Default directory mode (POSIX). ModeSocket os.FileMode = 0o666 - ModeFile os.FileMode = 0o666 + ModeFile os.FileMode = 0o666 // Default modes for regular files. ModeConfigFile os.FileMode = 0o664 + ModeSecretFile os.FileMode = 0o600 ModeBackupFile os.FileMode = 0o600 ) @@ -20,6 +22,7 @@ func ParseMode(s string, defaultMode os.FileMode) os.FileMode { if s == "" { return defaultMode } + mode, err := strconv.ParseUint(s, 8, 32) if err != nil || mode <= 0 { diff --git a/pkg/fs/purge.go b/pkg/fs/purge.go new file mode 100644 index 000000000..858aa26ab --- /dev/null +++ b/pkg/fs/purge.go @@ -0,0 +1,71 @@ +package fs + +import ( + gofs "io/fs" + "os" + "path/filepath" + "strings" +) + +// PurgeTestDbFiles removes temporary SQLite-related test files in dir. +// +// Patterns (case-insensitive), aligned with `make reset-sqlite`: +// - '.*.db' (hidden SQLite database files) +// - '.*.db-journal' (SQLite journal files) +// - '.test.*' (generic hidden test artifacts) +// +// If recursive is true, it traverses dir recursively. If false, it only checks +// files directly within dir so TestMain in a parent package won't affect +// sub-packages that may run in parallel. +// +// Errors from removing individual files are ignored; this is a best-effort +// cleanup helper for tests and local tooling. +func PurgeTestDbFiles(dir string, recursive bool) { + if dir == "" { + return + } + + // Common predicate used by both modes. + matchAndRemove := func(path, name string, info os.FileInfo) { + if info == nil || !info.Mode().IsRegular() { + return + } + lower := strings.ToLower(name) + if strings.HasPrefix(name, ".") { + if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".db-journal") || strings.HasPrefix(lower, ".test.") { + _ = os.Remove(path) + } + } + } + + if recursive { + _ = filepath.WalkDir(dir, func(path string, d gofs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + // Gather FileInfo to ensure regular file. + if info, statErr := d.Info(); statErr == nil { + matchAndRemove(path, d.Name(), info) + } + return nil + }) + return + } + + // Non-recursive: only immediate entries in dir. + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() { + continue + } + if info, statErr := e.Info(); statErr == nil { + matchAndRemove(filepath.Join(dir, e.Name()), e.Name(), info) + } + } +} diff --git a/pkg/fs/purge_test.go b/pkg/fs/purge_test.go new file mode 100644 index 000000000..9803d76b5 --- /dev/null +++ b/pkg/fs/purge_test.go @@ -0,0 +1,118 @@ +package fs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPurgeTestDbFiles_Recursive(t *testing.T) { + dir := t.TempDir() + + toCreate := []string{ + filepath.Join(dir, ".alpha.db"), // match '.*.db' + filepath.Join(dir, ".BETA.DB"), // case-insensitive + filepath.Join(dir, ".gamma.db-journal"), // match '.*.db-journal' + filepath.Join(dir, ".DELTA.DB-JOURNAL"), // case-insensitive + filepath.Join(dir, ".test.sqlite"), // match '.test.*' + filepath.Join(dir, ".test.anything"), // match '.test.*' + filepath.Join(dir, "epsilon.db"), // no leading dot → keep + filepath.Join(dir, "zeta"), // no extension → keep + } + + nestedDir := filepath.Join(dir, "nested") + if err := os.MkdirAll(nestedDir, ModeDir); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + toCreate = append(toCreate, + filepath.Join(nestedDir, ".theta.db"), + filepath.Join(nestedDir, "iota.db-journal"), // no leading dot → keep + ) + + for _, f := range toCreate { + if err := os.WriteFile(f, []byte("x"), ModeSecretFile); err != nil { + t.Fatalf("create file %s: %v", f, err) + } + } + + PurgeTestDbFiles(dir, true) + + // Expect deletions. + deleted := []string{ + filepath.Join(dir, ".alpha.db"), + filepath.Join(dir, ".BETA.DB"), + filepath.Join(dir, ".gamma.db-journal"), + filepath.Join(dir, ".DELTA.DB-JOURNAL"), + filepath.Join(dir, ".test.sqlite"), + filepath.Join(dir, ".test.anything"), + filepath.Join(nestedDir, ".theta.db"), + } + for _, f := range deleted { + if FileExists(f) { + t.Fatalf("expected %s to be deleted", f) + } + } + + // Expect survivors. + survivors := []string{ + filepath.Join(dir, "epsilon.db"), + filepath.Join(dir, "zeta"), + filepath.Join(nestedDir, "iota.db-journal"), + } + for _, f := range survivors { + if !FileExists(f) { + t.Fatalf("expected %s to remain", f) + } + } +} + +func TestPurgeTestDbFiles_NonRecursive(t *testing.T) { + dir := t.TempDir() + + // Top-level files + files := []string{ + filepath.Join(dir, ".a.db"), + filepath.Join(dir, ".b.db-journal"), + filepath.Join(dir, ".test.c"), + filepath.Join(dir, "should-stay.db"), + } + for _, f := range files { + if err := os.WriteFile(f, []byte("x"), ModeSecretFile); err != nil { + t.Fatalf("create %s: %v", f, err) + } + } + + // Nested files + nested := filepath.Join(dir, "sub") + if err := os.MkdirAll(nested, ModeDir); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + nestedFiles := []string{ + filepath.Join(nested, ".nested.db"), + filepath.Join(nested, ".test.nested"), + } + for _, f := range nestedFiles { + if err := os.WriteFile(f, []byte("x"), ModeSecretFile); err != nil { + t.Fatalf("create %s: %v", f, err) + } + } + + PurgeTestDbFiles(dir, false) + + // Top-level deleted + for _, f := range []string{filepath.Join(dir, ".a.db"), filepath.Join(dir, ".b.db-journal"), filepath.Join(dir, ".test.c")} { + if FileExists(f) { + t.Fatalf("expected %s to be deleted", f) + } + } + // Top-level survivor + if !FileExists(filepath.Join(dir, "should-stay.db")) { + t.Fatalf("expected top-level survivor to remain") + } + // Nested survivors (non-recursive should not touch these) + for _, f := range nestedFiles { + if !FileExists(f) { + t.Fatalf("expected nested file to remain: %s", f) + } + } +} diff --git a/pkg/fs/resolve_test.go b/pkg/fs/resolve_test.go index 53c4c2555..fe9f5caee 100644 --- a/pkg/fs/resolve_test.go +++ b/pkg/fs/resolve_test.go @@ -13,7 +13,7 @@ func TestResolve_FileAndSymlink(t *testing.T) { target := filepath.Join(dir, "file.txt") link := filepath.Join(dir, "link.txt") - assert.NoError(t, os.WriteFile(target, []byte("x"), 0o644)) + assert.NoError(t, os.WriteFile(target, []byte("x"), ModeFile)) // Create symlink if supported on this platform if err := os.Symlink(target, link); err != nil { t.Skipf("symlinks not supported: %v", err) diff --git a/pkg/fs/walk_test.go b/pkg/fs/walk_test.go index 0fe6b8869..728cf6775 100644 --- a/pkg/fs/walk_test.go +++ b/pkg/fs/walk_test.go @@ -1,7 +1,7 @@ package fs import ( - "io/fs" + iofs "io/fs" "testing" "github.com/karrick/godirwalk" @@ -25,7 +25,6 @@ func TestSkipWalk(t *testing.T) { assert.True(t, done["testdata/directory"].Exists()) assert.Equal(t, 2, len(done)) }) - t.Run("Storage", func(t *testing.T) { done := make(Done) ignore := NewIgnoreList(".ppignore", true, false) @@ -37,7 +36,7 @@ func TestSkipWalk(t *testing.T) { } if skip, result := SkipWalk("testdata/originals/storage", true, false, done, ignore); skip { - assert.Equal(t, fs.SkipDir, result) + assert.Equal(t, iofs.SkipDir, result) } else { t.Fatal("skip should be true because this is a directory and not a file") } @@ -45,7 +44,6 @@ func TestSkipWalk(t *testing.T) { assert.True(t, done["testdata/originals/storage"].Exists()) assert.Equal(t, 2, len(done)) }) - t.Run("Symlink", func(t *testing.T) { done := make(Done) ignore := NewIgnoreList(".ppignore", true, false) @@ -74,7 +72,6 @@ func TestSkipWalk(t *testing.T) { assert.True(t, done["testdata/directory/subdirectory/symlink/self/self"].Exists()) assert.Equal(t, 4, len(done)) }) - t.Run("Godirwalk", func(t *testing.T) { done := make(Done) var skipped []string diff --git a/pkg/fs/write_test.go b/pkg/fs/write_test.go index 8d6ce06d7..820173a5f 100644 --- a/pkg/fs/write_test.go +++ b/pkg/fs/write_test.go @@ -206,7 +206,7 @@ func TestCacheFileFromReader(t *testing.T) { func TestWriteFile_Truncates(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") - assert.NoError(t, os.WriteFile(p, []byte("LONGDATA"), 0o644)) + assert.NoError(t, os.WriteFile(p, []byte("LONGDATA"), ModeFile)) assert.NoError(t, WriteFile(p, []byte("short"), ModeFile)) b, err := os.ReadFile(p) assert.NoError(t, err) diff --git a/pkg/fs/yaml_test.go b/pkg/fs/yaml_test.go index 32415d7a4..8812685b6 100644 --- a/pkg/fs/yaml_test.go +++ b/pkg/fs/yaml_test.go @@ -18,14 +18,13 @@ func TestYamlFilePath(t *testing.T) { got := YamlFilePath("", "", rel) assert.Equal(t, expected, got) }) - t.Run("PreferYmlIfExists", func(t *testing.T) { dir := t.TempDir() name := "app-config" // Create .yml file ymlPath := filepath.Join(dir, name+ExtYml) - err := os.WriteFile(ymlPath, []byte("foo: bar\n"), 0o644) + err := os.WriteFile(ymlPath, []byte("foo: bar\n"), ModeFile) if err != nil { t.Fatalf("write %s: %v", ymlPath, err) } @@ -33,7 +32,6 @@ func TestYamlFilePath(t *testing.T) { got := YamlFilePath(name, dir, "") assert.Equal(t, ymlPath, got) }) - t.Run("DefaultYamlWhenYmlMissing", func(t *testing.T) { dir := t.TempDir() name := "settings" @@ -43,7 +41,6 @@ func TestYamlFilePath(t *testing.T) { got := YamlFilePath(name, dir, "") assert.Equal(t, expected, got) }) - t.Run("BothExistReturnsYml", func(t *testing.T) { dir := t.TempDir() name := "prefs" @@ -52,10 +49,11 @@ func TestYamlFilePath(t *testing.T) { ymlPath := filepath.Join(dir, name+ExtYml) yamlPath := filepath.Join(dir, name+ExtYaml) - if err := os.WriteFile(ymlPath, []byte("a: 1\n"), 0o644); err != nil { + if err := os.WriteFile(ymlPath, []byte("a: 1\n"), ModeFile); err != nil { t.Fatalf("write %s: %v", ymlPath, err) } - if err := os.WriteFile(yamlPath, []byte("a: 2\n"), 0o644); err != nil { + + if err := os.WriteFile(yamlPath, []byte("a: 2\n"), ModeFile); err != nil { t.Fatalf("write %s: %v", yamlPath, err) } diff --git a/pkg/geo/movement_test.go b/pkg/geo/movement_test.go index 6bc5e6549..8060da523 100644 --- a/pkg/geo/movement_test.go +++ b/pkg/geo/movement_test.go @@ -60,7 +60,6 @@ func TestMovement(t *testing.T) { assert.InEpsilon(t, 41.873295, posMid.Lat, 0.01) assert.InEpsilon(t, 67.434295, posMid.Lng, 0.01) }) - t.Run("PositionBefore", func(t *testing.T) { timeEst := time.Date(2019, time.July, 21, 11, 56, 47, 0, time.UTC) @@ -105,7 +104,6 @@ func TestMovement(t *testing.T) { assert.InEpsilon(t, 48.299200, posMid.Lat, 0.001) assert.InEpsilon(t, 8.929535, posMid.Lng, 0.01) }) - t.Run("TooFast", func(t *testing.T) { timeEst := time.Date(2019, time.July, 21, 11, 56, 47, 0, time.UTC) @@ -141,7 +139,6 @@ func TestMovement(t *testing.T) { assert.InEpsilon(t, 48.301200, posMid.Lat, 0.01) assert.InEpsilon(t, 8.928630, posMid.Lng, 0.01) }) - t.Run("PositionBetween", func(t *testing.T) { timeEst := time.Date(2019, time.July, 21, 11, 56, 47, 0, time.UTC) @@ -177,7 +174,6 @@ func TestMovement(t *testing.T) { assert.InEpsilon(t, 48.299300, posMid.Lat, 0.01) assert.InEpsilon(t, 8.929335, posMid.Lng, 0.01) }) - t.Run("NotRealistic", func(t *testing.T) { timeEst := time.Date(2013, time.August, 10, 00, 05, 37, 0, time.UTC) diff --git a/pkg/geo/pluscode/pluscode_test.go b/pkg/geo/pluscode/pluscode_test.go index a7b54b6f7..c9fed7fc0 100644 --- a/pkg/geo/pluscode/pluscode_test.go +++ b/pkg/geo/pluscode/pluscode_test.go @@ -14,13 +14,11 @@ func TestEncode(t *testing.T) { assert.Equal(t, expected, plusCode) }) - t.Run("lat_overflow", func(t *testing.T) { plusCode := Encode(548.56344833333333, 8.996878333333333) assert.Equal(t, "", plusCode) }) - t.Run("lng_overflow", func(t *testing.T) { plusCode := Encode(48.56344833333333, 258.996878333333333) @@ -39,7 +37,6 @@ func TestEncodeLength(t *testing.T) { assert.Equal(t, expected, plusCode) }) - t.Run("germany_8", func(t *testing.T) { plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 8) if err != nil { @@ -50,7 +47,6 @@ func TestEncodeLength(t *testing.T) { assert.Equal(t, expected, plusCode) }) - t.Run("germany_7", func(t *testing.T) { plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 7) if err != nil { @@ -61,7 +57,6 @@ func TestEncodeLength(t *testing.T) { assert.Equal(t, expected, plusCode) }) - t.Run("germany_6", func(t *testing.T) { plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 6) if err != nil { @@ -72,7 +67,6 @@ func TestEncodeLength(t *testing.T) { assert.Equal(t, expected, plusCode) }) - t.Run("lat_overflow", func(t *testing.T) { plusCode, err := EncodeLength(548.56344833333333, 8.996878333333333, 7) if err == nil { @@ -80,7 +74,6 @@ func TestEncodeLength(t *testing.T) { } assert.Equal(t, "", plusCode) }) - t.Run("lng_overflow", func(t *testing.T) { plusCode, err := EncodeLength(48.56344833333333, 258.996878333333333, 7) if err == nil { diff --git a/pkg/geo/s2/s2_test.go b/pkg/geo/s2/s2_test.go index 3f2ba55eb..056fb8ef9 100644 --- a/pkg/geo/s2/s2_test.go +++ b/pkg/geo/s2/s2_test.go @@ -14,14 +14,12 @@ func TestToken(t *testing.T) { assert.True(t, strings.HasPrefix(token, expected)) }) - t.Run("lat_overflow", func(t *testing.T) { token := Token(548.56344833333333, 8.996878333333333) expected := "" assert.Equal(t, expected, token) }) - t.Run("lng_overflow", func(t *testing.T) { token := Token(48.56344833333333, 258.996878333333333) expected := "" @@ -37,63 +35,54 @@ func TestTokenLevel(t *testing.T) { assert.Equal(t, expected, token) }) - t.Run("level_30_diff", func(t *testing.T) { plusCode := TokenLevel(48.56344839999999, 8.996878339999999, 30) expected := "4799e370ca54c8b7" assert.Equal(t, expected, plusCode) }) - t.Run("level_21", func(t *testing.T) { plusCode := TokenLevel(48.56344839999999, 8.996878339999999, 21) expected := "4799e370ca54" assert.Equal(t, expected, plusCode) }) - t.Run("level_18", func(t *testing.T) { token := TokenLevel(48.56344833333333, 8.996878333333333, 18) expected := "4799e370cb" assert.Equal(t, expected, token) }) - t.Run("level_18_diff", func(t *testing.T) { token := TokenLevel(48.56344839999999, 8.996878339999999, 18) expected := "4799e370cb" assert.Equal(t, expected, token) }) - t.Run("level_15", func(t *testing.T) { plusCode := TokenLevel(48.56344833333333, 8.996878333333333, 15) expected := "4799e370c" assert.Equal(t, expected, plusCode) }) - t.Run("level_10", func(t *testing.T) { token := TokenLevel(48.56344833333333, 8.996878333333333, 10) expected := "4799e3" assert.Equal(t, expected, token) }) - t.Run("lat_overflow", func(t *testing.T) { token := TokenLevel(548.56344833333333, 8.996878333333333, 30) expected := "" assert.Equal(t, expected, token) }) - t.Run("lng_overflow", func(t *testing.T) { token := TokenLevel(48.56344833333333, 258.996878333333333, 30) expected := "" assert.Equal(t, expected, token) }) - t.Run("lat & long 0.0", func(t *testing.T) { token := TokenLevel(0.0, 0.0, 30) expected := "" @@ -180,7 +169,6 @@ func TestLatLng(t *testing.T) { assert.Equal(t, 48.5634484, lat) assert.Equal(t, 8.9968783, lng) }) - t.Run("Invalid", func(t *testing.T) { lat, lng := LatLng("4799e370ca5q") assert.Equal(t, 0.0, lat) diff --git a/pkg/geo/s2/token_prefix_test.go b/pkg/geo/s2/token_prefix_test.go index 830d73f2b..bbfd47e68 100644 --- a/pkg/geo/s2/token_prefix_test.go +++ b/pkg/geo/s2/token_prefix_test.go @@ -16,7 +16,6 @@ func TestNormalizeToken(t *testing.T) { assert.Equal(t, "1242342bac", output) }) - t.Run("abc", func(t *testing.T) { input := "abc" @@ -36,7 +35,6 @@ func TestPrefix(t *testing.T) { assert.Equal(t, input, output) }) - t.Run("abc", func(t *testing.T) { input := "1242342bac" @@ -45,7 +43,6 @@ func TestPrefix(t *testing.T) { assert.Equal(t, TokenPrefix+input, output) }) - t.Run("empty string", func(t *testing.T) { output := Prefix("") @@ -61,14 +58,12 @@ func TestPrefixedToken(t *testing.T) { assert.True(t, strings.HasPrefix(token, expected)) }) - t.Run("lat_overflow", func(t *testing.T) { token := PrefixedToken(548.56344833333333, 8.996878333333333) expected := "" assert.Equal(t, expected, token) }) - t.Run("lng_overflow", func(t *testing.T) { token := PrefixedToken(48.56344833333333, 258.996878333333333) expected := "" diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index aa19ff011..6ec656939 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -21,12 +21,10 @@ func TestMsg(t *testing.T) { msg := Msg(ErrAlreadyExists, "A cat") assert.Equal(t, "A cat already exists", msg) }) - t.Run("unexpected error", func(t *testing.T) { msg := Msg(ErrUnexpected, "A cat") assert.Equal(t, "Something went wrong, try again", msg) }) - t.Run("already exists german", func(t *testing.T) { SetLocale("de") msgTrans := Msg(ErrAlreadyExists, "Eine Katze") @@ -35,7 +33,6 @@ func TestMsg(t *testing.T) { msgDefault := Msg(ErrAlreadyExists, "A cat") assert.Equal(t, "A cat already exists", msgDefault) }) - t.Run("already exists polish", func(t *testing.T) { SetLocale("pl") msgTrans := Msg(ErrAlreadyExists, "Kot") @@ -44,7 +41,6 @@ func TestMsg(t *testing.T) { msgDefault := Msg(ErrAlreadyExists, "A cat") assert.Equal(t, "A cat already exists", msgDefault) }) - t.Run("Brazilian Portuguese", func(t *testing.T) { SetLocale("pt_BR") msgTrans := Msg(ErrAlreadyExists, "Gata") @@ -60,12 +56,10 @@ func TestError(t *testing.T) { err := Error(ErrAlreadyExists, "A cat") assert.EqualError(t, err, "A cat already exists") }) - t.Run("unexpected error", func(t *testing.T) { err := Error(ErrUnexpected, "A cat") assert.EqualError(t, err, "Something went wrong, try again") }) - t.Run("already exists german", func(t *testing.T) { SetLocale("de") errGerman := Error(ErrAlreadyExists, "Eine Katze") @@ -87,7 +81,6 @@ func TestLower(t *testing.T) { errDefault := Lower(ErrAlreadyExists, "Eine Katze") assert.Equal(t, errDefault, "eine katze already exists") }) - t.Run("ErrForbidden", func(t *testing.T) { msg := Lower(ErrForbidden, "A cat") assert.Equal(t, "permission denied", msg) diff --git a/pkg/i18n/response_test.go b/pkg/i18n/response_test.go index 208c6ee9b..2766bdad1 100644 --- a/pkg/i18n/response_test.go +++ b/pkg/i18n/response_test.go @@ -15,14 +15,12 @@ func TestNewResponse(t *testing.T) { assert.Equal(t, "A cat already exists", resp.Err) assert.Equal(t, "", resp.Msg) }) - t.Run("unexpected error", func(t *testing.T) { resp := NewResponse(http.StatusInternalServerError, ErrUnexpected, "A cat") assert.Equal(t, http.StatusInternalServerError, resp.Code) assert.Equal(t, "Something went wrong, try again", resp.Err) assert.Equal(t, "", resp.Msg) }) - t.Run("changes saved", func(t *testing.T) { resp := NewResponse(http.StatusOK, MsgChangesSaved) assert.Equal(t, http.StatusOK, resp.Code) @@ -42,7 +40,6 @@ func TestResponse_String(t *testing.T) { resp := Response{404, "Not found", "page not found", "xyz"} assert.Equal(t, "Not found", resp.String()) }) - t.Run("no error", func(t *testing.T) { t.Run("error", func(t *testing.T) { resp := Response{200, "", "Ok", "xyz"} @@ -56,7 +53,6 @@ func TestResponse_LowerString(t *testing.T) { resp := Response{404, "Not found", "page not found", "xyz"} assert.Equal(t, "not found", resp.LowerString()) }) - t.Run("no error", func(t *testing.T) { t.Run("error", func(t *testing.T) { resp := Response{200, "", "Ok", "xyz"} @@ -70,7 +66,6 @@ func TestResponse_Error(t *testing.T) { resp := Response{404, "Not found", "page not found", "xyz"} assert.Equal(t, "Not found", resp.Error()) }) - t.Run("no error", func(t *testing.T) { t.Run("error", func(t *testing.T) { resp := Response{200, "", "Ok", "xyz"} @@ -84,7 +79,6 @@ func TestResponse_Success(t *testing.T) { resp := Response{404, "Not found", "page not found", "xyz"} assert.Equal(t, false, resp.Success()) }) - t.Run("no error", func(t *testing.T) { t.Run("error", func(t *testing.T) { resp := Response{200, "", "Ok", "xyz"} diff --git a/pkg/list/bench_test.go b/pkg/list/bench_test.go new file mode 100644 index 000000000..2001ac944 --- /dev/null +++ b/pkg/list/bench_test.go @@ -0,0 +1,65 @@ +package list + +import ( + "fmt" + "testing" +) + +func makeStrings(prefix string, n int) []string { + out := make([]string, n) + for i := 0; i < n; i++ { + out[i] = fmt.Sprintf("%s_%06d", prefix, i) + } + return out +} + +func shuffleEveryK(a []string, k int) []string { + out := make([]string, len(a)) + copy(out, a) + if k <= 1 { + return out + } + for i := 0; i < len(out)-k; i += k { + out[i], out[i+k-1] = out[i+k-1], out[i] + } + return out +} + +func BenchmarkContainsAny_LargeOverlap(b *testing.B) { + a := makeStrings("a", 5000) + bList := makeStrings("b", 5000) + // Introduce overlap: copy 20% of a into bList + for i := 0; i < 1000; i++ { + bList[i] = a[i*4] + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if !ContainsAny(a, bList) { + b.Fatalf("expected overlap") + } + } +} + +func BenchmarkContainsAny_Disjoint(b *testing.B) { + a := makeStrings("a", 5000) + bList := makeStrings("b", 5000) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if ContainsAny(a, bList) { + b.Fatalf("expected disjoint") + } + } +} + +func BenchmarkJoin_Large(b *testing.B) { + a := makeStrings("x", 5000) + j := append(makeStrings("y", 5000), a[:1000]...) // 1000 duplicates + j = shuffleEveryK(j, 7) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + out := Join(a, j) + if len(out) != 10000 { + b.Fatalf("unexpected length: %d", len(out)) + } + } +} diff --git a/pkg/list/contains.go b/pkg/list/contains.go index 1e1afe444..f45b976b9 100644 --- a/pkg/list/contains.go +++ b/pkg/list/contains.go @@ -24,19 +24,31 @@ func Contains(list []string, s string) bool { func ContainsAny(l, s []string) bool { if len(l) == 0 || len(s) == 0 { return false - } else if s[0] == All { - return true } - // Find matches. - for i := range l { - for j := range s { - if s[j] == l[i] || s[j] == All { - return true - } + // If second list contains All, it's a wildcard match. + if s[0] == All { + return true + } + for j := 1; j < len(s); j++ { + if s[j] == All { + return true } } - // Not found. + // Build a set from the smaller slice for O(n+m) intersection. + a, b := l, s + if len(a) > len(b) { + a, b = b, a + } + set := make(map[string]struct{}, len(a)) + for i := range a { + set[a[i]] = struct{}{} + } + for j := range b { + if _, ok := set[b[j]]; ok { + return true + } + } return false } diff --git a/pkg/list/join.go b/pkg/list/join.go index b5216ec2e..650879103 100644 --- a/pkg/list/join.go +++ b/pkg/list/join.go @@ -5,14 +5,31 @@ func Join(list []string, join []string) []string { if len(join) == 0 { return list } else if len(list) == 0 { - return join + // Return a copy to avoid surprising aliasing when callers append later. + out := make([]string, len(join)) + copy(out, join) + return out } - for j := range join { - if Excludes(list, join[j]) { - list = append(list, join[j]) + // Build a set of existing values for O(n+m) merging without duplicates. + set := make(map[string]struct{}, len(list)+len(join)) + out := make([]string, 0, len(list)+len(join)) + for i := range list { + v := list[i] + if _, ok := set[v]; !ok { + set[v] = struct{}{} + out = append(out, v) } } - - return list + for j := range join { + v := join[j] + if v == "" { + continue + } + if _, ok := set[v]; !ok { + set[v] = struct{}{} + out = append(out, v) + } + } + return out } diff --git a/pkg/media/colors/colorful_test.go b/pkg/media/colors/colorful_test.go index 09e8ab875..7d300054d 100644 --- a/pkg/media/colors/colorful_test.go +++ b/pkg/media/colors/colorful_test.go @@ -19,7 +19,6 @@ func TestColorful(t *testing.T) { assert.Equal(t, "purple", Colorful(color).Name()) }) - t.Run("cyan", func(t *testing.T) { c := color.RGBA{0xb2, 0xeb, 0xf2, 0xff} color, ok := colorful.MakeColor(c) diff --git a/pkg/media/colors/lightmap_test.go b/pkg/media/colors/lightmap_test.go index e4de3d990..5d564e764 100644 --- a/pkg/media/colors/lightmap_test.go +++ b/pkg/media/colors/lightmap_test.go @@ -24,7 +24,6 @@ func TestLightMap_Diff(t *testing.T) { t.Errorf("result should be 845: %d", result) } }) - t.Run("empty", func(t *testing.T) { var lum []Luminance lMap := LightMap(lum) @@ -34,7 +33,6 @@ func TestLightMap_Diff(t *testing.T) { t.Errorf("result should be 0: %d", result) } }) - t.Run("one", func(t *testing.T) { lum := []Luminance{0} lMap := LightMap(lum) @@ -44,7 +42,6 @@ func TestLightMap_Diff(t *testing.T) { t.Errorf("result should be 0: %d", result) } }) - t.Run("same", func(t *testing.T) { lum := []Luminance{1, 1, 1, 1, 1, 1, 1, 1, 1} lMap := LightMap(lum) @@ -54,7 +51,6 @@ func TestLightMap_Diff(t *testing.T) { t.Errorf("result should be 1023: %d", result) } }) - t.Run("similar", func(t *testing.T) { lum := []Luminance{1, 1, 1, 1, 1, 1, 1, 1, 2} lMap := LightMap(lum) @@ -64,7 +60,6 @@ func TestLightMap_Diff(t *testing.T) { t.Errorf("result should be 1023: %d", result) } }) - t.Run("happy", func(t *testing.T) { m1 := LightMap{8, 13, 7, 2, 2, 3, 6, 3, 4} d1 := m1.Diff() diff --git a/pkg/media/data_url_test.go b/pkg/media/data_url_test.go index f8bc5f48e..e1e63ff65 100644 --- a/pkg/media/data_url_test.go +++ b/pkg/media/data_url_test.go @@ -38,7 +38,6 @@ func TestReadUrl(t *testing.T) { assert.Equal(t, expected, data) } }) - t.Run("HttpServer", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("hello")) @@ -51,32 +50,26 @@ func TestReadUrl(t *testing.T) { } assert.Equal(t, []byte("hello"), data) }) - t.Run("InvalidEmpty", func(t *testing.T) { _, err := ReadUrl("", []string{"https"}) assert.Error(t, err) }) - t.Run("MissingScheme", func(t *testing.T) { _, err := ReadUrl("example.com/file.jpg", []string{"https"}) assert.Error(t, err) }) - t.Run("DisallowedScheme", func(t *testing.T) { _, err := ReadUrl("http://example.com", []string{"data"}) assert.Error(t, err) }) - t.Run("UnsupportedScheme", func(t *testing.T) { _, err := ReadUrl("ssh://host/path", []string{"ssh"}) assert.Error(t, err) }) - t.Run("InvalidDataUrl", func(t *testing.T) { _, err := ReadUrl("data:image/png;base64,", []string{"data"}) assert.Error(t, err) }) - t.Run("FileSchemeInvalidPath", func(t *testing.T) { // os.ReadFile will not accept a file:// URL; expect error path is exercised. _, err := ReadUrl("file:///this/does/not/exist", []string{"file"}) diff --git a/pkg/rnd/uuid.go b/pkg/rnd/uuid.go index f31a100ef..60065ebed 100644 --- a/pkg/rnd/uuid.go +++ b/pkg/rnd/uuid.go @@ -11,6 +11,14 @@ func UUID() string { return uuid.NewString() } +// UUIDv7 returns a sortable UUID version 7 (time-ordered). Falls back to v4 on error. +func UUIDv7() string { + if u, err := uuid.NewV7(); err == nil { + return u.String() + } + return uuid.NewString() +} + // State is an alias for UUID for use in the context of OpenID Connect (OIDC). func State() string { return UUID() diff --git a/pkg/service/http/header/ip.go b/pkg/service/http/header/ip.go index 9f9e73f1b..963d211ed 100644 --- a/pkg/service/http/header/ip.go +++ b/pkg/service/http/header/ip.go @@ -6,23 +6,51 @@ import ( ) // IpRegExp matches characters allowed in IPv4 or IPv6 network addresses. +// Kept for backwards compatibility (other packages reference it), but IP() no longer uses it. var IpRegExp = regexp.MustCompile(`[^a-zA-Z0-9:.]`) +const ( + IPv6Length = 39 +) + +// IsIP returns true if the string matches a valid IP address. +func IsIP(s string) bool { + return IP(s, "") != "" +} + // IP returns the sanitized and normalized network address if it is valid, or the default otherwise. func IP(s, defaultIp string) string { // Return default if invalid. - if s == "" || len(s) > 128 || s == defaultIp { + if s == "" || len(s) > 64 || s == defaultIp { return defaultIp } - // Remove invalid characters, including whitespace. - if s = IpRegExp.ReplaceAllString(s, ""); s == "" { - return defaultIp + // Filter invalid characters: allow only [A-Za-z0-9:.] + fastOK := true + for i := 0; i < len(s); i++ { + b := s[i] + if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == ':' || b == '.') { + fastOK = false + break + } + } + if !fastOK { + dst := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == ':' || c == '.' { + dst = append(dst, c) + } + } + s = string(dst) + if s == "" { + return defaultIp + } } // Limit string length to 39 characters. - if len(s) > 39 { - s = s[:39] + if len(s) > IPv6Length { + s = s[:IPv6Length] } // Parse IP address and return it as string. diff --git a/pkg/txt/clip/runes.go b/pkg/txt/clip/runes.go index 15deea25e..0082664db 100644 --- a/pkg/txt/clip/runes.go +++ b/pkg/txt/clip/runes.go @@ -3,6 +3,7 @@ package clip import "strings" // Runes limits a string to the given number of runes and removes all leading and trailing spaces. +// Fast paths avoid []rune allocation when the input is ASCII and/or already within size. func Runes(s string, size int) string { s = strings.TrimSpace(s) @@ -10,11 +11,27 @@ func Runes(s string, size int) string { return "" } - runes := []rune(s) - - if len(runes) > size { - s = string(runes[0:size]) + // If length in bytes <= size, the string cannot exceed size runes. + if len(s) <= size { + return s } + // ASCII fast path: byte length equals rune count → safe to slice by bytes. + ascii := true + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { + ascii = false + break + } + } + if ascii { + return strings.TrimSpace(s[:size]) + } + + // Fallback: count runes and slice exactly at rune boundary. + runes := []rune(s) + if len(runes) > size { + s = string(runes[:size]) + } return strings.TrimSpace(s) } diff --git a/pkg/txt/clip_test.go b/pkg/txt/clip_test.go index c4b22aa72..6c7569258 100644 --- a/pkg/txt/clip_test.go +++ b/pkg/txt/clip_test.go @@ -1,6 +1,7 @@ package txt import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -25,6 +26,22 @@ func TestClip(t *testing.T) { }) } +func BenchmarkClipRunesASCII(b *testing.B) { + s := strings.Repeat("abc def ghi ", 20) // ASCII + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = Clip(s, 50) + } +} + +func BenchmarkClipRunesUTF8(b *testing.B) { + s := strings.Repeat("Grüße 世", 20) // non-ASCII runes + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = Clip(s, 50) + } +} + func TestShorten(t *testing.T) { t.Run("ShortEnough", func(t *testing.T) { assert.Equal(t, "fox!", Shorten("fox!", 6, "...")) diff --git a/pkg/txt/contains.go b/pkg/txt/contains.go index 7be8ff6fc..dded95b05 100644 --- a/pkg/txt/contains.go +++ b/pkg/txt/contains.go @@ -1,15 +1,15 @@ package txt -import ( - "regexp" - "unicode" -) +import "unicode" -var ContainsNumberRegexp = regexp.MustCompile("\\d+") - -// ContainsNumber returns true if string contains a number. +// ContainsNumber returns true if string contains an ASCII digit. func ContainsNumber(s string) bool { - return ContainsNumberRegexp.MatchString(s) + for i := 0; i < len(s); i++ { + if s[i] >= '0' && s[i] <= '9' { + return true + } + } + return false } // ContainsLetters reports whether the string only contains letters. @@ -29,23 +29,23 @@ func ContainsLetters(s string) bool { // ContainsASCIILetters reports if the string only contains ascii chars without whitespace, numbers, and punctuation marks. func ContainsASCIILetters(s string) bool { - for _, r := range s { - if (r < 65 || r > 90) && (r < 97 || r > 122) { + for i := 0; i < len(s); i++ { + b := s[i] + if !((b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')) { return false } } - return true } // ContainsAlnumLower reports if the string only contains lower case ascii letters or numbers. func ContainsAlnumLower(s string) bool { - for _, r := range s { - if (r < 48 || r > 57) && (r < 97 || r > 122) { + for i := 0; i < len(s); i++ { + b := s[i] + if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'z')) { return false } } - return true } diff --git a/pkg/txt/contains_test.go b/pkg/txt/contains_test.go index a13cb458f..44516d08a 100644 --- a/pkg/txt/contains_test.go +++ b/pkg/txt/contains_test.go @@ -111,3 +111,20 @@ func TestContainsAlnumLower(t *testing.T) { assert.False(t, ContainsAlnumLower("_3kmib24yr3")) }) } + +func BenchmarkContainsNumber(b *testing.B) { + s := "The quick brown fox jumps over 13 lazy dogs" + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ContainsNumber(s) + } +} + +func BenchmarkSortCaseInsensitive(b *testing.B) { + words := []string{"Zebra", "apple", "Banana", "cherry", "Apricot", "banana", "zebra", "Cherry"} + b.ReportAllocs() + for i := 0; i < b.N; i++ { + w := append([]string(nil), words...) + SortCaseInsensitive(w) + } +} diff --git a/pkg/txt/country_test.go b/pkg/txt/country_test.go index f9cb30bfe..225d36b12 100644 --- a/pkg/txt/country_test.go +++ b/pkg/txt/country_test.go @@ -11,127 +11,102 @@ func TestCountryCode(t *testing.T) { result := CountryCode("London") assert.Equal(t, "gb", result) }) - t.Run("ReunionIsland", func(t *testing.T) { result := CountryCode("Reunion-Island-2019") assert.Equal(t, "zz", result) }) - t.Run("ReunionIslandFrance", func(t *testing.T) { result := CountryCode("Reunion-Island-france-2019") assert.Equal(t, "fr", result) }) - t.Run("Reunion", func(t *testing.T) { result := CountryCode("My-RéunioN-2019") assert.Equal(t, "fr", result) }) - t.Run("NYC", func(t *testing.T) { result := CountryCode("NYC 2019") assert.Equal(t, "us", result) }) - t.Run("Scuba", func(t *testing.T) { result := CountryCode("Scuba 2019") assert.Equal(t, "zz", result) }) - t.Run("Cuba", func(t *testing.T) { result := CountryCode("Cuba 2019") assert.Equal(t, "cu", result) }) - t.Run("SanFrancisco", func(t *testing.T) { result := CountryCode("San Francisco 2019") assert.Equal(t, "us", result) }) - t.Run("LosAngeles", func(t *testing.T) { result := CountryCode("I was in Los Angeles") assert.Equal(t, "us", result) }) - t.Run("Melbourne", func(t *testing.T) { result := CountryCode("The name Narrm is commonly used by the broader Aboriginal community\n\rto refer to the city, \t stemming from the traditional name recorded for the area on which the Melbourne city centre is built.") assert.Equal(t, "au", result) }) - t.Run("ZugspitzeMelbourne", func(t *testing.T) { result := CountryCode("The name Narrm is commonly used by the broader Zugspitze community\n\rto refer to the city, \t stemming from the traditional name recorded for the area on which the Melbourne city centre is built.") assert.Equal(t, "au", result) }) - t.Run("MelbourneZugspitze", func(t *testing.T) { result := CountryCode("The name Narrm is commonly used by the broader Melbourne community\n\rto refer to the city, \t stemming from the traditional name recorded for the area on which the Zugspitze city centre is built.") assert.Equal(t, "de", result) }) - t.Run("StGallen", func(t *testing.T) { result := CountryCode("St.----Gallen") assert.Equal(t, "ch", result) }) - t.Run("CongoBrazzaville", func(t *testing.T) { result := CountryCode("Congo Brazzaville") assert.Equal(t, "cg", result) }) - t.Run("Congo", func(t *testing.T) { result := CountryCode("Congo") assert.Equal(t, "cd", result) }) - t.Run("BornInTheUSA", func(t *testing.T) { result := CountryCode("Born in the U.S.A. is a song written and performed by Bruce Springsteen...") assert.Equal(t, "zz", result) }) - t.Run("SomebodyHelpUs", func(t *testing.T) { result := CountryCode("Somebody help us please!") assert.Equal(t, "zz", result) }) - t.Run("NeverMindNirvana", func(t *testing.T) { result := CountryCode("Never mind Nirvana.") assert.Equal(t, "zz", result) }) - t.Run("EmptyString", func(t *testing.T) { result := CountryCode("") assert.Equal(t, "zz", result) }) - t.Run("Unknown", func(t *testing.T) { result := CountryCode("zz") assert.Equal(t, "zz", result) }) - t.Run("DirectoryName", func(t *testing.T) { result := CountryCode("2018/Oktober 2018/1.-7. Oktober 2018 Berlin/_MG_9831-112.jpg") assert.Equal(t, "de", result) }) - t.Run("LittleItaly", func(t *testing.T) { result := CountryCode("Little Italy Montreal") assert.Equal(t, "ca", result) }) - t.Run("LittleMontreal", func(t *testing.T) { result := CountryCode("Little Montreal Italy") assert.Equal(t, "it", result) }) - t.Run("Sharjah", func(t *testing.T) { result := CountryCode("Sharjah") assert.Equal(t, "ae", result) }) - t.Run("Arabic", func(t *testing.T) { result := CountryCode("الشارقة") assert.Equal(t, "ae", result) }) - t.Run("Hebrew", func(t *testing.T) { result := CountryCode("באר שבע") assert.Equal(t, "il", result) diff --git a/pkg/txt/datetime_test.go b/pkg/txt/datetime_test.go index 0c8c798a0..8aea2a51a 100644 --- a/pkg/txt/datetime_test.go +++ b/pkg/txt/datetime_test.go @@ -11,43 +11,33 @@ func TestIsTime(t *testing.T) { t.Run("/2020/1212/20130518_142022_3D657EBD.jpg", func(t *testing.T) { assert.False(t, IsTime("/2020/1212/20130518_142022_3D657EBD.jpg")) }) - t.Run("telegram_2020_01_30_09_57_18.jpg", func(t *testing.T) { assert.False(t, IsTime("telegram_2020_01_30_09_57_18.jpg")) }) - t.Run("", func(t *testing.T) { assert.False(t, IsTime("")) }) - t.Run("Screenshot 2019_05_21 at 10.45.52.png", func(t *testing.T) { assert.False(t, IsTime("Screenshot 2019_05_21 at 10.45.52.png")) }) - t.Run("telegram_2020-01-30_09-57-18.jpg", func(t *testing.T) { assert.False(t, IsTime("telegram_2020-01-30_09-57-18.jpg")) }) - t.Run("2013-05-18", func(t *testing.T) { assert.True(t, IsTime("2013-05-18")) }) - t.Run("2013-05-18 12:01:01", func(t *testing.T) { assert.True(t, IsTime("2013-05-18 12:01:01")) }) - t.Run("20130518_142022", func(t *testing.T) { assert.True(t, IsTime("20130518_142022")) }) - t.Run("2020_01_30_09_57_18", func(t *testing.T) { assert.True(t, IsTime("2020_01_30_09_57_18")) }) - t.Run("2019_05_21 at 10.45.52", func(t *testing.T) { assert.True(t, IsTime("2019_05_21 at 10.45.52")) }) - t.Run("2020-01-30_09-57-18", func(t *testing.T) { assert.True(t, IsTime("2020-01-30_09-57-18")) }) @@ -57,11 +47,9 @@ func TestDateTime(t *testing.T) { t.Run("Nil", func(t *testing.T) { assert.Equal(t, "", DateTime(nil)) }) - t.Run("Zero", func(t *testing.T) { assert.Equal(t, "", DateTime(&time.Time{})) }) - t.Run("1665389030", func(t *testing.T) { now := time.Unix(1665389030, 0) assert.Equal(t, "2022-10-10 08:03:50", DateTime(&now)) @@ -72,7 +60,6 @@ func TestUnixTime(t *testing.T) { t.Run("Zero", func(t *testing.T) { assert.Equal(t, "", UnixTime(0)) }) - t.Run("1665389030", func(t *testing.T) { assert.Equal(t, "2022-10-10 08:03:50", UnixTime(1665389030)) }) diff --git a/pkg/txt/datetime_year_test.go b/pkg/txt/datetime_year_test.go index a51e9af01..1037edefd 100644 --- a/pkg/txt/datetime_year_test.go +++ b/pkg/txt/datetime_year_test.go @@ -11,42 +11,34 @@ func TestYear(t *testing.T) { result := Year("/2002/London 81/") assert.Equal(t, 2002, result) }) - t.Run("San Francisco 2019", func(t *testing.T) { result := Year("San Francisco 2019") assert.Equal(t, 2019, result) }) - t.Run("string with no number", func(t *testing.T) { result := Year("Born in the U.S.A. is a song written and performed by Bruce Springsteen...") assert.Equal(t, 0, result) }) - t.Run("file name", func(t *testing.T) { result := Year("/share/photos/243546/2003/01/myfile.jpg") assert.Equal(t, 2003, result) }) - t.Run("1981", func(t *testing.T) { result := Year("/root/1981/London 2005") assert.Equal(t, 1981, result) }) - t.Run("1970", func(t *testing.T) { result := Year("/root/1970/London 2005") assert.Equal(t, 2005, result) }) - t.Run("1969", func(t *testing.T) { result := Year("/root/1969/London 2005") assert.Equal(t, 2005, result) }) - t.Run("1950", func(t *testing.T) { result := Year("/root/1950/London 2005") assert.Equal(t, 2005, result) }) - t.Run("empty string", func(t *testing.T) { result := Year("") assert.Equal(t, 0, result) @@ -58,47 +50,38 @@ func TestExpandYear(t *testing.T) { result := ExpandYear("1977") assert.Equal(t, 1977, result) }) - t.Run("2002", func(t *testing.T) { result := ExpandYear("2002") assert.Equal(t, 2002, result) }) - t.Run("2019", func(t *testing.T) { result := ExpandYear("2019") assert.Equal(t, 2019, result) }) - t.Run("XXXX", func(t *testing.T) { result := ExpandYear("XXXX") assert.Equal(t, -1, result) }) - t.Run("88", func(t *testing.T) { result := ExpandYear("88") assert.Equal(t, -1, result) }) - t.Run("91", func(t *testing.T) { result := ExpandYear("91") assert.Equal(t, 1991, result) }) - t.Run("01", func(t *testing.T) { result := ExpandYear("01") assert.Equal(t, 2001, result) }) - t.Run("1", func(t *testing.T) { result := ExpandYear("1") assert.Equal(t, -1, result) }) - t.Run("12", func(t *testing.T) { result := ExpandYear("12") assert.Equal(t, 2012, result) }) - t.Run("22", func(t *testing.T) { result := ExpandYear("22") assert.Equal(t, 2022, result) diff --git a/pkg/txt/float_test.go b/pkg/txt/float_test.go index 0a3557a98..84b3fe284 100644 --- a/pkg/txt/float_test.go +++ b/pkg/txt/float_test.go @@ -10,35 +10,27 @@ func TestIsFloat(t *testing.T) { t.Run("Empty", func(t *testing.T) { assert.False(t, IsFloat("")) }) - t.Run("Zero", func(t *testing.T) { assert.True(t, IsFloat("0")) }) - t.Run("0.5", func(t *testing.T) { assert.True(t, IsFloat("0.5")) }) - t.Run("0,5", func(t *testing.T) { assert.True(t, IsFloat("0,5")) }) - t.Run("123000.45245", func(t *testing.T) { assert.True(t, IsFloat("123000.45245 ")) }) - t.Run("123000.", func(t *testing.T) { assert.True(t, IsFloat("123000. ")) }) - t.Run("01:00", func(t *testing.T) { assert.False(t, IsFloat("01:00")) }) - t.Run("LeadingZeros", func(t *testing.T) { assert.True(t, IsFloat(" 000123")) }) - t.Run("Comma", func(t *testing.T) { assert.True(t, IsFloat(" 123,556\t ")) }) @@ -49,47 +41,38 @@ func TestFloat64(t *testing.T) { result := Float64("") assert.Equal(t, 0.0, result) }) - t.Run("NonNumeric", func(t *testing.T) { result := Float64(" Screenshot ") assert.Equal(t, 0.0, result) }) - t.Run("Zero", func(t *testing.T) { result := Float64("0") assert.Equal(t, 0.0, result) }) - t.Run("0.5", func(t *testing.T) { result := Float64("0.5") assert.Equal(t, 0.5, result) }) - t.Run("01:00", func(t *testing.T) { result := Float64("01:00") assert.Equal(t, 100.0, result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Float64(" 000123") assert.Equal(t, 123.0, result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := Float64(" 123,556\t ") assert.Equal(t, 123.556, result) }) - t.Run("PositiveFloat", func(t *testing.T) { result := Float64("123,000.45245 ") assert.Equal(t, 123000.45245, result) }) - t.Run("NegativeFloat", func(t *testing.T) { result := Float64(" - 123,000.45245 ") assert.Equal(t, -123000.45245, result) }) - t.Run("MultipleDots", func(t *testing.T) { result := Float64("123.000.45245.44 m") assert.Equal(t, 1230004524544.0, result) @@ -101,12 +84,10 @@ func TestFloat32(t *testing.T) { result := Float32("") assert.Equal(t, float32(0), result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Float32(" 000123") assert.Equal(t, float32(123), result) }) - t.Run("LongFloat", func(t *testing.T) { result := Float32("123.87945632786543786547") assert.Equal(t, float32(123.87945632786543786547), result) @@ -120,77 +101,66 @@ func TestFloatRange(t *testing.T) { assert.Equal(t, 0.0, end) assert.Error(t, err) }) - t.Run("NonNumeric", func(t *testing.T) { start, end, err := FloatRange("Screenshot", 1, 31) assert.Equal(t, 0.0, start) assert.Equal(t, 0.0, end) assert.Error(t, err) }) - t.Run("Day", func(t *testing.T) { start, end, err := FloatRange("5.11-24.64", 1, 31) assert.Equal(t, 5.11, start) assert.Equal(t, 24.64, end) assert.NoError(t, err) }) - t.Run("Zero", func(t *testing.T) { start, end, err := FloatRange("0", 5, 10) assert.Equal(t, 5.0, start) assert.Equal(t, 5.0, end) assert.NoError(t, err) }) - t.Run("LeadingZeros", func(t *testing.T) { start, end, err := FloatRange("000123", 1, 1000) assert.Equal(t, 123.0, start) assert.Equal(t, 123.0, end) assert.NoError(t, err) }) - t.Run("WhitespacePadding", func(t *testing.T) { start, end, err := FloatRange(" 123\t ", 1, 1000) assert.Equal(t, 123.0, start) assert.Equal(t, 123.0, end) assert.NoError(t, err) }) - t.Run("PositiveInt", func(t *testing.T) { start, end, err := FloatRange("123", 1, 1000) assert.Equal(t, 123.0, start) assert.Equal(t, 123.0, end) assert.NoError(t, err) }) - t.Run("NegativeInt", func(t *testing.T) { start, end, err := FloatRange("-123", -1000, 1000) assert.Equal(t, -123.0, start) assert.Equal(t, -123.0, end) assert.NoError(t, err) }) - t.Run("ZeroOne", func(t *testing.T) { start, end, err := FloatRange("0-1", -10, 10) assert.Equal(t, 0.0, start) assert.Equal(t, 1.0, end) assert.NoError(t, err) }) - t.Run("NegativeRange", func(t *testing.T) { start, end, err := FloatRange("-99.9--50.005", -100, 1000) assert.Equal(t, -99.9, start) assert.Equal(t, -50.005, end) assert.NoError(t, err) }) - t.Run("PositiveRange", func(t *testing.T) { start, end, err := FloatRange("100 - 201", 1, 1000) assert.Equal(t, 100.0, start) assert.Equal(t, 201.0, end) assert.NoError(t, err) }) - t.Run("NegativeToPositive", func(t *testing.T) { start, end, err := FloatRange("-99999-123456563", -1000000, 1000000) assert.Equal(t, -99999.0, start) diff --git a/pkg/txt/int_test.go b/pkg/txt/int_test.go index a5bc54d9f..e4ca13b5c 100644 --- a/pkg/txt/int_test.go +++ b/pkg/txt/int_test.go @@ -11,32 +11,26 @@ func TestInt(t *testing.T) { result := Int("") assert.Equal(t, 0, result) }) - t.Run("NonNumeric", func(t *testing.T) { result := Int("Screenshot") assert.Equal(t, 0, result) }) - t.Run("Zero", func(t *testing.T) { result := Int("0") assert.Equal(t, 0, result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Int("000123") assert.Equal(t, 123, result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := Int(" 123\t ") assert.Equal(t, 123, result) }) - t.Run("PositiveInt", func(t *testing.T) { result := Int("123") assert.Equal(t, 123, result) }) - t.Run("NegativeInt", func(t *testing.T) { result := Int("-123") assert.Equal(t, -123, result) @@ -48,32 +42,26 @@ func TestIntVal(t *testing.T) { result := IntVal("", 1, 31, 1) assert.Equal(t, 1, result) }) - t.Run("NonNumeric", func(t *testing.T) { result := IntVal("Screenshot", 1, 31, -1) assert.Equal(t, -1, result) }) - t.Run("Zero", func(t *testing.T) { result := IntVal("0", -10, 10, -1) assert.Equal(t, 0, result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := IntVal("000123", 1, 1000, 1) assert.Equal(t, 123, result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := IntVal(" 123\t ", 1, 1000, 1) assert.Equal(t, 123, result) }) - t.Run("PositiveInt", func(t *testing.T) { result := IntVal("123", 1, 1000, 1) assert.Equal(t, 123, result) }) - t.Run("NegativeInt", func(t *testing.T) { result := IntVal("-123", -1000, 1000, 1) assert.Equal(t, -123, result) @@ -87,77 +75,66 @@ func TestIntRange(t *testing.T) { assert.Equal(t, 0, end) assert.Error(t, err) }) - t.Run("NonNumeric", func(t *testing.T) { start, end, err := IntRange("Screenshot", 1, 31) assert.Equal(t, 0, start) assert.Equal(t, 0, end) assert.Error(t, err) }) - t.Run("Day", func(t *testing.T) { start, end, err := IntRange("5-24", 1, 31) assert.Equal(t, 5, start) assert.Equal(t, 24, end) assert.NoError(t, err) }) - t.Run("Zero", func(t *testing.T) { start, end, err := IntRange("0", 5, 10) assert.Equal(t, 5, start) assert.Equal(t, 5, end) assert.NoError(t, err) }) - t.Run("LeadingZeros", func(t *testing.T) { start, end, err := IntRange("000123", 1, 1000) assert.Equal(t, 123, start) assert.Equal(t, 123, end) assert.NoError(t, err) }) - t.Run("WhitespacePadding", func(t *testing.T) { start, end, err := IntRange(" 123\t ", 1, 1000) assert.Equal(t, 123, start) assert.Equal(t, 123, end) assert.NoError(t, err) }) - t.Run("PositiveInt", func(t *testing.T) { start, end, err := IntRange("123", 1, 1000) assert.Equal(t, 123, start) assert.Equal(t, 123, end) assert.NoError(t, err) }) - t.Run("NegativeInt", func(t *testing.T) { start, end, err := IntRange("-123", -1000, 1000) assert.Equal(t, -123, start) assert.Equal(t, -123, end) assert.NoError(t, err) }) - t.Run("ZeroOne", func(t *testing.T) { start, end, err := IntRange("0-1", -10, 10) assert.Equal(t, 0, start) assert.Equal(t, 1, end) assert.NoError(t, err) }) - t.Run("NegativeRange", func(t *testing.T) { start, end, err := IntRange("-100--50", -100, 1000) assert.Equal(t, -100, start) assert.Equal(t, -50, end) assert.NoError(t, err) }) - t.Run("PositiveRange", func(t *testing.T) { start, end, err := IntRange("100 - 201", 1, 1000) assert.Equal(t, 100, start) assert.Equal(t, 201, end) assert.NoError(t, err) }) - t.Run("NegativeToPositive", func(t *testing.T) { start, end, err := IntRange("-99999-123456563", -1000000, 1000000) assert.Equal(t, -99999, start) @@ -171,27 +148,22 @@ func TestUInt(t *testing.T) { result := UInt("") assert.Equal(t, uint(0), result) }) - t.Run("NonNumeric", func(t *testing.T) { result := UInt("Screenshot") assert.Equal(t, uint(0), result) }) - t.Run("Zero", func(t *testing.T) { result := UInt("0") assert.Equal(t, uint(0), result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := UInt("000123") assert.Equal(t, uint(0x7b), result) }) - t.Run("PositiveInt", func(t *testing.T) { result := UInt("123") assert.Equal(t, uint(0x7b), result) }) - t.Run("NegativeInt", func(t *testing.T) { result := UInt("-123") assert.Equal(t, uint(0), result) @@ -203,37 +175,30 @@ func TestInt64(t *testing.T) { result := Int64("") assert.Equal(t, int64(0), result) }) - t.Run("NonNumeric", func(t *testing.T) { result := Int64(" Screenshot ") assert.Equal(t, int64(0), result) }) - t.Run("Zero", func(t *testing.T) { result := Int64("0") assert.Equal(t, int64(0), result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Int64(" 000123") assert.Equal(t, int64(123), result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := Int64(" 123,556\t ") assert.Equal(t, int64(123), result) }) - t.Run("PositiveFloat", func(t *testing.T) { result := Int64("123,000.45245 ") assert.Equal(t, int64(123000), result) }) - t.Run("NegativeFloat", func(t *testing.T) { result := Int64(" - 123,000.45245 ") assert.Equal(t, int64(-123000), result) }) - t.Run("MultipleDots", func(t *testing.T) { result := Int64("123.000.45245.44 m") assert.Equal(t, int64(1230004524544), result) diff --git a/pkg/txt/numeric_test.go b/pkg/txt/numeric_test.go index f0b66f1e5..3ab120d00 100644 --- a/pkg/txt/numeric_test.go +++ b/pkg/txt/numeric_test.go @@ -11,47 +11,38 @@ func TestNumeric(t *testing.T) { result := Numeric("") assert.Equal(t, "", result) }) - t.Run("NonNumeric", func(t *testing.T) { result := Numeric(" Screenshot ") assert.Equal(t, "", result) }) - t.Run("Zero", func(t *testing.T) { result := Numeric("0") assert.Equal(t, "0", result) }) - t.Run("0.5", func(t *testing.T) { result := Numeric("0.5") assert.Equal(t, "0.5", result) }) - t.Run("01:00", func(t *testing.T) { result := Numeric("01:00") assert.Equal(t, "0100", result) }) - t.Run("LeadingZeros", func(t *testing.T) { result := Numeric(" 000123") assert.Equal(t, "000123", result) }) - t.Run("WhitespacePadding", func(t *testing.T) { result := Numeric(" 123,556\t ") assert.Equal(t, "123.556", result) }) - t.Run("PositiveFloat", func(t *testing.T) { result := Numeric("123,000.45245 ") assert.Equal(t, "123000.45245", result) }) - t.Run("NegativeFloat", func(t *testing.T) { result := Numeric(" - 123,000.45245 ") assert.Equal(t, "-123000.45245", result) }) - t.Run("MultipleDots", func(t *testing.T) { result := Numeric("123.000.45245.44 m") assert.Equal(t, "1230004524544", result) diff --git a/pkg/txt/report/timestamp_test.go b/pkg/txt/report/timestamp_test.go index 442ff39f1..fb5995f29 100644 --- a/pkg/txt/report/timestamp_test.go +++ b/pkg/txt/report/timestamp_test.go @@ -11,11 +11,9 @@ func TestDateTime(t *testing.T) { t.Run("Nil", func(t *testing.T) { assert.Equal(t, "", DateTime(nil)) }) - t.Run("Zero", func(t *testing.T) { assert.Equal(t, "", DateTime(&time.Time{})) }) - t.Run("1665389030", func(t *testing.T) { now := time.Unix(1665389030, 0) assert.Equal(t, "2022-10-10 08:03:50", DateTime(&now)) @@ -26,7 +24,6 @@ func TestUnixTime(t *testing.T) { t.Run("Zero", func(t *testing.T) { assert.Equal(t, "", UnixTime(0)) }) - t.Run("1665389030", func(t *testing.T) { assert.Equal(t, "2022-10-10 08:03:50", UnixTime(1665389030)) }) diff --git a/pkg/txt/split_test.go b/pkg/txt/split_test.go index bc45f06ca..621948947 100644 --- a/pkg/txt/split_test.go +++ b/pkg/txt/split_test.go @@ -147,7 +147,6 @@ func TestSplitWithEscape(t *testing.T) { assert.Equal(t, expected, actual) }) - t.Run("UnTrimmedEmptyString", func(t *testing.T) { testString := "" expected := []string{} diff --git a/pkg/txt/states_test.go b/pkg/txt/states_test.go index c9bdbed66..62b43933e 100644 --- a/pkg/txt/states_test.go +++ b/pkg/txt/states_test.go @@ -11,12 +11,10 @@ func TestStatesByCountry(t *testing.T) { result := StatesByCountry[""]["QC"] assert.Equal(t, "", result) }) - t.Run("QCCanada", func(t *testing.T) { result := StatesByCountry["ca"]["QC"] assert.Equal(t, "Quebec", result) }) - t.Run("QCUnitedStates", func(t *testing.T) { result := StatesByCountry["us"]["QC"] assert.Equal(t, "", result) diff --git a/pkg/txt/words.go b/pkg/txt/words.go index 975ae342f..e434dc298 100644 --- a/pkg/txt/words.go +++ b/pkg/txt/words.go @@ -200,7 +200,23 @@ func UniqueKeywords(s string) (results []string) { // SortCaseInsensitive performs a case-insensitive slice sort. func SortCaseInsensitive(words []string) { - sort.Slice(words, func(i, j int) bool { return strings.ToLower(words[i]) < strings.ToLower(words[j]) }) + if len(words) < 2 { + return + } + type kv struct { + lower string + idx int + } + ks := make([]kv, len(words)) + for i, w := range words { + ks[i] = kv{lower: strings.ToLower(w), idx: i} + } + sort.SliceStable(ks, func(i, j int) bool { return ks[i].lower < ks[j].lower }) + tmp := make([]string, len(words)) + for i, k := range ks { + tmp[i] = words[k.idx] + } + copy(words, tmp) } // StopwordsOnly tests if the string contains stopwords only. diff --git a/pkg/txt/words_bench_test.go b/pkg/txt/words_bench_test.go new file mode 100644 index 000000000..f92334bb6 --- /dev/null +++ b/pkg/txt/words_bench_test.go @@ -0,0 +1,68 @@ +package txt + +import ( + "strings" + "testing" +) + +func makeLargeText(distinct, repeats int) string { + // Seed a pool of mixed tokens: ASCII, unicode, hyphenated, apostrophes. + base := []string{ + "alpha", "beta", "Gamma", "delta", "epsilon", "zeta", "eta", "theta", + "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", + "rho", "sigma", "tau", "upsilon", "phi", "chi", "psi", "omega", + "New-York", "ma'am", "réseau", "Schäferhund", "Île", "Réunion", + "Cote-d'Azur", "San_Francisco", "île-de-france", "vacation", "mountain", + "beach", "sunset", "family", "holiday", "猫", "桥船", "ландшафт", "árvore", + "52nd", "80s", "IMG20240101", "VID_2023-12-31", "IMG-20201231-WA1234", + } + if distinct > len(base) { + distinct = len(base) + } + base = base[:distinct] + + var sb strings.Builder + // Rough preallocation: average word ~6 chars + space + sb.Grow(distinct * repeats * 8) + for r := 0; r < repeats; r++ { + for i, w := range base { + if i%17 == 0 { + sb.WriteString(" ") + } + sb.WriteString(w) + if i%13 == 0 { + sb.WriteString(", ") + } else { + sb.WriteByte(' ') + } + } + if r%10 == 0 { + sb.WriteString(" and or WITH in AT ") + } + } + return sb.String() +} + +func BenchmarkWords_Large(b *testing.B) { + s := makeLargeText(200, 200) // ~40k tokens mixed + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = Words(s) + } +} + +func BenchmarkUniqueKeywords_Large(b *testing.B) { + s := makeLargeText(200, 200) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = UniqueKeywords(s) + } +} + +func BenchmarkUniqueKeywords_ManyDup(b *testing.B) { + s := makeLargeText(20, 2000) // many repeats, few distinct + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = UniqueKeywords(s) + } +} diff --git a/pkg/vector/values_test.go b/pkg/vector/values_test.go index 4512c3630..5e18dbe54 100644 --- a/pkg/vector/values_test.go +++ b/pkg/vector/values_test.go @@ -27,7 +27,6 @@ func TestVector(t *testing.T) { assert.InDelta(t, 0, e.EuclideanNorm(), 0.01) assert.Equal(t, d.EuclideanNorm(), e.EuclideanNorm()) }) - t.Run("EuclideanDist", func(t *testing.T) { assert.InDelta(t, 2, a.EuclideanDist(b), 0.01) assert.InDelta(t, a.EuclideanDist(b), b.EuclideanDist(a), 0.01) @@ -36,7 +35,6 @@ func TestVector(t *testing.T) { assert.True(t, math.IsNaN(e.EuclideanDist(d))) assert.InDelta(t, 0.9999999779072661, c.EuclideanDist(n), 0.01) }) - t.Run("CosineDist", func(t *testing.T) { assert.InDelta(t, 0.978021978021978, a.CosineDist(b), 0.01) assert.True(t, math.IsNaN(c.CosineDist(d))) @@ -48,27 +46,22 @@ func TestVector(t *testing.T) { assert.InDelta(t, 1.0, a.CosineDist(a), 0.01) assert.InDelta(t, 1.0, b.CosineDist(b), 0.01) }) - t.Run("Product", func(t *testing.T) { _, err = Product(a, b) assert.Equal(t, nil, err, "failed to calculate vector product") }) - t.Run("DotProduct", func(t *testing.T) { _, err = DotProduct(a, b) assert.Equal(t, nil, err, "failed to calculate dot product") }) - t.Run("EuclideanDist", func(t *testing.T) { result := EuclideanDist(a, b) assert.InDelta(t, 2.0, result, 0.01) }) - t.Run("CosineDist", func(t *testing.T) { result := CosineDist(a, b) assert.InDelta(t, 0.978021978021978, result, 0.01) }) - t.Run("Cor", func(t *testing.T) { _, err = Cor(a, b) assert.Equal(t, nil, err, "failed to calculate vector correlation") @@ -86,7 +79,6 @@ func TestVector_CosineDist(t *testing.T) { assert.InDelta(t, 1.000000, result, 0.00001) }) - t.Run("Faces", func(t *testing.T) { a := Vector([]float64{0.09050429612398148, -0.034165557473897934, 0.04864225909113884, -0.0072485702112317085, -0.023092104122042656, 0.027721818536520004, -0.054512765258550644, -0.011073374189436436, -0.10611298680305481, -0.09373591840267181, 0.05169020965695381, 0.022325381636619568, 0.0660574808716774, 0.0745176374912262, 0.033539049327373505, -0.05087396129965782, 0.03592195361852646, 0.048213306814432144, -0.022667650133371353, 0.03860087692737579, 0.05497220903635025, -0.07314137369394302, 0.0407942458987236, 0.007624434307217598, 0.026637494564056396, -0.02921448089182377, 0.0562191866338253, 0.0022813105024397373, 0.06954964995384216, 0.011513718403875828, 0.051686715334653854, -0.06144102290272713, -0.06638970226049423, 0.017356356605887413, -0.037446312606334686, 0.011931972578167915, -0.09081587195396423, 0.007885996252298355, 0.004822328686714172, 0.07497039437294006, -0.0758754089474678, 0.01736759953200817, -0.03094332665205002, -0.028449613600969315, 0.04941004887223244, 0.043144915252923965, 0.020656095817685127, 0.00013218115782365203, 0.010540441609919071, -0.005086455028504133, -0.045420411974191666, -0.012117994017899036, 0.027115555480122566, 0.09600701183080673, 0.024567779153585434, -0.036064546555280685, 0.047646667808294296, 0.010342034511268139, 0.042122356593608856, 0.011761351488530636, 0.02835131250321865, -0.04516693949699402, 0.04595448076725006, 0.03437191620469093, 0.11455260962247849, 0.03494664281606674, 0.004416515585035086, -0.07535701245069504, -0.012427383102476597, -0.0013752385275438428, -0.026557253673672676, 0.029679754748940468, 0.014478369615972042, -0.010105332359671593, -0.012286004610359669, 0.008126290515065193, 0.029303785413503647, -0.057241108268499374, 0.0033099683932960033, 0.01164975669234991, -0.007513932418078184, 0.0242016538977623, 0.05745712295174599, 0.028663545846939087, 0.03970225155353546, -0.023029271513223648, 0.02981036901473999, 0.03829219192266464, 0.03987501189112663, 0.017537616193294525, 0.02726835384964943, -0.013689219951629639, -0.04987820237874985, -0.040511664003133774, -0.021752934902906418, -0.07194412499666214, -0.051058970391750336, 0.023398589342832565, 0.03610694780945778, 0.00046719887177459896, 0.037188317626714706, -0.02566867135465145, 0.01604439504444599, -0.003634978784248233, 0.023078899830579758, 0.04129897058010101, -0.014649354852735996, 0.005342431832104921, -0.05193427577614784, -0.0388319157063961, -0.04907385632395744, 0.07699707895517349, -0.025095880031585693, 0.008442633785307407, 0.02021537348628044, 0.001264346414245665, -0.07380999624729156, -0.048401474952697754, -0.01882218010723591, -0.03912215307354927, -0.019224153831601143, 0.04983046278357506, -0.02566423825919628, -0.04404774308204651, 0.026545586064457893, -0.011196354404091835, -0.07557094842195511, -0.01379274670034647, 0.05286092683672905, 0.0013972970191389322, 0.006785087287425995, 0.011092058382928371, -0.05664386600255966, 0.08189822733402252, 0.01435012836009264, 0.0044399346224963665, -0.010694965720176697, 0.040221404284238815, -0.027927834540605545, -0.07454574108123779, 0.0553150437772274, 0.05394366756081581, -0.0033581850584596395, -0.024510426446795464, 0.008418681100010872, 0.03598837926983833, 0.01587156392633915, 0.0038127924781292677, 0.01119104865938425, 0.04693349450826645, 0.006098508834838867, -0.0017983769066631794, -0.04955652356147766, 0.09051105380058289, 0.0073089939542114735, -0.019370773807168007, 0.007884875871241093, -0.06199871003627777, -0.01850189082324505, -0.05228694528341293, -0.05400950461626053, -0.005632052198052406, 0.0020052779000252485, 0.03451100364327431, 0.036398425698280334, 0.05265820398926735, -0.011337222531437874, 0.013296777382493019, -0.0591934509575367, -0.06284786015748978, -0.0047112139873206615, -0.05348097160458565, 0.01298768911510706, -0.0456625372171402, -0.006065149325877428, -0.025567762553691864, -0.02817298099398613, -0.05127967894077301, -0.038833457976579666, 0.06326121836900711, 0.03279256448149681, -0.06116756424307823, -0.00897540058940649, -0.06233922019600868, 0.009809938259422779, -0.005387857090681791, -0.036401160061359406, -0.04316997900605202, -0.05489049851894379, -0.09730759263038635, -0.07952211797237396, 0.02321644127368927, 0.02643345668911934, -0.029610754922032356, -0.0429108701646328, 0.015382878482341766, -0.05127616599202156, -0.06552313268184662, 0.03043435700237751, -0.004739899653941393, -0.013985950499773026, 0.03178086504340172, -0.04262787476181984, 0.050286486744880676, 0.024078307673335075, -0.02595296874642372, -0.09277850389480591, -0.02931750938296318, 0.02375088632106781, -0.0027845173608511686, -0.007097273599356413, 0.013409079983830452, -0.037381891161203384, 0.030457323417067528, -0.05184711143374443, -0.008799600414931774, 0.02957642823457718, 0.05132228881120682, 0.060836631804704666, -0.000043339907279005274, -0.04915972426533699, -0.0038748879451304674, 0.008784785866737366, -0.020982224494218826, 0.06149329990148544, 0.02837146446108818, -0.044175948947668076, 0.06567257642745972, -0.03963049501180649, -0.07002358138561249, 0.006709821987897158, 0.05959227308630943, 0.03949318826198578, -0.014092321507632732, 0.09836959093809128, -0.07943648844957352, -0.009000610560178757, 0.0030425817240029573, 0.06436953693628311, -0.026696493849158287, 0.00682637095451355, -0.01507520116865635, 0.04695310816168785, 0.029429970309138298, 0.023842934519052505, -0.020885659381747246, -0.09611587971448898, -0.07405658066272736, -0.04233424365520477, -0.0011613338720053434, 0.057546716183423996, 0.03273669630289078, -0.048568133264780045, 0.0012128526577726007, 0.053861137479543686, -0.0350908562541008, 0.022841928526759148, 0.0620933435857296, -0.009771752171218395, 0.0021118440199643373, -0.028183909133076668, -0.0776321291923523, 0.018880240619182587, -0.028937598690390587, -0.04704895243048668, -0.041743259876966476, 0.07385464012622833, -0.05170309543609619, -0.028290364891290665, 0.02530353143811226, 0.01889736019074917, 0.026071889325976372, 0.035946063697338104, -0.05380414053797722, -0.04859302192926407, 0.0003545390209183097, 0.030886799097061157, -0.022649751976132393, 0.022308727726340294, -0.027548562735319138, 0.033452969044446945, -0.05277237668633461, -0.052690643817186356, 0.04759489372372627, 0.031058967113494873, 0.04526153579354286, 0.028865164145827293, -0.07438746839761734, 0.0024590680841356516, -0.028084587305784225, 0.02709362842142582, 0.013910548761487007, 0.03424328565597534, -0.02858562208712101, 0.0588177889585495, 0.01846511848270893, -0.037898849695920944, 0.042697105556726456, -0.013334364630281925, -0.0257448498159647, 0.12665124237537384, -0.019745446741580963, -0.002844252623617649, -0.03478461131453514, 0.014344505965709686, -0.02670290693640709, 0.011817696504294872, 0.048732347786426544, -0.0477047935128212, 0.06578560918569565, -0.06530530750751495, -0.09535658359527588, 0.02058124914765358, 0.0795418918132782, -0.011797895655035973, 0.03838758170604706, -0.03237924724817276, -0.10276861488819122, -0.030437085777521133, 0.12717247009277344, -0.011626948602497578, 0.015079738572239876, -0.011959588155150414, -0.05451022833585739, 0.009935147128999233, -0.02114112488925457, 0.02175278402864933, 0.03643997013568878, 0.0015244412934407592, 0.008756493218243122, 0.028317170217633247, -0.030474519357085228, -0.00388348544947803, -0.05619083344936371, 0.06022652983665466, -0.05782870203256607, -0.02699786052107811, 0.023685157299041748, -0.038485050201416016, 0.05153445899486542, -0.060548875480890274, -0.01506721694022417, -0.007553438656032085, 0.039509836584329605, 0.03681030124425888, 0.1338905692100525, -0.0035669414792209864, -0.012824718840420246, 0.08026544004678726, 0.0007068145787343383, 0.05609028413891792, -0.09387584030628204, 0.053474973887205124, -0.02244669944047928, -0.03398754075169563, 0.03549772873520851, -0.034875307232141495, -0.0047780852764844894, -0.0516449399292469, 0.061801642179489136, 0.019724003970623016, -0.05515078082680702, -0.039201926440000534, 0.06470708549022675, 0.04355214163661003, -0.033806391060352325, 0.0747586265206337, 0.008061372675001621, -0.031078290194272995, 0.02051386795938015, -0.010661521926522255, 0.007028735242784023, 0.052813757210969925, -0.014618860557675362, 0.010391696356236935, 0.01685873605310917, 0.005331454332917929, -0.05162535980343819, -0.014504718594253063, -0.026801610365509987, -0.00626059714704752, -0.06905800849199295, -0.10446352511644363, -0.05261509492993355, -0.04950559511780739, 0.013261653482913971, -0.025283565744757652, -0.03371249884366989, -0.009910509921610355, 0.05506015568971634, -0.0032501542009413242, 0.06410012394189835, -0.02705288864672184, 0.03838157281279564, -0.0028722372371703386, 0.10523688048124313, -0.005469444673508406, 0.0006657811463810503, -0.022171825170516968, 0.007440139539539814, -0.009108834899961948, 0.09758798778057098, 0.07208772003650665, -0.03589840233325958, 0.01901053451001644, 0.01944487728178501, -0.03779883682727814, -0.005568244960159063, -0.04835351184010506, 0.028863374143838882, 0.013382171280682087, 0.01990400068461895, -0.02292671613395214, 0.033316295593976974, 0.0007049285341054201, -0.08170327544212341, 0.005303904879838228, 0.017129195854067802, 0.048035942018032074, 0.0177064910531044, -0.07409890741109848, 0.002690644469112158, -0.09358572959899902, -0.02494824305176735, 0.005160054191946983, 0.01309937983751297, 0.01733747310936451, -0.028727075085043907, 0.07431124895811081, 0.019230933859944344, 0.044469334185123444, 0.07735494524240494, 0.014045624993741512, 0.02358667366206646, -0.029009323567152023, 0.011838157661259174, -0.03148479387164116, -0.02401883713901043, 0.07134886831045151, 0.03462617099285126, 0.09141889214515686, -0.032590366899967194, -0.017935005947947502, 0.01033404003828764, 0.021358856931328773, 0.052892062813043594, -0.016272393986582756, -0.0021813628263771534, -0.014650222845375538, 0.02733163721859455, 0.012284406460821629, -0.00011409903527237475, -0.0019222642295062542, 0.011368582025170326, 0.08538514375686646, 0.07764560729265213, -0.00016646491712890565, -0.021158777177333832, -0.003685612231492996, 0.0027680168859660625, -0.07727153599262238, -0.045435767620801926, 0.027570463716983795, 0.004876504186540842, -0.023479128256440163, -0.05900833383202553, 0.021972816437482834, 0.08244964480400085, -0.04722096025943756, 0.09820368885993958, -0.03607189655303955, 0.024602027609944344, 0.09130720794200897, 0.0060490816831588745, 0.003609518986195326, -0.05344478040933609, -0.013851549476385117, -0.0840664803981781, -0.07599852979183197, -0.04853493720293045, 0.02409926988184452, 0.007712183985859156, 0.047192711383104324, -0.02735353447496891, -0.0286621805280447, 0.051183052361011505, 0.026429185643792152, 0.047100841999053955, 0.07225227355957031, 0.05775126442313194, -0.07109982520341873, -0.019554149359464645, 0.004804393742233515, 0.07182382047176361, -0.05714774131774902, -0.03718779981136322, 0.010022906586527824, 0.040755949914455414, 0.035538796335458755, 0.0547688826918602, 0.018436597660183907, -0.009496998973190784, -0.034701500087976456, -0.07542424649000168, -0.024086780846118927, 0.03786386549472809, 0.08617071062326431, 0.061641909182071686, -0.10206557810306549, 0.008707753382623196, 0.07315801829099655, 0.017708826810121536}) b := Vector([]float64{-0.008137415163218975, -0.08006370067596436, 0.07025300711393356, 0.05047300457954407, -0.048540644347667694, 0.02255844883620739, -0.032381802797317505, -0.030422719195485115, -0.002007395029067993, 0.02374134585261345, 0.06882382184267044, -0.0014070027973502874, 0.0016334111569449306, -0.0006230792496353388, 0.024960000067949295, 0.05986246466636658, -0.024349741637706757, 0.04618404060602188, -0.08673757314682007, 0.05276675894856453, 0.0027138087898492813, 0.008117659948766232, -0.0037429891526699066, -0.01570519059896469, -0.13779018819332123, 0.06322789937257767, -0.058197781443595886, 0.04746529087424278, -0.08711255341768265, 0.019238201901316643, -0.05508963391184807, 0.017365770414471626, 0.02562207169830799, -0.002929446753114462, -0.07081708312034607, 0.03103666938841343, 0.0022698792163282633, 0.051838476210832596, 0.01691291667521, 0.005320006050169468, 0.030585378408432007, 0.06941074877977371, 0.008247891440987587, -0.03533969074487686, 0.05122591182589531, 0.04988280311226845, 0.08505327254533768, 0.04561468958854675, -0.062374748289585114, 0.022638529539108276, -0.022780820727348328, 0.0775931254029274, 0.02398429997265339, 0.03495755046606064, -0.012407658621668816, -0.03283195570111275, 0.06503018736839294, -0.03657644987106323, -0.029164006933569908, 0.058266233652830124, -0.019877741113305092, -0.04159046709537506, 0.0050654299557209015, -0.015958501026034355, -0.0295542161911726, 0.02208816446363926, 0.017241189256310463, -0.00469202920794487, -0.016516422852873802, 0.018105173483490944, 0.04346457123756409, 0.04661091789603233, 0.0091007174924016, -0.02539660967886448, -0.05948945879936218, 0.008711921982467175, -0.05822482705116272, 0.06004893407225609, -0.04515865445137024, -0.07707851380109787, 0.0032580809202045202, 0.006324823014438152, 0.02883482724428177, -0.01455928385257721, -0.03203008323907852, 0.010120648890733719, 0.02116110734641552, -0.028596824035048485, -0.06669415533542633, -0.03394141048192978, -0.021578358486294746, -0.0029677071142941713, -0.07965116947889328, -0.0005286909872666001, 0.048941437155008316, 0.05800784006714821, 0.042431481182575226, -0.03241220489144325, 0.0220502782613039, -0.03481437265872955, -0.04674076661467552, -0.004196728114038706, -0.07022743672132492, 0.07238440960645676, 0.04464253410696983, -0.04207949340343475, 0.021947475150227547, 0.013727233745157719, -0.06659548729658127, -0.00480034900829196, -0.010313994251191616, 0.02640429139137268, -0.0018985014175996184, -0.017516719177365303, 0.05424032732844353, 0.03034326806664467, -0.009059063158929348, -0.014670928940176964, 0.03108176961541176, -0.06324155628681183, 0.009439341723918915, -0.03230450302362442, 0.03011772409081459, -0.08903207629919052, 0.049357179552316666, -0.018093667924404144, -0.09150006622076035, 0.03277801349759102, -0.020068803802132607, -0.12405339628458023, 0.039792630821466446, -0.01258617639541626, -0.050734683871269226, 0.01388365589082241, -0.020610308274626732, -0.01117456890642643, -0.047699980437755585, 0.00904754176735878, -0.009436380118131638, 0.03356689587235451, 0.05179798603057861, -0.003932574763894081, 0.004325704649090767, 0.013883235864341259, -0.01672312431037426, -0.009120902046561241, 0.02865191549062729, -0.00018901238217949867, -0.14957121014595032, 0.06165143474936485, -0.010900797322392464, -0.04367987439036369, -0.08651748299598694, -0.04971740022301674, -0.012035397812724113, 0.05332765355706215, -0.03252051770687103, 0.027909329161047935, -0.043622151017189026, -0.03164845332503319, -0.03198356553912163, 0.05128004029393196, -0.040155746042728424, -0.0056242248974740505, -0.047345563769340515, 0.012517815455794334, -0.04241799935698509, -0.05661820247769356, 0.025342857465147972, -0.03272772207856178, -0.020752016454935074, 0.02841475047171116, 0.026285288855433464, -0.023260189220309258, 0.10821936279535294, 0.042671091854572296, 0.02412649802863598, -0.04032492637634277, 0.02996264584362507, -0.04249041527509689, -0.06933289021253586, 0.027393169701099396, 0.05308475345373154, -0.02078690193593502, -0.0067361886613070965, -0.0238595362752676, 0.01484128087759018, 0.021988647058606148, 0.06511303037405014, -0.08086460083723068, 0.03650467097759247, -0.021279210224747658, 0.045867037028074265, 0.06642837822437286, -0.003237910568714142, -0.01581430248916149, -0.02993408963084221, -0.0012522733304649591, -0.10658521205186844, -0.004413484595716, 0.014794287271797657, 0.04928160458803177, -0.029931025579571724, -0.0077111730352044106, -0.0732073038816452, 0.04210418090224266, -0.02385932393372059, 0.010084617882966995, -0.03800346329808235, -0.0682244822382927, 0.05564611777663231, 0.03370542451739311, 0.03176405653357506, -0.03024214692413807, 0.06749926507472992, -0.04842272028326988, 0.024823250249028206, 0.03411858156323433, 0.012186306528747082, 0.000344925036188215, 0.008661405183374882, -0.06331458687782288, -0.06190492585301399, -0.08768630772829056, 0.00041797355515882373, -0.018540306016802788, 0.04823239892721176, 0.06846356391906738, -0.03144964575767517, 0.03312361240386963, 0.023701541125774384, 0.06020048260688782, -0.018800485879182816, -0.01420025434345007, -0.01672394946217537, -0.016691889613866806, 0.011016116477549076, 0.013350298628211021, 0.01238208170980215, 0.01691477745771408, -0.0010262437863275409, 0.053074032068252563, 0.018735406920313835, 0.035321593284606934, -0.022181423380970955, -0.017033280804753304, 0.032775890082120895, 0.05400064215064049, -0.019599098712205887, -0.014503749087452888, -0.021623743698000908, -0.020413488149642944, 0.033719275146722794, 0.015080427750945091, 0.044618159532547, 0.10514744371175766, -0.07683298736810684, -0.04230527579784393, -0.04676186293363571, 0.1221809908747673, 0.018557196483016014, -0.10948510468006134, -0.007052143104374409, 0.03444751724600792, -0.12690776586532593, 0.01613660342991352, 0.026724737137556076, -0.01689913682639599, 0.032920803874731064, 0.0033084892202168703, -0.008477253839373589, 0.005826534237712622, 0.05720831826329231, -0.01001140009611845, 0.06672288477420807, -0.03293010592460632, -0.008007185533642769, 0.03565505892038345, -0.045379389077425, -0.01783432625234127, -0.06628750264644623, -0.0027283544186502695, 0.03038688562810421, -0.020332351326942444, -0.06984596699476242, 0.07357253134250641, 0.10730107873678207, -0.015427534468472004, -0.0743771567940712, -0.043926578015089035, 0.04178789258003235, 0.011173141188919544, 0.025277631357312202, 0.07788840681314468, 0.004462982527911663, -0.05092375725507736, -0.008599985390901566, -0.011293918825685978, -0.027740459889173508, -0.08115565031766891, -0.009694007225334644, -0.07026723772287369, 0.02862926386296749, 0.01975271850824356, 0.00902275275439024, 0.05126506835222244, -0.07478014379739761, 0.021499518305063248, 0.055629830807447433, 0.031052052974700928, 0.05949579179286957, -0.028020750731229782, -0.02862796001136303, 0.056453678756952286, -0.031041307374835014, 0.02422715350985527, -0.07100068032741547, 0.032912544906139374, 0.009702234528958797, 0.06579262763261795, -0.08040975034236908, 0.06385798007249832, -0.009425429627299309, 0.0210894588381052, 0.018046097829937935, 0.005574067123234272, -0.04380524903535843, 0.022144699469208717, 0.025359811261296272, 0.04807835817337036, 0.0006021010922268033, -0.07143598049879074, 0.020978737622499466, -0.05171458050608635, 0.0382511243224144, 0.025260137394070625, 0.09952495992183685, 0.014374740421772003, -0.03502845764160156, 0.008830498903989792, -0.06433495879173279, -0.07015430927276611, 0.0705648809671402, 0.010429518297314644, 0.01585286110639572, -0.056704264134168625, 0.00618960103020072, 0.03986428678035736, 0.003704571630805731, -0.015225011855363846, 0.008472353219985962, 0.07302212715148926, -0.020373830571770668, 0.003514211857691407, 0.013997740112245083, -0.0038872845470905304, -0.03703470155596733, -0.03875543549656868, -0.012492465786635876, 0.059783436357975006, 0.014407767914235592, 0.032212793827056885, 0.03943135216832161, -0.04783743992447853, -0.08073361217975616, 0.025175172835588455, -0.07072967290878296, 0.06343924254179001, 0.041898149996995926, -0.056953541934490204, 0.029663341119885445, 0.04826335236430168, 0.010070821270346642, -0.037220172584056854, 0.01026119664311409, 0.061207421123981476, 0.0938570499420166, -0.037144023925065994, 0.04894149303436279, -0.011274874210357666, -0.0167219378054142, -0.06375480443239212, 0.022223835811018944, -0.05070881545543671, 0.01647593080997467, -0.022871576249599457, 0.06493012607097626, -0.10572224110364914, -0.042038556188344955, -0.026631822809576988, 0.04876351356506348, 0.03300929814577103, 0.010545262135565281, -0.011176304891705513, -0.034550394862890244, 0.019394656643271446, -0.033105626702308655, -0.01746574230492115, 0.04380633309483528, 0.021436357870697975, -0.01687694527208805, 0.009079670533537865, -0.0019935970194637775, 0.02397148869931698, 0.020745644345879555, -0.021921435371041298, 0.013043859973549843, -0.001916338107548654, -0.04032173752784729, 0.010448900051414967, -0.01346014253795147, -0.048173755407333374, 0.0696289911866188, 0.0027435971423983574, -0.020373817533254623, 0.03318791463971138, -0.05028868094086647, -0.06621105223894119, 0.08377863466739655, -0.06662183254957199, 0.040066834539175034, -0.031038448214530945, -0.0012645371025428176, -0.08046844601631165, -0.07508288323879242, -0.011627450585365295, 0.05332736670970917, -0.050804637372493744, -0.02298901602625847, 0.017321497201919556, 0.04411279037594795, 0.02128334902226925, 0.026375887915492058, -0.006951047573238611, 0.026754219084978104, 0.04742620140314102, -0.01177502702921629, 0.060023725032806396, 0.005484475754201412, -0.00279219844378531, -0.09506803750991821, 0.08349333703517914, -0.02932984009385109, -0.0005634548142552376, -0.009349865838885307, -0.04879671335220337, 0.021670177578926086, -0.03875391557812691, -0.028711730614304543, -0.03708324581384659, -0.11263322830200195, -0.033891621977090836, 0.04228183254599571, 0.045042142271995544, 0.028116095811128616, 0.005323487799614668, 0.10822916775941849, -0.011182534508407116, -0.060331087559461594, -0.027081234380602837, -0.030490467324852943, -0.050583478063344955, 0.013974534347653389, -0.006292127538472414, 0.05019136518239975, 0.044325292110443115, 0.008860588073730469, 0.0005901191616430879, -0.04545517638325691, 0.028188807889819145, 0.02233756333589554, -0.07275871932506561, -0.04000203683972359, 0.05174611508846283, -0.050140008330345154, 0.017476622015237808, 0.0713571161031723, 0.042271941900253296, -0.004472524859011173, 0.0516694039106369, 0.07247994840145111, -0.027353506535291672, -0.024733062833547592, -0.049786582589149475, -0.01696055382490158, 0.06537499278783798, 0.04510447010397911, -0.04963269829750061, 0.018454866483807564, 0.03766272962093353, -0.08027862757444382, 0.06390812247991562, 0.009653930552303791, -0.017673317342996597, 0.008882815018296242, 0.001341609749943018, -0.0008363581146113575, 0.005991082638502121, 0.030882244929671288, -0.0010816589929163456, -0.043741147965192795, -0.02244267612695694, 0.020251808688044548, -0.008073689416050911, 0.0008555973181501031, -0.025090467184782028, 0.014763036742806435, 0.008378133177757263, 0.06943207234144211, 0.04518865421414375, -0.051252108067274094, 0.004460426978766918})