package core import ( "archive/zip" "compress/flate" "context" "errors" "fmt" "io" "io/fs" "log/slog" "os" "path" "path/filepath" "strings" "time" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/store" ) // CreateBackup creates a new backup of the current app pb_data directory. // // If name is empty, it will be autogenerated. // If backup with the same name exists, the new backup file will replace it. // // To safely perform the backup, it is recommended to have free disk space // for at least 2x the size of the pb_data directory. // // By default backups are stored in pb_data/backups // (the backups directory itself is excluded from the generated backup). // // Backups can be stored on S3 if it is configured in app.Settings().Backups. // When using S3 storage for the uploaded collection files, you have to // take care manually to backup those since they are not part of the pb_data. // // DB write locks are minimal and isolated only for the duration of the // VACUUM INTO statement that creates a live copy of the app database. // // The backup works as follow: // // 1. Start listening for DELETED storage files. // In case a file is being deleted while the backup is still ongoing, // we directly copy it in the zip before the deletion and mark it as "excluded". // // 2. Copy the main database with VACUUM INTO, write it in the zip and mark it as "excluded". // // 3. Stop listening for DELETED files. // // 4. Start listening for NEW storage files and mark all new files from this point as "excluded". // // 5. Copy the logs database with VACUUM INTO, write it in the zip and mark it as "excluded". // // 6. Copy the rest of the pb_data files in the zip while ignoring the "excluded" list (it should be concurrent safe). // // 7. Stop listening for NEW storage files. // // While there is a risk for a race condition between steps 1, 2 and 3, it is an // acceptable trade-off between performance and correctness because in // the worst case there will be some unused storage files in the backup that don't do any harm. func (app *BaseApp) CreateBackup(ctx context.Context, name string) error { if app.Store().Has(StoreKeyActiveBackup) { return errors.New("try again later - another backup/restore operation has already been started") } app.Store().Set(StoreKeyActiveBackup, name) defer app.Store().Remove(StoreKeyActiveBackup) startTime := time.Now() event := new(BackupEvent) event.App = app event.Context = ctx event.Name = name // default root dir entries to exclude from the backup generation event.Exclude = []string{ LocalBackupsDirName, LocalTempDirName, LocalNotifyDirName, LocalAutocertCacheDirName, lostFoundDirName, } return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error { if e.Name == "" { e.Name = generateBackupName(e.App, "pb_backup_") } // create backup zip // (it needs to be inside the current pb_data to avoid "cross-device link" errors) // ----------------------------------------------------------- tempZipPath := filepath.Join(app.DataDir(), LocalTempDirName, "pb_backup_"+security.PseudorandomString(6)) err := createZip(e, tempZipPath) if err != nil { return err } defer os.Remove(tempZipPath) // persist the backup in the backups filesystem // ----------------------------------------------------------- fsys, err := e.App.NewBackupsFilesystem() if err != nil { return err } defer fsys.Close() fsys.SetContext(e.Context) file, err := filesystem.NewFileFromPath(tempZipPath) if err != nil { return err } file.OriginalName = e.Name file.Name = file.OriginalName err = fsys.UploadFile(file, file.Name) if err != nil { return err } app.Logger().Debug( "["+e.Name+"] zip archive completed", slog.Float64("execTime", float64(time.Since(startTime))/float64(time.Millisecond)), ) return nil }) } func createZip(be *BackupEvent, tempZipPath string) error { logPrefix := "[" + be.Name + "] " // make sure that the special temp directory exists localTempDir := filepath.Dir(tempZipPath) if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil { return fmt.Errorf(logPrefix+"failed to create temp dir: %w", err) } const tempFilesHookId = "__pbTempBackupFilesystemWatcher__" defer func() { // unbind again in cacase of an error be.App.onFilesystemDelete().Unbind(tempFilesHookId) be.App.onFilesystemNewWriter().Unbind(tempFilesHookId) }() zf, err := os.Create(tempZipPath) if err != nil { return err } zw := zip.NewWriter(zf) zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { return flate.NewWriter(out, flate.BestSpeed) }) closeZip := func() error { return errors.Join(zw.Close(), zf.Close()) } // call defer even though zf.Close will error if invoked multiple times // because otherwise the code become too brittle defer closeZip() excluded := store.New[string, struct{}](nil) for _, name := range be.Exclude { excluded.Set(normalizePathExclude(name), struct{}{}) } // init deleted files tracker // --------------------------------------------------------------- be.App.onFilesystemDelete().Bind(&hook.Handler[*FilesystemDeleteEvent]{ Id: tempFilesHookId, Priority: -99, Func: func(e *FilesystemDeleteEvent) error { // note: the zip header name allow only forward slashes zipPath := path.Join(LocalStorageDirName, e.FileKey) if excluded.Has(normalizePathExclude(zipPath)) || be.App.Settings().S3.Enabled { return e.Next() } localPath := filepath.Join( be.App.DataDir(), LocalStorageDirName, e.FileKey, ) // copy to zip before delete err := copyFileToZip(zw, localPath, zipPath) if err != nil { be.App.Logger().Warn( logPrefix+"failed to copy file in backup zip before delete", slog.Any("error", err), slog.String("file", e.FileKey), ) } else { // mark that it was already copied excluded.Set(normalizePathExclude(zipPath), struct{}{}) } // proceed with the normal deletion return e.Next() }, }) // copy data.db // --------------------------------------------------------------- dataStartTime := time.Now() tempDataDBPath := filepath.Join(localTempDir, dataDBFilename) _, err = be.App.NonconcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempDataDBPath}).Execute() if err != nil { return err } // eagerly stop listening for deleted files since we already have what we needed be.App.onFilesystemDelete().Unbind(tempFilesHookId) be.App.Logger().Debug( logPrefix+dataDBFilename+" copy completed", slog.Float64("execTime", float64(time.Since(dataStartTime))/float64(time.Millisecond)), ) err = copyFileToZip(zw, tempDataDBPath, dataDBFilename) if err != nil { _ = os.Remove(tempDataDBPath) return err } _ = os.Remove(tempDataDBPath) excluded.Set(normalizePathExclude(dataDBFilename), struct{}{}) excluded.Set(normalizePathExclude(dataDBFilename+"-wal"), struct{}{}) excluded.Set(normalizePathExclude(dataDBFilename+"-shm"), struct{}{}) // init to-be-created files tracker // --------------------------------------------------------------- be.App.onFilesystemNewWriter().Bind(&hook.Handler[*FilesystemNewWriterEvent]{ Id: tempFilesHookId, Priority: -99, Func: func(e *FilesystemNewWriterEvent) error { if !be.App.Settings().S3.Enabled { // mark for exclude even if the writer eventually fails // (all record files have random name so collisions are unlikely) name := normalizePathExclude(filepath.Join(LocalStorageDirName, e.FileKey)) excluded.Set(name, struct{}{}) } return e.Next() }, }) // copy auxiliary.db // --------------------------------------------------------------- auxStartTime := time.Now() tempAuxDBPath := filepath.Join(localTempDir, auxDBFilename) _, err = be.App.AuxNonconcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempAuxDBPath}).Execute() if err != nil { return err } be.App.Logger().Debug( logPrefix+auxDBFilename+" copy completed", slog.Float64("execTime", float64(time.Since(auxStartTime))/float64(time.Millisecond)), ) err = copyFileToZip(zw, tempAuxDBPath, auxDBFilename) if err != nil { _ = os.Remove(tempAuxDBPath) return err } _ = os.Remove(tempAuxDBPath) excluded.Set(normalizePathExclude(auxDBFilename), struct{}{}) excluded.Set(normalizePathExclude(auxDBFilename+"-wal"), struct{}{}) excluded.Set(normalizePathExclude(auxDBFilename+"-shm"), struct{}{}) // copy the rest of the pb_data // --------------------------------------------------------------- err = copyDirToZip(zw, os.DirFS(be.App.DataDir()), excluded) if err != nil { return err } return closeZip() } // normalize the provided file path to always use and end with forward slash func normalizePathExclude(filePath string) string { return path.Clean(filePath) + "/" } func copyFileToZip(w *zip.Writer, localPath string, zipPath string) error { info, err := os.Stat(localPath) if err != nil { return err } if info.IsDir() { return nil } h, err := zip.FileInfoHeader(info) if err != nil { return err } h.Name = zipPath h.Method = zip.Deflate fw, err := w.CreateHeader(h) if err != nil { return err } f, err := os.Open(localPath) if err != nil { return err } defer f.Close() _, err = io.Copy(fw, f) return err } func copyDirToZip(w *zip.Writer, fsys fs.FS, excludedPrefixes *store.Store[string, struct{}]) error { return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error { if err != nil { return err } // skip excluded prefixes if excludedPrefixes != nil { check := normalizePathExclude(name) prefixes := excludedPrefixes.Keys() // refetch to avoid races for _, prefix := range prefixes { if strings.HasPrefix(check, prefix) { if d.IsDir() { return filepath.SkipDir } return nil } } } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return err } h, err := zip.FileInfoHeader(info) if err != nil { return err } h.Name = name h.Method = zip.Deflate fw, err := w.CreateHeader(h) if err != nil { return err } f, err := fsys.Open(name) if err != nil { return err } defer f.Close() _, err = io.Copy(fw, f) return err }) }