API: Add GET /cluster/theme endpoint and refactor config package #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-09-11 06:47:45 +02:00
parent 68d387778e
commit 0d572032a9
28 changed files with 656 additions and 129 deletions

View file

@ -1,4 +1,4 @@
# Local build files and directories
# Local build files and directories:
/photos/*
/frontend/node_modules/*
/node_modules
@ -16,32 +16,51 @@
/build
/photoprism
/photoprism-*
/coverage.*
/frontend/tests/acceptance/screenshots
/test/
# Custom config, database, log, and temporary files
/tmp/
.env
# Docker configuration files:
.dockerignore
*.log
*.jsonl
*.db
*.db-journal
Dockerfile
docker-compose*
compose.yaml
compose.*.yaml
# Customization, database, log, and temporary files:
*.socket
*.lock
*.sock
*.pid
*.log
*.jsonl
*.db
*.db-journal
*.sqlite
*.override.yml
*.override.yaml
*.tmp.yml
*.override.yaml
*.tmp.yaml
*.out
*.test
*.exe
*.exe~
*.dll
*.so
*.dylib
*.tmp
*.img
*.img.xz
*.img.gz
# Automatically generated files, e.g. by editors and operating systems
/*.zip
/coverage.*
__pycache__
venv
.venv
.env
.tmp
.nv
.eslintcache
.gocache
.DS_Store
.DS_Store?
._*
@ -51,10 +70,10 @@ ehthumbs.db
Thumbs.db
.heartbeat
.idea
.glide
*~
.goutputstream*
.c9revisions
.settings
.swp
.tmp
.glide
/tmp/

1
.gitignore vendored
View file

@ -31,6 +31,7 @@ venv
.tmp
.nv
.eslintcache
.gocache
/pro
/plus
/tmp/

View file

@ -55,6 +55,8 @@ Note: Across our public documentation, official images, and in production, the c
## Code Style & Lint
- Go: `go fmt` / `goimports` (see `Makefile` targets such as `fmt` / `fmt-go`)
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
- JS/Vue: use the lint/format scripts in `frontend/package.json` (ESLint + Prettier)
## Safety & Data

View file

@ -16,20 +16,15 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
type CloseableResponseRecorder struct {
*httptest.ResponseRecorder
closeCh chan bool
}
func (r *CloseableResponseRecorder) CloseNotify() <-chan bool {
return r.closeCh
}
func (r *CloseableResponseRecorder) closeClient() {
r.closeCh <- true
// Ensure assets path is set so TestMain in this package can initialize config.
func init() {
if os.Getenv("PHOTOPRISM_ASSETS_PATH") == "" {
_ = os.Setenv("PHOTOPRISM_ASSETS_PATH", fs.Abs("../../assets"))
}
}
func TestMain(m *testing.M) {
@ -52,6 +47,19 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
type CloseableResponseRecorder struct {
*httptest.ResponseRecorder
closeCh chan bool
}
func (r *CloseableResponseRecorder) CloseNotify() <-chan bool {
return r.closeCh
}
func (r *CloseableResponseRecorder) closeClient() {
r.closeCh <- true
}
// NewApiTest returns new API test helper.
func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config) {
gin.SetMode(gin.TestMode)

View file

@ -0,0 +1,141 @@
package api
import (
"archive/zip"
gofs "io/fs"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
// ClusterGetTheme returns custom theme files as zip, if available.
//
// @Summary returns custom theme files as zip, if available
// @Id ClusterGetTheme
// @Tags Cluster
// @Produce application/zip
// @Success 200 {file} application/zip
// @Failure 401,403,404,429 {object} i18n.Response
// @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)
if s.Abort(c) {
return
}
/*
TODO - Consider the following optional hardening measures:
1. Track a hadError flag to log "partial success" if some files fail to zip.
2. Set limits (total size/entry count) in case theme directories grow unexpectedly.
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.ClusterPortal() {
AbortFeatureDisabled(c)
return
}
clientIp := ClientIP(c)
themePath := conf.ClusterThemePath()
// 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))
AbortNotFound(c)
return
} else {
themePath = resolved
}
// 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)
AbortNotFound(c)
return
} else {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, s.RefID, clean.Log(themePath))
}
// Add response headers.
AddDownloadHeader(c, "theme.zip")
AddContentTypeHeader(c, header.ContentTypeZip)
// Create zip writer to stream the theme files.
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))
}
}(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))
// If the error occurs on a directory, skip descending to avoid cascading errors.
if info != nil && info.IsDir() {
return gofs.SkipDir
}
return nil
}
// Get file base name.
name := info.Name()
// Skip any subdirectories to enhance security.
if info.IsDir() {
if filePath != themePath {
return gofs.SkipDir
}
return nil
}
// Skip non-regular files and symlinks.
if !info.Type().IsRegular() || info.Type()&gofs.ModeSymlink != 0 {
return nil
}
// Skip hidden files by name.
if fs.FileNameHidden(name) {
return nil
}
// 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))
// 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))
}
return nil
})
// Log result.
if err != nil {
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Failed, "%s"}, s.RefID, clean.Error(err))
} else {
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Succeeded}, s.RefID)
}
})
}

View file

@ -0,0 +1,132 @@
package api
import (
"archive/zip"
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
func TestClusterGetTheme(t *testing.T) {
t.Run("FeatureDisabled", func(t *testing.T) {
app, router, conf := NewApiTest()
// Ensure portal feature flag is disabled.
conf.Options().ClusterPortal = false
ClusterGetTheme(router)
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.
conf.Options().ClusterPortal = true
ClusterGetTheme(router)
missing := filepath.Join(os.TempDir(), "photoprism-test-missing-theme")
_ = os.RemoveAll(missing)
conf.SetThemePath(missing)
assert.False(t, fs.PathExists(conf.ThemePath()))
req := httptest.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Accept", "application/json")
w := httptest.NewRecorder()
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.
conf.Options().ClusterPortal = true
ClusterGetTheme(router)
tempTheme, err := os.MkdirTemp("", "pp-theme-*")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tempTheme) }()
conf.SetThemePath(tempTheme)
assert.NoError(t, os.MkdirAll(filepath.Join(tempTheme, "sub"), 0o755))
// Visible files
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))
// Hidden file
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden.txt"), []byte("secret\n"), 0o644))
// 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))
// 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))
// Symlink (should be skipped); best-effort
_ = os.Symlink(filepath.Join(tempTheme, "style.css"), filepath.Join(tempTheme, "link.css"))
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/theme")
assert.Equal(t, http.StatusOK, r.Code)
// Verify headers
assert.Equal(t, header.ContentTypeZip, r.Header().Get(header.ContentType))
assert.Contains(t, r.Header().Get(header.ContentDisposition), "attachment; filename=theme.zip")
// Verify zip contents
body := r.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
assert.NoError(t, err)
names := make([]string, 0, len(zr.File))
for _, f := range zr.File {
names = append(names, f.Name)
}
// Included
assert.Contains(t, names, "style.css")
// Subdirectories are not included for security reasons
assert.NotContains(t, names, "sub/visible.txt")
// Excluded (hidden files/dirs and symlinks)
assert.NotContains(t, names, ".hidden.txt")
assert.NotContains(t, names, ".git/HEAD")
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.
conf.Options().ClusterPortal = true
ClusterGetTheme(router)
// Create an empty temporary theme directory (no includable files).
tempTheme, err := os.MkdirTemp("", "pp-theme-empty-*")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tempTheme) }()
conf.SetThemePath(tempTheme)
// Hidden-only content to ensure exclusion yields empty archive.
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))
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/theme")
assert.Equal(t, http.StatusOK, r.Code)
// Verify headers
assert.Equal(t, header.ContentTypeZip, r.Header().Get(header.ContentType))
assert.Contains(t, r.Header().Get(header.ContentDisposition), "attachment; filename=theme.zip")
// Verify zip is valid and empty (no files included)
body := r.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
assert.NoError(t, err)
assert.Equal(t, 0, len(zr.File))
})
}

View file

@ -83,7 +83,7 @@ func ZipCreate(router *gin.RouterGroup) {
// Configure file names.
dlName := DownloadName(c)
zipPath := path.Join(conf.TempPath(), "zip")
zipPath := path.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)
@ -172,7 +172,7 @@ func ZipDownload(router *gin.RouterGroup) {
conf := get.Config()
zipBaseName := clean.FileName(filepath.Base(c.Param("filename")))
zipPath := path.Join(conf.TempPath(), "zip")
zipPath := path.Join(conf.TempPath(), fs.ZipDir)
zipFileName := path.Join(zipPath, zipBaseName)
if !fs.FileExists(zipFileName) {

View file

@ -65,7 +65,7 @@ const (
ResourceWebhooks Resource = "webhooks"
ResourceMetrics Resource = "metrics"
ResourceVision Resource = "vision"
ResourcePortal Resource = "portal"
ResourceCluster Resource = "cluster"
ResourceFeedback Resource = "feedback"
ResourceDefault Resource = "default"
)

View file

@ -27,7 +27,7 @@ var ResourceNames = []Resource{
ResourceWebhooks,
ResourceMetrics,
ResourceVision,
ResourcePortal,
ResourceCluster,
ResourceFeedback,
ResourceDefault,
}

View file

@ -108,7 +108,7 @@ var Rules = ACL{
RoleAdmin: GrantFullAccess,
RoleClient: GrantUseOwn,
},
ResourcePortal: Roles{
ResourceCluster: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantSearchDownloadUpdateOwn,
},

View file

@ -81,7 +81,7 @@ func downloadAction(ctx *cli.Context) error {
var downloadPath, downloadFile string
downloadPath = filepath.Join(conf.TempPath(), "download_"+rnd.Base36(12))
downloadPath = filepath.Join(conf.TempPath(), fs.DownloadDir+"_"+rnd.Base36(12))
if err := fs.MkdirAll(downloadPath); err != nil {
return err

View file

@ -73,12 +73,12 @@ func (c *Config) AppColor() string {
// AppIconsPath returns the path to the app icons.
func (c *Config) AppIconsPath(name ...string) string {
if len(name) > 0 {
filePath := []string{c.StaticPath(), "icons"}
filePath := []string{c.StaticPath(), fs.IconsDir}
filePath = append(filePath, name...)
return filepath.Join(filePath...)
}
return filepath.Join(c.StaticPath(), "icons")
return filepath.Join(c.StaticPath(), fs.IconsDir)
}
// AppConfig returns the progressive web app config.

View file

@ -45,7 +45,7 @@ func (c *Config) BackupBasePath() string {
return fs.Abs(c.options.BackupPath)
}
return filepath.Join(c.StoragePath(), "backup")
return filepath.Join(c.StoragePath(), fs.BackupDir)
}
// BackupSchedule returns the backup schedule in cron format, e.g. "0 12 * * *" for daily at noon.
@ -93,9 +93,9 @@ func (c *Config) BackupAlbums() bool {
// BackupAlbumsPath returns the backup path for album YAML files.
func (c *Config) BackupAlbumsPath() string {
if dir := filepath.Join(c.StoragePath(), "albums"); fs.PathExists(dir) {
if dir := filepath.Join(c.StoragePath(), fs.AlbumsDir); fs.PathExists(dir) {
return dir
}
return c.BackupPath("albums")
return c.BackupPath(fs.AlbumsDir)
}

View file

@ -0,0 +1,53 @@
package config
import (
"path/filepath"
"github.com/photoprism/photoprism/pkg/fs"
)
// InstanceSecret returns the node instance secret, if configured.
func (c *Config) InstanceSecret() string {
return c.options.InstanceSecret
}
// PortalUrl returns the URL of the cluster portal server, if configured.
func (c *Config) PortalUrl() string {
return c.options.PortalUrl
}
// PortalClient returns the portal client ID, if configured.
func (c *Config) PortalClient() string {
return c.options.PortalClient
}
// PortalSecret returns the portal client secret, if configured.
func (c *Config) PortalSecret() string {
return c.options.PortalSecret
}
// ClusterPortal returns true if this instance should act as a cluster portal.
func (c *Config) ClusterPortal() bool {
return c.options.ClusterPortal
}
// ClusterNode returns true if this instance should be configured as a cluster node.
func (c *Config) ClusterNode() bool {
return c.options.ClusterNode
}
// ClusterConfigPath returns the path to portal config files.
func (c *Config) ClusterConfigPath() string {
return filepath.Join(c.ConfigPath(), fs.ClusterDir)
}
// ClusterThemePath returns the path to the shared theme files.
func (c *Config) ClusterThemePath() string {
// Prefer the cluster-specific theme directory if it exists.
if dir := filepath.Join(c.ClusterConfigPath(), fs.ThemeDir); fs.PathExists(dir) {
return dir
}
// Fallback to the default theme directory in the main config path.
return c.ThemePath()
}

View file

@ -0,0 +1,90 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestConfig_Cluster(t *testing.T) {
t.Run("Flags", func(t *testing.T) {
c := NewConfig(CliTestContext())
// Defaults
assert.False(t, c.ClusterPortal())
assert.False(t, c.ClusterNode())
// Toggle values
c.options.ClusterPortal = true
c.options.ClusterNode = true
assert.True(t, c.ClusterPortal())
assert.True(t, c.ClusterNode())
})
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
// ClusterConfigPath always points to a "cluster" subfolder under ConfigPath.
expectedCluster := filepath.Join(c.ConfigPath(), fs.ClusterDir)
assert.Equal(t, expectedCluster, c.ClusterConfigPath())
// ClusterThemePath falls back to ThemePath if cluster dir does not exist.
expectedTheme := filepath.Join(c.ConfigPath(), fs.ThemeDir)
assert.Equal(t, expectedTheme, c.ClusterThemePath())
// When only the cluster directory exists (without a theme subfolder), it still falls back to ThemePath.
assert.NoError(t, os.MkdirAll(expectedCluster, 0o755))
assert.Equal(t, expectedTheme, c.ClusterThemePath())
// When the cluster theme directory exists, ClusterThemePath returns it.
expectedClusterTheme := filepath.Join(expectedCluster, fs.ThemeDir)
assert.NoError(t, os.MkdirAll(expectedClusterTheme, 0o755))
assert.Equal(t, expectedClusterTheme, c.ClusterThemePath())
})
t.Run("PortalAndSecrets", func(t *testing.T) {
c := NewConfig(CliTestContext())
// Defaults
assert.Equal(t, "", c.PortalUrl())
assert.Equal(t, "", c.PortalClient())
assert.Equal(t, "", c.PortalSecret())
assert.Equal(t, "", c.InstanceSecret())
// Set and read back values
c.options.PortalUrl = "https://portal.example.test"
c.options.PortalClient = "client-id"
c.options.PortalSecret = "client-secret"
c.options.InstanceSecret = "instance-secret"
assert.Equal(t, "https://portal.example.test", c.PortalUrl())
assert.Equal(t, "client-id", c.PortalClient())
assert.Equal(t, "client-secret", c.PortalSecret())
assert.Equal(t, "instance-secret", c.InstanceSecret())
})
t.Run("AbsolutePaths", func(t *testing.T) {
c := NewConfig(CliTestContext())
tempCfg := t.TempDir()
c.options.ConfigPath = tempCfg
// ThemePath should be absolute.
assert.True(t, filepath.IsAbs(c.ThemePath()))
// ClusterThemePath should be absolute (fallback case).
assert.True(t, filepath.IsAbs(c.ClusterThemePath()))
// Create cluster theme directory and verify again.
clusterTheme := filepath.Join(c.ClusterConfigPath(), fs.ThemeDir)
assert.NoError(t, os.MkdirAll(clusterTheme, 0o755))
assert.True(t, filepath.IsAbs(c.ClusterThemePath()))
})
}

View file

@ -198,14 +198,14 @@ func (c *Config) HttpSocket() *url.URL {
// TemplatesPath returns the server templates path.
func (c *Config) TemplatesPath() string {
return filepath.Join(c.AssetsPath(), "templates")
return filepath.Join(c.AssetsPath(), fs.TemplatesDir)
}
// CustomTemplatesPath returns the path to custom templates.
func (c *Config) CustomTemplatesPath() string {
if dir := c.CustomAssetsPath(); dir == "" {
return ""
} else if dir = filepath.Join(dir, "templates"); fs.PathExists(dir) {
} else if dir = filepath.Join(dir, fs.TemplatesDir); fs.PathExists(dir) {
return dir
}
@ -269,30 +269,40 @@ func (c *Config) TemplateName() string {
// StaticPath returns the static assets' path.
func (c *Config) StaticPath() string {
return filepath.Join(c.AssetsPath(), "static")
return filepath.Join(c.AssetsPath(), fs.StaticDir)
}
// StaticFile returns the path to a static file.
func (c *Config) StaticFile(fileName string) string {
return filepath.Join(c.AssetsPath(), "static", fileName)
return filepath.Join(c.AssetsPath(), fs.StaticDir, fileName)
}
// BuildPath returns the static build path.
func (c *Config) BuildPath() string {
return filepath.Join(c.StaticPath(), "build")
return filepath.Join(c.StaticPath(), fs.BuildDir)
}
// ImgPath returns the path to static image files.
func (c *Config) ImgPath() string {
return filepath.Join(c.StaticPath(), "img")
return filepath.Join(c.StaticPath(), fs.ImgDir)
}
// ThemePath returns the path to static theme files.
func (c *Config) ThemePath() string {
return filepath.Join(c.ConfigPath(), "theme")
if c.options.CustomThemePath != "" {
return c.options.CustomThemePath
}
return filepath.Join(c.ConfigPath(), fs.ThemeDir)
}
// PortalPath returns the path to portal config files.
func (c *Config) PortalPath() string {
return filepath.Join(c.ConfigPath(), "portal")
// SetThemePath sets a custom theme files path.
func (c *Config) SetThemePath(dir string) *Config {
if dir != "" {
dir = fs.Abs(dir)
}
c.options.CustomThemePath = dir
return c
}

View file

@ -237,11 +237,11 @@ func (c *Config) CreateDirectories() error {
// ConfigPath returns the config path.
func (c *Config) ConfigPath() string {
if c.options.ConfigPath == "" {
if fs.PathExists(filepath.Join(c.StoragePath(), "settings")) {
return filepath.Join(c.StoragePath(), "settings")
if fs.PathExists(filepath.Join(c.StoragePath(), fs.SettingsDir)) {
return filepath.Join(c.StoragePath(), fs.SettingsDir)
}
return filepath.Join(c.StoragePath(), "config")
return filepath.Join(c.StoragePath(), fs.ConfigDir)
} else if fs.FileExists(c.options.ConfigPath) {
if c.options.OptionsYaml == "" {
c.options.OptionsYaml = c.options.ConfigPath
@ -357,7 +357,7 @@ func (c *Config) ImportAllow() fs.ExtList {
// SidecarPath returns the storage path for generated sidecar files (relative or absolute).
func (c *Config) SidecarPath() string {
if c.options.SidecarPath == "" {
c.options.SidecarPath = filepath.Join(c.StoragePath(), "sidecar")
c.options.SidecarPath = filepath.Join(c.StoragePath(), fs.SidecarDir)
}
return c.options.SidecarPath
@ -377,7 +377,7 @@ func (c *Config) SidecarWritable() bool {
func (c *Config) UsersPath() string {
// Set default.
if c.options.UsersPath == "" {
return "users"
return fs.UsersDir
}
return clean.UserPath(c.options.UsersPath)
@ -390,7 +390,7 @@ func (c *Config) UsersOriginalsPath() string {
// UsersStoragePath returns the users storage base path.
func (c *Config) UsersStoragePath() string {
return filepath.Join(c.StoragePath(), "users")
return filepath.Join(c.StoragePath(), fs.UsersDir)
}
// UserStoragePath returns the storage path for user assets.
@ -414,7 +414,7 @@ func (c *Config) UserUploadPath(userUid, token string) (string, error) {
return "", fmt.Errorf("invalid uid")
}
dir := filepath.Join(c.UserStoragePath(userUid), "upload", clean.Token(token))
dir := filepath.Join(c.UserStoragePath(userUid), fs.UploadDir, clean.Token(token))
if err := fs.MkdirAll(dir); err != nil {
return "", err
@ -486,7 +486,7 @@ func (c *Config) tempPath() string {
// CachePath returns the path for cache files.
func (c *Config) CachePath() string {
if c.options.CachePath == "" {
return filepath.Join(c.StoragePath(), "cache")
return filepath.Join(c.StoragePath(), fs.CacheDir)
}
return fs.Abs(c.options.CachePath)
@ -494,7 +494,7 @@ func (c *Config) CachePath() string {
// CmdCachePath returns a path that external CLI tools can use as cache directory.
func (c *Config) CmdCachePath() string {
return filepath.Join(c.CachePath(), "cmd")
return filepath.Join(c.CachePath(), fs.CmdDir)
}
// CmdLibPath returns the dynamic loader path that external CLI tools should use.
@ -508,7 +508,7 @@ func (c *Config) CmdLibPath() string {
// MediaCachePath returns the main media cache path.
func (c *Config) MediaCachePath() string {
return filepath.Join(c.CachePath(), "media")
return filepath.Join(c.CachePath(), fs.MediaDir)
}
// MediaFileCachePath returns the cache subdirectory path for a given file hash.
@ -536,17 +536,15 @@ func (c *Config) MediaFileCachePath(hash string) string {
// ThumbCachePath returns the thumbnail storage path.
func (c *Config) ThumbCachePath() string {
return filepath.Join(c.CachePath(), "thumbnails")
return filepath.Join(c.CachePath(), fs.ThumbnailsDir)
}
// StoragePath returns the path for generated files like cache and index.
func (c *Config) StoragePath() string {
if c.options.StoragePath == "" {
const dirName = "storage"
// Default directories.
originalsDir := fs.Abs(filepath.Join(c.OriginalsPath(), fs.PPHiddenPathname, dirName))
storageDir := fs.Abs(dirName)
originalsDir := fs.Abs(filepath.Join(c.OriginalsPath(), fs.PPHiddenPathname, fs.StorageDir))
storageDir := fs.Abs(fs.StorageDir)
// Find existing directories.
if fs.PathWritable(originalsDir) && !c.ReadOnly() {
@ -557,12 +555,12 @@ func (c *Config) StoragePath() string {
// Fallback to backup storage path.
if fs.PathWritable(c.options.BackupPath) {
return fs.Abs(filepath.Join(c.options.BackupPath, dirName))
return fs.Abs(filepath.Join(c.options.BackupPath, fs.StorageDir))
}
// Use .photoprism in home directory?
if usr, _ := user.Current(); usr.HomeDir != "" {
p := fs.Abs(filepath.Join(usr.HomeDir, fs.PPHiddenPathname, dirName))
p := fs.Abs(filepath.Join(usr.HomeDir, fs.PPHiddenPathname, fs.StorageDir))
if fs.PathWritable(p) || c.ReadOnly() {
return p
@ -571,7 +569,7 @@ func (c *Config) StoragePath() string {
// Fallback directory in case nothing else works.
if c.ReadOnly() {
return fs.Abs(filepath.Join(fs.PPHiddenPathname, dirName))
return fs.Abs(filepath.Join(fs.PPHiddenPathname, fs.StorageDir))
}
// Store cache and index in "originals/.photoprism/storage".
@ -602,7 +600,7 @@ func (c *Config) CustomAssetsPath() string {
// ProfilesPath returns the path where processing profile files are stored.
func (c *Config) ProfilesPath() string {
return filepath.Join(c.AssetsPath(), "profiles")
return filepath.Join(c.AssetsPath(), fs.ProfilesDir)
}
// IccProfilesPath returns the path where ICC color profile files are stored.
@ -614,7 +612,7 @@ func (c *Config) IccProfilesPath() string {
func (c *Config) CustomStaticPath() string {
if dir := c.CustomAssetsPath(); dir == "" {
return ""
} else if dir = filepath.Join(dir, "static"); !fs.PathExists(dir) {
} else if dir = filepath.Join(dir, fs.StaticDir); !fs.PathExists(dir) {
return ""
} else {
return dir
@ -650,17 +648,17 @@ func (c *Config) CustomStaticAssetUri(res string) string {
// LocalesPath returns the translation locales path.
func (c *Config) LocalesPath() string {
return filepath.Join(c.AssetsPath(), "locales")
return filepath.Join(c.AssetsPath(), fs.LocalesDir)
}
// ExamplesPath returns the example files path.
func (c *Config) ExamplesPath() string {
return filepath.Join(c.AssetsPath(), "examples")
return filepath.Join(c.AssetsPath(), fs.ExamplesDir)
}
// TestdataPath returns the test files path.
func (c *Config) TestdataPath() string {
return filepath.Join(c.StoragePath(), "testdata")
return filepath.Join(c.StoragePath(), fs.TestdataDir)
}
// MariadbBin returns the mariadb executable file name.
@ -680,5 +678,5 @@ func (c *Config) SqliteBin() string {
// OriginalsAlbumsPath returns the optional album YAML file path inside originals.
func (c *Config) OriginalsAlbumsPath() string {
return filepath.Join(c.OriginalsPath(), "albums")
return filepath.Join(c.OriginalsPath(), fs.AlbumsDir)
}

View file

@ -13,6 +13,14 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// Set the assets path so that NewConfig(CliTestContext) always works for the package tests.
func init() {
if os.Getenv("PHOTOPRISM_ASSETS_PATH") == "" {
// From internal/config to repo root assets.
_ = os.Setenv("PHOTOPRISM_ASSETS_PATH", filepath.Clean("../../assets"))
}
}
func TestMain(m *testing.M) {
_ = os.Setenv("PHOTOPRISM_TEST", "true")
log = logrus.StandardLogger()
@ -249,22 +257,18 @@ func TestConfig_BuildPath(t *testing.T) {
func TestConfig_ImgPath(t *testing.T) {
c := NewConfig(CliTestContext())
path := c.ImgPath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/img", path)
result := c.ImgPath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/img", result)
}
func TestConfig_ThemePath(t *testing.T) {
c := NewConfig(CliTestContext())
path := c.ThemePath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/config/theme", path)
}
func TestConfig_PortalPath(t *testing.T) {
c := NewConfig(CliTestContext())
path := c.PortalPath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/config/portal", path)
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/config/theme", c.ThemePath())
c.SetThemePath("testdata/static/img/wallpaper")
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/internal/config/testdata/static/img/wallpaper", c.ThemePath())
c.SetThemePath("")
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/config/theme", c.ThemePath())
}
func TestConfig_IndexWorkers(t *testing.T) {

View file

@ -14,7 +14,7 @@ const (
// CertificatesPath returns the path to the TLS certificates and keys.
func (c *Config) CertificatesPath() string {
return filepath.Join(c.ConfigPath(), "certificates")
return filepath.Join(c.ConfigPath(), fs.CertificatesDir)
}
// TLSEmail returns the email address to enable automatic HTTPS via Let's Encrypt

View file

@ -49,7 +49,7 @@ func (c *Config) ModelsPath() string {
return fs.Abs(c.options.ModelsPath)
}
if dir := filepath.Join(c.AssetsPath(), "models"); fs.PathExists(dir) {
if dir := filepath.Join(c.AssetsPath(), fs.ModelsDir); fs.PathExists(dir) {
c.options.ModelsPath = dir
return c.options.ModelsPath
}

View file

@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/service/hub/places"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/http/header"
@ -227,7 +228,7 @@ var Flags = CliFlags{
Flag: &cli.StringFlag{
Name: "users-path",
Usage: "relative `PATH` to create base and upload subdirectories for users",
Value: "users",
Value: fs.UsersDir,
EnvVars: EnvVars("USERS_PATH"),
}}, {
Flag: &cli.PathFlag{
@ -298,6 +299,13 @@ var Flags = CliFlags{
EnvVars: EnvVars("ASSETS_PATH"),
TakesFile: true,
}}, {
Flag: &cli.PathFlag{
Name: "theme-path",
Usage: "custom user interface theme `PATH` containing styles, scripts, and images",
EnvVars: EnvVars("THEME_PATH"),
TakesFile: true,
Hidden: true,
}}, {
Flag: &cli.PathFlag{
Name: "models-path",
Usage: "custom model assets `PATH` where computer vision models are located",
@ -1094,6 +1102,41 @@ var Flags = CliFlags{
Value: face.MatchDist,
EnvVars: EnvVars("FACE_MATCH_DIST"),
}}, {
Flag: &cli.StringFlag{
Name: "instance-secret",
Usage: "unique `TOKEN` for authenticating this instance in a cluster",
EnvVars: EnvVars("INSTANCE_SECRET"),
}}, {
Flag: &cli.StringFlag{
Name: "portal-url",
Usage: "portal server `URL` for joining a cluster",
EnvVars: EnvVars("PORTAL_URL"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "portal-client",
Usage: "client `ID` for registering this instance as a new cluster node",
EnvVars: EnvVars("PORTAL_CLIENT"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "portal-secret",
Usage: "client `SECRET` for registering this instance as a new cluster node",
EnvVars: EnvVars("PORTAL_SECRET"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.BoolFlag{
Name: "cluster-node",
Usage: "register this instance as a cluster node, if possible",
EnvVars: EnvVars("CLUSTER_NODE"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.BoolFlag{
Name: "cluster-portal",
Usage: "runs this instance as a portal server for managing a cluster, if possible",
EnvVars: EnvVars("CLUSTER_PORTAL"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "pid-filename",
Usage: "process id `FILENAME`*daemon-mode only*",
@ -1106,35 +1149,5 @@ var Flags = CliFlags{
Value: "",
EnvVars: EnvVars("LOG_FILENAME"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "portal-url",
Usage: "PhotoPrism® Portal server `URL`",
EnvVars: EnvVars("PORTAL_URL"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "portal-client",
Usage: "PhotoPrism® Portal client `ID`",
EnvVars: EnvVars("PORTAL_CLIENT"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "portal-secret",
Usage: "PhotoPrism® Portal client `SECRET`",
EnvVars: EnvVars("PORTAL_SECRET"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "instance-roles",
Usage: "`ROLES` of this instance within a cluster (library, vision, portal)",
EnvVars: EnvVars("INSTANCE_ROLES"),
Hidden: true,
}, Tags: []string{Pro}}, {
Flag: &cli.StringFlag{
Name: "instance-secret",
Usage: "`SECRET` for authenticating this instance in a cluster (must be unique)",
EnvVars: EnvVars("INSTANCE_SECRET"),
Hidden: true,
}, Tags: []string{Pro}},
}},
}

View file

@ -77,6 +77,7 @@ type Options struct {
TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"`
AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"`
CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path" tags:"plus,pro"`
CustomThemePath string `yaml:"-" json:"-" flag:"theme-path"`
ModelsPath string `yaml:"ModelsPath" json:"-" flag:"models-path"`
SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
SidecarYaml bool `yaml:"SidecarYaml" json:"SidecarYaml" flag:"sidecar-yaml" default:"true"`
@ -216,14 +217,15 @@ type Options struct {
FaceClusterCore int `yaml:"-" json:"-" flag:"face-cluster-core"`
FaceClusterDist float64 `yaml:"-" json:"-" flag:"face-cluster-dist"`
FaceMatchDist float64 `yaml:"-" json:"-" flag:"face-match-dist"`
PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"`
LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"`
DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"`
InstanceSecret string `yaml:"InstanceSecret" json:"-" flag:"instance-secret"`
PortalUrl string `yaml:"PortalUrl" json:"-" flag:"portal-url"`
PortalClient string `yaml:"PortalClient" json:"-" flag:"portal-client"`
PortalSecret string `yaml:"PortalSecret" json:"-" flag:"portal-secret"`
InstanceRoles string `yaml:"InstanceRoles" json:"-" flag:"instance-roles"`
InstanceSecret string `yaml:"InstanceSecret" json:"-" flag:"instance-secret"`
ClusterNode bool `yaml:"ClusterNode" json:"-" flag:"cluster-node"`
ClusterPortal bool `yaml:"ClusterPortal" json:"-" flag:"cluster-portal"`
PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"`
LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"`
DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"`
}
// NewOptions creates a new configuration entity by using two methods:

View file

@ -168,12 +168,6 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"cors-headers", c.CORSHeaders()},
{"cors-methods", c.CORSMethods()},
// Portal Server.
{"portal-url", fmt.Sprintf("%s", c.Options().PortalUrl)},
{"portal-client", fmt.Sprintf("%s", c.Options().PortalClient)},
{"portal-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.Options().PortalSecret)))},
{"instance-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.Options().InstanceSecret)))},
// URIs.
{"base-uri", c.BaseUri("/")},
{"api-uri", c.ApiUri()},
@ -277,6 +271,16 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"face-cluster-dist", fmt.Sprintf("%f", c.FaceClusterDist())},
{"face-match-dist", fmt.Sprintf("%f", c.FaceMatchDist())},
// Cluster Configuration.
{"instance-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.InstanceSecret())))},
{"portal-url", c.PortalUrl()},
{"portal-client", c.PortalClient()},
{"portal-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.PortalSecret())))},
{"cluster-node", fmt.Sprintf("%t", c.ClusterNode())},
{"cluster-portal", fmt.Sprintf("%t", c.ClusterPortal())},
{"cluster-config-path", c.ClusterConfigPath()},
{"cluster-theme-path", c.ClusterThemePath()},
// Daemon Mode.
{"pid-filename", c.PIDFilename()},
{"log-filename", c.LogFilename()},

View file

@ -35,9 +35,9 @@ var OptionsReportSections = []ReportSection{
{Start: "PHOTOPRISM_VISION_YAML", Title: "Computer Vision"},
{Start: "PHOTOPRISM_FACE_SIZE", Title: "Face Recognition",
Info: faceFlagsInfo},
{Start: "PHOTOPRISM_INSTANCE_SECRET", Title: "Cluster Configuration"},
{Start: "PHOTOPRISM_PID_FILENAME", Title: "Daemon Mode",
Info: "If you start the server as a *daemon* in the background, you can additionally specify a filename for the log and the process ID:"},
{Start: "PHOTOPRISM_PORTAL_URL", Title: "Cluster Configuration"},
}
// YamlReportSections is used to generate config options reports in ../commands/show_config_yaml.go.
@ -60,7 +60,7 @@ var YamlReportSections = []ReportSection{
{Start: "ThumbLibrary", Title: "Preview Images"},
{Start: "JpegQuality", Title: "Image Quality"},
{Start: "VisionYaml", Title: "Computer Vision"},
{Start: "InstanceSecret", Title: "Cluster Configuration"},
{Start: "PIDFilename", Title: "Daemon Mode",
Info: "If you start the server as a *daemon* in the background, you can additionally specify a filename for the log and the process ID:"},
{Start: "PortalUrl", Title: "Cluster Configuration"},
}

View file

@ -56,7 +56,7 @@ func NewTestOptions(pkg string) *Options {
storagePath = fs.Abs("../../storage")
}
dataPath := filepath.Join(storagePath, "testdata")
dataPath := filepath.Join(storagePath, fs.TestdataDir)
pkg = PkgNameRegexp.ReplaceAllString(pkg, "")
driver := os.Getenv("PHOTOPRISM_TEST_DRIVER")

15
internal/event/const.go Normal file
View file

@ -0,0 +1,15 @@
package event
const (
Succeeded = "succeeded"
Failed = "failed"
Denied = "denied"
Granted = "granted"
Created = "created"
Added = "added"
Deleted = "deleted"
Removed = "removed"
Verified = "verified"
Activated = "activated"
Deactivated = "deactivated"
)

View file

@ -193,6 +193,9 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.BatchPhotosPrivate(APIv1)
api.BatchPhotosDelete(APIv1)
// Cluster Operations.
api.ClusterGetTheme(APIv1)
// Technical Endpoints.
api.GetSvg(APIv1)
api.GetStatus(APIv1)

View file

@ -1,8 +1,40 @@
package fs
// Common file names and patterns used across packages.
const (
PPIgnoreFilename = ".ppignore"
PPIgnoreAll = "*"
PPStorageFilename = ".ppstorage"
PPHiddenPathname = ".photoprism"
)
// Common directory names used across packages (sorted by name).
const (
AlbumsDir = "albums"
DownloadDir = "download"
BackupDir = "backup"
BuildDir = "build"
CacheDir = "cache"
CertificatesDir = "certificates"
ClusterDir = "cluster"
CmdDir = "cmd"
ConfigDir = "config"
ExamplesDir = "examples"
IconsDir = "icons"
ImgDir = "img"
LocalesDir = "locales"
MediaDir = "media"
ModelsDir = "models"
ProfilesDir = "profiles"
SettingsDir = "settings"
SidecarDir = "sidecar"
StaticDir = "static"
StorageDir = "storage"
TemplatesDir = "templates"
TestdataDir = "testdata"
ThemeDir = "theme"
ThumbnailsDir = "thumbnails"
UploadDir = "upload"
UsersDir = "users"
ZipDir = "zip"
)