diff --git a/cmd/photoprism/photoprism.go b/cmd/photoprism/photoprism.go index c82874041..5b9dcce3e 100644 --- a/cmd/photoprism/photoprism.go +++ b/cmd/photoprism/photoprism.go @@ -63,6 +63,7 @@ func main() { commands.ResampleCommand, commands.MigrateCommand, commands.BackupCommand, + commands.RestoreCommand, commands.ResetCommand, commands.ConfigCommand, commands.PasswdCommand, diff --git a/internal/commands/backup.go b/internal/commands/backup.go index 6e957503c..4362bcce7 100644 --- a/internal/commands/backup.go +++ b/internal/commands/backup.go @@ -5,13 +5,14 @@ import ( "context" "errors" "fmt" - "github.com/photoprism/photoprism/pkg/txt" "io/ioutil" "os" "os/exec" "path/filepath" "time" + "github.com/photoprism/photoprism/pkg/txt" + "github.com/photoprism/photoprism/internal/config" "github.com/urfave/cli" ) @@ -19,7 +20,7 @@ import ( // BackupCommand configures the backup cli command. var BackupCommand = cli.Command{ Name: "backup", - Usage: "Creates a database backup", + Usage: "Creates an index database backup", Flags: backupFlags, Action: backupAction, } @@ -31,7 +32,7 @@ var backupFlags = []cli.Flag{ }, } -// migrateAction automatically migrates or initializes database +// backupAction creates a database backup. func backupAction(ctx *cli.Context) error { start := time.Now() @@ -55,8 +56,7 @@ func backupAction(ctx *cli.Context) error { } if _, err := os.Stat(fileName); err == nil && !ctx.Bool("force") { - log.Errorf("backup file already exists: %s", fileName) - return nil + return fmt.Errorf("backup file already exists: %s", fileName) } else if err == nil { log.Warnf("replacing existing backup file") } diff --git a/internal/commands/migrate.go b/internal/commands/migrate.go index 793149b9f..60382c448 100644 --- a/internal/commands/migrate.go +++ b/internal/commands/migrate.go @@ -11,11 +11,11 @@ import ( // MigrateCommand is used to register the migrate cli command var MigrateCommand = cli.Command{ Name: "migrate", - Usage: "Automatically initializes and migrates the database", + Usage: "Initializes and migrates the index database if needed", Action: migrateAction, } -// migrateAction automatically migrates or initializes database +// migrateAction initializes and migrates the database. func migrateAction(ctx *cli.Context) error { start := time.Now() diff --git a/internal/commands/restore.go b/internal/commands/restore.go new file mode 100644 index 000000000..c01a3bf8b --- /dev/null +++ b/internal/commands/restore.go @@ -0,0 +1,163 @@ +package commands + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "os/exec" + "path/filepath" + "regexp" + "time" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/txt" + + "github.com/photoprism/photoprism/internal/config" + "github.com/urfave/cli" +) + +// RestoreCommand configures the backup cli command. +var RestoreCommand = cli.Command{ + Name: "restore", + Usage: "Restores the index from a backup", + Flags: restoreFlags, + Action: restoreAction, +} + +var restoreFlags = []cli.Flag{ + cli.BoolFlag{ + Name: "force, f", + Usage: "overwrite existing index", + }, +} + +// restoreAction restores a database backup. +func restoreAction(ctx *cli.Context) error { + start := time.Now() + + conf := config.NewConfig(ctx) + + _, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := conf.Init(); err != nil { + return err + } + + // Use command argument as backup file name. + fileName := ctx.Args().First() + + // If empty, use default backup file name. + if fileName == "" { + backupPath := filepath.Join(conf.BackupPath(), conf.DatabaseDriver()) + + matches, err := filepath.Glob(filepath.Join(regexp.QuoteMeta(backupPath), "*.sql")) + + if err != nil { + return err + } + + if len(matches) == 0 { + log.Errorf("no backup files found in %s", backupPath) + return nil + } + + fileName = matches[len(matches)-1] + } + + if !fs.FileExists(fileName) { + log.Errorf("backup file not found: %s", fileName) + return nil + } + + counts := struct{ Photos int }{} + + conf.Db().Unscoped().Table("photos"). + Select("COUNT(*) AS photos"). + Take(&counts) + + if counts.Photos == 0 { + // Do nothing; + } else if !ctx.Bool("force") { + return fmt.Errorf("use --force to replace exisisting index with %d photos", counts.Photos) + } else { + log.Warnf("replacing existing index with %d photos", counts.Photos) + } + + log.Infof("restoring index from %s", txt.Quote(fileName)) + + sqlBackup, err := ioutil.ReadFile(fileName) + + if err != nil { + return err + } + + entity.SetDbProvider(conf) + tables := entity.Entities + + var cmd *exec.Cmd + + switch conf.DatabaseDriver() { + case config.MySQL: + cmd = exec.Command( + conf.MysqlBin(), + "-h", conf.DatabaseHost(), + "-P", conf.DatabasePortString(), + "-u", conf.DatabaseUser(), + "-p"+conf.DatabasePassword(), + "-f", + conf.DatabaseName(), + ) + case config.SQLite: + log.Infoln("dropping existing tables") + tables.Drop() + cmd = exec.Command( + conf.SqliteBin(), + conf.DatabaseDsn(), + ) + default: + return fmt.Errorf("unsupported database type: %s", conf.DatabaseDriver()) + } + + // Fetch command output. + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + stdin, err := cmd.StdinPipe() + + if err != nil { + log.Fatal(err) + } + + go func() { + defer stdin.Close() + if _, err := io.WriteString(stdin, string(sqlBackup)); err != nil { + log.Errorf(err.Error()) + } + }() + + // Run backup command. + if err := cmd.Run(); err != nil { + if stderr.String() != "" { + log.Debugln(stderr.String()) + log.Warnf("index could not be restored completely") + } + } + + log.Infoln("migrating database") + + conf.InitDb() + + elapsed := time.Since(start) + + log.Infof("database restored in %s", elapsed) + + conf.Shutdown() + + return nil +} diff --git a/internal/config/db.go b/internal/config/db.go index 444188959..b1b6e2757 100644 --- a/internal/config/db.go +++ b/internal/config/db.go @@ -19,10 +19,10 @@ import ( ) var dsnPattern = regexp.MustCompile( -`^(?:(?P.*?)(?::(?P.*))?@)?` + -`(?:(?P[^\(]*)(?:\((?P[^\)]*)\))?)?` + -`\/(?P.*?)` + -`(?:\?(?P[^\?]*))?$`) + `^(?:(?P.*?)(?::(?P.*))?@)?` + + `(?:(?P[^\(]*)(?:\((?P[^\)]*)\))?)?` + + `\/(?P.*?)` + + `(?:\?(?P[^\?]*))?$`) // DatabaseDriver returns the database driver name. func (c *Config) DatabaseDriver() string { @@ -231,6 +231,12 @@ func (c *Config) InitTestDb() { go entity.SaveErrorMessages() } +// TruncateDb drops all contents so that they can be restored from a backup. +func (c *Config) TruncateDb() { + entity.SetDbProvider(c) + entity.Entities.Truncate() +} + // connectDb establishes a database connection. func (c *Config) connectDb() error { mutex.Db.Lock() diff --git a/internal/config/db_test.go b/internal/config/db_test.go index 2a588e114..9f20420c8 100644 --- a/internal/config/db_test.go +++ b/internal/config/db_test.go @@ -1,8 +1,9 @@ package config import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func TestConfig_DatabaseDriver(t *testing.T) { @@ -14,7 +15,7 @@ func TestConfig_DatabaseDriver(t *testing.T) { func TestConfig_ParseDatabaseDsn(t *testing.T) { c := NewConfig(CliTestContext()) - c.params.DatabaseDsn ="foo:b@r@tcp(honeypot:1234)/baz?charset=utf8mb4,utf8&parseTime=true" + c.params.DatabaseDsn = "foo:b@r@tcp(honeypot:1234)/baz?charset=utf8mb4,utf8&parseTime=true" assert.Equal(t, "honeypot:1234", c.DatabaseServer()) assert.Equal(t, "honeypot", c.DatabaseHost()) @@ -65,4 +66,4 @@ func TestConfig_DatabaseDsn(t *testing.T) { dsn := c.DatabaseDriver() assert.Equal(t, SQLite, dsn) -} \ No newline at end of file +} diff --git a/internal/config/filenames.go b/internal/config/filenames.go index 9d028379d..a923b12c0 100644 --- a/internal/config/filenames.go +++ b/internal/config/filenames.go @@ -341,6 +341,11 @@ func (c *Config) TestdataPath() string { return filepath.Join(c.StoragePath(), "testdata") } +// MysqlBin returns the mysql executable file name. +func (c *Config) MysqlBin() string { + return findExecutable("", "mysql") +} + // MysqldumpBin returns the mysqldump executable file name. func (c *Config) MysqldumpBin() string { return findExecutable("", "mysqldump") @@ -349,4 +354,4 @@ func (c *Config) MysqldumpBin() string { // SqliteBin returns the sqlite executable file name. func (c *Config) SqliteBin() string { return findExecutable("", "sqlite3") -} \ No newline at end of file +}