Config: Move database DSN-related functionality to "pkg/dsn" #47 #5285

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-03 13:40:34 +01:00
parent 40097b6285
commit 06df64281d
20 changed files with 469 additions and 408 deletions

View File

@@ -15,6 +15,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/dsn"
)
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
@@ -66,8 +67,8 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
assert.Equal(t, "pp-node-02", gjson.Get(out, "Node.Name").String())
assert.Equal(t, cluster.ExampleClientSecret, gjson.Get(out, "Secrets.ClientSecret").String())
assert.Equal(t, "pwd", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn)
dbDsn := gjson.Get(out, "Database.DSN").String()
parsed := dsn.Parse(dbDsn)
assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
@@ -238,8 +239,8 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
assert.Equal(t, "pp-node-04", gjson.Get(out, "Node.Name").String())
assert.Equal(t, secret, gjson.Get(out, "Secrets.ClientSecret").String())
assert.Equal(t, "pwd3", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn)
dbDsn := gjson.Get(out, "Database.DSN").String()
parsed := dsn.Parse(dbDsn)
assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd3", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
@@ -304,8 +305,8 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "pp-node-05", gjson.Get(out, "Node.Name").String())
assert.Equal(t, "pwd4", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn)
dbDsn := gjson.Get(out, "Database.DSN").String()
parsed := dsn.Parse(dbDsn)
assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd4", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
@@ -627,8 +628,8 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "pp-node-07", gjson.Get(out, "Node.Name").String())
assert.Equal(t, "pwd7", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn)
dbDsn := gjson.Get(out, "Database.DSN").String()
parsed := dsn.Parse(dbDsn)
assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd7", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)

View File

@@ -21,6 +21,7 @@ import (
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/dsn"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -28,26 +29,12 @@ import (
// TODO: PostgreSQL support requires upgrading GORM, so generic column data types can be used.
const (
Auto = "auto"
MySQL = "mysql"
MariaDB = "mariadb"
Postgres = "postgres"
SQLite3 = "sqlite3"
MySQL = dsn.DriverMySQL
MariaDB = dsn.DriverMariaDB
Postgres = dsn.DriverPostgres
SQLite3 = dsn.DriverSQLite3
)
// SQLite default DSNs.
const (
SQLiteTestDB = ".test.db"
SQLiteMemoryDSN = ":memory:"
)
// DatabaseDSNParams maps required DSN parameters by driver type.
var DatabaseDSNParams = Values{
MySQL: "charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
MariaDB: "charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
Postgres: "sslmode=disable TimeZone=UTC",
SQLite3: "_busy_timeout=5000",
}
// DatabaseDriver returns the database driver name.
func (c *Config) DatabaseDriver() string {
c.normalizeDatabaseDSN()
@@ -145,7 +132,7 @@ func (c *Config) DatabaseDSN() string {
c.DatabasePassword(),
databaseServer,
c.DatabaseName(),
DatabaseDSNParams[MySQL],
dsn.Params[dsn.DriverMySQL],
c.DatabaseTimeout(),
)
case Postgres:
@@ -157,10 +144,10 @@ func (c *Config) DatabaseDSN() string {
c.DatabaseHost(),
c.DatabasePort(),
c.DatabaseTimeout(),
DatabaseDSNParams[Postgres],
dsn.Params[dsn.DriverPostgres],
)
case SQLite3:
return filepath.Join(c.StoragePath(), fmt.Sprintf("index.db?%s", DatabaseDSNParams[SQLite3]))
return filepath.Join(c.StoragePath(), fmt.Sprintf("index.db?%s", dsn.Params[dsn.DriverSQLite3]))
default:
log.Errorf("config: empty database dsn")
return ""
@@ -172,7 +159,7 @@ func (c *Config) DatabaseDSN() string {
c.options.DatabaseDSN = fmt.Sprintf(
"%s?%s&timeout=%ds",
c.options.DatabaseDSN,
DatabaseDSNParams[MySQL],
dsn.Params[dsn.DriverMySQL],
c.DatabaseTimeout())
}
@@ -209,7 +196,7 @@ func (c *Config) ParseDatabaseDSN() {
return
}
d := NewDSN(c.options.DatabaseDSN)
d := dsn.Parse(c.options.DatabaseDSN)
c.options.DatabaseName = d.Name
c.options.DatabaseServer = d.Server
@@ -244,8 +231,8 @@ func (c *Config) DatabaseHost() string {
return ""
}
dsn := NewDSN(c.DatabaseDSN())
return dsn.Host()
d := dsn.Parse(c.DatabaseDSN())
return d.Host()
}
// DatabasePort the database server port.
@@ -256,8 +243,8 @@ func (c *Config) DatabasePort() int {
return 0
}
dsn := NewDSN(c.DatabaseDSN())
return dsn.Port()
d := dsn.Parse(c.DatabaseDSN())
return d.Port()
}
// DatabasePortString the database server port as string.

