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`).
|
||||
|
||||
## 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.
|
||||
|
||||
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,
|
||||
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
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