From 195813c058def96773c588b18c189fbb0aaf769a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 21 Dec 2023 16:47:17 +0000 Subject: [PATCH] backups: triggerable in ui, viewable, downloadable new "Backups" menu in settings lists all available backups, lets you trigger a new one, and lets you download them. --- api.go | 63 +++++++++++++++++++++++++++++++++++ config.go | 1 + daemon.go | 60 +++++++++++++++++++++++++++------- html/admin.html | 37 ++++++++++++++++++++- lang/admin/en-us.json | 14 +++++++- models.go | 11 +++++++ router.go | 3 ++ ts/admin.ts | 4 +++ ts/modules/common.ts | 16 +++++++++ ts/modules/settings.ts | 74 +++++++++++++++++++++++++++++++++++++++++- ts/typings/d.ts | 2 ++ 11 files changed, 271 insertions(+), 14 deletions(-) diff --git a/api.go b/api.go index 1b926e0..52a670a 100644 --- a/api.go +++ b/api.go @@ -1,6 +1,9 @@ package main import ( + "os" + "path/filepath" + "sort" "strings" "time" @@ -545,3 +548,63 @@ func (app *appContext) Restart() error { time.Sleep(time.Second) return nil } + +// @Summary Creates a backup of the database. +// @Router /backups [post] +// @Success 200 {object} CreateBackupDTO +// @Security Bearer +// @tags Other +func (app *appContext) CreateBackup(gc *gin.Context) { + backup := app.makeBackup() + gc.JSON(200, backup) +} + +// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser. +// @Param fname path string true "backup filename" +// @Router /backups/{fname} [get] +// @Produce octet-stream +// @Produce json +// @Success 200 {body} file +// @Success 400 {object} boolResponse +// @Security Bearer +// @tags Other +func (app *appContext) GetBackup(gc *gin.Context) { + fname := gc.Param("fname") + // Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess. + ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX) + t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX)) + if !ok || err != nil || t.IsZero() { + app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err) + respondBool(400, false, gc) + return + } + path := app.config.Section("backups").Key("path").String() + fullpath := filepath.Join(path, fname) + gc.FileAttachment(fullpath, fname) +} + +// @Summary Get a list of backups. +// @Router /backups [get] +// @Produce json +// @Success 200 {object} GetBackupsDTO +// @Security Bearer +// @tags Other +func (app *appContext) GetBackups(gc *gin.Context) { + path := app.config.Section("backups").Key("path").String() + backups := app.getBackups() + sort.Sort(backups) + resp := GetBackupsDTO{} + resp.Backups = make([]CreateBackupDTO, backups.count) + + for i, item := range backups.files[:backups.count] { + resp.Backups[i].Name = item.Name() + fullpath := filepath.Join(path, item.Name()) + resp.Backups[i].Path = fullpath + resp.Backups[i].Date = backups.dates[i].Unix() + fstat, err := os.Stat(fullpath) + if err == nil { + resp.Backups[i].Size = fileSize(fstat.Size()) + } + } + gc.JSON(200, resp) +} diff --git a/config.go b/config.go index ba4373c..91ea6bd 100644 --- a/config.go +++ b/config.go @@ -114,6 +114,7 @@ func (app *appContext) loadConfig() error { app.MustSetValue("backups", "every_n_minutes", "1440") app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups")) + app.MustSetValue("backups", "keep_n_backups", "20") app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("device").SetValue("jfa-go") diff --git a/daemon.go b/daemon.go index e541cab..2a2cc8a 100644 --- a/daemon.go +++ b/daemon.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "sort" @@ -87,6 +88,7 @@ func (app *appContext) clearTelegram() { type BackupList struct { files []os.DirEntry dates []time.Time + count int } func (bl BackupList) Len() int { return len(bl.files) } @@ -108,24 +110,37 @@ func (bl BackupList) Less(i, j int) bool { return bl.dates[j].After(bl.dates[i]) } -func (app *appContext) makeBackup() { - toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20) - fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX +// Get human-readable file size from f.Size() result. +// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html +func fileSize(l int64) string { + const unit = 1000 + if l < unit { + return fmt.Sprintf("%dB", l) + } + div, exp := int64(unit), 0 + for n := l / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp]) +} + +func (app *appContext) getBackups() *BackupList { path := app.config.Section("backups").Key("path").String() err := os.MkdirAll(path, 0755) if err != nil { app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err) - return + return nil } items, err := os.ReadDir(path) if err != nil { app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err) - return + return nil } - backups := BackupList{} + backups := &BackupList{} backups.files = items backups.dates = make([]time.Time, len(items)) - backupCount := 0 + backups.count = 0 for i, item := range items { if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) { continue @@ -136,10 +151,22 @@ func (app *appContext) makeBackup() { continue } backups.dates[i] = t - backupCount++ + backups.count++ } - toDelete := backupCount + 1 - toKeep - if toDelete > 0 { + return backups +} + +func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) { + toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20) + fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX + path := app.config.Section("backups").Key("path").String() + backups := app.getBackups() + if backups == nil { + return + } + toDelete := backups.count + 1 - toKeep + // fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files)) + if toDelete > 0 && toDelete <= backups.count { sort.Sort(backups) for _, item := range backups.files[:toDelete] { fullpath := filepath.Join(path, item.Name()) @@ -163,10 +190,21 @@ func (app *appContext) makeBackup() { app.err.Printf("Failed to create backup: %v\n", err) return } + + fstat, err := f.Stat() + if err != nil { + app.err.Printf("Failed to get info on new backup: %v\n", err) + return + } + fileDetails.Size = fileSize(fstat.Size()) + fileDetails.Name = fname + fileDetails.Path = fullpath + // fmt.Printf("Created backup %+v\n", fileDetails) + return } func (app *appContext) clearActivities() { - app.debug.Println("Husekeeping: Cleaning up Activity log...") + app.debug.Println("Housekeeping: Cleaning up Activity log...") keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90) minAge := time.Now().AddDate(0, 0, -maxAgeDays) diff --git a/html/admin.html b/html/admin.html index a035767..cad852b 100644 --- a/html/admin.html +++ b/html/admin.html @@ -328,6 +328,40 @@ + +