View File

@@ -1,282 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDSN_Parse(t *testing.T) {
tests := []struct {
name string
in string
want DSN
}{
{
name: "ClassicTCP",
in: "user:secret@tcp(localhost:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
want: DSN{
DSN: "user:secret@tcp(localhost:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
Driver: MySQL,
User: "user",
Password: "secret",
Net: "tcp",
Server: "localhost:3306",
Name: "photoprism",
Params: "charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
},
},
{
name: "URIStyle",
in: "mysql://user:secret@localhost:3306/photoprism?parseTime=true",
want: DSN{
DSN: "mysql://user:secret@localhost:3306/photoprism?parseTime=true",
Driver: MySQL,
User: "user",
Password: "secret",
Server: "localhost:3306",
Name: "photoprism",
Params: "parseTime=true",
},
},
{
name: "UnixSocket",
in: "user:secret@unix(/var/run/mysql.sock)/photoprism",
want: DSN{
DSN: "user:secret@unix(/var/run/mysql.sock)/photoprism",
Driver: MySQL,
User: "user",
Password: "secret",
Net: "unix",
Server: "/var/run/mysql.sock",
Name: "photoprism",
},
},
{
name: "FileDSN",
in: "file:/data/index.db?_busy_timeout=5000",
want: DSN{
DSN: "file:/data/index.db?_busy_timeout=5000",
Driver: SQLite3,
Server: "file:/data",
Name: "index.db",
Params: "_busy_timeout=5000",
},
},
{
name: "SQLite",
in: "/index.db?_busy_timeout=5000",
want: DSN{
DSN: "/index.db?_busy_timeout=5000",
Driver: SQLite3,
Server: "",
Name: "index.db",
Params: "_busy_timeout=5000",
},
},
{
name: "PostgresKeyValue",
in: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
want: DSN{
DSN: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
Driver: Postgres,
User: "alice",
Password: "s3cr3t",
Server: "db.internal:5432",
Name: "app",
Params: "connect_timeout=5 sslmode=require",
},
},
{
name: "EmptyInput",
in: "",
want: DSN{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewDSN(tt.in)
assert.Equal(t, tt.in, got.String())
if got != tt.want {
t.Fatalf("NewDSN(%q) = %#v, want %#v", tt.in, got, tt.want)
}
})
}
}
func TestDSN_HostAndPort(t *testing.T) {
tests := []struct {
name string
in string
host string
port int
}{
{
name: "MySQLTCP",
in: "user:secret@tcp(localhost:3307)/photoprism?parseTime=true",
host: "localhost",
port: 3307,
},
{
name: "MySQLIPv6",
in: "user:secret@tcp([2001:db8::1]:3307)/photoprism",
host: "2001:db8::1",
port: 3307,
},
{
name: "MySQLDefaultPort",
in: "user:secret@tcp(mysql.local)/photoprism",
host: "mysql.local",
port: 3306,
},
{
name: "PostgresURL",
in: "postgres://user:secret@localhost/mydb",
host: "localhost",
port: 5432,
},
{
name: "PostgresKeyValue",
in: "user=alice password=secret host=/var/run/postgresql port=6432 dbname=app",
host: "/var/run/postgresql",
port: 6432,
},
{
name: "PostgresPortOnly",
in: "user=alice password=secret port=5433 dbname=app",
host: "",
port: 5433,
},
{
name: "SQLite",
in: "file:/data/index.db",
host: "",
port: 0,
},
{
name: "InvalidPortFallback",
in: "user:secret@tcp(localhost:abc)/photoprism",
host: "localhost",
port: 3306,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := NewDSN(tt.in)
assert.Equal(t, tt.host, d.Host())
assert.Equal(t, tt.port, d.Port())
})
}
}
func TestDSN_MaskPassword(t *testing.T) {
d := NewDSN("user:secret@tcp(localhost:3306)/db")
assert.Equal(t, "user:***@tcp(localhost:3306)/db", d.MaskPassword())
p := NewDSN("user=alice password=s3cr3t dbname=app")
assert.Equal(t, "user=alice password=*** dbname=app", p.MaskPassword())
noPass := NewDSN("user@tcp(localhost:3306)/db")
assert.Equal(t, "user@tcp(localhost:3306)/db", noPass.MaskPassword())
}
func TestDSN_ParsePostgres(t *testing.T) {
cases := []struct {
name string
in string
want DSN
ok bool
}{
{
name: "Basic",
in: "user=alice password=s3cr3t dbname=app",
want: DSN{
DSN: "user=alice password=s3cr3t dbname=app",
Driver: Postgres,
User: "alice",
Password: "s3cr3t",
Name: "app",
},
ok: true,
},
{
name: "WithHostPortAndParams",
in: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
want: DSN{
DSN: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
Driver: Postgres,
User: "alice",
Password: "s3cr3t",
Server: "db.internal:5432",
Name: "app",
Params: "connect_timeout=5 sslmode=require",
},
ok: true,
},
{
name: "QuotedValues",
in: `user="alice" password="s ec ret" dbname="app" host=db.internal`,
want: DSN{
DSN: `user="alice" password="s ec ret" dbname="app" host=db.internal`,
Driver: Postgres,
User: "alice",
Password: "s ec ret",
Server: "db.internal",
Name: "app",
},
ok: true,
},
{
name: "MissingDatabase",
in: "user=alice host=db.internal",
want: DSN{DSN: "user=alice host=db.internal"},
ok: false,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
d := DSN{DSN: tt.in}
ok := d.parsePostgres()
assert.Equal(t, tt.in, d.String())
if ok != tt.ok {
t.Fatalf("parsePostgres(%q) ok=%v, want %v", tt.in, ok, tt.ok)
}
if ok && d != tt.want {
t.Fatalf("parsePostgres(%q) = %#v, want %#v", tt.in, d, tt.want)
}
})
}
}
func TestMaskDatabaseDSN(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{name: "Empty", in: "", out: ""},
{name: "NoPassword", in: "user@tcp(localhost:3306)/db", out: "user@tcp(localhost:3306)/db"},
{name: "WithPassword", in: "user:secret@tcp(localhost:3306)/db", out: "user:***@tcp(localhost:3306)/db"},
{name: "URIStyle", in: "mysql://user:secret@localhost:3306/db?parseTime=true", out: "mysql://user:***@localhost:3306/db?parseTime=true"},
{name: "FallbackWhenNoNeedle", in: "user:secret@tcp(localhost:3306)/db?password=secret", out: "user:***@tcp(localhost:3306)/db?password=secret"},
{name: "UnixSocket", in: "user:secret@unix(/var/run/mysql.sock)/db", out: "user:***@unix(/var/run/mysql.sock)/db"},
{name: "NoPasswordQuery", in: "user@tcp(localhost:3306)/db?password=secret", out: "user@tcp(localhost:3306)/db?password=secret"},
{name: "Postgres", in: "user=alice password=s3cr3t dbname=app", out: "user=alice password=*** dbname=app"},
{name: "PostgresQuoted", in: "user=alice password=\"s ec ret\" dbname=app", out: "user=alice password=\"***\" dbname=app"},
{name: "PostgresSingleQuoted", in: "password='secret' user=alice dbname=app", out: "password='***' user=alice dbname=app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MaskDatabaseDSN(tt.in); got != tt.out {
t.Fatalf("MaskDatabaseDSN(%q) = %q, want %q", tt.in, got, tt.out)
}
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/pkg/dsn"
)
// Report returns global config values as a table for reporting.
@@ -223,7 +224,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
if reportDatabaseDSN {
rows = append(rows, [][]string{
{"database-driver", c.DatabaseDriver()},
{"database-dsn", MaskDatabaseDSN(c.DatabaseDSN())},
{"database-dsn", dsn.Mask(c.DatabaseDSN())},
}...)
} else {
rows = append(rows, [][]string{
@@ -241,8 +242,8 @@ func (c *Config) Report() (rows [][]string, cols []string) {
rows = append(rows, [][]string{
{"database-provision-driver", c.options.DatabaseProvisionDriver},
{"database-provision-prefix", c.DatabaseProvisionPrefix()},
{"database-provision-dsn", MaskDatabaseDSN(c.options.DatabaseProvisionDSN)},
{"database-provision-proxy-dsn", MaskDatabaseDSN(c.options.DatabaseProvisionProxyDSN)},
{"database-provision-dsn", dsn.Mask(c.options.DatabaseProvisionDSN)},
{"database-provision-proxy-dsn", dsn.Mask(c.options.DatabaseProvisionProxyDSN)},
}...)
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/capture"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/dsn"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt/report"
@@ -121,28 +122,28 @@ func NewTestOptionsForPath(dbName, dataPath string) *Options {
// Example PHOTOPRISM_TEST_DSN for MariaDB / MySQL:
// - "photoprism:photoprism@tcp(mariadb:4001)/photoprism?parseTime=true"
dbName = PkgNameRegexp.ReplaceAllString(dbName, "")
driver := os.Getenv("PHOTOPRISM_TEST_DRIVER")
dsn := os.Getenv("PHOTOPRISM_TEST_DSN")
testDriver := os.Getenv("PHOTOPRISM_TEST_DRIVER")
testDsn := os.Getenv("PHOTOPRISM_TEST_DSN")
// Set default test database driver.
if driver == "test" || driver == "sqlite" || driver == "" || dsn == "" {
driver = SQLite3
if testDriver == "test" || testDriver == "sqlite" || testDriver == "" || testDsn == "" {
testDriver = dsn.DriverSQLite3
}
// Set default database DSN.
if driver == SQLite3 {
if dsn == "" && dbName != "" {
if dsn = fmt.Sprintf(".%s.db", clean.TypeLower(dbName)); !fs.FileExists(dsn) {
log.Tracef("sqlite: test database %s does not already exist", clean.Log(dsn))
} else if err := os.Remove(dsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
if testDriver == dsn.DriverSQLite3 {
if testDsn == "" && dbName != "" {
if testDsn = fmt.Sprintf(".%s.db", clean.TypeLower(dbName)); !fs.FileExists(testDsn) {
log.Tracef("sqlite: test database %s does not already exist", clean.Log(testDsn))
} else if err := os.Remove(testDsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(testDsn), err)
}
} else if dsn == "" || dsn == SQLiteTestDB {
dsn = SQLiteTestDB
if !fs.FileExists(dsn) {
log.Tracef("sqlite: test database %s does not already exist", clean.Log(dsn))
} else if err := os.Remove(dsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
} else if testDsn == "" || testDsn == dsn.SQLiteTestDB {
testDsn = dsn.SQLiteTestDB
if !fs.FileExists(testDsn) {
log.Tracef("sqlite: test database %s does not already exist", clean.Log(testDsn))
} else if err := os.Remove(testDsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(testDsn), err)
}
}
}
@@ -177,8 +178,8 @@ func NewTestOptionsForPath(dbName, dataPath string) *Options {
TempPath: filepath.Join(dataPath, "temp"),
BackupRetain: DefaultBackupRetain,
BackupSchedule: DefaultBackupSchedule,
DatabaseDriver: driver,
DatabaseDSN: dsn,
DatabaseDriver: testDriver,
DatabaseDSN: testDsn,
AdminPassword: "photoprism",
ClusterCIDR: "",
JWTScope: DefaultJWTAllowedScopes,

View File

@@ -8,12 +8,14 @@ import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/photoprism/photoprism/pkg/dsn"
)
// Supported test databases.
const (
MySQL = "mysql"
SQLite3 = "sqlite3"
MySQL = dsn.DriverMySQL
SQLite3 = dsn.DriverSQLite3
SQLiteTestDB = ".test.db"
SQLiteMemoryDSN = ":memory:?cache=shared"
)

View File

@@ -46,7 +46,7 @@ func InitDb(opt migrate.Options) {
}
// InitTestDb connects to and completely initializes the test database incl fixtures.
func InitTestDb(driver, dsn string) *DbConn {
func InitTestDb(driver, dbDsn string) *DbConn {
if HasDbProvider() {
return nil
}
@@ -54,28 +54,28 @@ func InitTestDb(driver, dsn string) *DbConn {
start := time.Now()
// Set default test database driver.
if driver == "test" || driver == "sqlite" || driver == "" || dsn == "" {
if driver == "test" || driver == "sqlite" || driver == "" || dbDsn == "" {
driver = SQLite3
}
// Set default database DSN.
if driver == SQLite3 {
if dsn == "" || dsn == SQLiteTestDB {
dsn = SQLiteTestDB
if !fs.FileExists(dsn) {
log.Debugf("sqlite: test database %s does not already exist", clean.Log(dsn))
} else if err := os.Remove(dsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
if dbDsn == "" || dbDsn == SQLiteTestDB {
dbDsn = SQLiteTestDB
if !fs.FileExists(dbDsn) {
log.Debugf("sqlite: test database %s does not already exist", clean.Log(dbDsn))
} else if err := os.Remove(dbDsn); err != nil {
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dbDsn), err)
}
}
}
log.Infof("initializing %s test db in %s", driver, dsn)
log.Infof("initializing %s test db in %s", driver, dbDsn)
// Create gorm.DB connection provider.
db := &DbConn{
Driver: driver,
Dsn: dsn,
Dsn: dbDsn,
}
// Insert test fixtures into the database.

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/dsn"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -60,7 +61,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
JWKSUrl: jwksURL,
Database: cluster.RegisterDatabase{
Driver: config.MySQL,
Driver: dsn.DriverMySQL,
Host: "db.local",
Port: 3306,
Name: "pp_db",
@@ -91,7 +92,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
expectedAppName = c.About()
expectedAppVersion = c.Version()
// Gate rotate=true: driver mysql and no DSN/fields.
c.Options().DatabaseDriver = config.MySQL
c.Options().DatabaseDriver = dsn.DriverMySQL
c.Options().DatabaseDSN = ""
c.Options().DatabaseName = ""
c.Options().DatabaseUser = ""
@@ -104,7 +105,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
// DSN branch should be preferred and persisted.
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
assert.Equal(t, dsn.DriverMySQL, c.Options().DatabaseDriver)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
assert.Equal(t, "192.0.2.0/24", c.ClusterCIDR())
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/dsn"
)
// Credentials contains the connection details returned when ensuring a node database.
@@ -34,9 +35,9 @@ func EnsureCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeN
driver := strings.ToLower(DatabaseDriver)
switch driver {
case config.MySQL, config.MariaDB:
case dsn.DriverMySQL, dsn.DriverMariaDB:
// ok
case config.SQLite3, config.Postgres:
case dsn.DriverSQLite3, dsn.DriverPostgres:
return out, false, errors.New("database must be MySQL/MariaDB for auto-provisioning")
default:
// Driver is configured externally for the provisioner (decoupled from app config).

View File

@@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/dsn"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -66,9 +67,9 @@ func GenerateCredentials(conf *config.Config, nodeUUID, nodeName string) (dbName
func BuildDSN(driver, host string, port int, user, pass, name string) string {
d := strings.ToLower(driver)
switch d {
case config.MySQL, config.MariaDB:
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=true",
user, pass, host, port, name,
case dsn.DriverMySQL, dsn.DriverMariaDB:
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s",
user, pass, host, port, name, dsn.Params[dsn.DriverMySQL],
)
default:
log.Warnf("provisioner: unsupported driver %q, falling back to mysql DSN format", driver)

View File

@@ -79,10 +79,10 @@ func TestGenerateCredentials_CustomPrefix(t *testing.T) {
}
func TestBuildDSN(t *testing.T) {
dsn := BuildDSN("mysql", "mariadb", 3306, "user", "pass", "dbname")
assert.Contains(t, dsn, "user:pass@tcp(mariadb:3306)/dbname")
assert.Contains(t, dsn, "charset=utf8mb4")
assert.Contains(t, dsn, "parseTime=true")
d := BuildDSN("mysql", "mariadb", 3306, "user", "pass", "dbname")
assert.Contains(t, d, "user:pass@tcp(mariadb:3306)/dbname")
assert.Contains(t, d, "charset=utf8mb4")
assert.Contains(t, d, "parseTime=true")
}
func TestHmacBase32_LowercaseDeterministic(t *testing.T) {

View File

@@ -118,19 +118,19 @@ func applyProxySQL(ctx context.Context, db *sql.DB) error {
}
// normalizeProxyDSN adds interpolateParams to ProxySQL admin DSNs when missing so prepared statements work.
func normalizeProxyDSN(dsn string) string {
if dsn == "" || strings.Contains(dsn, "interpolateParams=") {
return dsn
func normalizeProxyDSN(proxyDsn string) string {
if proxyDsn == "" || strings.Contains(proxyDsn, "interpolateParams=") {
return proxyDsn
}
sep := "?"
if strings.Contains(dsn, "?") {
if strings.HasSuffix(dsn, "?") || strings.HasSuffix(dsn, "&") {
if strings.Contains(proxyDsn, "?") {
if strings.HasSuffix(proxyDsn, "?") || strings.HasSuffix(proxyDsn, "&") {
sep = ""
} else {
sep = "&"
}
}
return dsn + sep + "interpolateParams=true"
return proxyDsn + sep + "interpolateParams=true"
}

23
pkg/dsn/driver.go Normal file
View File

@@ -0,0 +1,23 @@
package dsn
// SQL database drivers.
const (
DriverMySQL = "mysql"
DriverMariaDB = "mariadb"
DriverPostgres = "postgres"
DriverSQLite3 = "sqlite3"
)
// SQLite default DSNs.
const (
SQLiteTestDB = ".test.db"
SQLiteMemory = ":memory:"
)
// Params maps required DSN parameters by driver type.
var Params = Values{
DriverMySQL: "charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
DriverMariaDB: "charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
DriverPostgres: "sslmode=disable TimeZone=UTC",
DriverSQLite3: "_busy_timeout=5000",
}

View File

@@ -1,4 +1,29 @@
package config
/*
Package dsn provides helpers for parsing database data source names, masking
credentials, and sharing driver-specific defaults used throughout PhotoPrism.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package dsn
import (
"net"
@@ -30,13 +55,6 @@ type DSN struct {
Params string
}
// NewDSN creates a new DSN struct from a string.
func NewDSN(dsn string) DSN {
d := DSN{DSN: dsn}
d.parse()
return d
}
// String returns the original DSN string.
func (d *DSN) String() string {
return d.DSN
@@ -57,7 +75,7 @@ func (d *DSN) MaskPassword() (s string) {
}
// Mask password in PostgreSQL-style DSN.
if d.Driver == Postgres || strings.Contains(s, "password=") {
if d.Driver == DriverPostgres || strings.Contains(s, "password=") {
return dsnPostgresPasswordPattern.ReplaceAllStringFunc(s, func(segment string) string {
matches := dsnPostgresPasswordPattern.FindStringSubmatch(segment)
if len(matches) != 3 {
@@ -89,7 +107,7 @@ func (d *DSN) MaskPassword() (s string) {
// Host the database server host.
func (d *DSN) Host() string {
if d.Driver == SQLite3 {
if d.Driver == DriverSQLite3 {
return ""
}
@@ -100,16 +118,16 @@ func (d *DSN) Host() string {
// Port the database server port.
func (d *DSN) Port() int {
switch d.Driver {
case SQLite3:
case DriverSQLite3:
return 0
}
defaultPort := 0
switch d.Driver {
case MySQL, MariaDB:
case DriverMySQL, DriverMariaDB:
defaultPort = 3306
case Postgres:
case DriverPostgres:
defaultPort = 5432
}
@@ -232,7 +250,7 @@ func (d *DSN) parsePostgres() bool {
}
}
d.Driver = Postgres
d.Driver = DriverPostgres
d.User = values["user"]
d.Password = values["password"]
d.Name = name
@@ -354,13 +372,13 @@ func (d *DSN) detectDriver() {
switch driver {
case "postgres", "postgresql":
d.Driver = Postgres
d.Driver = DriverPostgres
return
case "mysql", "mariadb":
d.Driver = MySQL
d.Driver = DriverMySQL
return
case "sqlite", "sqlite3", "file":
d.Driver = SQLite3
d.Driver = DriverSQLite3
return
}
@@ -372,44 +390,26 @@ func (d *DSN) detectDriver() {
lower := strings.ToLower(d.DSN)
if strings.Contains(lower, "postgres://") || strings.Contains(lower, "postgresql://") {
d.Driver = Postgres
d.Driver = DriverPostgres
return
}
if d.Net == "tcp" || d.Net == "unix" || strings.Contains(lower, "@tcp(") || strings.Contains(lower, "@unix(") {
d.Driver = MySQL
d.Driver = DriverMySQL
return
}
if strings.HasPrefix(lower, "file:") || strings.HasSuffix(lower, ".db") || strings.HasSuffix(strings.ToLower(d.Name), ".db") {
d.Driver = SQLite3
d.Driver = DriverSQLite3
return
}
if strings.Contains(lower, " host=") && strings.Contains(lower, " dbname=") {
d.Driver = Postgres
d.Driver = DriverPostgres
return
}
if d.Server != "" && (strings.Contains(d.Server, ":") || d.Net != "") && d.Driver == "" {
d.Driver = MySQL
d.Driver = DriverMySQL
}
}
// MaskDatabaseDSN hides the password portion of a DSN while leaving the rest untouched for logging/reporting.
func MaskDatabaseDSN(dsn string) string {
if dsn == "" {
return ""
}
// Parse database DSN.
d := NewDSN(dsn)
// Return original DSN if no password was found.
if d.Password == "" {
return dsn
}
// Return DSN with masked password.
return d.MaskPassword()
}

156
pkg/dsn/dsn_test.go Normal file
View File

@@ -0,0 +1,156 @@
package dsn
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDSN_HostAndPort(t *testing.T) {
tests := []struct {
name string
in string
host string
port int
}{
{
name: "MySQLTCP",
in: "user:secret@tcp(localhost:3307)/photoprism?parseTime=true",
host: "localhost",
port: 3307,
},
{
name: "MySQLIPv6",
in: "user:secret@tcp([2001:db8::1]:3307)/photoprism",
host: "2001:db8::1",
port: 3307,
},
{
name: "MySQLDefaultPort",
in: "user:secret@tcp(mysql.local)/photoprism",
host: "mysql.local",
port: 3306,
},
{
name: "PostgresURL",
in: "postgres://user:secret@localhost/mydb",
host: "localhost",
port: 5432,
},
{
name: "PostgresKeyValue",
in: "user=alice password=secret host=/var/run/postgresql port=6432 dbname=app",
host: "/var/run/postgresql",
port: 6432,
},
{
name: "PostgresPortOnly",
in: "user=alice password=secret port=5433 dbname=app",
host: "",
port: 5433,
},
{
name: "SQLite",
in: "file:/data/index.db",
host: "",
port: 0,
},
{
name: "InvalidPortFallback",
in: "user:secret@tcp(localhost:abc)/photoprism",
host: "localhost",
port: 3306,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := Parse(tt.in)
assert.Equal(t, tt.host, d.Host())
assert.Equal(t, tt.port, d.Port())
})
}
}
func TestDSN_MaskPassword(t *testing.T) {
d := Parse("user:secret@tcp(localhost:3306)/db")
assert.Equal(t, "user:***@tcp(localhost:3306)/db", d.MaskPassword())
p := Parse("user=alice password=s3cr3t dbname=app")
assert.Equal(t, "user=alice password=*** dbname=app", p.MaskPassword())
noPass := Parse("user@tcp(localhost:3306)/db")
assert.Equal(t, "user@tcp(localhost:3306)/db", noPass.MaskPassword())
}
func TestDSN_ParsePostgres(t *testing.T) {
cases := []struct {
name string
in string
want DSN
ok bool
}{
{
name: "Basic",
in: "user=alice password=s3cr3t dbname=app",
want: DSN{
DSN: "user=alice password=s3cr3t dbname=app",
Driver: DriverPostgres,
User: "alice",
Password: "s3cr3t",
Name: "app",
},
ok: true,
},
{
name: "WithHostPortAndParams",
in: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
want: DSN{
DSN: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
Driver: DriverPostgres,
User: "alice",
Password: "s3cr3t",
Server: "db.internal:5432",
Name: "app",
Params: "connect_timeout=5 sslmode=require",
},
ok: true,
},
{
name: "QuotedValues",
in: `user="alice" password="s ec ret" dbname="app" host=db.internal`,
want: DSN{
DSN: `user="alice" password="s ec ret" dbname="app" host=db.internal`,
Driver: DriverPostgres,
User: "alice",
Password: "s ec ret",
Server: "db.internal",
Name: "app",
},
ok: true,
},
{
name: "MissingDatabase",
in: "user=alice host=db.internal",
want: DSN{DSN: "user=alice host=db.internal"},
ok: false,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
d := DSN{DSN: tt.in}
ok := d.parsePostgres()
assert.Equal(t, tt.in, d.String())
if ok != tt.ok {
t.Fatalf("parsePostgres(%q) ok=%v, want %v", tt.in, ok, tt.ok)
}
if ok && d != tt.want {
t.Fatalf("parsePostgres(%q) = %#v, want %#v", tt.in, d, tt.want)
}
})
}
}

19
pkg/dsn/mask.go Normal file
View File

@@ -0,0 +1,19 @@
package dsn
// Mask hides the password portion of a DSN while leaving the rest untouched for logging/reporting.
func Mask(dsn string) string {
if dsn == "" {
return ""
}
// Parse database DSN.
d := Parse(dsn)
// Return original DSN if no password was found.
if d.Password == "" {
return dsn
}
// Return DSN with masked password.
return d.MaskPassword()
}

32
pkg/dsn/mask_test.go Normal file
View File

@@ -0,0 +1,32 @@
package dsn
import (
"testing"
)
func TestMask(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{name: "Empty", in: "", out: ""},
{name: "NoPassword", in: "user@tcp(localhost:3306)/db", out: "user@tcp(localhost:3306)/db"},
{name: "WithPassword", in: "user:secret@tcp(localhost:3306)/db", out: "user:***@tcp(localhost:3306)/db"},
{name: "URIStyle", in: "mysql://user:secret@localhost:3306/db?parseTime=true", out: "mysql://user:***@localhost:3306/db?parseTime=true"},
{name: "FallbackWhenNoNeedle", in: "user:secret@tcp(localhost:3306)/db?password=secret", out: "user:***@tcp(localhost:3306)/db?password=secret"},
{name: "UnixSocket", in: "user:secret@unix(/var/run/mysql.sock)/db", out: "user:***@unix(/var/run/mysql.sock)/db"},
{name: "NoPasswordQuery", in: "user@tcp(localhost:3306)/db?password=secret", out: "user@tcp(localhost:3306)/db?password=secret"},
{name: "Postgres", in: "user=alice password=s3cr3t dbname=app", out: "user=alice password=*** dbname=app"},
{name: "PostgresQuoted", in: "user=alice password=\"s ec ret\" dbname=app", out: "user=alice password=\"***\" dbname=app"},
{name: "PostgresSingleQuoted", in: "password='secret' user=alice dbname=app", out: "password='***' user=alice dbname=app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Mask(tt.in); got != tt.out {
t.Fatalf("Mask(%q) = %q, want %q", tt.in, got, tt.out)
}
})
}
}

8
pkg/dsn/parse.go Normal file
View File

@@ -0,0 +1,8 @@
package dsn
// Parse creates a new DSN struct containing the parsed data from the specified string.
func Parse(dsn string) DSN {
d := DSN{DSN: dsn}
d.parse()
return d
}

106
pkg/dsn/parse_test.go Normal file
View File

@@ -0,0 +1,106 @@
package dsn
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want DSN
}{
{
name: "ClassicTCP",
in: "user:secret@tcp(localhost:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
want: DSN{
DSN: "user:secret@tcp(localhost:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
Driver: DriverMySQL,
User: "user",
Password: "secret",
Net: "tcp",
Server: "localhost:3306",
Name: "photoprism",
Params: "charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
},
},
{
name: "URIStyle",
in: "mysql://user:secret@localhost:3306/photoprism?parseTime=true",
want: DSN{
DSN: "mysql://user:secret@localhost:3306/photoprism?parseTime=true",
Driver: DriverMySQL,
User: "user",
Password: "secret",
Server: "localhost:3306",
Name: "photoprism",
Params: "parseTime=true",
},
},
{
name: "UnixSocket",
in: "user:secret@unix(/var/run/mysql.sock)/photoprism",
want: DSN{
DSN: "user:secret@unix(/var/run/mysql.sock)/photoprism",
Driver: DriverMySQL,
User: "user",
Password: "secret",
Net: "unix",
Server: "/var/run/mysql.sock",
Name: "photoprism",
},
},
{
name: "FileDSN",
in: "file:/data/index.db?_busy_timeout=5000",
want: DSN{
DSN: "file:/data/index.db?_busy_timeout=5000",
Driver: DriverSQLite3,
Server: "file:/data",
Name: "index.db",
Params: "_busy_timeout=5000",
},
},
{
name: "SQLite",
in: "/index.db?_busy_timeout=5000",
want: DSN{
DSN: "/index.db?_busy_timeout=5000",
Driver: DriverSQLite3,
Server: "",
Name: "index.db",
Params: "_busy_timeout=5000",
},
},
{
name: "PostgresKeyValue",
in: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
want: DSN{
DSN: "user=alice password=s3cr3t dbname=app host=db.internal port=5432 connect_timeout=5 sslmode=require",
Driver: DriverPostgres,
User: "alice",
Password: "s3cr3t",
Server: "db.internal:5432",
Name: "app",
Params: "connect_timeout=5 sslmode=require",
},
},
{
name: "EmptyInput",
in: "",
want: DSN{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Parse(tt.in)
assert.Equal(t, tt.in, got.String())
if got != tt.want {
t.Fatalf("Parse(%q) = %#v, want %#v", tt.in, got, tt.want)
}
})
}
}

4
pkg/dsn/values.go Normal file
View File

@@ -0,0 +1,4 @@
package dsn
// Values is a shorthand alias for map[string]interface{}.
type Values = map[string]interface{}