Settings: Use PHOTOPRISM_DISABLE_FEATURES to initialize default features

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-21 15:19:15 +01:00
parent 82b0ecea65
commit 9d86b2a512
7 changed files with 285 additions and 36 deletions

View File

@@ -1,10 +1,12 @@
# Config Package Guide
## PhotoPrism — Config Package
## Overview
**Last Updated:** November 21, 2025
### Overview
PhotoPrisms [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:
@@ -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.
## 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:
@@ -36,7 +38,7 @@ Example output:
| site-author | @photoprism_app |
| site-title | PhotoPrism |
## CLI Reference
### CLI Reference
- `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.

View File

@@ -0,0 +1,29 @@
## PhotoPrism — Customize Package
**Last Updated:** November 21, 2025
### Overview
The `customize` package defines user-facing configuration defaults for PhotoPrisms 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/...`.

View 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
}

View 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")
}

View File

@@ -70,37 +70,7 @@ func NewSettings(theme, language, timeZone string) *Settings {
Animate: 0,
Style: DefaultMapsStyle,
},
Features: FeatureSettings{
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,
},
Features: NewFeatures(),
Import: ImportSettings{
Path: RootPath,
Move: false,

35
pkg/clean/fieldname.go Normal file
View 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))
}

View 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))
})
}
}