Auth: Refactor cluster configuration and provisioning API endpoints #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-24 08:28:38 +02:00
parent 3baabebf50
commit 61ced7119c
242 changed files with 4477 additions and 1789 deletions

View File

@@ -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`).
### NextSession Reminders
- If we add Postgres provisioning support, extend BuildDSN and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
- Consider surfacing a short “uuid → db/user” mapping helper in CLI (e.g., `nodes show --creds`) if operators request it.
- Prefer targeted runs for speed:
- Unit/subpackage: `go test ./internal/<pkg> -run <Name> -count=1`
- Commands: `go test ./internal/commands -run <Name> -count=1`
@@ -338,6 +358,8 @@ The following conventions summarize the insights gained when adding new configur
- Use the clientbacked registry (`NewClientRegistryWithConfig`).
- The filebacked registry is historical; do not add new references to it.
- Migration “done” checklist: swap callsites → build → API tests → CLI tests → remove legacy references.
- Primary node identifier: UUID v7 (called `NodeUUID` in code/config). In API/CLI responses this is exposed as `uuid`. The OAuth client identifier (`NodeClientID`) is used only for OAuth and is exposed as `clientId`.
- Lookups should prefer `uuid``clientId` (legacy) → DNSlabel name. Portal endpoints for nodes use `/api/v1/cluster/nodes/{uuid}`.
### API/CLI Tests: Known Pitfalls
@@ -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 OAuthonly.
- Registry interface is UUIDfirst: `Get(uuid)`, `FindByNodeUUID`, `FindByClientID`, `Delete(uuid)`, `RotateSecret(uuid)`, and `DeleteAllByUUID(uuid)` for admin cleanup.
- DTO shapes
- API `cluster.Node`: `uuid` (required) first, `clientId` optional. `database` includes `driver`.
- Registry `Node`: mirrors the API shape; `ClientID` optional.
- DTO fields are normalized:
- `database` (not `db`) with fields `name`, `user`, `driver`, `rotatedAt`.
- Node rotation timestamps use `rotatedAt`.
- Registration secrets expose `clientSecret` in API responses; the CLI persists it into config options as `NodeClientSecret`.
- Admin responses may include `advertiseUrl` and `database`; non-admin responses are redacted by default.
- CLI
- Resolution order is `uuid → clientId → name`. `nodes rm` supports `--all-ids` to delete all client rows that share a UUID. Tables include a “DB Driver” column.
- Provisioner
- DB/user names are UUIDbased without slugs: `photoprism_d<hmac11>`, `photoprism_u<hmac11>`. HMAC is scoped by ClusterUUID+NodeUUID.
- BuildDSN accepts `driver`; unsupported values fall back to MySQL format with a warning.
- ClientData cleanup
- `NodeUUID` removed from `ClientData`; it lives on the toplevel client row (`auth_clients.node_uuid`). `ClientDatabase` now includes `driver`.
- Testing patterns:
- Use `httptest` for Portal endpoints and `pkg/fs.Unzip` with size caps for extraction tests.

View File

@@ -173,7 +173,28 @@ Conventions & Rules of Thumb
- Never log secrets; compare tokens constanttime.
- Dont import Portal internals from cluster instance/service bootstraps; use HTTP.
- Prefer small, hermetic unit tests; isolate filesystem paths with `t.TempDir()` and env like `PHOTOPRISM_STORAGE_PATH`.
- Cluster nodes: identify by UUID v7 (internally stored as `NodeUUID`; exposed as `uuid` in API/CLI). The OAuth client ID (`NodeClientID`, exposed as `clientId`) is for OAuth only. Registry lookups and CLI commands accept uuid, clientId, or DNSlabel name (priority in that order).
Filesystem Permissions & io/fs Aliasing
- Use `github.com/photoprism/photoprism/pkg/fs` permission variables when creating files/dirs:
- `fs.ModeDir` (0o755), `fs.ModeFile` (0o644), `fs.ModeConfigFile` (0o664), `fs.ModeSecret` (0o600), `fs.ModeBackupFile` (0o600).
- Do not use stdlib `io/fs` mode bits as permission arguments. When importing stdlib `io/fs`, alias it (`iofs`/`gofs`) to avoid `fs.*` collisions with our package.
- Prefer `filepath.Join` for filesystem paths across platforms; use `path.Join` for URLs only.
Cluster Registry & Provisioner Cheatsheet
- UUIDfirst everywhere: API paths `{uuid}`, Registry `Get/Delete/RotateSecret` by UUID; explicit `FindByClientID` exists for OAuth.
- Node/DTO fields: `uuid` required; `clientId` optional; database metadata includes `driver`.
- Provisioner naming (no slugs):
- database: `photoprism_d<hmac11>`
- username: `photoprism_u<hmac11>`
HMAC is base32 of ClusterUUID+NodeUUID; drivers currently `mysql|mariadb`.
- DSN builder: `BuildDSN(driver, host, port, user, pass, name)`; warns and falls back to MySQL format for unsupported drivers.
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful).
- Public API and internal registry DTOs use normalized field names:
- `database` (not `db`) with `name`, `user`, `driver`, `rotatedAt`.
- Node-level rotation timestamps use `rotatedAt`.
- Registration returns `secrets.clientSecret`; the CLI persists it under config `NodeClientSecret`.
- Admin responses may include `advertiseUrl` and `database`; non-admin responses are redacted by default.
Frequently Touched Files (by topic)
- CLI wiring: `cmd/photoprism/photoprism.go`, `internal/commands/commands.go`

2
go.mod
View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"})
})
}

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestClusterNodesRegister(t *testing.T) {
@@ -20,6 +21,37 @@ func TestClusterNodesRegister(t *testing.T) {
assert.Equal(t, http.StatusForbidden, r.Code)
})
// Register with existing ClientID requires clientSecret
t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Pre-create a node via registry and rotate to get a plaintext secret for tests
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
assert.NoError(t, regy.Put(n))
nr, err := regy.RotateSecret(n.UUID)
assert.NoError(t, err)
secret := nr.ClientSecret
// Missing secret → 401
body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Wrong secret → 401
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"WRONG"}`
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Correct secret → 200 (existing-node path)
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"` + secret + `"}`
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("MissingToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -28,19 +60,96 @@ func TestClusterNodesRegister(t *testing.T) {
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`)
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("DriverConflict", func(t *testing.T) {
t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// With SQLite driver in tests, provisioning should fail with conflict.
// Provisioner is independent of the main DB; with MariaDB admin DSN configured
// it should successfully provision and return 201.
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, "t0k3n")
assert.Equal(t, http.StatusConflict, r.Code)
assert.Contains(t, r.Body.String(), "portal database must be MySQL/MariaDB")
assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String()
assert.Contains(t, body, "\"database\"")
assert.Contains(t, body, "\"secrets\"")
// New nodes return the client secret; include alias for clarity.
assert.Contains(t, body, "\"clientSecret\"")
})
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Pre-create node with a UUID
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
assert.NoError(t, regy.Put(n))
// Attempt to change UUID via name without client credentials → 409
newUUID := rnd.UUIDv7()
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, "t0k3n")
assert.Equal(t, http.StatusConflict, r.Code)
})
t.Run("BadAdvertiseUrlRejected", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// http scheme for public host must be rejected (require https unless localhost).
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, "t0k3n")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// https is allowed for public host
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, "t0k3n")
assert.Equal(t, http.StatusCreated, r.Code)
// http is allowed for localhost
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, "t0k3n")
assert.Equal(t, http.StatusCreated, r.Code)
})
t.Run("SiteUrlValidation", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Reject http siteUrl for public host
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, "t0k3n")
assert.Equal(t, http.StatusBadRequest, r.Code)
// Accept https siteUrl
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, "t0k3n")
assert.Equal(t, http.StatusCreated, r.Code)
})
t.Run("NormalizeName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Mixed separators and case should normalize to DNS label
body := `{"nodeName":"My.Node/Name:Prod"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
assert.Equal(t, http.StatusCreated, r.Code)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n, err := regy.FindByName("my-node-name-prod")
assert.NoError(t, err)
if assert.NotNil(t, n) {
assert.Equal(t, "my-node-name-prod", n.Name)
}
})
t.Run("BadName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
@@ -51,22 +160,23 @@ func TestClusterNodesRegister(t *testing.T) {
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) {
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path
// and rotates the secret before attempting DB ensure.
// and rotates the secret before attempting DB ensure. Don't reuse the
// Monitoring fixture client ID to avoid changing its secret, which is
// used by OAuth tests running in the same package.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
n := &reg.Node{Name: "pp-node-01", Role: "instance"}
assert.NoError(t, regy.Put(n))
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
assert.Equal(t, http.StatusConflict, r.Code) // DB conflict under SQLite
assert.Equal(t, http.StatusOK, r.Code)
// Secret should have rotated and been persisted even though DB ensure failed.
// Fetch by name (most-recently-updated) to avoid flakiness if another test adds
@@ -74,10 +184,9 @@ func TestClusterNodesRegister(t *testing.T) {
n2, err := regy.FindByName("pp-node-01")
assert.NoError(t, err)
// With client-backed registry, plaintext secret is not persisted; only rotation timestamp is updated.
assert.NotEmpty(t, n2.SecretRot)
assert.NotEmpty(t, n2.RotatedAt)
})
t.Run("ExistingNodeSiteUrlPersistsEvenOnDBConflict", func(t *testing.T) {
t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
@@ -89,13 +198,36 @@ func TestClusterNodesRegister(t *testing.T) {
n := &reg.Node{Name: "pp-node-02", Role: "instance"}
assert.NoError(t, regy.Put(n))
// With SQLite driver in tests, provisioning should fail with 409, but metadata should still persist.
// Provisioner is independent; endpoint should respond 200 and persist metadata.
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, "t0k3n")
assert.Equal(t, http.StatusConflict, r.Code)
assert.Equal(t, http.StatusOK, r.Code)
// Ensure normalized/persisted siteUrl.
n2, err := regy.FindByName("pp-node-02")
assert.NoError(t, err)
assert.Equal(t, "https://photos.example.com", n2.SiteUrl)
})
t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router)
// Register without nodeUUID; server should assign one (UUID v7 preferred).
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, "t0k3n")
assert.Equal(t, http.StatusCreated, r.Code)
// Response must include node.uuid
body := r.Body.String()
assert.Contains(t, body, "\"uuid\"")
// Verify it is persisted in the registry
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n, err := regy.FindByName("pp-node-uuid")
assert.NoError(t, err)
if assert.NotNil(t, n) {
assert.NotEmpty(t, n.UUID)
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package api
import (
"archive/zip"
gofs "io/fs"
"net"
"path/filepath"
"github.com/gin-gonic/gin"
@@ -26,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)
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }()

View File

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

View File

@@ -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())

View 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-], 132 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 ""
}

View 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)
}

View File

@@ -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.

View File

@@ -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")
}

View File

@@ -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; 163 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; 163 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
}

View File

@@ -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-], 132, 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
}

View File

@@ -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"

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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")

View File

@@ -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())

View File

@@ -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()

View File

@@ -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
View 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] })
}

View 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)
})
}

View File

@@ -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; 163 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; 163 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{

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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())},

View File

@@ -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"},

View File

@@ -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

View File

@@ -10,7 +10,7 @@ HttpMode: release
HttpPort: 81
HttpPassword:
DatabaseDriver: sqlite
DatabaseDsn: .photoprism.db
DatabaseDSN: .photoprism.db
Theme: lavendel
Language: english
JpegQuality: 87

View File

@@ -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", ""})

View File

@@ -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

View File

@@ -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",

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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}

View File

@@ -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)
}

View File

@@ -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{})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"}

View File

@@ -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()

View File

@@ -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{}

View File

@@ -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