mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Settings: Use PHOTOPRISM_DISABLE_FEATURES to initialize default features
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
# Config Package Guide
|
## PhotoPrism — Config Package
|
||||||
|
|
||||||
## Overview
|
**Last Updated:** November 21, 2025
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
PhotoPrism’s [runtime configuration](https://docs.photoprism.app/developer-guide/configuration/) is managed by this package. Fields are defined in [`options.go`](options.go) and then initialized with values from command-line flags, [environment variables](https://docs.photoprism.app/getting-started/config-options/), and [optional YAML files](https://docs.photoprism.app/getting-started/config-files/) (`storage/config/*.yml`).
|
PhotoPrism’s [runtime configuration](https://docs.photoprism.app/developer-guide/configuration/) is managed by this package. Fields are defined in [`options.go`](options.go) and then initialized with values from command-line flags, [environment variables](https://docs.photoprism.app/getting-started/config-options/), and [optional YAML files](https://docs.photoprism.app/getting-started/config-files/) (`storage/config/*.yml`).
|
||||||
|
|
||||||
## Sources and Precedence
|
### Sources and Precedence
|
||||||
|
|
||||||
PhotoPrism loads configuration in the following order:
|
PhotoPrism loads configuration in the following order:
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ The `PHOTOPRISM_CONFIG_PATH` variable controls where PhotoPrism looks for YAML f
|
|||||||
|
|
||||||
> Any change to configuration (flags, env vars, YAML files) requires a restart. The Go process reads options during startup and does not watch for changes.
|
> Any change to configuration (flags, env vars, YAML files) requires a restart. The Go process reads options during startup and does not watch for changes.
|
||||||
|
|
||||||
## Inspect Before Editing
|
### Inspect Before Editing
|
||||||
|
|
||||||
Before changing environment variables or YAML files, run `photoprism config | grep -i <flag>` to confirm the current value of a flag, such as `site-url`, or `site` to show all related values:
|
Before changing environment variables or YAML files, run `photoprism config | grep -i <flag>` to confirm the current value of a flag, such as `site-url`, or `site` to show all related values:
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ Example output:
|
|||||||
| site-author | @photoprism_app |
|
| site-author | @photoprism_app |
|
||||||
| site-title | PhotoPrism |
|
| site-title | PhotoPrism |
|
||||||
|
|
||||||
## CLI Reference
|
### CLI Reference
|
||||||
|
|
||||||
- `photoprism help` (or `photoprism --help`) lists all subcommands and global flags.
|
- `photoprism help` (or `photoprism --help`) lists all subcommands and global flags.
|
||||||
- `photoprism show config` (alias `photoprism config`) renders every active option along with its current value. Pass `--json`, `--md`, `--tsv`, or `--csv` to change the output format.
|
- `photoprism show config` (alias `photoprism config`) renders every active option along with its current value. Pass `--json`, `--md`, `--tsv`, or `--csv` to change the output format.
|
||||||
|
|||||||
29
internal/config/customize/README.md
Normal file
29
internal/config/customize/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
## PhotoPrism — Customize Package
|
||||||
|
|
||||||
|
**Last Updated:** November 21, 2025
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The `customize` package defines user-facing configuration defaults for PhotoPrism’s Web UI, search, maps, imports, indexing, and feature flags. The settings are assembled by `NewDefaultSettings()` / `NewSettings()` and serialized through YAML so they can be stored or loaded at runtime.
|
||||||
|
|
||||||
|
### Feature Defaults
|
||||||
|
|
||||||
|
- Feature flags live in `FeatureSettings` and are initialized via the new `DefaultFeatures` variable.
|
||||||
|
- `NewFeatures()` returns a copy of `DefaultFeatures`, letting callers mutate per-request or per-user state without modifying the shared defaults.
|
||||||
|
|
||||||
|
### Environment Overrides
|
||||||
|
|
||||||
|
- Set `PHOTOPRISM_DISABLE_FEATURES` to disable specific features at startup.
|
||||||
|
- The value may be comma- or space-separated (case-insensitive); hyphens/underscores are ignored.
|
||||||
|
- Tokens are inflected so singular/plural variants match (for example, `albums`, `album`, or `Album` all disable the Albums flag).
|
||||||
|
|
||||||
|
### Settings Lifecycle
|
||||||
|
|
||||||
|
- `NewDefaultSettings()` seeds UI, search, maps, imports, indexing, templates, downloads, and features from the defaults in this package.
|
||||||
|
- `Settings.Load()` / `Save()` round-trip YAML configuration files.
|
||||||
|
- `Settings.Propagate()` ensures required defaults (language, timezone, start page, map style) remain populated after loading.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit tests cover feature default copying, environment-based disabling, scope application, and ACL interactions.
|
||||||
|
- Run `go test ./internal/config/customize/...` or the lints via `golangci-lint run ./internal/config/customize/...`.
|
||||||
101
internal/config/customize/features_default.go
Normal file
101
internal/config/customize/features_default.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package customize
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jinzhu/inflection"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultFeatures holds the baseline feature flags applied to new settings instances.
|
||||||
|
// Values may be overridden at startup via PHOTOPRISM_DISABLE_FEATURES.
|
||||||
|
var DefaultFeatures FeatureSettings
|
||||||
|
|
||||||
|
// init wires DefaultFeatures from defaults and environment overrides.
|
||||||
|
func init() {
|
||||||
|
DefaultFeatures = initDefaultFeatures()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeatures returns a copy of the default feature flags so callers can mutate
|
||||||
|
// the result without changing the shared defaults.
|
||||||
|
func NewFeatures() FeatureSettings {
|
||||||
|
return DefaultFeatures
|
||||||
|
}
|
||||||
|
|
||||||
|
// initDefaultFeatures builds the package-level defaults and applies any disable
|
||||||
|
// list supplied via PHOTOPRISM_DISABLE_FEATURES.
|
||||||
|
func initDefaultFeatures() FeatureSettings {
|
||||||
|
features := FeatureSettings{}
|
||||||
|
|
||||||
|
disabled := buildDisabledSet(os.Getenv("PHOTOPRISM_DISABLE_FEATURES"))
|
||||||
|
|
||||||
|
val := reflect.ValueOf(&features).Elem()
|
||||||
|
typ := val.Type()
|
||||||
|
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
field := typ.Field(i)
|
||||||
|
|
||||||
|
if len(disabled) > 0 {
|
||||||
|
candidates := []string{
|
||||||
|
clean.FieldNameLower(field.Tag.Get("json")),
|
||||||
|
clean.FieldNameLower(field.Name),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDisabled(disabled, candidates) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val.Field(i).SetBool(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return features
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDisabledSet tokenizes the disable list into normalized feature names.
|
||||||
|
func buildDisabledSet(disable string) map[string]struct{} {
|
||||||
|
if disable == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.FieldsFunc(disable, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n'
|
||||||
|
})
|
||||||
|
|
||||||
|
disabled := make(map[string]struct{}, len(parts))
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
name := clean.FieldNameLower(part)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled[name] = struct{}{}
|
||||||
|
disabled[clean.FieldNameLower(inflection.Singular(name))] = struct{}{}
|
||||||
|
disabled[clean.FieldNameLower(inflection.Plural(name))] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDisabled checks whether any of the candidate field names are present in the disabled set.
|
||||||
|
func isDisabled(disabled map[string]struct{}, candidates []string) bool {
|
||||||
|
if len(disabled) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := disabled[candidate]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
63
internal/config/customize/features_default_test.go
Normal file
63
internal/config/customize/features_default_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package customize
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitDefaultFeatures_DisableList(t *testing.T) {
|
||||||
|
origEnv, envSet := os.LookupEnv("PHOTOPRISM_DISABLE_FEATURES")
|
||||||
|
origDefaults := DefaultFeatures
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if envSet {
|
||||||
|
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", origEnv)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv("PHOTOPRISM_DISABLE_FEATURES")
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultFeatures = initDefaultFeatures()
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels")
|
||||||
|
DefaultFeatures = initDefaultFeatures()
|
||||||
|
|
||||||
|
assert.False(t, DefaultFeatures.Upload)
|
||||||
|
assert.False(t, DefaultFeatures.Videos)
|
||||||
|
assert.False(t, DefaultFeatures.Share)
|
||||||
|
assert.False(t, DefaultFeatures.BatchEdit)
|
||||||
|
assert.False(t, DefaultFeatures.Labels)
|
||||||
|
|
||||||
|
// unaffected feature stays enabled
|
||||||
|
assert.True(t, DefaultFeatures.Favorites)
|
||||||
|
|
||||||
|
// ensure the defaults are not permanently changed
|
||||||
|
assert.NotEqual(t, origDefaults, FeatureSettings{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSettingsCopiesDefaultFeatures(t *testing.T) {
|
||||||
|
origEnv, envSet := os.LookupEnv("PHOTOPRISM_DISABLE_FEATURES")
|
||||||
|
origDefaults := DefaultFeatures
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if envSet {
|
||||||
|
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", origEnv)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv("PHOTOPRISM_DISABLE_FEATURES")
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultFeatures = origDefaults
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = os.Unsetenv("PHOTOPRISM_DISABLE_FEATURES")
|
||||||
|
DefaultFeatures = initDefaultFeatures()
|
||||||
|
|
||||||
|
settings := NewSettings("", "", "")
|
||||||
|
settings.Features.Upload = false
|
||||||
|
settings.Features.Download = false
|
||||||
|
|
||||||
|
assert.True(t, DefaultFeatures.Upload, "DefaultFeatures should remain unchanged after mutation")
|
||||||
|
assert.True(t, DefaultFeatures.Download, "DefaultFeatures should remain unchanged after mutation")
|
||||||
|
}
|
||||||
@@ -70,37 +70,7 @@ func NewSettings(theme, language, timeZone string) *Settings {
|
|||||||
Animate: 0,
|
Animate: 0,
|
||||||
Style: DefaultMapsStyle,
|
Style: DefaultMapsStyle,
|
||||||
},
|
},
|
||||||
Features: FeatureSettings{
|
Features: NewFeatures(),
|
||||||
Favorites: true,
|
|
||||||
Reactions: true,
|
|
||||||
Ratings: true,
|
|
||||||
Upload: true,
|
|
||||||
Download: true,
|
|
||||||
Private: true,
|
|
||||||
Files: true,
|
|
||||||
Videos: true,
|
|
||||||
Folders: true,
|
|
||||||
Albums: true,
|
|
||||||
Calendar: true,
|
|
||||||
Moments: true,
|
|
||||||
Estimates: true,
|
|
||||||
People: true,
|
|
||||||
Labels: true,
|
|
||||||
Places: true,
|
|
||||||
Edit: true,
|
|
||||||
BatchEdit: true,
|
|
||||||
Archive: true,
|
|
||||||
Review: true,
|
|
||||||
Share: true,
|
|
||||||
Library: true,
|
|
||||||
Import: true,
|
|
||||||
Logs: true,
|
|
||||||
Search: true,
|
|
||||||
Settings: true,
|
|
||||||
Services: true,
|
|
||||||
Account: true,
|
|
||||||
Delete: true,
|
|
||||||
},
|
|
||||||
Import: ImportSettings{
|
Import: ImportSettings{
|
||||||
Path: RootPath,
|
Path: RootPath,
|
||||||
Move: false,
|
Move: false,
|
||||||
|
|||||||
35
pkg/clean/fieldname.go
Normal file
35
pkg/clean/fieldname.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package clean
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FieldName normalizes a struct field identifier so it can be compared safely.
|
||||||
|
// It strips all characters outside [A-Za-z0-9], rejects empty strings, and
|
||||||
|
// returns an empty string for inputs longer than 255 bytes to avoid abuse.
|
||||||
|
func FieldName(s string) string {
|
||||||
|
if s == "" || len(s) > 255 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all invalid characters.
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldNameLower normalizes a struct field identifier and lowercases it first.
|
||||||
|
// Useful when callers want case-insensitive comparisons against normalized data.
|
||||||
|
func FieldNameLower(s string) string {
|
||||||
|
if s == "" || len(s) > 255 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return FieldName(strings.ToLower(s))
|
||||||
|
}
|
||||||
49
pkg/clean/fieldname_test.go
Normal file
49
pkg/clean/fieldname_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package clean
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFieldName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "Path", in: "/go/src/github.com/photoprism/photoprism", want: "gosrcgithubcomphotoprismphotoprism"},
|
||||||
|
{name: "DotsAndUpper", in: "filename.TXT", want: "filenameTXT"},
|
||||||
|
{name: "SpacesAndPunctuation", in: "The quick brown fox.", want: "Thequickbrownfox"},
|
||||||
|
{name: "QuestionAndDot", in: "file?name.jpg", want: "filenamejpg"},
|
||||||
|
{name: "ControlCharacter", in: "filename." + string(rune(127)), want: "filename"},
|
||||||
|
{name: "Empty", in: "", want: ""},
|
||||||
|
{name: "TooLong", in: strings.Repeat("a", 256), want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, FieldName(tt.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldNameLower(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "LowerCase", in: "file?name.JPG", want: "filenamejpg"},
|
||||||
|
{name: "UpperOnly", in: "ABC", want: "abc"},
|
||||||
|
{name: "MixedSeparators", in: "Album-Photos_123", want: "albumphotos123"},
|
||||||
|
{name: "TooLong", in: strings.Repeat("B", 300), want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, FieldNameLower(tt.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user