mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
API: Add GET /cluster/theme endpoint and refactor config package #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
68d387778e
commit
0d572032a9
28 changed files with 656 additions and 129 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -31,6 +31,7 @@ venv
|
|||
.tmp
|
||||
.nv
|
||||
.eslintcache
|
||||
.gocache
|
||||
/pro
|
||||
/plus
|
||||
/tmp/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
141
internal/api/cluster_theme.go
Normal file
141
internal/api/cluster_theme.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
132
internal/api/cluster_theme_test.go
Normal file
132
internal/api/cluster_theme_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ var ResourceNames = []Resource{
|
|||
ResourceWebhooks,
|
||||
ResourceMetrics,
|
||||
ResourceVision,
|
||||
ResourcePortal,
|
||||
ResourceCluster,
|
||||
ResourceFeedback,
|
||||
ResourceDefault,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ var Rules = ACL{
|
|||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantUseOwn,
|
||||
},
|
||||
ResourcePortal: Roles{
|
||||
ResourceCluster: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantSearchDownloadUpdateOwn,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
53
internal/config/config_cluster.go
Normal file
53
internal/config/config_cluster.go
Normal 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()
|
||||
}
|
||||
90
internal/config/config_cluster_test.go
Normal file
90
internal/config/config_cluster_test.go
Normal 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()))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
}},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
15
internal/event/const.go
Normal 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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue