diff --git a/api/v1/system_setting.go b/api/v1/system_setting.go index d01fff79..eb2925e9 100644 --- a/api/v1/system_setting.go +++ b/api/v1/system_setting.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "strings" "github.com/labstack/echo/v4" @@ -39,6 +40,8 @@ const ( SystemSettingMemoDisplayWithUpdatedTsName SystemSettingName = "memo-display-with-updated-ts" // SystemSettingOpenAIConfigName is the name of OpenAI config. SystemSettingOpenAIConfigName SystemSettingName = "openai-config" + // SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds. + SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval" ) // CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item. @@ -139,6 +142,20 @@ func (upsert UpsertSystemSettingRequest) Validate() error { if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil { return fmt.Errorf(systemSettingUnmarshalError, settingName) } + case SystemSettingAutoBackupIntervalName: + var value string + if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil { + return fmt.Errorf(systemSettingUnmarshalError, settingName) + } + if value != "" { + v, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf(systemSettingUnmarshalError, settingName) + } + if v < 0 { + return fmt.Errorf("backup interval should > 0") + } + } case SystemSettingTelegramBotTokenName: if upsert.Value == "" { return nil diff --git a/go.mod b/go.mod index 5d8875d0..5669cf1f 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( golang.org/x/mod v0.8.0 golang.org/x/net v0.7.0 golang.org/x/oauth2 v0.5.0 - modernc.org/sqlite v1.22.1 + modernc.org/sqlite v1.24.0 ) require ( diff --git a/go.sum b/go.sum index 6122b128..f566adbf 100644 --- a/go.sum +++ b/go.sum @@ -662,8 +662,8 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE= -modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI= +modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= diff --git a/server/backup.go b/server/backup.go new file mode 100644 index 00000000..c7ab5ec1 --- /dev/null +++ b/server/backup.go @@ -0,0 +1,48 @@ +package server + +import ( + "context" + "fmt" + "strconv" + "time" + + apiv1 "github.com/usememos/memos/api/v1" + "github.com/usememos/memos/common/log" + "github.com/usememos/memos/store" + "go.uber.org/zap" +) + +func autoBackup(ctx context.Context, s *store.Store) { + intervalStr := s.GetSystemSettingValueWithDefault(&ctx, apiv1.SystemSettingAutoBackupIntervalName.String(), "") + if intervalStr == "" { + log.Info("no SystemSettingAutoBackupIntervalName setting, disable auto backup") + return + } + + interval, err := strconv.Atoi(intervalStr) + if err != nil || interval <= 0 { + log.Error(fmt.Sprintf("invalid SystemSettingAutoBackupIntervalName value %s, disable auto backup", intervalStr), zap.Error(err)) + return + } + + log.Info("enable auto backup every " + intervalStr + " seconds") + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + var t time.Time + for { + select { + case <-ctx.Done(): + log.Info("stop auto backup graceful.") + return + case t = <-ticker.C: + } + + filename := s.Profile.DSN + t.Format("-20060102-150405.bak") + log.Info(fmt.Sprintf("create backup to %s", filename)) + err := s.BackupTo(ctx, filename) + if err != nil { + log.Error("fail to create backup", zap.Error(err)) + } + } +} diff --git a/server/server.go b/server/server.go index 78e82f14..30e4c711 100644 --- a/server/server.go +++ b/server/server.go @@ -103,6 +103,7 @@ func (s *Server) Start(ctx context.Context) error { } go s.telegramBot.Start(ctx) + go autoBackup(ctx, s.Store) return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port)) } diff --git a/store/store.go b/store/store.go index 6002ec2f..a902d6fb 100644 --- a/store/store.go +++ b/store/store.go @@ -3,9 +3,11 @@ package store import ( "context" "database/sql" + "fmt" "sync" "github.com/usememos/memos/server/profile" + "modernc.org/sqlite" ) // Store provides database access to all raw objects. @@ -31,6 +33,43 @@ func (s *Store) GetDB() *sql.DB { return s.db } +func (s *Store) BackupTo(ctx context.Context, filename string) error { + conn, err := s.db.Conn(ctx) + if err != nil { + return fmt.Errorf("fail to get conn %s", err) + } + defer conn.Close() + + err = conn.Raw(func(driverConn any) error { + type backuper interface { + NewBackup(string) (*sqlite.Backup, error) + } + backupConn, ok := driverConn.(backuper) + if !ok { + return fmt.Errorf("db connection is not a sqlite backuper") + } + + bck, err := backupConn.NewBackup(filename) + if err != nil { + return fmt.Errorf("fail to create sqlite backup %s", err) + } + + for more := true; more; { + more, err = bck.Step(-1) + if err != nil { + return fmt.Errorf("fail to execute sqlite backup %s", err) + } + } + + return bck.Finish() + }) + if err != nil { + return fmt.Errorf("fail to backup %s", err) + } + + return nil +} + func (s *Store) Vacuum(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil {