Config: Read admin and database password from file #2302

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-03-28 21:00:56 +01:00
parent a89f186f40
commit ad5baf2823
11 changed files with 171 additions and 85 deletions

View File

@@ -40,19 +40,21 @@ var backupFlags = []cli.Flag{
Aliases: []string{"a"},
Usage: "create YAML files to back up album metadata (in the standard backup path if no other path is specified)",
},
&cli.StringFlag{
&cli.PathFlag{
Name: "albums-path",
Usage: "custom album backup `PATH`",
TakesFile: true,
},
&cli.BoolFlag{
Name: "database",
Aliases: []string{"index", "i"},
Usage: "create index database backup (in the backup path with the date as filename if no filename is passed, or sent to stdout if - is passed as filename)",
},
&cli.StringFlag{
&cli.PathFlag{
Name: "database-path",
Aliases: []string{"index-path"},
Usage: "custom database backup `PATH`",
TakesFile: true,
},
&cli.IntFlag{
Name: "retain",

View File

@@ -1,6 +1,7 @@
package config
import (
"os"
"regexp"
"time"
@@ -80,7 +81,15 @@ func (c *Config) AdminUser() string {
// AdminPassword returns the initial admin password.
func (c *Config) AdminPassword() string {
// Read password from file if requested, otherwise return value from options.
if fileName := FlagFilePath("ADMIN_PASSWORD"); fileName == "" {
return clean.Password(c.options.AdminPassword)
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read admin password from %s (%s)", fileName, err)
return ""
} else {
return clean.Password(string(b))
}
}
// PasswordLength returns the minimum password length in characters.

View File

@@ -1,6 +1,7 @@
package config
import (
"os"
"testing"
"time"
@@ -50,6 +51,19 @@ func TestAuthMode(t *testing.T) {
c.options.Debug = false
}
func TestConfig_AdminPassword(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "photoprism", c.AdminPassword())
// Test setting the password via secret file.
_ = os.Setenv(FlagFileVar("ADMIN_PASSWORD"), "testdata/secret_admin")
assert.Equal(t, "Foo-Bar23", c.AdminPassword())
_ = os.Setenv(FlagFileVar("ADMIN_PASSWORD"), "")
assert.Equal(t, "photoprism", c.AdminPassword())
}
func TestPasswordLength(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, 8, c.PasswordLength())

View File

@@ -249,9 +249,16 @@ func (c *Config) DatabasePassword() string {
return ""
}
// Read password from file if requested, otherwise return value from options.
if fileName := FlagFilePath("DATABASE_PASSWORD"); fileName == "" {
c.ParseDatabaseDsn()
return c.options.DatabasePassword
return clean.Password(c.options.DatabasePassword)
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read database password from %s (%s)", fileName, err)
return ""
} else {
return clean.Password(string(b))
}
}
// DatabaseTimeout returns the TCP timeout in seconds for establishing a database connection:

View File

@@ -1,6 +1,7 @@
package config
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -107,6 +108,16 @@ func TestConfig_DatabasePassword(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.DatabasePassword())
// Test setting the password via secret file.
_ = os.Setenv(FlagFileVar("DATABASE_PASSWORD"), "testdata/secret_database")
assert.Equal(t, "", c.DatabasePassword())
c.Options().DatabaseDriver = MySQL
assert.Equal(t, "StoryOfAmélie", c.DatabasePassword())
c.Options().DatabaseDriver = SQLite3
_ = os.Setenv(FlagFileVar("DATABASE_PASSWORD"), "")
assert.Equal(t, "", c.DatabasePassword())
}
func TestConfig_DatabaseDsn(t *testing.T) {

View File

@@ -208,13 +208,6 @@ func TestConfig_AdminUser(t *testing.T) {
assert.Equal(t, "admin", c.AdminUser())
}
func TestConfig_AdminPassword(t *testing.T) {
c := NewConfig(CliTestContext())
result := c.AdminPassword()
assert.Equal(t, "photoprism", result)
}
func TestConfig_ExamplesPath(t *testing.T) {
c := NewConfig(CliTestContext())

View File

@@ -4,6 +4,7 @@ import (
"os"
"strings"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -51,3 +52,19 @@ func Env(vars ...string) bool {
return false
}
// FlagFileVar returns the name of the environment variable that can contain a filename to load a config value from.
func FlagFileVar(flag string) string {
return EnvVar(flag) + "_FILE"
}
// FlagFilePath returns the name of the that contains the value of the specified config flag, if any.
func FlagFilePath(flag string) string {
if envVar := os.Getenv(FlagFileVar(flag)); envVar == "" {
return ""
} else if absName := fs.Abs(envVar); fs.FileExistsNotEmpty(absName) {
return absName
}
return ""
}

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -59,3 +60,18 @@ func TestEnv(t *testing.T) {
assert.False(t, Env("TESTENV_0"))
})
}
func TestFlagFileVar(t *testing.T) {
t.Run("AdminPassword", func(t *testing.T) {
assert.Equal(t, "PHOTOPRISM_ADMIN_PASSWORD_FILE", FlagFileVar("ADMIN_PASSWORD"))
})
}
func TestFlagFilePath(t *testing.T) {
t.Run("AdminPassword", func(t *testing.T) {
_ = os.Setenv("PHOTOPRISM_ADMIN_PASSWORD_FILE", "./testdata/secret_admin")
actual := FlagFilePath("ADMIN_PASSWORD")
expected := "internal/config/testdata/secret_admin"
assert.True(t, strings.Contains(actual, expected), expected+" was expected")
})
}

View File

@@ -181,11 +181,12 @@ var Flags = CliFlags{
Usage: "hosting partner id",
EnvVars: EnvVars("PARTNER_ID"),
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "config-path",
Aliases: []string{"c"},
Usage: "config storage `PATH`, values in options.yml override CLI flags and environment variables if present",
EnvVars: EnvVars("CONFIG_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "defaults-yaml",
@@ -193,12 +194,14 @@ var Flags = CliFlags{
Usage: "load config defaults from `FILE` if exists, does not override CLI flags and environment variables",
Value: "/etc/photoprism/defaults.yml",
EnvVars: EnvVars("DEFAULTS_YAML"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "originals-path",
Aliases: []string{"o"},
Usage: "storage `PATH` of your original media files (photos and videos)",
EnvVars: EnvVars("ORIGINALS_PATH"),
TakesFile: true,
}}, {
Flag: &cli.IntFlag{
Name: "originals-limit",
@@ -220,22 +223,25 @@ var Flags = CliFlags{
Value: "users",
EnvVars: EnvVars("USERS_PATH"),
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "storage-path",
Aliases: []string{"s"},
Usage: "writable storage `PATH` for sidecar, cache, and database files",
EnvVars: EnvVars("STORAGE_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "import-path",
Aliases: []string{"im"},
Usage: "base `PATH` from which files can be imported to originals*optional*",
EnvVars: EnvVars("IMPORT_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "import-dest",
Usage: "relative originals `PATH` to which the files should be imported by default*optional*",
EnvVars: EnvVars("IMPORT_DEST"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "import-allow",
@@ -253,25 +259,28 @@ var Flags = CliFlags{
Usage: "allow to upload these file types (comma-separated list of `EXTENSIONS`; leave blank to allow all)",
EnvVars: EnvVars("UPLOAD_ALLOW"),
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "cache-path",
Aliases: []string{"ca"},
Usage: "custom cache `PATH` for sessions and thumbnail files*optional*",
EnvVars: EnvVars("CACHE_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "temp-path",
Aliases: []string{"tmp"},
Usage: "temporary file `PATH`*optional*",
EnvVars: EnvVars("TEMP_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "assets-path",
Aliases: []string{"as"},
Usage: "assets `PATH` containing static resources like icons, models, and translations",
EnvVars: EnvVars("ASSETS_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "sidecar-path",
Aliases: []string{"sc"},
Usage: "custom relative or absolute sidecar `PATH`*optional*",
@@ -292,11 +301,12 @@ var Flags = CliFlags{
Usage: "maximum aggregated size of all indexed files in `GB` (0 for unlimited)",
EnvVars: EnvVars("FILES_QUOTA"),
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "backup-path",
Aliases: []string{"ba"},
Usage: "custom base `PATH` for creating and restoring backups*optional*",
EnvVars: EnvVars("BACKUP_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "backup-schedule",
@@ -826,17 +836,19 @@ var Flags = CliFlags{
Value: "thm",
EnvVars: EnvVars("DARKTABLE_EXCLUDE", "DARKTABLE_BLACKLIST"),
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "darktable-cache-path",
Usage: "custom Darktable cache `PATH`",
Value: "",
EnvVars: EnvVars("DARKTABLE_CACHE_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Flag: &cli.PathFlag{
Name: "darktable-config-path",
Usage: "custom Darktable config `PATH`",
Value: "",
EnvVars: EnvVars("DARKTABLE_CONFIG_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "rawtherapee-bin",
@@ -994,11 +1006,13 @@ var Flags = CliFlags{
Name: "pid-filename",
Usage: "process id `FILE`*daemon-mode only*",
EnvVars: EnvVars("PID_FILENAME"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "log-filename",
Usage: "server log `FILE`*daemon-mode only*",
Value: "",
EnvVars: EnvVars("LOG_FILENAME"),
TakesFile: true,
}},
}

1
internal/config/testdata/secret_admin vendored Normal file
View File

@@ -0,0 +1 @@
Foo-Bar23

View File

@@ -0,0 +1,2 @@
StoryOfAmélie