mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)},
|
||||
}...)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
23
pkg/dsn/driver.go
Normal 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",
|
||||
}
|
||||
@@ -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
156
pkg/dsn/dsn_test.go
Normal 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
19
pkg/dsn/mask.go
Normal 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
32
pkg/dsn/mask_test.go
Normal 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
8
pkg/dsn/parse.go
Normal 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
106
pkg/dsn/parse_test.go
Normal 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
4
pkg/dsn/values.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package dsn
|
||||
|
||||
// Values is a shorthand alias for map[string]interface{}.
|
||||
type Values = map[string]interface{}
|
||||
Reference in New Issue
Block a user