Skip to content

Commit 29ca2c1

Browse files
committed
CLI: Improve "photoprism cluster" sub-commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
1 parent 2fe4860 commit 29ca2c1

File tree

7 files changed

+347
-17
lines changed

7 files changed

+347
-17
lines changed

internal/api/swagger.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9455,8 +9455,6 @@
94559455
"type": "integer",
94569456
"format": "int64",
94579457
"enum": [
9458-
-9223372036854775808,
9459-
9223372036854775807,
94609458
1,
94619459
1000,
94629460
1000000,
@@ -9465,8 +9463,6 @@
94659463
3600000000000
94669464
],
94679465
"x-enum-varnames": [
9468-
"minDuration",
9469-
"maxDuration",
94709466
"Nanosecond",
94719467
"Microsecond",
94729468
"Millisecond",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
11+
"github.com/photoprism/photoprism/internal/service/cluster"
12+
"github.com/photoprism/photoprism/pkg/service/http/header"
13+
)
14+
15+
// obtainClientCredentialsViaRegister calls the portal register endpoint using a join token
16+
// to (re)register the node, rotating the secret when necessary, and returns client id/secret.
17+
func obtainClientCredentialsViaRegister(portalURL, joinToken, nodeName string) (id, secret string, err error) {
18+
u, err := url.Parse(strings.TrimRight(portalURL, "/"))
19+
if err != nil || u.Scheme == "" || u.Host == "" {
20+
return "", "", fmt.Errorf("invalid portal-url: %s", portalURL)
21+
}
22+
endpoint := *u
23+
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/cluster/nodes/register"
24+
25+
reqBody := map[string]any{
26+
"nodeName": nodeName,
27+
"nodeRole": cluster.RoleInstance,
28+
"rotateSecret": true,
29+
}
30+
b, _ := json.Marshal(reqBody)
31+
req, _ := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(b))
32+
req.Header.Set("Content-Type", "application/json")
33+
header.SetAuthorization(req, joinToken)
34+
35+
resp, err := (&http.Client{}).Do(req)
36+
if err != nil {
37+
return "", "", err
38+
}
39+
defer resp.Body.Close()
40+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
41+
return "", "", fmt.Errorf("%s", resp.Status)
42+
}
43+
var regResp cluster.RegisterResponse
44+
if err := json.NewDecoder(resp.Body).Decode(&regResp); err != nil {
45+
return "", "", err
46+
}
47+
id = regResp.Node.ID
48+
if regResp.Secrets != nil {
49+
secret = regResp.Secrets.NodeSecret
50+
}
51+
if id == "" || secret == "" {
52+
return "", "", fmt.Errorf("missing client credentials in response")
53+
}
54+
return id, secret, nil
55+
}

internal/commands/cluster_theme_pull.go

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package commands
22

