@@ -2,9 +2,12 @@ package commands
22
33import (
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+
154228func dirNonEmpty (dir string ) (bool , error ) {
155229 entries , err := os .ReadDir (dir )
156230 if err != nil {
0 commit comments