mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-27 05:08:13 +08:00
Auth: Refactor cluster configuration and provisioning API endpoints #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
45
AGENTS.md
45
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/<file>.go`, always place them in `path/to/pkg/<file>_test.go` (create this file if it does not yet exist). For the same function, group related cases as sub-tests with `t.Run(...)` (table-driven where helpful).
|
||||
- Client IDs & UUIDs in tests:
|
||||
- For OAuth client IDs, generate valid IDs via `rnd.GenerateUID(entity.ClientUID)` or use a static, valid ID (16 chars, starts with `c`). To validate shape, use `rnd.IsUID(id, entity.ClientUID)`.
|
||||
- For node UUIDs, prefer `rnd.UUIDv7()` and treat it as required in node responses (`node.uuid`).
|
||||
|
||||
### Next‑Session Reminders
|
||||
- If we add Postgres provisioning support, extend BuildDSN and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
|
||||
- Consider surfacing a short “uuid → db/user” mapping helper in CLI (e.g., `nodes show --creds`) if operators request it.
|
||||
- Prefer targeted runs for speed:
|
||||
- Unit/subpackage: `go test ./internal/<pkg> -run <Name> -count=1`
|
||||
- Commands: `go test ./internal/commands -run <Name> -count=1`
|
||||
@@ -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<hmac11>`, `photoprism_u<hmac11>`. HMAC is scoped by ClusterUUID+NodeUUID.
|
||||
- BuildDSN accepts `driver`; unsupported values fall back to MySQL format with a warning.
|
||||
- ClientData cleanup
|
||||
- `NodeUUID` removed from `ClientData`; it lives on the top‑level client row (`auth_clients.node_uuid`). `ClientDatabase` now includes `driver`.
|
||||
- Testing patterns:
|
||||
- Use `httptest` for Portal endpoints and `pkg/fs.Unzip` with size caps for extraction tests.
|
||||
|
21
CODEMAP.md
21
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<hmac11>`
|
||||
- username: `photoprism_u<hmac11>`
|
||||
HMAC is base32 of ClusterUUID+NodeUUID; drivers currently `mysql|mariadb`.
|
||||
- DSN builder: `BuildDSN(driver, host, port, user, pass, name)`; warns and falls back to MySQL format for unsupported drivers.
|
||||
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful).
|
||||
- Public API and internal registry DTOs use normalized field names:
|
||||
- `database` (not `db`) with `name`, `user`, `driver`, `rotatedAt`.
|
||||
- Node-level rotation timestamps use `rotatedAt`.
|
||||
- Registration returns `secrets.clientSecret`; the CLI persists it under config `NodeClientSecret`.
|
||||
- Admin responses may include `advertiseUrl` and `database`; non-admin responses are redacted by default.
|
||||
|
||||
Frequently Touched Files (by topic)
|
||||
- CLI wiring: `cmd/photoprism/photoprism.go`, `internal/commands/commands.go`
|
||||
|
2
go.mod
2
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
|
||||
|
@@ -28,7 +28,6 @@ func TestLabelRule_Find(t *testing.T) {
|
||||
assert.Equal(t, -2, result.Priority)
|
||||
assert.Equal(t, float32(1), result.Threshold)
|
||||
})
|
||||
|
||||
t.Run("not existing rule", func(t *testing.T) {
|
||||
result, ok := rules.Find("fish")
|
||||
assert.False(t, ok)
|
||||
|
@@ -20,7 +20,6 @@ func TestLabel_NewLocationLabel(t *testing.T) {
|
||||
assert.Equal(t, 24, LocLabel.Uncertainty)
|
||||
assert.Equal(t, "locationtest", LocLabel.Name)
|
||||
})
|
||||
|
||||
t.Run("locationtest - minus", func(t *testing.T) {
|
||||
LocLabel := LocationLabel("locationtest - minus", 80)
|
||||
t.Log(LocLabel)
|
||||
@@ -28,7 +27,6 @@ func TestLabel_NewLocationLabel(t *testing.T) {
|
||||
assert.Equal(t, 80, LocLabel.Uncertainty)
|
||||
assert.Equal(t, "locationtest", LocLabel.Name)
|
||||
})
|
||||
|
||||
t.Run("label as name", func(t *testing.T) {
|
||||
LocLabel := LocationLabel("barracouta", 80)
|
||||
t.Log(LocLabel)
|
||||
@@ -45,7 +43,6 @@ func TestLabel_Title(t *testing.T) {
|
||||
LocLabel := LocationLabel("locationtest123", 23)
|
||||
assert.Equal(t, "Locationtest123", LocLabel.Title())
|
||||
})
|
||||
|
||||
t.Run("Berlin/Neukölln", func(t *testing.T) {
|
||||
LocLabel := LocationLabel("berlin/neukölln_hasenheide", 23)
|
||||
assert.Equal(t, "Berlin / Neukölln Hasenheide", LocLabel.Title())
|
||||
|
@@ -21,7 +21,6 @@ func TestLabel_AppendLabel(t *testing.T) {
|
||||
assert.Equal(t, "cat", labelsNew[0].Name)
|
||||
assert.Equal(t, "cow", labelsNew[2].Name)
|
||||
})
|
||||
|
||||
t.Run("labelWithoutName", func(t *testing.T) {
|
||||
assert.Equal(t, 2, labels.Len())
|
||||
cow := Label{Name: "", Source: "location", Uncertainty: 80, Priority: 5}
|
||||
@@ -39,7 +38,6 @@ func TestLabels_Title(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "cat", labels.Title("fallback"))
|
||||
})
|
||||
|
||||
t.Run("second", func(t *testing.T) {
|
||||
cat := Label{Name: "cat", Source: "location", Uncertainty: 61, Priority: 5}
|
||||
dog := Label{Name: "dog", Source: "location", Uncertainty: 10, Priority: 4}
|
||||
@@ -47,7 +45,6 @@ func TestLabels_Title(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "dog", labels.Title("fallback"))
|
||||
})
|
||||
|
||||
t.Run("fallback", func(t *testing.T) {
|
||||
cat := Label{Name: "cat", Source: "location", Uncertainty: 80, Priority: 5}
|
||||
dog := Label{Name: "dog", Source: "location", Uncertainty: 80, Priority: 4}
|
||||
@@ -55,13 +52,11 @@ func TestLabels_Title(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "fallback", labels.Title("fallback"))
|
||||
})
|
||||
|
||||
t.Run("empty labels", func(t *testing.T) {
|
||||
labels := Labels{}
|
||||
|
||||
assert.Equal(t, "", labels.Title(""))
|
||||
})
|
||||
|
||||
t.Run("label priority < 0", func(t *testing.T) {
|
||||
cat := Label{Name: "cat", Source: "location", Uncertainty: 59, Priority: -1}
|
||||
dog := Label{Name: "dog", Source: "location", Uncertainty: 10, Priority: -1}
|
||||
@@ -69,7 +64,6 @@ func TestLabels_Title(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "fallback", labels.Title("fallback"))
|
||||
})
|
||||
|
||||
t.Run("label priority = 0", func(t *testing.T) {
|
||||
cat := Label{Name: "cat", Source: "location", Uncertainty: 59, Priority: 0}
|
||||
dog := Label{Name: "dog", Source: "location", Uncertainty: 62, Priority: 0}
|
||||
|
@@ -2,7 +2,7 @@ package tensorflow
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io/fs"
|
||||
iofs "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -35,7 +35,7 @@ func loadLabelsFromPath(path string) (labels []string, err error) {
|
||||
func LoadLabels(modelPath string, expectedLabels int) (labels []string, err error) {
|
||||
|
||||
dir := os.DirFS(modelPath)
|
||||
matches, err := fs.Glob(dir, "labels*.txt")
|
||||
matches, err := iofs.Glob(dir, "labels*.txt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@@ -66,14 +66,12 @@ func TestUpdateAlbum(t *testing.T) {
|
||||
assert.Equal(t, "false", val2.String())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateAlbum(router)
|
||||
r := PerformRequestWithBody(app, "PUT", "/api/v1/albums"+uid, `{"Title": 333, "Description": "Created via unit test", "Notes": "", "Favorite": true}`)
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateAlbum(router)
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
// Delete by NodeUUID
|
||||
if err = regy.Delete(uuid); err != nil {
|
||||
if err == reg.ErrNotFound {
|
||||
AbortEntityNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
if err = regy.Delete(id); err != nil {
|
||||
} else {
|
||||
AbortUnexpectedError(c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "delete", id, event.Succeeded})
|
||||
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "delete", uuid, event.Succeeded})
|
||||
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
|
||||
})
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Verifies redaction differences between admin and non-admin on list endpoint.
|
||||
@@ -23,9 +24,10 @@ func TestClusterListNodes_Redaction(t *testing.T) {
|
||||
// Seed one node with internal URL and DB metadata.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
|
||||
n.DB.Name = "pp_db"
|
||||
n.DB.User = "pp_user"
|
||||
// Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List().
|
||||
n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
|
||||
n.Database.Name = "pp_db"
|
||||
n.Database.User = "pp_user"
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Admin session shows internal fields
|
||||
@@ -55,8 +57,8 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
// Seed node with internal URL and DB meta.
|
||||
n := ®.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}
|
||||
n.DB.Name = "pp_db2"
|
||||
n.DB.User = "pp_user2"
|
||||
n.Database.Name = "pp_db2"
|
||||
n.Database.User = "pp_user2"
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Create client session with cluster scope and no user (redacted view expected).
|
||||
|
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/server/limiter"
|
||||
@@ -20,14 +21,18 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
// RegisterRequireClientSecret controls whether registrations that reference an
|
||||
// existing ClientID must also present the matching client secret. Enabled by default.
|
||||
var RegisterRequireClientSecret = true
|
||||
|
||||
// ClusterNodesRegister registers the Portal-only node registration endpoint.
|
||||
//
|
||||
// @Summary registers a node, provisions DB credentials, and issues nodeSecret
|
||||
// @Summary registers a node, provisions DB credentials, and issues clientSecret
|
||||
// @Id ClusterNodesRegister
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl, rotateDatabase, rotateSecret)"
|
||||
// @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl; to authorize UUID/name changes include clientId+clientSecret; rotation: rotateDatabase, rotateSecret)"
|
||||
// @Success 200,201 {object} cluster.RegisterResponse
|
||||
// @Failure 400,401,403,409,429 {object} i18n.Response
|
||||
// @Router /api/v1/cluster/nodes/register [post]
|
||||
@@ -65,10 +70,13 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
// Parse request.
|
||||
var req struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUUID string `json:"nodeUUID"`
|
||||
NodeRole string `json:"nodeRole"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
AdvertiseUrl string `json:"advertiseUrl"`
|
||||
SiteUrl string `json:"siteUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RotateDatabase bool `json:"rotateDatabase"`
|
||||
RotateSecret bool `json:"rotateSecret"`
|
||||
}
|
||||
@@ -79,13 +87,58 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
name := clean.TypeLowerDash(req.NodeName)
|
||||
// If an existing ClientID is provided, require the corresponding client secret for verification.
|
||||
if RegisterRequireClientSecret && req.ClientID != "" {
|
||||
if !rnd.IsUID(req.ClientID, entity.ClientUID) {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid client id"})
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
pw := entity.FindPassword(req.ClientID)
|
||||
if pw == nil || req.ClientSecret == "" || !pw.Valid(req.ClientSecret) {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid client secret"})
|
||||
AbortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" || len(name) < 1 || len(name) > 63 {
|
||||
name := clean.DNSLabel(req.NodeName)
|
||||
|
||||
// Enforce DNS label semantics for node names: lowercase [a-z0-9-], 1–32, start/end alnum.
|
||||
if name == "" || len(name) > 32 || name[0] == '-' || name[len(name)-1] == '-' {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid name"})
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
b := name[i]
|
||||
if !(b == '-' || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')) {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid name chars"})
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate advertise URL if provided (https required for non-local domains).
|
||||
if u := strings.TrimSpace(req.AdvertiseUrl); u != "" {
|
||||
if !validateAdvertiseURL(u) {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid advertise url"})
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate site URL if provided (https required for non-local domains).
|
||||
if su := strings.TrimSpace(req.SiteUrl); su != "" {
|
||||
if !validateSiteURL(su) {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "invalid site url"})
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize requested NodeUUID; generation happens later depending on path (existing vs new).
|
||||
requestedUUID := rnd.SanitizeUUID(req.NodeUUID)
|
||||
|
||||
// Registry (client-backed).
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
@@ -98,6 +151,14 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
|
||||
// Try to find existing node.
|
||||
if n, _ := regy.FindByName(name); n != nil {
|
||||
// If caller attempts to change UUID by name without proving client secret, block with 409.
|
||||
if RegisterRequireClientSecret {
|
||||
if requestedUUID != "" && n.UUID != "" && requestedUUID != n.UUID && req.ClientID == "" {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "uuid change requires client secret", event.Denied, "name %s", clean.LogQuote(name)})
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "client secret required to change node uuid"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Update mutable metadata when provided.
|
||||
if req.AdvertiseUrl != "" {
|
||||
n.AdvertiseUrl = req.AdvertiseUrl
|
||||
@@ -108,6 +169,19 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
if s := normalizeSiteURL(req.SiteUrl); s != "" {
|
||||
n.SiteUrl = s
|
||||
}
|
||||
// Apply UUID changes for existing node: if a UUID was requested and differs, or if none exists yet.
|
||||
if requestedUUID != "" {
|
||||
oldUUID := n.UUID
|
||||
if oldUUID != requestedUUID {
|
||||
n.UUID = requestedUUID
|
||||
// Emit audit event for UUID change.
|
||||
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "uuid changed", event.Succeeded, "name %s", "old %s", "new %s"}, clean.LogQuote(name), clean.Log(oldUUID), clean.Log(requestedUUID))
|
||||
}
|
||||
} else if n.UUID == "" {
|
||||
// Assign a fresh UUID if missing and none requested.
|
||||
n.UUID = rnd.UUIDv7()
|
||||
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "uuid changed", event.Succeeded, "name %s", "old %s", "new %s"}, clean.LogQuote(name), clean.Log(""), clean.Log(n.UUID))
|
||||
}
|
||||
// Persist metadata changes so UpdatedAt advances.
|
||||
if putErr := regy.Put(n); putErr != nil {
|
||||
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr))
|
||||
@@ -117,12 +191,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
// Optional rotations.
|
||||
var respSecret *cluster.RegisterSecrets
|
||||
if req.RotateSecret {
|
||||
if n, err = regy.RotateSecret(n.ID); err != nil {
|
||||
if n, err = regy.RotateSecret(n.UUID); err != nil {
|
||||
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate secret", event.Failed, "%s"}, clean.Error(err))
|
||||
AbortUnexpectedError(c)
|
||||
return
|
||||
}
|
||||
respSecret = &cluster.RegisterSecrets{NodeSecret: n.Secret, SecretRotatedAt: n.SecretRot}
|
||||
respSecret = &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt}
|
||||
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate secret", event.Succeeded, "node %s"}, clean.LogQuote(name))
|
||||
|
||||
// Extra safety: ensure the updated secret is persisted even if subsequent steps fail.
|
||||
@@ -134,7 +208,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Ensure that a database for this node exists (rotation optional).
|
||||
creds, _, credsErr := provisioner.EnsureNodeDatabase(c, conf, name, req.RotateDatabase)
|
||||
creds, _, credsErr := provisioner.GetCredentials(c, conf, n.UUID, name, req.RotateDatabase)
|
||||
|
||||
if credsErr != nil {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(credsErr))
|
||||
@@ -143,7 +217,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
if req.RotateDatabase {
|
||||
n.DB.RotAt = creds.LastRotatedAt
|
||||
n.Database.RotatedAt = creds.RotatedAt
|
||||
n.Database.Driver = provisioner.DatabaseDriver
|
||||
if putErr := regy.Put(n); putErr != nil {
|
||||
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr))
|
||||
AbortUnexpectedError(c)
|
||||
@@ -155,8 +230,9 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
// Build response with struct types.
|
||||
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
|
||||
resp := cluster.RegisterResponse{
|
||||
UUID: conf.ClusterUUID(),
|
||||
Node: reg.BuildClusterNode(*n, opts),
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.DB.Name, User: n.DB.User},
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver},
|
||||
Secrets: respSecret,
|
||||
AlreadyRegistered: true,
|
||||
AlreadyProvisioned: true,
|
||||
@@ -166,7 +242,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
if req.RotateDatabase {
|
||||
resp.Database.Password = creds.Password
|
||||
resp.Database.DSN = creds.DSN
|
||||
resp.Database.RotatedAt = creds.LastRotatedAt
|
||||
resp.Database.RotatedAt = creds.RotatedAt
|
||||
}
|
||||
|
||||
c.Header(header.CacheControl, header.CacheControlNoStore)
|
||||
@@ -174,30 +250,39 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
// New node.
|
||||
// New node (client UID will be generated in registry.Put).
|
||||
n := ®.Node{
|
||||
ID: rnd.UUID(),
|
||||
Name: name,
|
||||
Role: clean.TypeLowerDash(req.NodeRole),
|
||||
UUID: requestedUUID,
|
||||
Labels: req.Labels,
|
||||
AdvertiseUrl: req.AdvertiseUrl,
|
||||
}
|
||||
if n.UUID == "" {
|
||||
n.UUID = rnd.UUIDv7()
|
||||
}
|
||||
// Derive a sensible default advertise URL when not provided by the client.
|
||||
if req.AdvertiseUrl != "" {
|
||||
n.AdvertiseUrl = req.AdvertiseUrl
|
||||
} else if d := conf.ClusterDomain(); d != "" {
|
||||
n.AdvertiseUrl = "https://" + name + "." + d
|
||||
}
|
||||
if s := normalizeSiteURL(req.SiteUrl); s != "" {
|
||||
n.SiteUrl = s
|
||||
}
|
||||
|
||||
// Generate node secret.
|
||||
n.Secret = rnd.Base62(48)
|
||||
n.SecretRot = nowRFC3339()
|
||||
// Generate node secret (must satisfy client secret format for entity.Client).
|
||||
n.ClientSecret = rnd.ClientSecret()
|
||||
n.RotatedAt = nowRFC3339()
|
||||
|
||||
// Ensure DB (force rotation at create path to return password).
|
||||
creds, _, err := provisioner.EnsureNodeDatabase(c, conf, name, true)
|
||||
creds, _, err := provisioner.GetCredentials(c, conf, n.UUID, name, true)
|
||||
if err != nil {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(err))
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n.DB.Name, n.DB.User, n.DB.RotAt = creds.Name, creds.User, creds.LastRotatedAt
|
||||
n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt
|
||||
n.Database.Driver = provisioner.DatabaseDriver
|
||||
|
||||
if err = regy.Put(n); err != nil {
|
||||
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(err))
|
||||
@@ -207,8 +292,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
|
||||
Secrets: &cluster.RegisterSecrets{NodeSecret: n.Secret, SecretRotatedAt: n.SecretRot},
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.LastRotatedAt},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt},
|
||||
AlreadyRegistered: false,
|
||||
AlreadyProvisioned: false,
|
||||
}
|
||||
@@ -242,3 +327,26 @@ func normalizeSiteURL(u string) string {
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// validateAdvertiseURL checks that the URL is absolute with a host and scheme,
|
||||
// and requires https for non-local hosts. http is allowed only for localhost/127.0.0.1/::1.
|
||||
func validateAdvertiseURL(u string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(u))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if parsed.Scheme == "https" {
|
||||
return true
|
||||
}
|
||||
if parsed.Scheme == "http" {
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateSiteURL applies the same rules as validateAdvertiseURL.
|
||||
func validateSiteURL(u string) bool { return validateAdvertiseURL(u) }
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
func TestClusterNodesRegister(t *testing.T) {
|
||||
@@ -20,6 +21,37 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, r.Code)
|
||||
})
|
||||
|
||||
// Register with existing ClientID requires clientSecret
|
||||
t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Pre-create a node via registry and rotate to get a plaintext secret for tests
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
nr, err := regy.RotateSecret(n.UUID)
|
||||
assert.NoError(t, err)
|
||||
secret := nr.ClientSecret
|
||||
|
||||
// Missing secret → 401
|
||||
body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}`
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
|
||||
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||
|
||||
// Wrong secret → 401
|
||||
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"WRONG"}`
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
|
||||
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||
|
||||
// Correct secret → 200 (existing-node path)
|
||||
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"` + secret + `"}`
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
t.Run("MissingToken", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
@@ -28,19 +60,96 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`)
|
||||
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||
})
|
||||
|
||||
t.Run("DriverConflict", func(t *testing.T) {
|
||||
t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// With SQLite driver in tests, provisioning should fail with conflict.
|
||||
// Provisioner is independent of the main DB; with MariaDB admin DSN configured
|
||||
// it should successfully provision and return 201.
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusConflict, r.Code)
|
||||
assert.Contains(t, r.Body.String(), "portal database must be MySQL/MariaDB")
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
body := r.Body.String()
|
||||
assert.Contains(t, body, "\"database\"")
|
||||
assert.Contains(t, body, "\"secrets\"")
|
||||
// New nodes return the client secret; include alias for clarity.
|
||||
assert.Contains(t, body, "\"clientSecret\"")
|
||||
})
|
||||
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
// Pre-create node with a UUID
|
||||
n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Attempt to change UUID via name without client credentials → 409
|
||||
newUUID := rnd.UUIDv7()
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusConflict, r.Code)
|
||||
})
|
||||
t.Run("BadAdvertiseUrlRejected", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// http scheme for public host must be rejected (require https unless localhost).
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// https is allowed for public host
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
|
||||
// http is allowed for localhost
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
})
|
||||
t.Run("SiteUrlValidation", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Reject http siteUrl for public host
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
|
||||
// Accept https siteUrl
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
})
|
||||
t.Run("NormalizeName", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Mixed separators and case should normalize to DNS label
|
||||
body := `{"nodeName":"My.Node/Name:Prod"}`
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n, err := regy.FindByName("my-node-name-prod")
|
||||
assert.NoError(t, err)
|
||||
if assert.NotNil(t, n) {
|
||||
assert.Equal(t, "my-node-name-prod", n.Name)
|
||||
}
|
||||
})
|
||||
t.Run("BadName", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
@@ -51,22 +160,23 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
|
||||
t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) {
|
||||
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Pre-create node in registry so handler goes through existing-node path
|
||||
// and rotates the secret before attempting DB ensure.
|
||||
// and rotates the secret before attempting DB ensure. Don't reuse the
|
||||
// Monitoring fixture client ID to avoid changing its secret, which is
|
||||
// used by OAuth tests running in the same package.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusConflict, r.Code) // DB conflict under SQLite
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Secret should have rotated and been persisted even though DB ensure failed.
|
||||
// Fetch by name (most-recently-updated) to avoid flakiness if another test adds
|
||||
@@ -74,10 +184,9 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
n2, err := regy.FindByName("pp-node-01")
|
||||
assert.NoError(t, err)
|
||||
// With client-backed registry, plaintext secret is not persisted; only rotation timestamp is updated.
|
||||
assert.NotEmpty(t, n2.SecretRot)
|
||||
assert.NotEmpty(t, n2.RotatedAt)
|
||||
})
|
||||
|
||||
t.Run("ExistingNodeSiteUrlPersistsEvenOnDBConflict", func(t *testing.T) {
|
||||
t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
@@ -89,13 +198,36 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
n := ®.Node{Name: "pp-node-02", Role: "instance"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// With SQLite driver in tests, provisioning should fail with 409, but metadata should still persist.
|
||||
// Provisioner is independent; endpoint should respond 200 and persist metadata.
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusConflict, r.Code)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Ensure normalized/persisted siteUrl.
|
||||
n2, err := regy.FindByName("pp-node-02")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://photos.example.com", n2.SiteUrl)
|
||||
})
|
||||
t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Register without nodeUUID; server should assign one (UUID v7 preferred).
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, "t0k3n")
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
|
||||
// Response must include node.uuid
|
||||
body := r.Body.String()
|
||||
assert.Contains(t, body, "\"uuid\"")
|
||||
|
||||
// Verify it is persisted in the registry
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n, err := regy.FindByName("pp-node-uuid")
|
||||
assert.NoError(t, err)
|
||||
if assert.NotNil(t, n) {
|
||||
assert.NotEmpty(t, n.UUID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
41
internal/api/cluster_nodes_register_url_test.go
Normal file
41
internal/api/cluster_nodes_register_url_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateAdvertiseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
u string
|
||||
ok bool
|
||||
}{
|
||||
{"https://example.com", true},
|
||||
{"http://example.com", false},
|
||||
{"http://localhost:2342", true},
|
||||
{"https://127.0.0.1", true},
|
||||
{"ftp://example.com", false},
|
||||
{"https://", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := validateAdvertiseURL(c.u); got != c.ok {
|
||||
t.Fatalf("validateAdvertiseURL(%q) = %v, want %v", c.u, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSiteURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
u string
|
||||
ok bool
|
||||
}{
|
||||
{"https://photos.example.com", true},
|
||||
{"http://photos.example.com", false},
|
||||
{"http://127.0.0.1:2342", true},
|
||||
{"mailto:me@example.com", false},
|
||||
{"://bad", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := validateSiteURL(c.u); got != c.ok {
|
||||
t.Fatalf("validateSiteURL(%q) = %v, want %v", c.u, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
func TestClusterEndpoints(t *testing.T) {
|
||||
@@ -26,16 +27,16 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
// Seed nodes in the registry
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance"}
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n2 := ®.Node{Name: "pp-node-02", Role: "service"}
|
||||
n2 := ®.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}
|
||||
assert.NoError(t, regy.Put(n2))
|
||||
// Resolve actual IDs (client-backed registry generates IDs)
|
||||
n, err = regy.FindByName("pp-node-01")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get by id
|
||||
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.ID)
|
||||
// Get by UUID
|
||||
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.UUID)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// 404 for missing id
|
||||
@@ -43,7 +44,7 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
|
||||
// Patch (manage requires Auth; our Auth() in tests allows admin; skip strict role checks here)
|
||||
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.ID, `{"advertiseUrl":"http://n1:2342"}`)
|
||||
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"advertiseUrl":"http://n1:2342"}`)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Pagination: count=1 returns exactly one
|
||||
@@ -55,11 +56,11 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Delete existing
|
||||
r = PerformRequest(app, http.MethodDelete, "/api/v1/cluster/nodes/"+n.ID)
|
||||
r = PerformRequest(app, http.MethodDelete, "/api/v1/cluster/nodes/"+n.UUID)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// GET after delete -> 404
|
||||
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.ID)
|
||||
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.UUID)
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
|
||||
// DELETE nonexistent id -> 404
|
||||
@@ -75,8 +76,8 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
}
|
||||
|
||||
// Test that ClusterGetNode validates the :id path parameter and rejects unsafe values.
|
||||
func TestClusterGetNode_IDValidation(t *testing.T) {
|
||||
// Test that ClusterGetNode validates the :uuid path parameter and rejects unsafe values.
|
||||
func TestClusterGetNode_UUIDValidation(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
|
||||
@@ -86,13 +87,13 @@ func TestClusterGetNode_IDValidation(t *testing.T) {
|
||||
// Seed a node and resolve its actual ID.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-99", Role: "instance"}
|
||||
n := ®.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n, err = regy.FindByName("pp-node-99")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Valid ID returns 200.
|
||||
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.ID)
|
||||
// Valid UUID returns 200.
|
||||
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+n.UUID)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Uppercase letters are not allowed.
|
||||
|
@@ -8,9 +8,10 @@ import (
|
||||
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Verifies that PATCH /cluster/nodes/{id} normalizes/validates siteUrl and persists only when valid.
|
||||
// Verifies that PATCH /cluster/nodes/{uuid} normalizes/validates siteUrl and persists only when valid.
|
||||
func TestClusterUpdateNode_SiteUrl(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
@@ -21,22 +22,22 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
// Seed node
|
||||
n := ®.Node{Name: "pp-node-siteurl", Role: "instance"}
|
||||
n := ®.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n, err = regy.FindByName("pp-node-siteurl")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Invalid scheme: ignored (200 OK but no update)
|
||||
r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.ID, `{"siteUrl":"ftp://invalid"}`)
|
||||
r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"siteUrl":"ftp://invalid"}`)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
n2, err := regy.Get(n.ID)
|
||||
n2, err := regy.FindByNodeUUID(n.UUID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", n2.SiteUrl)
|
||||
|
||||
// Valid https URL: persisted and normalized
|
||||
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.ID, `{"siteUrl":"HTTPS://PHOTOS.EXAMPLE.COM"}`)
|
||||
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"siteUrl":"HTTPS://PHOTOS.EXAMPLE.COM"}`)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
n3, err := regy.Get(n.ID)
|
||||
n3, err := regy.FindByNodeUUID(n.UUID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://photos.example.com", n3.SiteUrl)
|
||||
}
|
||||
|
@@ -30,7 +30,6 @@ func TestClusterPermissions(t *testing.T) {
|
||||
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster")
|
||||
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||
})
|
||||
|
||||
t.Run("ForbiddenFromCDN", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
@@ -44,7 +43,6 @@ func TestClusterPermissions(t *testing.T) {
|
||||
app.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
|
||||
t.Run("AdminCanAccess", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
|
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"archive/zip"
|
||||
gofs "io/fs"
|
||||
"net"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -26,12 +27,29 @@ 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/*
|
||||
TODO - Consider the following optional hardening measures:
|
||||
@@ -40,21 +58,16 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
3. Optionally, return a 404 or 204 error code when no files are added, though an empty zip file is acceptable.
|
||||
*/
|
||||
|
||||
// Get app config.
|
||||
conf := get.Config()
|
||||
|
||||
// Abort if this is not a portal server.
|
||||
if !conf.IsPortal() {
|
||||
AbortFeatureDisabled(c)
|
||||
return
|
||||
}
|
||||
|
||||
clientIp := ClientIP(c)
|
||||
themePath := conf.ThemePath()
|
||||
|
||||
// Resolve symbolic links.
|
||||
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to resolve path"}, s.RefID, clean.Error(err))
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to resolve path"}, refID, clean.Error(err))
|
||||
AbortNotFound(c)
|
||||
return
|
||||
} else {
|
||||
@@ -63,7 +76,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
|
||||
// Check if theme path exists.
|
||||
if !fs.PathExists(themePath) {
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "theme path not found"}, s.RefID)
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "theme path not found"}, refID)
|
||||
AbortNotFound(c)
|
||||
return
|
||||
}
|
||||
@@ -72,12 +85,12 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
// This aligns with bootstrap behavior, which only installs a theme when
|
||||
// app.js exists locally or can be fetched from the Portal.
|
||||
if !fs.FileExistsNotEmpty(filepath.Join(themePath, "app.js")) {
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "app.js missing or empty"}, s.RefID)
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "app.js missing or empty"}, refID)
|
||||
AbortNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, s.RefID, clean.Log(themePath))
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, refID, clean.Log(themePath))
|
||||
|
||||
// Add response headers.
|
||||
AddDownloadHeader(c, "theme.zip")
|
||||
@@ -87,14 +100,14 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
zipWriter := zip.NewWriter(c.Writer)
|
||||
defer func(w *zip.Writer) {
|
||||
if closeErr := w.Close(); closeErr != nil {
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to close", "%s"}, s.RefID, clean.Error(closeErr))
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to close", "%s"}, refID, clean.Error(closeErr))
|
||||
}
|
||||
}(zipWriter)
|
||||
|
||||
err := filepath.WalkDir(themePath, func(filePath string, info gofs.DirEntry, walkErr error) error {
|
||||
// Handle errors.
|
||||
if walkErr != nil {
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to traverse theme path", "%s"}, s.RefID, clean.Error(walkErr))
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to traverse theme path", "%s"}, refID, clean.Error(walkErr))
|
||||
|
||||
// If the error occurs on a directory, skip descending to avoid cascading errors.
|
||||
if info != nil && info.IsDir() {
|
||||
@@ -130,11 +143,11 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
// Get the relative file name to use as alias in the zip.
|
||||
alias := filepath.ToSlash(fs.RelName(filePath, themePath))
|
||||
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "adding %s to archive"}, s.RefID, clean.Log(alias))
|
||||
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "adding %s to archive"}, refID, clean.Log(alias))
|
||||
|
||||
// Stream zipped file contents.
|
||||
if zipErr := fs.ZipFile(zipWriter, filePath, alias, false); zipErr != nil {
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to add %s", "%s"}, s.RefID, clean.Log(alias), clean.Error(zipErr))
|
||||
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to add %s", "%s"}, refID, clean.Log(alias), clean.Error(zipErr))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -142,9 +155,9 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
|
||||
// Log result.
|
||||
if err != nil {
|
||||
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Failed, "%s"}, s.RefID, clean.Error(err))
|
||||
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Failed, "%s"}, refID, clean.Error(err))
|
||||
} else {
|
||||
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Succeeded}, s.RefID)
|
||||
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Succeeded}, refID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@@ -26,7 +26,6 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/theme")
|
||||
assert.Equal(t, http.StatusForbidden, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal feature flag for this endpoint.
|
||||
@@ -44,7 +43,6 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
app.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal feature flag for this endpoint.
|
||||
@@ -56,19 +54,19 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
defer func() { _ = os.RemoveAll(tempTheme) }()
|
||||
conf.SetThemePath(tempTheme)
|
||||
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "sub"), 0o755))
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "sub"), fs.ModeDir))
|
||||
// Visible files
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "sub", "visible.txt"), []byte("ok\n"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), fs.ModeFile))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), fs.ModeFile))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "sub", "visible.txt"), []byte("ok\n"), fs.ModeFile))
|
||||
// Hidden file
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden.txt"), []byte("secret\n"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden.txt"), []byte("secret\n"), fs.ModeFile))
|
||||
// Hidden directory
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".git"), 0o755))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".git"), fs.ModeDir))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), fs.ModeFile))
|
||||
// Hidden directory pattern "_.folder"
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "_.folder"), 0o755))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "_.folder", "secret.txt"), []byte("hidden\n"), 0o644))
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "_.folder"), fs.ModeDir))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "_.folder", "secret.txt"), []byte("hidden\n"), fs.ModeFile))
|
||||
// Symlink (should be skipped); best-effort
|
||||
_ = os.Symlink(filepath.Join(tempTheme, "style.css"), filepath.Join(tempTheme, "link.css"))
|
||||
|
||||
@@ -100,7 +98,6 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
assert.NotContains(t, names, "_.folder/secret.txt")
|
||||
assert.NotContains(t, names, "link.css")
|
||||
})
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal feature flag for this endpoint.
|
||||
@@ -114,9 +111,9 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
conf.SetThemePath(tempTheme)
|
||||
|
||||
// Hidden-only content and no app.js should yield 404.
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".hidden-dir"), 0o755))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden-dir", "file.txt"), []byte("secret\n"), 0o644))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden"), []byte("secret\n"), 0o644))
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, ".hidden-dir"), fs.ModeDir))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden-dir", "file.txt"), []byte("secret\n"), fs.ModeFile))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden"), []byte("secret\n"), fs.ModeFile))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -124,4 +121,26 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
app.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
t.Run("CIDRAllowWithoutAuth", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal role and set CIDR to loopback/10.0.0.0/8 for test.
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().ClusterCIDR = "10.0.0.0/8"
|
||||
ClusterGetTheme(router)
|
||||
|
||||
tempTheme, err := os.MkdirTemp("", "pp-theme-cidr-*")
|
||||
assert.NoError(t, err)
|
||||
defer func() { _ = os.RemoveAll(tempTheme) }()
|
||||
conf.SetThemePath(tempTheme)
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), fs.ModeFile))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), fs.ModeFile))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
// Simulate request from 10.1.2.3
|
||||
req.RemoteAddr = "10.1.2.3:12345"
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, header.ContentTypeZip, w.Header().Get(header.ContentType))
|
||||
})
|
||||
}
|
||||
|
@@ -23,7 +23,6 @@ func TestGetFace(t *testing.T) {
|
||||
assert.LessOrEqual(t, int64(4), val2.Int())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("Lowercase", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
GetFace(router)
|
||||
@@ -34,7 +33,6 @@ func TestGetFace(t *testing.T) {
|
||||
assert.Equal(t, "js6sg6b1qekk9jx8", val2.String())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
GetFace(router)
|
||||
@@ -57,7 +55,6 @@ func TestUpdateFace(t *testing.T) {
|
||||
assert.Equal(t, "js6sg6b1qekk9jx8", val2.String())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateFace(router)
|
||||
|
@@ -16,14 +16,12 @@ func TestGetFolderCover(t *testing.T) {
|
||||
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/tile_500")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidType", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
FolderCover(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/xxx")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidToken", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
@@ -32,7 +30,6 @@ func TestGetFolderCover(t *testing.T) {
|
||||
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/xxx/tile_500")
|
||||
assert.Equal(t, http.StatusForbidden, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
FolderCover(router)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -16,7 +16,6 @@ func TestPhotoUnstack(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
// t.Logf("RESP: %s", r.Body.String())
|
||||
})
|
||||
|
||||
t.Run("unstack bridge3.jpg", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
PhotoUnstack(router)
|
||||
@@ -25,7 +24,6 @@ func TestPhotoUnstack(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
// t.Logf("RESP: %s", r.Body.String())
|
||||
})
|
||||
|
||||
t.Run("not existing file", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
PhotoUnstack(router)
|
||||
|
@@ -18,7 +18,6 @@ func TestSearchPhotos(t *testing.T) {
|
||||
assert.LessOrEqual(t, int64(2), count.Int())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("ViewerJSON", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
SearchPhotos(router)
|
||||
@@ -31,7 +30,6 @@ func TestSearchPhotos(t *testing.T) {
|
||||
assert.LessOrEqual(t, int64(2), count.Int())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidRequest", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
SearchPhotos(router)
|
||||
|
@@ -64,7 +64,6 @@ func TestGetPhoto(t *testing.T) {
|
||||
val := gjson.Get(r.Body.String(), "Iso")
|
||||
assert.Equal(t, "", val.String())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
GetPhoto(router)
|
||||
@@ -84,14 +83,12 @@ func TestUpdatePhoto(t *testing.T) {
|
||||
assert.Equal(t, "de", val2.String())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("BadRequest", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdatePhoto(router)
|
||||
r := PerformRequestWithBody(app, "PUT", "/api/v1/photos/ps6sg6be2lvl0y13", `{"Name": "Updated01", "Country": 123}`)
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdatePhoto(router)
|
||||
@@ -109,14 +106,12 @@ func TestGetPhotoDownload(t *testing.T) {
|
||||
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7/dl?t="+conf.DownloadToken())
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetPhotoDownload(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/photos/xxx/dl?t="+conf.DownloadToken())
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidToken", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
@@ -138,7 +133,6 @@ func TestLikePhoto(t *testing.T) {
|
||||
val := gjson.Get(r2.Body.String(), "Favorite")
|
||||
assert.Equal(t, "true", val.String())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
LikePhoto(router)
|
||||
@@ -158,7 +152,6 @@ func TestDislikePhoto(t *testing.T) {
|
||||
val := gjson.Get(r2.Body.String(), "Favorite")
|
||||
assert.Equal(t, "false", val.String())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
DislikePhoto(router)
|
||||
@@ -181,7 +174,6 @@ func TestPhotoPrimary(t *testing.T) {
|
||||
val2 := gjson.Get(r3.Body.String(), "Primary")
|
||||
assert.Equal(t, "false", val2.String())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
PhotoPrimary(router)
|
||||
@@ -199,7 +191,6 @@ func TestGetPhotoYaml(t *testing.T) {
|
||||
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7/yaml")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
GetPhotoYaml(router)
|
||||
@@ -222,7 +213,6 @@ func TestApprovePhoto(t *testing.T) {
|
||||
val := gjson.Get(r2.Body.String(), "Quality")
|
||||
assert.Equal(t, "3", val.String())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
ApprovePhoto(router)
|
||||
|
@@ -108,7 +108,6 @@ func TestUpdateService(t *testing.T) {
|
||||
assert.Equal(t, "CreateTestUpdated", val3.String())
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateService(router)
|
||||
@@ -117,7 +116,6 @@ func TestUpdateService(t *testing.T) {
|
||||
assert.Equal(t, i18n.Msg(i18n.ErrAccountNotFound), val.String())
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
|
||||
t.Run("SaveFailed", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
UpdateService(router)
|
||||
@@ -150,7 +148,6 @@ func TestDeleteService(t *testing.T) {
|
||||
assert.Equal(t, i18n.Msg(i18n.ErrAccountNotFound), val2.String())
|
||||
assert.Equal(t, http.StatusNotFound, r2.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
DeleteService(router)
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
},
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -19,49 +19,42 @@ func TestGetVideo(t *testing.T) {
|
||||
mimeType := fmt.Sprintf("video/mp4; codecs=\"%s\"", clean.Codec("avc1"))
|
||||
assert.Equal(t, header.ContentTypeMp4AvcMain, video.ContentType(mimeType, "mp4", "avc1", false))
|
||||
})
|
||||
|
||||
t.Run("NoHash", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetVideo(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/videos//"+conf.PreviewToken()+"/mp4")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidHash", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetVideo(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6/"+conf.PreviewToken()+"/mp4")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NoType", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetVideo(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/")
|
||||
assert.Equal(t, http.StatusMovedPermanently, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidType", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetVideo(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/xxx")
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetVideo(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/mp4")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("FileError", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
GetVideo(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd832/"+conf.PreviewToken()+"/mp4")
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidToken", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
|
@@ -14,7 +14,6 @@ func TestWebsocket(t *testing.T) {
|
||||
r := PerformRequest(app, "GET", "/api/v1/ws")
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
|
||||
t.Run("NoRouter", func(t *testing.T) {
|
||||
app, _, _ := NewApiTest()
|
||||
WebSocket(nil)
|
||||
|
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -83,10 +82,11 @@ func ZipCreate(router *gin.RouterGroup) {
|
||||
|
||||
// Configure file names.
|
||||
dlName := DownloadName(c)
|
||||
zipPath := path.Join(conf.TempPath(), fs.ZipDir)
|
||||
// Build filesystem paths using filepath for OS compatibility.
|
||||
zipPath := filepath.Join(conf.TempPath(), fs.ZipDir)
|
||||
zipToken := rnd.Base36(8)
|
||||
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
|
||||
zipFileName := path.Join(zipPath, zipBaseName)
|
||||
zipFileName := filepath.Join(zipPath, zipBaseName)
|
||||
|
||||
// Create temp directory.
|
||||
if err = os.MkdirAll(zipPath, 0700); err != nil {
|
||||
@@ -99,15 +99,10 @@ func ZipCreate(router *gin.RouterGroup) {
|
||||
if newZipFile, err = os.Create(zipFileName); err != nil {
|
||||
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
|
||||
return
|
||||
} else {
|
||||
defer newZipFile.Close()
|
||||
}
|
||||
|
||||
// Create zip writer.
|
||||
zipWriter := zip.NewWriter(newZipFile)
|
||||
defer func(w *zip.Writer) {
|
||||
logErr("zip", w.Close())
|
||||
}(zipWriter)
|
||||
|
||||
var aliases = make(map[string]int)
|
||||
|
||||
@@ -145,6 +140,18 @@ func ZipCreate(router *gin.RouterGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all data is flushed to disk before responding to the client
|
||||
// to avoid rare races where the follow-up GET happens before the
|
||||
// zip writer/file have been fully closed.
|
||||
if cerr := zipWriter.Close(); cerr != nil {
|
||||
Error(c, http.StatusInternalServerError, cerr, i18n.ErrZipFailed)
|
||||
return
|
||||
}
|
||||
if ferr := newZipFile.Close(); ferr != nil {
|
||||
Error(c, http.StatusInternalServerError, ferr, i18n.ErrZipFailed)
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
|
||||
log.Infof("download: created %s [%s]", clean.Log(zipBaseName), time.Since(start))
|
||||
@@ -172,8 +179,8 @@ func ZipDownload(router *gin.RouterGroup) {
|
||||
|
||||
conf := get.Config()
|
||||
zipBaseName := clean.FileName(filepath.Base(c.Param("filename")))
|
||||
zipPath := path.Join(conf.TempPath(), fs.ZipDir)
|
||||
zipFileName := path.Join(zipPath, zipBaseName)
|
||||
zipPath := filepath.Join(conf.TempPath(), fs.ZipDir)
|
||||
zipFileName := filepath.Join(zipPath, zipBaseName)
|
||||
|
||||
if !fs.FileExists(zipFileName) {
|
||||
log.Errorf("download: %s", c.AbortWithError(http.StatusNotFound, fmt.Errorf("%s not found", clean.Log(zipFileName))))
|
||||
|
@@ -36,18 +36,15 @@ func TestRoleStrings_CliUsageString(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
assert.Equal(t, "", (RoleStrings{}).CliUsageString())
|
||||
})
|
||||
|
||||
t.Run("single", func(t *testing.T) {
|
||||
m := RoleStrings{"admin": RoleAdmin}
|
||||
assert.Equal(t, "admin", m.CliUsageString())
|
||||
})
|
||||
|
||||
t.Run("two", func(t *testing.T) {
|
||||
m := RoleStrings{"guest": RoleGuest, "admin": RoleAdmin}
|
||||
// Note the comma before "or" matches current implementation.
|
||||
assert.Equal(t, "admin, or guest", m.CliUsageString())
|
||||
})
|
||||
|
||||
t.Run("three", func(t *testing.T) {
|
||||
m := RoleStrings{"visitor": RoleVisitor, "guest": RoleGuest, "admin": RoleAdmin}
|
||||
assert.Equal(t, "admin, guest, or visitor", m.CliUsageString())
|
||||
@@ -63,7 +60,6 @@ func TestRoles_Allow(t *testing.T) {
|
||||
assert.True(t, roles.Allow(RoleVisitor, ActionDownload))
|
||||
assert.False(t, roles.Allow(RoleVisitor, ActionDelete))
|
||||
})
|
||||
|
||||
t.Run("default fallback used", func(t *testing.T) {
|
||||
roles := Roles{
|
||||
RoleDefault: GrantViewAll, // allows view, denies delete
|
||||
@@ -71,7 +67,6 @@ func TestRoles_Allow(t *testing.T) {
|
||||
assert.True(t, roles.Allow(RoleUser, ActionView))
|
||||
assert.False(t, roles.Allow(RoleUser, ActionDelete))
|
||||
})
|
||||
|
||||
t.Run("specific overrides default (no fallback)", func(t *testing.T) {
|
||||
roles := Roles{
|
||||
RoleVisitor: GrantViewShared, // denies delete
|
||||
@@ -79,7 +74,6 @@ func TestRoles_Allow(t *testing.T) {
|
||||
}
|
||||
assert.False(t, roles.Allow(RoleVisitor, ActionDelete))
|
||||
})
|
||||
|
||||
t.Run("no match and no default", func(t *testing.T) {
|
||||
roles := Roles{
|
||||
RoleVisitor: GrantViewShared,
|
||||
@@ -98,7 +92,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
|
||||
assert.NotEqual(t, "", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UserRoles Strings include alias none, exclude empty", func(t *testing.T) {
|
||||
got := UserRoles.Strings()
|
||||
assert.ElementsMatch(t, []string{"admin", "guest", "none", "visitor"}, got)
|
||||
@@ -106,7 +99,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
|
||||
assert.NotEqual(t, "", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ClientRoles CliUsageString includes none and or before last", func(t *testing.T) {
|
||||
u := ClientRoles.CliUsageString()
|
||||
// Should list known roles and end with "or none" (alias present).
|
||||
@@ -115,7 +107,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
|
||||
}
|
||||
assert.Regexp(t, `, or none$`, u)
|
||||
})
|
||||
|
||||
t.Run("UserRoles CliUsageString includes none and or before last", func(t *testing.T) {
|
||||
u := UserRoles.CliUsageString()
|
||||
for _, s := range []string{"admin", "guest", "visitor", "none"} {
|
||||
@@ -123,7 +114,6 @@ func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
|
||||
}
|
||||
assert.Regexp(t, `, or none$`, u)
|
||||
})
|
||||
|
||||
t.Run("Alias none maps to RoleNone", func(t *testing.T) {
|
||||
assert.Equal(t, RoleNone, ClientRoles[RoleAliasNone])
|
||||
assert.Equal(t, RoleNone, UserRoles[RoleAliasNone])
|
||||
|
@@ -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 {
|
||||
|
@@ -44,9 +44,9 @@ func obtainClientCredentialsViaRegister(portalURL, joinToken, nodeName string) (
|
||||
if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
id = regResp.Node.ID
|
||||
id = regResp.Node.ClientID
|
||||
if regResp.Secrets != nil {
|
||||
secret = regResp.Secrets.NodeSecret
|
||||
secret = regResp.Secrets.ClientSecret
|
||||
}
|
||||
if id == "" || secret == "" {
|
||||
return "", "", fmt.Errorf("missing client credentials in response")
|
||||
|
@@ -78,15 +78,15 @@ func clusterNodesListAction(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"ID", "Name", "Role", "Labels", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
|
||||
cols := []string{"UUID", "ClientID", "Name", "Role", "Labels", "Internal URL", "DB Driver", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
|
||||
rows := make([][]string, 0, len(out))
|
||||
for _, n := range out {
|
||||
var dbName, dbUser, dbRot string
|
||||
var dbName, dbUser, dbRot, dbDriver string
|
||||
if n.Database != nil {
|
||||
dbName, dbUser, dbRot = n.Database.Name, n.Database.User, n.Database.RotatedAt
|
||||
dbName, dbUser, dbRot, dbDriver = n.Database.Name, n.Database.User, n.Database.RotatedAt, n.Database.Driver
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
n.ID, n.Name, n.Role, formatLabels(n.Labels), n.AdvertiseUrl, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
|
||||
n.UUID, n.ClientID, n.Name, n.Role, formatLabels(n.Labels), n.AdvertiseUrl, dbDriver, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -44,9 +44,14 @@ func clusterNodesModAction(ctx *cli.Context) error {
|
||||
return cli.Exit(err, 1)
|
||||
}
|
||||
|
||||
n, getErr := r.Get(key)
|
||||
if getErr != nil {
|
||||
name := clean.TypeLowerDash(key)
|
||||
// Resolve by NodeUUID first, then by client UID, then by normalized name.
|
||||
var n *reg.Node
|
||||
var getErr error
|
||||
if n, getErr = r.FindByNodeUUID(key); getErr != nil || n == nil {
|
||||
n, getErr = r.FindByClientID(key)
|
||||
}
|
||||
if getErr != nil || n == nil {
|
||||
name := clean.DNSLabel(key)
|
||||
if name == "" {
|
||||
return cli.Exit(fmt.Errorf("invalid node identifier"), 2)
|
||||
}
|
||||
|
@@ -18,6 +18,7 @@ var ClusterNodesRemoveCommand = &cli.Command{
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"},
|
||||
&cli.BoolFlag{Name: "all-ids", Usage: "delete all records that share the same UUID (admin cleanup)"},
|
||||
},
|
||||
Action: clusterNodesRemoveAction,
|
||||
}
|
||||
@@ -39,29 +40,36 @@ func clusterNodesRemoveAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Resolve to id for deletion, but also support name.
|
||||
id := key
|
||||
if _, getErr := r.Get(id); getErr != nil {
|
||||
if n, err2 := r.FindByName(clean.TypeLowerDash(key)); err2 == nil && n != nil {
|
||||
id = n.ID
|
||||
// Resolve UUID to delete: accept uuid → clientId → name.
|
||||
uuid := key
|
||||
if n, err2 := r.FindByNodeUUID(uuid); err2 == nil && n != nil {
|
||||
uuid = n.UUID
|
||||
} else if n, err2 := r.FindByClientID(uuid); err2 == nil && n != nil {
|
||||
uuid = n.UUID
|
||||
} else if n, err2 := r.FindByName(clean.DNSLabel(key)); err2 == nil && n != nil {
|
||||
uuid = n.UUID
|
||||
} else {
|
||||
return cli.Exit(fmt.Errorf("node not found"), 3)
|
||||
}
|
||||
}
|
||||
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
if !confirmed {
|
||||
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(id)), IsConfirm: true}
|
||||
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(uuid)), IsConfirm: true}
|
||||
if _, err := prompt.Run(); err != nil {
|
||||
log.Infof("node %s was not deleted", clean.Log(id))
|
||||
log.Infof("node %s was not deleted", clean.Log(uuid))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.Delete(id); err != nil {
|
||||
if ctx.Bool("all-ids") {
|
||||
if err := r.DeleteAllByUUID(uuid); err != nil {
|
||||
return cli.Exit(err, 1)
|
||||
}
|
||||
} else if err := r.Delete(uuid); err != nil {
|
||||
return cli.Exit(err, 1)
|
||||
}
|
||||
|
||||
log.Infof("node %s has been deleted", clean.Log(id))
|
||||
log.Infof("node %s has been deleted", clean.Log(uuid))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
@@ -39,12 +39,14 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Determine node name. On portal, resolve id->name via registry; otherwise treat key as name.
|
||||
name := clean.TypeLowerDash(key)
|
||||
name := clean.DNSLabel(key)
|
||||
if conf.IsPortal() {
|
||||
if r, err := reg.NewClientRegistryWithConfig(conf); err == nil {
|
||||
if n, err := r.Get(key); err == nil && n != nil {
|
||||
if n, err := r.FindByNodeUUID(key); err == nil && n != nil {
|
||||
name = n.Name
|
||||
} else if n, err := r.FindByName(clean.TypeLowerDash(key)); err == nil && n != nil {
|
||||
} else if n, err := r.FindByClientID(key); err == nil && n != nil {
|
||||
name = n.Name
|
||||
} else if n, err := r.FindByName(clean.DNSLabel(key)); err == nil && n != nil {
|
||||
name = n.Name
|
||||
}
|
||||
}
|
||||
@@ -131,17 +133,17 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "Port"}
|
||||
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Role, resp.Database.Name, resp.Database.User, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
|
||||
cols := []string{"UUID", "ClientID", "Name", "Role", "DB Driver", "DB Name", "DB User", "Host", "Port"}
|
||||
rows := [][]string{{resp.Node.UUID, resp.Node.ClientID, resp.Node.Name, resp.Node.Role, resp.Database.Driver, resp.Database.Name, resp.Database.User, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
|
||||
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
|
||||
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.Database.Password != "" {
|
||||
if (resp.Secrets != nil && resp.Secrets.ClientSecret != "") || resp.Database.Password != "" {
|
||||
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
|
||||
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.Database.Password))
|
||||
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
|
||||
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" && resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "DB Password", resp.Database.Password))
|
||||
} else if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "", ""))
|
||||
} else if resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
|
||||
}
|
||||
|
@@ -38,9 +38,12 @@ func clusterNodesShowAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Resolve by id first, then by normalized name.
|
||||
n, getErr := r.Get(key)
|
||||
if getErr != nil {
|
||||
name := clean.TypeLowerDash(key)
|
||||
n, getErr := r.FindByNodeUUID(key)
|
||||
if getErr != nil || n == nil {
|
||||
n, getErr = r.FindByClientID(key)
|
||||
}
|
||||
if getErr != nil || n == nil {
|
||||
name := clean.DNSLabel(key)
|
||||
if name == "" {
|
||||
return cli.Exit(fmt.Errorf("invalid node identifier"), 2)
|
||||
}
|
||||
@@ -59,12 +62,12 @@ func clusterNodesShowAction(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"ID", "Name", "Role", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
|
||||
var dbName, dbUser, dbRot string
|
||||
cols := []string{"UUID", "ClientID", "Name", "Role", "Internal URL", "DB Driver", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
|
||||
var dbName, dbUser, dbRot, dbDriver string
|
||||
if dto.Database != nil {
|
||||
dbName, dbUser, dbRot = dto.Database.Name, dto.Database.User, dto.Database.RotatedAt
|
||||
dbName, dbUser, dbRot, dbDriver = dto.Database.Name, dto.Database.User, dto.Database.RotatedAt, dto.Database.Driver
|
||||
}
|
||||
rows := [][]string{{dto.ID, dto.Name, dto.Role, dto.AdvertiseUrl, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
|
||||
rows := [][]string{{dto.UUID, dto.ClientID, dto.Name, dto.Role, dto.AdvertiseUrl, dbDriver, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
|
||||
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
if err != nil {
|
||||
|
@@ -7,12 +7,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
@@ -34,22 +36,27 @@ var (
|
||||
regPortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
|
||||
regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"}
|
||||
regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"}
|
||||
regDryRun = &cli.BoolFlag{Name: "dry-run", Usage: "print derived values and payload without performing registration"}
|
||||
)
|
||||
|
||||
// ClusterRegisterCommand registers a node with the Portal via HTTP.
|
||||
var ClusterRegisterCommand = &cli.Command{
|
||||
Name: "register",
|
||||
Usage: "Registers/rotates a node via Portal (HTTP)",
|
||||
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag}, report.CliFlags...)),
|
||||
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, regDryRun}, report.CliFlags...)),
|
||||
Action: clusterRegisterAction,
|
||||
}
|
||||
|
||||
func clusterRegisterAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
// Resolve inputs
|
||||
name := clean.TypeLowerDash(ctx.String("name"))
|
||||
name := clean.DNSLabel(ctx.String("name"))
|
||||
derivedName := false
|
||||
if name == "" { // default from config if set
|
||||
name = clean.TypeLowerDash(conf.NodeName())
|
||||
name = clean.DNSLabel(conf.NodeName())
|
||||
if name != "" {
|
||||
derivedName = true
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return cli.Exit(fmt.Errorf("node name is required (use --name or set node-name)"), 2)
|
||||
@@ -62,9 +69,74 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
portalURL := ctx.String("portal-url")
|
||||
derivedPortal := false
|
||||
if portalURL == "" {
|
||||
portalURL = conf.PortalUrl()
|
||||
if portalURL != "" {
|
||||
derivedPortal = true
|
||||
}
|
||||
}
|
||||
// In dry-run, we allow empty portalURL (will print derived/empty values).
|
||||
|
||||
// Derive advertise/site URLs when omitted.
|
||||
advertise := ctx.String("advertise-url")
|
||||
if advertise == "" {
|
||||
advertise = conf.AdvertiseUrl()
|
||||
}
|
||||
site := conf.SiteUrl()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"nodeRole": nodeRole,
|
||||
"labels": parseLabelSlice(ctx.StringSlice("label")),
|
||||
"advertiseUrl": advertise,
|
||||
"rotate": ctx.Bool("rotate"),
|
||||
"rotateSecret": ctx.Bool("rotate-secret"),
|
||||
}
|
||||
// If we already have client credentials (e.g., re-register), include them so the
|
||||
// portal can verify and authorize UUID/name moves or metadata updates.
|
||||
if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" {
|
||||
body["clientId"] = id
|
||||
body["clientSecret"] = secret
|
||||
}
|
||||
if site != "" && site != advertise {
|
||||
body["siteUrl"] = site
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
if ctx.Bool("dry-run") {
|
||||
if ctx.Bool("json") {
|
||||
out := map[string]any{"portalUrl": portalURL, "payload": body}
|
||||
jb, _ := json.Marshal(out)
|
||||
fmt.Println(string(jb))
|
||||
} else {
|
||||
fmt.Printf("Portal URL: %s\n", portalURL)
|
||||
fmt.Printf("Node Name: %s\n", name)
|
||||
if derivedPortal || derivedName || advertise == conf.AdvertiseUrl() {
|
||||
fmt.Println("(derived defaults were used where flags were omitted)")
|
||||
}
|
||||
fmt.Printf("Advertise: %s\n", advertise)
|
||||
if v, ok := body["siteUrl"].(string); ok && v != "" {
|
||||
fmt.Printf("Site URL: %s\n", v)
|
||||
}
|
||||
// Warn if non-HTTPS on public host; server will enforce too.
|
||||
if warnInsecurePublicURL(advertise) {
|
||||
fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).")
|
||||
}
|
||||
if v, ok := body["siteUrl"].(string); ok && v != "" && warnInsecurePublicURL(v) {
|
||||
fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).")
|
||||
}
|
||||
// Single-line summary for quick operator scan
|
||||
if v, ok := body["siteUrl"].(string); ok && v != "" {
|
||||
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, v)
|
||||
} else {
|
||||
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For actual registration, require portal URL and token.
|
||||
if portalURL == "" {
|
||||
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
|
||||
}
|
||||
@@ -76,16 +148,6 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"nodeRole": nodeRole,
|
||||
"labels": parseLabelSlice(ctx.StringSlice("label")),
|
||||
"advertiseUrl": ctx.String("advertise-url"),
|
||||
"rotate": ctx.Bool("rotate"),
|
||||
"rotateSecret": ctx.Bool("rotate-secret"),
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
// POST with bounded backoff on 429
|
||||
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
|
||||
var resp cluster.RegisterResponse
|
||||
@@ -115,8 +177,8 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
jb, _ := json.Marshal(resp)
|
||||
fmt.Println(string(jb))
|
||||
} else {
|
||||
// Human-readable: node row and credentials if present
|
||||
cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "Port"}
|
||||
// Human-readable: node row and credentials if present (UUID first as primary identifier)
|
||||
cols := []string{"UUID", "ClientID", "Name", "Role", "DB Driver", "DB Name", "DB User", "Host", "Port"}
|
||||
var dbName, dbUser string
|
||||
if resp.Database.Name != "" {
|
||||
dbName = resp.Database.Name
|
||||
@@ -124,18 +186,18 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
if resp.Database.User != "" {
|
||||
dbUser = resp.Database.User
|
||||
}
|
||||
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Role, dbName, dbUser, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
|
||||
rows := [][]string{{resp.Node.UUID, resp.Node.ClientID, resp.Node.Name, resp.Node.Role, resp.Database.Driver, dbName, dbUser, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
|
||||
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
|
||||
// Secrets/credentials block if any
|
||||
// Show secrets in up to two tables, then print DSN if present
|
||||
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.Database.Password != "" {
|
||||
if (resp.Secrets != nil && resp.Secrets.ClientSecret != "") || resp.Database.Password != "" {
|
||||
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
|
||||
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.Database.Password))
|
||||
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
|
||||
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" && resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "DB Password", resp.Database.Password))
|
||||
} else if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Client Secret", resp.Secrets.ClientSecret, "", ""))
|
||||
} else if resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
|
||||
}
|
||||
@@ -218,6 +280,22 @@ func stringsTrimRightSlash(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// warnInsecurePublicURL returns true if the URL uses http and the host is not localhost/127.0.0.1/::1.
|
||||
func warnInsecurePublicURL(u string) bool {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
if parsed.Scheme != "http" {
|
||||
return false
|
||||
}
|
||||
h := parsed.Hostname()
|
||||
if h == "localhost" || h == "127.0.0.1" || h == "::1" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Persistence helpers for --write-config
|
||||
func parseLabelSlice(labels []string) map[string]string {
|
||||
if len(labels) == 0 {
|
||||
@@ -239,20 +317,20 @@ func parseLabelSlice(labels []string) map[string]string {
|
||||
|
||||
// Persistence helpers for --write-config
|
||||
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
|
||||
// Node secret file
|
||||
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
|
||||
// Prefer PHOTOPRISM_NODE_SECRET_FILE; otherwise config cluster path
|
||||
fileName := os.Getenv(config.FlagFileVar("NODE_SECRET"))
|
||||
// Node client secret file
|
||||
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
||||
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
|
||||
fileName := os.Getenv(config.FlagFileVar("NODE_CLIENT_SECRET"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Join(conf.PortalConfigPath(), "node-secret")
|
||||
}
|
||||
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fileName, []byte(resp.Secrets.NodeSecret), 0o600); err != nil {
|
||||
if err := os.WriteFile(fileName, []byte(resp.Secrets.ClientSecret), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("wrote node secret to %s", clean.Log(fileName))
|
||||
log.Infof("wrote node client secret to %s", clean.Log(fileName))
|
||||
}
|
||||
|
||||
// DB settings (MySQL/MariaDB only)
|
||||
@@ -293,5 +371,5 @@ func mergeOptionsYaml(conf *config.Config, kv map[string]any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(fileName, b, 0o644)
|
||||
return os.WriteFile(fileName, b, fs.ModeFile)
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
)
|
||||
|
||||
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
@@ -30,8 +31,8 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret", "secretRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": false,
|
||||
"alreadyProvisioned": false,
|
||||
})
|
||||
@@ -44,7 +45,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
// Parse JSON
|
||||
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "secret", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "secret", gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, "pwd", gjson.Get(out, "database.password").String())
|
||||
dsn := gjson.Get(out, "database.dsn").String()
|
||||
parsed := cfg.NewDSN(dsn)
|
||||
@@ -70,8 +71,8 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret2", "secretRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret2", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -89,7 +90,7 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, out, "pp-node-03")
|
||||
assert.Contains(t, out, "Node Secret")
|
||||
assert.Contains(t, out, "Node Client Secret")
|
||||
assert.Contains(t, out, "DB Password")
|
||||
}
|
||||
|
||||
@@ -108,8 +109,8 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret3", "secretRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret3", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -127,7 +128,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "secret3", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "secret3", gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, "pwd3", gjson.Get(out, "database.password").String())
|
||||
dsn := gjson.Get(out, "database.dsn").String()
|
||||
parsed := cfg.NewDSN(dsn)
|
||||
@@ -161,7 +162,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n3", "name": "pp-node-05", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
// secrets omitted on DB-only rotate
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
@@ -188,7 +189,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
assert.Equal(t, "tcp", parsed.Net)
|
||||
assert.Equal(t, "db:3306", parsed.Server)
|
||||
assert.Equal(t, "pp_db", parsed.Name)
|
||||
assert.Equal(t, "", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "secrets.clientSecret").String())
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
@@ -213,8 +214,8 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n4", "name": "pp-node-06", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret4", "secretRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret4", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -230,7 +231,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "secret4", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "secret4", gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "database.password").String())
|
||||
}
|
||||
|
||||
@@ -266,6 +267,34 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterRegister_DryRun_JSON(t *testing.T) {
|
||||
// No server needed; dry-run avoids HTTP
|
||||
get.Config().Options().PortalUrl = cfg.DefaultPortalUrl
|
||||
get.Config().Options().ClusterDomain = "cluster.dev"
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--dry-run", "--json",
|
||||
})
|
||||
// Should not fail; output must include portalUrl and payload
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assert.NotEmpty(t, gjson.Get(out, "portalUrl").String())
|
||||
assert.Equal(t, "instance", gjson.Get(out, "payload.nodeRole").String())
|
||||
// nodeName may be derived; ensure non-empty
|
||||
assert.NotEmpty(t, gjson.Get(out, "payload.nodeName").String())
|
||||
}
|
||||
|
||||
func TestClusterRegister_DryRun_Text(t *testing.T) {
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assert.Contains(t, out, "Portal URL:")
|
||||
assert.Contains(t, out, "Node Name:")
|
||||
}
|
||||
|
||||
func TestClusterRegister_HTTPBadRequest(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@@ -294,7 +323,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -368,7 +397,7 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -401,7 +430,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n5", "name": "pp-node-07", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -442,8 +471,8 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n6", "name": "pp-node-08", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "pwd8secret", "secretRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "pwd8secret", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -455,6 +484,6 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "database.password").String())
|
||||
}
|
||||
|
@@ -34,8 +34,8 @@ var ClusterThemePullCommand = &cli.Command{
|
||||
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"},
|
||||
&cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"},
|
||||
&cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
|
||||
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"},
|
||||
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"},
|
||||
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeClientID from config)"},
|
||||
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeClientSecret from config)"},
|
||||
// JSON output supported via report.CliFlags on parent command where applicable
|
||||
},
|
||||
Action: clusterThemePullAction,
|
||||
@@ -58,11 +58,11 @@ func clusterThemePullAction(ctx *cli.Context) error {
|
||||
// Credentials: prefer OAuth client credentials (client-id/secret), fallback to join-token for compatibility.
|
||||
clientID := ctx.String("client-id")
|
||||
if clientID == "" {
|
||||
clientID = conf.NodeID()
|
||||
clientID = conf.NodeClientID()
|
||||
}
|
||||
clientSecret := ctx.String("client-secret")
|
||||
if clientSecret == "" {
|
||||
clientSecret = conf.NodeSecret()
|
||||
clientSecret = conf.NodeClientSecret()
|
||||
}
|
||||
token := ""
|
||||
if clientID != "" && clientSecret != "" {
|
||||
@@ -75,7 +75,7 @@ func clusterThemePullAction(ctx *cli.Context) error {
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
// Try join-token assisted path. If NodeID/NodeSecret not available, attempt register to obtain them, then OAuth.
|
||||
// Try join-token assisted path. If NodeClientID/NodeClientSecret not available, attempt register to obtain them, then OAuth.
|
||||
jt := ctx.String("join-token")
|
||||
if jt == "" {
|
||||
jt = conf.JoinToken()
|
||||
|
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Verifies OAuth path in cluster theme pull using client_id/client_secret.
|
||||
@@ -92,14 +93,15 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
sawRotateSecret = req.RotateSecret
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return NodeID and a fresh secret
|
||||
// Return NodeClientID and a fresh secret
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
|
||||
Node: cluster.Node{ID: "cid123", Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"},
|
||||
UUID: rnd.UUID(),
|
||||
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
|
||||
})
|
||||
case "/api/v1/oauth/token":
|
||||
// Expect Basic for the returned creds
|
||||
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cid123:s3cr3t")) {
|
||||
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cs5gfen1bgxz7s9i:s3cr3t")) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/capture"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// TODO: Several CLI commands defer conf.Shutdown(), which closes the shared
|
||||
@@ -41,6 +42,9 @@ func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Purge local SQLite test artifacts created during this package's tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
|
@@ -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,10 +137,14 @@ 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
|
||||
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
|
||||
@@ -126,12 +156,32 @@ func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
if !found {
|
||||
t.Fatalf("expected at least one mp4 in %s", outDir)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// 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 }()
|
||||
|
@@ -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 {
|
||||
|
@@ -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())
|
||||
|
101
internal/config/cluster_defaults.go
Normal file
101
internal/config/cluster_defaults.go
Normal file
@@ -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 ""
|
||||
}
|
44
internal/config/cluster_defaults_test.go
Normal file
44
internal/config/cluster_defaults_test.go
Normal file
@@ -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)
|
||||
}
|
@@ -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.
|
||||
|
@@ -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")
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
// 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)
|
||||
}
|
||||
// Otherwise, only trust a persisted value from the current options.yml.
|
||||
if fs.FileExists(c.OptionsYaml()) {
|
||||
return c.options.ClusterUUID
|
||||
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, "/") + "/"
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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"
|
||||
|
@@ -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")
|
||||
|
@@ -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) {
|
||||
|
@@ -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()
|
||||
|
@@ -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")
|
||||
|
@@ -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())
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -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()
|
||||
|
||||
|
17
internal/config/expand.go
Normal file
17
internal/config/expand.go
Normal file
@@ -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] })
|
||||
}
|
66
internal/config/expand_test.go
Normal file
66
internal/config/expand_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
@@ -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{
|
||||
|
@@ -142,14 +142,16 @@ type Options struct {
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
@@ -172,7 +174,7 @@ type Options struct {
|
||||
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"`
|
||||
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"`
|
||||
@@ -180,6 +182,8 @@ type Options struct {
|
||||
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"`
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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())},
|
||||
|
@@ -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"},
|
||||
|
@@ -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
|
||||
|
2
internal/config/testdata/config.yml
vendored
2
internal/config/testdata/config.yml
vendored
@@ -10,7 +10,7 @@ HttpMode: release
|
||||
HttpPort: 81
|
||||
HttpPassword:
|
||||
DatabaseDriver: sqlite
|
||||
DatabaseDsn: .photoprism.db
|
||||
DatabaseDSN: .photoprism.db
|
||||
Theme: lavendel
|
||||
Language: english
|
||||
JpegQuality: 87
|
@@ -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", ""})
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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"`
|
||||
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
})
|
||||
}
|
||||
|
@@ -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}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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{})
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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"
|
||||
|
@@ -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",
|
||||
|
@@ -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"}
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -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{}
|
||||
|
@@ -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)
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user