33
import (
44
"archive/zip"
5+
"encoding/base64"
6+
"encoding/json"
57
"fmt"
68
"io"
79
"net/http"
10+
"net/url"
811
"os"
912
"path/filepath"
1013
"strings"
@@ -25,12 +28,14 @@ var ClusterThemePullCommand = &cli.Command{
2528
Subcommands: []*cli.Command{
2629
{
2730
Name: "pull",
28-
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path",
31+
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path. If only a join token is provided, this command first registers the node to obtain client credentials, then downloads the theme (no extra command needed).",
2932
Flags: []cli.Flag{
3033
&cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""},
3134
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"},
3235
&cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"},
3336
&cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
37+
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"},
38+
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"},
3439
JsonFlag,
3540
},
3641
Action: clusterThemePullAction,
@@ -50,15 +55,44 @@ func clusterThemePullAction(ctx *cli.Context) error {
5055
if portalURL == "" {
5156
return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL")
5257
}
53-
token := ctx.String("join-token")
54-
if token == "" {
55-
token = conf.JoinToken()
58+
// Credentials: prefer OAuth client credentials (client-id/secret), fallback to join-token for compatibility.
59+
clientID := ctx.String("client-id")
60+
if clientID == "" {
61+
clientID = conf.NodeID()
5662
}
57-
if token == "" {
58-
token = os.Getenv(config.EnvVar("join-token"))
63+
clientSecret := ctx.String("client-secret")
64+
if clientSecret == "" {
65+
clientSecret = conf.NodeSecret()
66+
}
67+
token := ""
68+
if clientID != "" && clientSecret != "" {
69+
// OAuth client_credentials
70+
t, err := obtainOAuthToken(portalURL, clientID, clientSecret)
71+
if err != nil {
72+
log.Warnf("cluster: oauth token failed, falling back to join token (%s)", clean.Error(err))
73+
} else {
74+
token = t
75+
}
5976
}
6077
if token == "" {
61-
return fmt.Errorf("join-token not configured; set --join-token or PHOTOPRISM_JOIN_TOKEN")
78+
// Try join-token assisted path. If NodeID/NodeSecret not available, attempt register to obtain them, then OAuth.
79+
jt := ctx.String("join-token")
80+
if jt == "" {
81+
jt = conf.JoinToken()
82+
}
83+
if jt == "" {
84+
jt = os.Getenv(config.EnvVar("join-token"))
85+
}
86+
if jt != "" && (clientID == "" || clientSecret == "") {
87+
if id, sec, err := obtainClientCredentialsViaRegister(portalURL, jt, conf.NodeName()); err == nil {
88+
if t, err := obtainOAuthToken(portalURL, id, sec); err == nil {
89+
token = t
90+
}
91+
}
92+
}
93+
if token == "" {
94+
return fmt.Errorf("authentication required: provide --client-id/--client-secret or a join token to obtain credentials")
95+
}
6296
}
6397

6498
dest := ctx.Path("dest")
@@ -151,6 +185,46 @@ func clusterThemePullAction(ctx *cli.Context) error {
151185
})
152186
}
153187

188+
// obtainOAuthToken requests an access token via client_credentials using Basic auth.
189+
func obtainOAuthToken(portalURL, clientID, clientSecret string) (string, error) {
190+
u, err := url.Parse(strings.TrimRight(portalURL, "/"))
191+
if err != nil || u.Scheme == "" || u.Host == "" {
192+
return "", fmt.Errorf("invalid portal-url: %s", portalURL)
193+
}
194+
tokenURL := *u
195+
tokenURL.Path = strings.TrimRight(tokenURL.Path, "/") + "/api/v1/oauth/token"
196+
197+
form := url.Values{}
198+
form.Set("grant_type", "client_credentials")
199+
req, _ := http.NewRequest(http.MethodPost, tokenURL.String(), strings.NewReader(form.Encode()))
200+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
201+
req.Header.Set("Accept", "application/json")
202+
basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
203+
req.Header.Set("Authorization", "Basic "+basic)
204+
205+
client := &http.Client{Timeout: cluster.BootstrapRegisterTimeout}
206+
resp, err := client.Do(req)
207+
if err != nil {
208+
return "", err
209+
}
210+
defer resp.Body.Close()
211+
if resp.StatusCode != http.StatusOK {
212+
return "", fmt.Errorf("%s", resp.Status)
213+
}
214+
var tok struct {
215+
AccessToken string `json:"access_token"`
216+
TokenType string `json:"token_type"`
217+
Scope string `json:"scope"`
218+
}
219+
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
220+
return "", err
221+
}
222+
if tok.AccessToken == "" {
223+
return "", fmt.Errorf("empty access_token")
224+
}
225+
return tok.AccessToken, nil
226+
}
227+
154228
func dirNonEmpty(dir string) (bool, error) {
155229
entries, err := os.ReadDir(dir)
156230
if err != nil {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package commands
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"encoding/base64"
7+
"encoding/json"
8+
"net/http"
9+
"net/http/httptest"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/stretchr/testify/assert"
14+
15+
"github.com/photoprism/photoprism/internal/service/cluster"
16+
)
17+
18+
// Verifies OAuth path in cluster theme pull using client_id/client_secret.
19+
func TestClusterThemePull_OAuth(t *testing.T) {
20+
// Build an in-memory zip with one file
21+
var zipBuf bytes.Buffer
22+
zw := zip.NewWriter(&zipBuf)
23+
f, _ := zw.Create("ok.txt")
24+
_, _ = f.Write([]byte("ok\n"))
25+
_ = zw.Close()
26+
27+
// Fake portal server
28+
var gotBasic string
29+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30+
switch r.URL.Path {
31+
case "/api/v1/oauth/token":
32+
// Expect Basic auth for nodeid:secret
33+
gotBasic = r.Header.Get("Authorization")
34+
w.Header().Set("Content-Type", "application/json")
35+
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer", "scope": "cluster vision"})
36+
case "/api/v1/cluster/theme":
37+
if r.Header.Get("Authorization") != "Bearer tok" {
38+
w.WriteHeader(http.StatusUnauthorized)
39+
return
40+
}
41+
w.Header().Set("Content-Type", "application/zip")
42+
w.WriteHeader(http.StatusOK)
43+
_, _ = w.Write(zipBuf.Bytes())
44+
default:
45+
http.NotFound(w, r)
46+
}
47+
}))
48+
defer ts.Close()
49+
50+
// Prepare destination
51+
dest := t.TempDir()
52+
// Run CLI with OAuth creds
53+
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
54+
"pull", "--dest", dest, "-f",
55+
"--portal-url=" + ts.URL,
56+
"--client-id=nodeid",
57+
"--client-secret=secret",
58+
})
59+
_ = out
60+
assert.NoError(t, err)
61+
// Verify file extracted
62+
assert.FileExists(t, filepath.Join(dest, "ok.txt"))
63+
// Verify Basic header format
64+
expect := "Basic " + base64.StdEncoding.EncodeToString([]byte("nodeid:secret"))
65+
assert.Equal(t, expect, gotBasic)
66+
}
67+
68+
// Verifies that when only a join token is provided, the command obtains
69+
// client credentials via the register endpoint, then uses OAuth to pull the theme.
70+
func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
71+
// Zip fixture
72+
var zipBuf bytes.Buffer
73+
zw := zip.NewWriter(&zipBuf)
74+
_, _ = zw.Create("ok2.txt")
75+
_ = zw.Close()
76+
77+
// Fake portal server responds with register then token then theme
78+
var sawRotateSecret bool
79+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80+
switch r.URL.Path {
81+
case "/api/v1/cluster/nodes/register":
82+
// Must have Bearer join token
83+
if r.Header.Get("Authorization") != "Bearer jt" {
84+
w.WriteHeader(http.StatusUnauthorized)
85+
return
86+
}
87+
// Read body to check rotateSecret flag
88+
var req struct {
89+
RotateSecret bool `json:"rotateSecret"`
90+
NodeName string `json:"nodeName"`
91+
}
92+
_ = json.NewDecoder(r.Body).Decode(&req)
93+
sawRotateSecret = req.RotateSecret
94+
w.Header().Set("Content-Type", "application/json")
95+
// Return NodeID and a fresh secret
96+
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
97+
Node: cluster.Node{ID: "cid123", Name: "pp-node-01"},
98+
Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"},
99+
})
100+
case "/api/v1/oauth/token":
101+
// Expect Basic for the returned creds
102+
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cid123:s3cr3t")) {
103+
w.WriteHeader(http.StatusUnauthorized)
104+
return
105+
}
106+
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok2", "token_type": "Bearer"})
107+
case "/api/v1/cluster/theme":
108+
if r.Header.Get("Authorization") != "Bearer tok2" {
109+
w.WriteHeader(http.StatusUnauthorized)
110+
return
111+
}
112+
w.Header().Set("Content-Type", "application/zip")
113+
w.WriteHeader(http.StatusOK)
114+
_, _ = w.Write(zipBuf.Bytes())
115+
default:
116+
http.NotFound(w, r)
117+
}
118+
}))
119+
defer ts.Close()
120+
121+
dest := t.TempDir()
122+
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
123+
"pull", "--dest", dest, "-f",
124+
"--portal-url=" + ts.URL,
125+
"--join-token=jt",
126+
})
127+
_ = out
128+
assert.NoError(t, err)
129+
assert.True(t, sawRotateSecret)
130+
}

0 commit comments

Comments
 (0)