mirror of
https://github.com/photoprism/photoprism.git
synced 2025-10-05 08:47:12 +08:00
CLI: Add cluster operations and management commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
230
internal/commands/cluster_theme_pull.go
Normal file
230
internal/commands/cluster_theme_pull.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// ClusterThemePullCommand downloads the Portal theme and installs it.
|
||||
var ClusterThemePullCommand = &cli.Command{
|
||||
Name: "theme",
|
||||
Usage: "Theme subcommands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "pull",
|
||||
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path",
|
||||
Flags: []cli.Flag{
|
||||
&cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""},
|
||||
&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: "portal-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
|
||||
JsonFlag,
|
||||
},
|
||||
Action: clusterThemePullAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func clusterThemePullAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
portalURL := strings.TrimRight(ctx.String("portal-url"), "/")
|
||||
if portalURL == "" {
|
||||
portalURL = strings.TrimRight(conf.PortalUrl(), "/")
|
||||
}
|
||||
if portalURL == "" {
|
||||
portalURL = strings.TrimRight(os.Getenv(config.EnvVar("portal-url")), "/")
|
||||
}
|
||||
if portalURL == "" {
|
||||
return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL")
|
||||
}
|
||||
token := ctx.String("portal-token")
|
||||
if token == "" {
|
||||
token = conf.PortalToken()
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv(config.EnvVar("portal-token"))
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("portal-token not configured; set --portal-token or PHOTOPRISM_PORTAL_TOKEN")
|
||||
}
|
||||
|
||||
dest := ctx.Path("dest")
|
||||
if dest == "" {
|
||||
dest = conf.ThemePath()
|
||||
}
|
||||
dest = fs.Abs(dest)
|
||||
|
||||
// Destination must be a directory. Create if needed.
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
return fmt.Errorf("destination is a file, expected a directory: %s", clean.Log(dest))
|
||||
} else if err != nil {
|
||||
if err := fs.MkdirAll(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If destination contains files and --force not set, refuse.
|
||||
if !ctx.Bool("force") {
|
||||
if nonEmpty, _ := dirNonEmpty(dest); nonEmpty {
|
||||
return fmt.Errorf("destination is not empty; use --force to replace existing files: %s", clean.Log(dest))
|
||||
}
|
||||
} else {
|
||||
// Clean destination contents, but keep the directory itself.
|
||||
if err := removeDirContents(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Download zip to a temp file.
|
||||
zipURL := portalURL + "/api/v1/cluster/theme"
|
||||
tmpFile, err := os.CreateTemp("", "photoprism-theme-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, zipURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Map common codes to clearer messages
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("unauthorized; check portal token and permissions (%s)", resp.Status)
|
||||
case http.StatusTooManyRequests:
|
||||
return fmt.Errorf("rate limited by portal (%s)", resp.Status)
|
||||
case http.StatusNotFound:
|
||||
return fmt.Errorf("portal theme not found (%s)", resp.Status)
|
||||
default:
|
||||
return fmt.Errorf("download failed: %s", resp.Status)
|
||||
}
|
||||
}
|
||||
if _, err = io.Copy(tmpFile, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract safely into destination.
|
||||
if err := unzipSafe(tmpFile.Name(), dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Bool("json") {
|
||||
fmt.Printf("{\"installed\":\"%s\"}\n", clean.Log(dest))
|
||||
} else {
|
||||
log.Infof("installed theme files to %s", clean.Log(dest))
|
||||
fmt.Println(dest)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func dirNonEmpty(dir string) (bool, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for range entries {
|
||||
// Ignore typical dotfiles? Keep it simple: any entry counts
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func removeDirContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
p := filepath.Join(dir, e.Name())
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzipSafe(zipPath, dest string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if len(r.File) == 0 {
|
||||
return errors.New("theme archive is empty")
|
||||
}
|
||||
for _, f := range r.File {
|
||||
// Directories are indicated by trailing '/'; ensure canonical path
|
||||
name := filepath.Clean(f.Name)
|
||||
if name == "." || name == ".." || strings.HasPrefix(name, "../") || strings.Contains(name, ":") {
|
||||
continue
|
||||
}
|
||||
// Disallow absolute and Windows drive paths
|
||||
if filepath.IsAbs(name) {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(dest, name)
|
||||
// Ensure path stays within dest
|
||||
if !strings.HasPrefix(target+string(os.PathSeparator), dest+string(os.PathSeparator)) && target != dest {
|
||||
continue
|
||||
}
|
||||
// Skip entries that look like hidden files or directories
|
||||
base := filepath.Base(name)
|
||||
if fs.FileNameHidden(base) {
|
||||
continue
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := fs.MkdirAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Ensure parent exists
|
||||
if err := fs.MkdirAll(filepath.Dir(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Open for read
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Create/truncate target
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.Mode())
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user