mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Merge branch 'develop' into feature/batch-edit
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# PhotoPrism® Repository Guidelines
|
||||
|
||||
**Last Updated:** September 25, 2025
|
||||
**Last Updated:** September 26, 2025
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -197,7 +197,7 @@ Note: Across our public documentation, official images, and in production, the c
|
||||
- Explicit “replace” actions or admin tools where the user confirmed overwrite.
|
||||
- Not for import/index flows; Originals must not be clobbered.
|
||||
|
||||
- ### Archive Extraction — Security Checklist
|
||||
### Archive Extraction — Security Checklist
|
||||
|
||||
- Always validate ZIP entry names with a safe join; reject:
|
||||
- absolute paths (e.g., `/etc/passwd`).
|
||||
@@ -231,11 +231,8 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
|
||||
## Agent Quick Tips (Do This)
|
||||
|
||||
### Next‑Session Priorities
|
||||
- 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 the CLI (e.g., `nodes show --creds`) if operators request it.
|
||||
|
||||
### Testing & Fixtures
|
||||
|
||||
- Go tests live next to their sources (`path/to/pkg/<file>_test.go`); group related cases as `t.Run(...)` sub-tests to keep table-driven coverage readable.
|
||||
- Prefer focused `go test` runs for speed (`go test ./internal/<pkg> -run <Name> -count=1`, `go test ./internal/commands -run <Name> -count=1`) and avoid `./...` unless you need the entire suite.
|
||||
- Heavy packages such as `internal/entity` and `internal/photoprism` run migrations and fixtures; expect 30–120s on first run and narrow with `-run` to keep iterations low.
|
||||
@@ -244,8 +241,10 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
- Generate identifiers with `rnd.GenerateUID(entity.ClientUID)` for OAuth client IDs and `rnd.UUIDv7()` for node UUIDs; treat `node.uuid` as required in responses.
|
||||
- Shared fixtures live under `storage/testdata`; `NewTestConfig("<pkg>")` already calls `InitializeTestData()`, but call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) when you construct custom configs so originals/import/cache/temp exist. `InitializeTestData()` clears old data, downloads fixtures if needed, then calls `CreateDirectories()`.
|
||||
- For slimmer tests that only need config objects, prefer the new helpers in `internal/config/test.go`: `NewMinimalTestConfig(t.TempDir())` when no database is needed, or `NewMinimalTestConfigWithDb("<pkg>", t.TempDir())` to spin up an isolated SQLite schema without seeding all fixtures.
|
||||
- When you need illustrative credentials (join tokens, client IDs/secrets, etc.), reuse the shared `Example*` constants (see `internal/service/cluster/examples.go`) so tests, docs, and examples stay consistent.
|
||||
|
||||
### Roles & ACL
|
||||
|
||||
- Map roles via the shared tables: users through `acl.ParseRole(s)` / `acl.UserRoles[...]`, clients through `acl.ClientRoles[...]`.
|
||||
- Treat `RoleAliasNone` ("none") and an empty string as `RoleNone`; no caller-specific overrides.
|
||||
- Default unknown client roles to `RoleClient`; `acl.ParseRole` already handles `0/false/nil` as none for users.
|
||||
@@ -258,6 +257,7 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
- Mixed roots: when testing related files, keep `ExamplesPath()/ImportPath()/OriginalsPath()` consistent so `RelatedFiles` and `AllowExt` behave as expected.
|
||||
|
||||
### CLI Usage & Assertions
|
||||
|
||||
- Wrap CLI tests in `RunWithTestContext(cmd, args)` so `urfave/cli` cannot exit the process; assert quoted `show` output with `assert.Contains`/regex for the trailing ", or <last>" rule.
|
||||
- Prefer `--json` responses for automation. `photoprism show commands --json [--nested]` exposes the tree view (add `--all` for hidden entries).
|
||||
- Use `internal/commands/catalog` to inspect commands/flags without running the binary; when validating large JSON docs, marshal DTOs via `catalog.BuildFlat/BuildNode` instead of parsing CLI stdout.
|
||||
@@ -355,4 +355,5 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
- Registration flow: send `rotate=true` only for MySQL/MariaDB nodes without credentials, treat 401/403/404 as terminal, include `clientId` + `clientSecret` when renaming an existing node, and persist only newly generated secrets or DB settings.
|
||||
- Registry & DTOs: use the client-backed registry (`NewClientRegistryWithConfig`)—the file-backed version is legacy—and treat migration as complete only after swapping callsites, building, and running focused API/CLI tests. Nodes are keyed by UUID v7 (`/api/v1/cluster/nodes/{uuid}`), the registry interface stays UUID-first (`Get`, `FindByNodeUUID`, `FindByClientID`, `RotateSecret`, `DeleteAllByUUID`), CLI lookups resolve `uuid → clientId → name`, and DTOs normalize `database.{name,user,driver,rotatedAt}` while exposing `clientSecret` only during creation/rotation. `nodes rm --all-ids` cleans duplicate client rows, admin responses may include `advertiseUrl`/`database`, client/user sessions stay redacted, registry files live under `conf.PortalConfigPath()/nodes/` (mode 0600), and `ClientData` no longer stores `NodeUUID`.
|
||||
- Provisioner & DSN: database/user names use UUID-based HMACs (`photoprism_d<hmac11>`, `photoprism_u<hmac11>`); `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported.
|
||||
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
|
||||
- Testing: exercise Portal endpoints with `httptest`, guard extraction paths with `pkg/fs.Unzip` size caps, and expect admin-only fields to disappear when authenticated as a client/user session.
|
||||
|
@@ -25,7 +25,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Pre-create a node via registry and rotate to get a plaintext secret for tests
|
||||
@@ -39,17 +39,17 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
|
||||
// Missing secret → 401
|
||||
body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}`
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
|
||||
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")
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
|
||||
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")
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
t.Run("MissingToken", func(t *testing.T) {
|
||||
@@ -63,12 +63,12 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// 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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
body := r.Body.String()
|
||||
assert.Contains(t, body, "\"database\"")
|
||||
@@ -79,7 +79,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
@@ -91,56 +91,56 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
|
||||
// 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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, cluster.ExampleJoinToken)
|
||||
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"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, cluster.ExampleJoinToken)
|
||||
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"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, cluster.ExampleJoinToken)
|
||||
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")
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken)
|
||||
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"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, cluster.ExampleJoinToken)
|
||||
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")
|
||||
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken)
|
||||
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"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
@@ -154,17 +154,17 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("BadName", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Empty nodeName → 400
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Pre-create node in registry so handler goes through existing-node path
|
||||
@@ -176,7 +176,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
n := ®.Node{Node: cluster.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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Secret should have rotated and been persisted even though DB ensure failed.
|
||||
@@ -190,7 +190,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Pre-create node in registry so handler goes through existing-node path.
|
||||
@@ -200,7 +200,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// 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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
// Ensure normalized/persisted siteUrl.
|
||||
@@ -211,11 +211,11 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
conf.Options().JoinToken = cluster.ExampleJoinToken
|
||||
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")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, cluster.ExampleJoinToken)
|
||||
assert.Equal(t, http.StatusCreated, r.Code)
|
||||
|
||||
// Response must include node.uuid
|
||||
|
@@ -5,11 +5,13 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
)
|
||||
|
||||
func TestExitCodes_Register_ValidationAndUnauthorized(t *testing.T) {
|
||||
t.Run("MissingURL", func(t *testing.T) {
|
||||
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
|
||||
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", cluster.ExampleJoinToken})
|
||||
err := ClusterRegisterCommand.Action(ctx)
|
||||
assert.Error(t, err)
|
||||
if ec, ok := err.(cli.ExitCoder); ok {
|
||||
|
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
)
|
||||
|
||||
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
@@ -23,7 +24,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -32,7 +33,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
_ = 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", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": cluster.ExampleClientSecret, "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": false,
|
||||
"alreadyProvisioned": false,
|
||||
})
|
||||
@@ -40,12 +41,12 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
|
||||
"register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
|
||||
})
|
||||
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.clientSecret").String())
|
||||
assert.Equal(t, cluster.ExampleClientSecret, 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)
|
||||
@@ -58,12 +59,13 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
|
||||
func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
// Fake Portal register endpoint for rotation
|
||||
secret := cluster.ExampleClientSecret
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/cluster/nodes/register" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -72,7 +74,7 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
_ = 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", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret2", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -80,13 +82,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
|
||||
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_CLI")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--secret", "--yes", "pp-node-03",
|
||||
"rotate", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--secret", "--yes", "pp-node-03",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, out, "pp-node-03")
|
||||
@@ -96,12 +98,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
|
||||
func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
// Fake Portal register endpoint for rotation in JSON mode
|
||||
secret := cluster.ExampleClientSecret
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/cluster/nodes/register" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -110,7 +113,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
_ = 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", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret3", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -118,7 +121,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
|
||||
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
@@ -128,7 +131,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.clientSecret").String())
|
||||
assert.Equal(t, secret, 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)
|
||||
@@ -145,7 +148,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -171,7 +174,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
|
||||
_ = os.Setenv("PHOTOPRISM_YES", "true")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
@@ -193,12 +196,13 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
secret := cluster.ExampleClientSecret
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/cluster/nodes/register" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -215,7 +219,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
_ = 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", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "secret4", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -223,7 +227,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
@@ -231,7 +235,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.clientSecret").String())
|
||||
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "database.password").String())
|
||||
}
|
||||
|
||||
@@ -258,7 +262,7 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
|
||||
"register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
|
||||
})
|
||||
if ec, ok := err.(cli.ExitCoder); ok {
|
||||
assert.Equal(t, 5, ec.ExitCode())
|
||||
@@ -302,7 +306,7 @@ func TestClusterRegister_HTTPBadRequest(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
|
||||
"register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
|
||||
})
|
||||
if ec, ok := err.(cli.ExitCoder); ok {
|
||||
assert.Equal(t, 2, ec.ExitCode())
|
||||
@@ -331,7 +335,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate", "--json",
|
||||
"register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String())
|
||||
@@ -360,7 +364,7 @@ func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-x",
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-x",
|
||||
})
|
||||
if ec, ok := err.(cli.ExitCoder); ok {
|
||||
assert.Equal(t, 5, ec.ExitCode())
|
||||
@@ -376,7 +380,7 @@ func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp node invalid",
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp node invalid",
|
||||
})
|
||||
if ec, ok := err.(cli.ExitCoder); ok {
|
||||
assert.Equal(t, 2, ec.ExitCode())
|
||||
@@ -405,7 +409,7 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-rl2",
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-rl2",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-rl2", gjson.Get(out, "node.name").String())
|
||||
@@ -417,7 +421,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -438,7 +442,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate", "--json",
|
||||
"register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String())
|
||||
@@ -453,12 +457,13 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
secret := cluster.ExampleClientSecret
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/cluster/nodes/register" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -472,7 +477,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
_ = 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", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": "pwd8secret", "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -480,10 +485,10 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate-secret", "--json",
|
||||
"register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate-secret", "--json",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "database.password").String())
|
||||
}
|
||||
|
@@ -64,7 +64,7 @@ func TestClusterThemePullCommand(t *testing.T) {
|
||||
|
||||
func TestClusterRegisterCommand(t *testing.T) {
|
||||
t.Run("ValidationMissingURL", func(t *testing.T) {
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", cluster.ExampleJoinToken})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
})
|
||||
@@ -124,7 +124,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -140,11 +140,11 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
|
||||
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=test-token"})
|
||||
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken})
|
||||
assert.NoError(t, err)
|
||||
// Expect extracted file
|
||||
assert.FileExists(t, filepath.Join(destDir, "test.txt"))
|
||||
|
@@ -81,7 +81,7 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/cluster/nodes/register":
|
||||
// Must have Bearer join token
|
||||
if r.Header.Get("Authorization") != "Bearer jt" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -97,12 +97,12 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
|
||||
UUID: rnd.UUID(),
|
||||
ClusterCIDR: "203.0.113.0/24",
|
||||
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
|
||||
Node: cluster.Node{ClientID: cluster.ExampleClientID, Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
|
||||
})
|
||||
case "/api/v1/oauth/token":
|
||||
// Expect Basic for the returned creds
|
||||
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cs5gfen1bgxz7s9i:s3cr3t")) {
|
||||
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte(cluster.ExampleClientID+":"+cluster.ExampleClientSecret)) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
|
||||
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
|
||||
"pull", "--dest", dest, "-f",
|
||||
"--portal-url=" + ts.URL,
|
||||
"--join-token=jt",
|
||||
"--join-token=" + cluster.ExampleJoinToken,
|
||||
})
|
||||
_ = out
|
||||
assert.NoError(t, err)
|
||||
|
@@ -105,18 +105,54 @@ func (c *Config) PortalThemePath() string {
|
||||
return c.ThemePath()
|
||||
}
|
||||
|
||||
// JoinToken returns the token required to access the portal API endpoints.
|
||||
// JoinToken returns the token required to use the node register API endpoint.
|
||||
// Example: k9sEFe6-A7gt6zqm-gY9gFh0
|
||||
func (c *Config) JoinToken() string {
|
||||
if c.options.JoinToken != "" {
|
||||
return c.options.JoinToken
|
||||
} else if fileName := FlagFilePath("JOIN_TOKEN"); fileName == "" {
|
||||
return ""
|
||||
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
|
||||
log.Warnf("config: failed to read portal token from %s (%s)", fileName, err)
|
||||
return ""
|
||||
} else {
|
||||
return string(b)
|
||||
if s := strings.TrimSpace(c.options.JoinToken); rnd.IsJoinToken(s, false) {
|
||||
c.options.JoinToken = s
|
||||
return s
|
||||
}
|
||||
|
||||
if fileName := FlagFilePath("JOIN_TOKEN"); fileName != "" && fs.FileExistsNotEmpty(fileName) {
|
||||
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
|
||||
log.Warnf("config: could not read portal token from %s (%s)", fileName, err)
|
||||
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
|
||||
return s
|
||||
} else {
|
||||
log.Warnf("config: portal join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.IsPortal() {
|
||||
return ""
|
||||
}
|
||||
|
||||
fileName := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
|
||||
|
||||
if fs.FileExistsNotEmpty(fileName) {
|
||||
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
|
||||
log.Warnf("config: could not read portal token from %s (%s)", fileName, err)
|
||||
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
|
||||
c.options.JoinToken = s
|
||||
return s
|
||||
} else {
|
||||
log.Warnf("config: portal join token stored in %s is shorter than %d characters; generating a new one", fileName, rnd.JoinTokenLength)
|
||||
}
|
||||
}
|
||||
|
||||
token := rnd.JoinToken()
|
||||
if !rnd.IsJoinToken(token, true) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
|
||||
log.Errorf("config: could not write portal join token (%s)", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
c.options.JoinToken = token
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// deriveNodeNameAndDomainFromHttpHost attempts to derive cluster host and domain name from the site URL.
|
||||
|
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
const shortTestJoinToken = "short-token"
|
||||
|
||||
func TestConfig_PortalUrl(t *testing.T) {
|
||||
t.Run("Unset", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
@@ -23,6 +25,32 @@ func TestConfig_PortalUrl(t *testing.T) {
|
||||
assert.Equal(t, "", c.PortalUrl())
|
||||
c.options.PortalUrl = DefaultPortalUrl
|
||||
})
|
||||
t.Run("JoinTokenTooShort", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
c.options.JoinToken = shortTestJoinToken
|
||||
assert.Equal(t, "", c.JoinToken())
|
||||
})
|
||||
t.Run("PortalAutoGeneratesJoinToken", func(t *testing.T) {
|
||||
tempCfg := t.TempDir()
|
||||
ctx := CliTestContext()
|
||||
assert.NoError(t, ctx.Set("config-path", tempCfg))
|
||||
c := NewConfig(ctx)
|
||||
c.options.NodeRole = cluster.RolePortal
|
||||
c.options.JoinToken = ""
|
||||
|
||||
token := c.JoinToken()
|
||||
assert.NotEmpty(t, token)
|
||||
assert.GreaterOrEqual(t, len(token), rnd.JoinTokenLength)
|
||||
assert.True(t, rnd.IsJoinToken(token, false))
|
||||
assert.True(t, rnd.IsJoinToken(token, true))
|
||||
|
||||
secretFile := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
|
||||
assert.FileExists(t, secretFile)
|
||||
info, err := os.Stat(secretFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, fs.ModeSecretFile, info.Mode().Perm())
|
||||
assert.Equal(t, token, c.JoinToken())
|
||||
})
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
c.options.PortalUrl = DefaultPortalUrl
|
||||
@@ -209,11 +237,11 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
|
||||
// Set and read back values
|
||||
c.options.PortalUrl = "https://portal.example.test"
|
||||
c.options.JoinToken = "join-token"
|
||||
c.options.JoinToken = cluster.ExampleJoinToken
|
||||
c.options.NodeClientSecret = "node-secret"
|
||||
|
||||
assert.Equal(t, "https://portal.example.test", c.PortalUrl())
|
||||
assert.Equal(t, "join-token", c.JoinToken())
|
||||
assert.Equal(t, cluster.ExampleJoinToken, c.JoinToken())
|
||||
assert.Equal(t, "node-secret", c.NodeClientSecret())
|
||||
})
|
||||
t.Run("AbsolutePaths", func(t *testing.T) {
|
||||
@@ -298,8 +326,8 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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))
|
||||
assert.NoError(t, os.WriteFile(nsFile, []byte(cluster.ExampleClientSecret), fs.ModeSecretFile))
|
||||
assert.NoError(t, os.WriteFile(tkFile, []byte(cluster.ExampleJoinTokenAlt), fs.ModeSecretFile))
|
||||
|
||||
// Clear inline values so file-based lookup is used.
|
||||
c.options.NodeClientSecret = ""
|
||||
@@ -308,8 +336,8 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
// Point env vars at the files and verify.
|
||||
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", nsFile)
|
||||
t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", tkFile)
|
||||
assert.Equal(t, "s3cr3t", c.NodeClientSecret())
|
||||
assert.Equal(t, "t0k3n", c.JoinToken())
|
||||
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
|
||||
assert.Equal(t, cluster.ExampleJoinTokenAlt, c.JoinToken())
|
||||
|
||||
// Empty / missing should yield empty strings.
|
||||
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", filepath.Join(dir, "missing"))
|
||||
|
@@ -689,7 +689,7 @@ var Flags = CliFlags{
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "join-token",
|
||||
Usage: "secret `TOKEN` required to join the cluster",
|
||||
Usage: "secret `TOKEN` required to join a cluster; min 24 chars",
|
||||
EnvVars: EnvVars("JOIN_TOKEN"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
|
13
internal/service/cluster/examples.go
Normal file
13
internal/service/cluster/examples.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package cluster
|
||||
|
||||
// Example values used by documentation and tests to illustrate cluster tokens.
|
||||
const (
|
||||
// ExampleJoinToken represents a valid portal join token.
|
||||
ExampleJoinToken = "pGVplw8-eISgkdQN-Mep62nQ"
|
||||
// ExampleJoinTokenAlt provides an alternative join token for negative/rotation tests.
|
||||
ExampleJoinTokenAlt = "k9sEFe6-A7gt6zqm-gY9gFh0"
|
||||
// ExampleClientID is a sample node client identifier issued by the portal.
|
||||
ExampleClientID = "cs5gfen1bgxz7s9i"
|
||||
// ExampleClientSecret is a sample node client secret matching the format generated by rnd.ClientSecret().
|
||||
ExampleClientSecret = "A1B2C3D4E5F6G7H8J9K0L1M2N3P4Q5R6"
|
||||
)
|
@@ -49,7 +49,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
UUID: rnd.UUID(),
|
||||
ClusterCIDR: "192.0.2.0/24",
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
|
||||
JWKSUrl: jwksURL,
|
||||
Database: cluster.RegisterDatabase{
|
||||
Driver: config.MySQL,
|
||||
@@ -77,7 +77,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
|
||||
// Configure Portal.
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
c.Options().JoinToken = cluster.ExampleJoinToken
|
||||
// Gate rotate=true: driver mysql and no DSN/fields.
|
||||
c.Options().DatabaseDriver = config.MySQL
|
||||
c.Options().DatabaseDSN = ""
|
||||
@@ -89,7 +89,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
assert.NoError(t, InitConfig(c))
|
||||
|
||||
// Options should be reloaded; check values.
|
||||
assert.Equal(t, "SECRET", c.NodeClientSecret())
|
||||
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
|
||||
// DSN branch should be preferred and persisted.
|
||||
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
|
||||
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
|
||||
@@ -106,13 +106,14 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
_ = zw.Close()
|
||||
|
||||
// Fake Portal server (register -> oauth token -> theme)
|
||||
clientSecret := cluster.ExampleClientSecret
|
||||
var jwksURL2 string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/cluster/nodes/register":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: clientSecret}, JWKSUrl: jwksURL2})
|
||||
case "/api/v1/oauth/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
|
||||
@@ -132,7 +133,7 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
|
||||
// Point Portal.
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
c.Options().JoinToken = cluster.ExampleJoinToken
|
||||
|
||||
// Ensure theme dir is empty and unique.
|
||||
tempTheme, err := os.MkdirTemp("", "pp-theme-*")
|
||||
@@ -160,7 +161,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
|
||||
ClusterCIDR: "203.0.113.0/24",
|
||||
JWKSUrl: jwksURL3,
|
||||
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
|
||||
@@ -178,7 +179,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
|
||||
// SQLite driver by default; set Portal.
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
c.Options().JoinToken = cluster.ExampleJoinToken
|
||||
// Remember original DSN so we can ensure it is not changed.
|
||||
origDSN := c.Options().DatabaseDSN
|
||||
t.Cleanup(func() { _ = os.Remove(origDSN) })
|
||||
@@ -187,7 +188,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
assert.NoError(t, InitConfig(c))
|
||||
|
||||
// NodeClientSecret should persist, but DB should remain SQLite (no DSN update).
|
||||
assert.Equal(t, "SECRET", c.NodeClientSecret())
|
||||
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
|
||||
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
|
||||
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
|
||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
||||
@@ -210,7 +211,7 @@ func TestRegister_404_NoRetry(t *testing.T) {
|
||||
defer c.CloseDb()
|
||||
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
c.Options().JoinToken = cluster.ExampleJoinToken
|
||||
|
||||
// Run bootstrap; registration should attempt once and stop on 404.
|
||||
_ = InitConfig(c)
|
||||
|
@@ -10,12 +10,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SessionIdLength = 64
|
||||
AuthTokenLength = 48
|
||||
AppPasswordLength = 27
|
||||
AppPasswordSeparator = '-'
|
||||
SessionIdLength = 64
|
||||
AuthTokenLength = 48
|
||||
JoinTokenLength = 24
|
||||
AppPasswordLength = 27
|
||||
Separator = '-'
|
||||
)
|
||||
|
||||
// joinTokenSeparators determines where token separators (hyphens) appear.
|
||||
var joinTokenSeparators = [...]int{7, 16}
|
||||
|
||||
// AuthToken generates a random hexadecimal character token for authenticating client applications.
|
||||
//
|
||||
// Examples: 9fa8e562564dac91b96881040e98f6719212a1a364e0bb25
|
||||
@@ -49,7 +53,7 @@ func AppPassword() string {
|
||||
|
||||
for i := 0; i < AppPasswordLength; i++ {
|
||||
if (i+1)%7 == 0 {
|
||||
b = append(b, AppPasswordSeparator)
|
||||
b = append(b, Separator)
|
||||
} else if i == AppPasswordLength-1 {
|
||||
b = append(b, checksum.Char(b))
|
||||
return string(b)
|
||||
@@ -71,7 +75,7 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
|
||||
// Check characters.
|
||||
sep := 0
|
||||
for _, r := range s {
|
||||
if r == AppPasswordSeparator {
|
||||
if r == Separator {
|
||||
sep++
|
||||
} else if (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
||||
return false
|
||||
@@ -89,6 +93,78 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
|
||||
return s[AppPasswordLength-1] == checksum.Char([]byte(s[:AppPasswordLength-1]))
|
||||
}
|
||||
|
||||
// JoinToken generates a random, human-friendly cluster join token.
|
||||
// The token has a length of 24 characters and is separated by 2 dashes.
|
||||
//
|
||||
// Example: pGVplw8-eISgkdQN-Mep62nQ
|
||||
func JoinToken() string {
|
||||
m := big.NewInt(int64(len(CharsetBase62)))
|
||||
token := make([]byte, 0, JoinTokenLength)
|
||||
|
||||
for i := 0; i < JoinTokenLength-1; i++ {
|
||||
if isJoinTokenSeparatorIndex(i) {
|
||||
token = append(token, Separator)
|
||||
continue
|
||||
}
|
||||
|
||||
ch := CharsetBase62[0]
|
||||
if r, err := rand.Int(rand.Reader, m); err == nil {
|
||||
ch = CharsetBase62[r.Int64()]
|
||||
}
|
||||
|
||||
token = append(token, ch)
|
||||
}
|
||||
|
||||
token = append(token, checksum.Char(token))
|
||||
|
||||
return string(token)
|
||||
}
|
||||
|
||||
func isJoinTokenSeparatorIndex(i int) bool {
|
||||
for _, pos := range joinTokenSeparators {
|
||||
if i == pos {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsJoinToken checks if the string represents a join token.
|
||||
func IsJoinToken(s string, strict bool) bool {
|
||||
// Non-strict mode: only enforce minimum length so legacy tokens that were
|
||||
// longer than the auto-generated format continue to work.
|
||||
if !strict {
|
||||
return len(s) >= JoinTokenLength
|
||||
}
|
||||
|
||||
// Strict validation enforces canonical formatting and checksum.
|
||||
if len(s) != JoinTokenLength {
|
||||
return false
|
||||
}
|
||||
|
||||
sep := 0
|
||||
for idx, r := range s {
|
||||
if r == Separator {
|
||||
if !isJoinTokenSeparatorIndex(idx) {
|
||||
return false
|
||||
}
|
||||
sep++
|
||||
continue
|
||||
}
|
||||
|
||||
if (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if sep != len(joinTokenSeparators) {
|
||||
return false
|
||||
}
|
||||
|
||||
return s[JoinTokenLength-1] == checksum.Char([]byte(s[:JoinTokenLength-1]))
|
||||
}
|
||||
|
||||
// IsAuthAny checks if the string represents a valid auth token or app password.
|
||||
func IsAuthAny(s string) bool {
|
||||
// Check if string might be a regular auth token.
|
||||
|
@@ -104,6 +104,52 @@ func BenchmarkAppPasswordIgnoreChecksum(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinToken(t *testing.T) {
|
||||
for n := 0; n < 10; n++ {
|
||||
s := JoinToken()
|
||||
t.Logf("JoinToken %d: %s", n, s)
|
||||
assert.Equal(t, JoinTokenLength, len(s))
|
||||
assert.True(t, IsJoinToken(s, true))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsJoinToken(t *testing.T) {
|
||||
t.Run("VerifyChecksum", func(t *testing.T) {
|
||||
assert.True(t, IsJoinToken("pGVplw8-eISgkdQN-Mep62nQ", true))
|
||||
assert.True(t, IsJoinToken("k9sEFe6-A7gt6zqm-gY9gFh0", true))
|
||||
assert.False(t, IsJoinToken("M2hMhlx-f4bD1zCQ-VklHch1", true))
|
||||
assert.False(t, IsJoinToken("oWIEZ6e-FnKWzaGz-vkTBf84", true))
|
||||
assert.True(t, IsJoinToken(JoinToken(), true))
|
||||
assert.True(t, IsJoinToken(JoinToken(), true))
|
||||
assert.False(t, IsJoinToken(AppPassword(), true))
|
||||
assert.False(t, IsJoinToken(AppPassword(), true))
|
||||
assert.False(t, IsJoinToken(AuthToken(), true))
|
||||
assert.False(t, IsJoinToken(AuthToken(), true))
|
||||
assert.False(t, IsJoinToken(SessionID(AuthToken()), true))
|
||||
assert.False(t, IsJoinToken(SessionID(AuthToken()), true))
|
||||
assert.False(t, IsJoinToken("pGVplw8e-ISgkdQN-Mep62nQ", true))
|
||||
assert.False(t, IsJoinToken("55785BAC-9H4B-4747-B090-EE123FFEE437", true))
|
||||
assert.False(t, IsJoinToken("4B1FEF2D1CF4A5BE38B263E0637EDEAD", true))
|
||||
assert.False(t, IsJoinToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", true))
|
||||
assert.False(t, IsJoinToken("", true))
|
||||
})
|
||||
t.Run("MinLengthOnly", func(t *testing.T) {
|
||||
assert.True(t, IsJoinToken("pGVplw8-eISgkdQN-Mep62nQ", false))
|
||||
assert.True(t, IsJoinToken("M2hMhlx-f4bD1zCQ-VklHchp", false))
|
||||
assert.True(t, IsJoinToken(AppPassword(), false))
|
||||
assert.True(t, IsJoinToken("55785BAC-9H4B-4747-B090-EE123FFEE437", false))
|
||||
assert.True(t, IsJoinToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", false))
|
||||
assert.False(t, IsJoinToken("abcdefghijklmnopqrstuvw", false))
|
||||
assert.False(t, IsJoinToken("", false))
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkJoinToken(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
JoinToken()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthAny(t *testing.T) {
|
||||
assert.True(t, IsAuthAny("MPkOqm-RtKGOi-ctIvXm-Qv3XhN"))
|
||||
assert.True(t, IsAuthAny("9q2JHc-P0LzNE-xzvY9j-vMoefj"))
|
||||
|
Reference in New Issue
Block a user