mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 17:10:10 +00:00
Merge backups
Backups, manual & scheduled
This commit is contained in:
commit
7f518f55b2
117
api-backups.go
Normal file
117
api-backups.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @Summary Creates a backup of the database.
|
||||||
|
// @Router /backups [post]
|
||||||
|
// @Success 200 {object} CreateBackupDTO
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Backups
|
||||||
|
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
|
||||||
|
// @Failure 400 {object} boolResponse
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Backups
|
||||||
|
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.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
|
||||||
|
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), 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 Backups
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Restore a backup file stored locally to the server.
|
||||||
|
// @Param fname path string true "backup filename"
|
||||||
|
// @Router /backups/restore/{fname} [post]
|
||||||
|
// @Produce json
|
||||||
|
// @Failure 400 {object} boolResponse
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Backups
|
||||||
|
func (app *appContext) RestoreLocalBackup(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)
|
||||||
|
LOADBAK = fullpath
|
||||||
|
app.restart(gc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Restore a backup file uploaded by the user.
|
||||||
|
// @Param file formData file true ".bak file"
|
||||||
|
// @Router /backups/restore [post]
|
||||||
|
// @Produce json
|
||||||
|
// @Failure 400 {object} boolResponse
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Backups
|
||||||
|
func (app *appContext) RestoreBackup(gc *gin.Context) {
|
||||||
|
file, err := gc.FormFile("backups-file")
|
||||||
|
if err != nil {
|
||||||
|
app.err.Printf("Failed to get file from form data: %v\n", err)
|
||||||
|
respondBool(400, false, gc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.debug.Printf("Got uploaded file \"%s\"\n", file.Filename)
|
||||||
|
path := app.config.Section("backups").Key("path").String()
|
||||||
|
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
|
||||||
|
gc.SaveUploadedFile(file, fullpath)
|
||||||
|
app.debug.Printf("Saved to \"%s\"\n", fullpath)
|
||||||
|
LOADBAK = fullpath
|
||||||
|
app.restart(gc)
|
||||||
|
}
|
4
args.go
4
args.go
@ -23,6 +23,7 @@ func (app *appContext) loadArgs(firstCall bool) {
|
|||||||
HOST = flag.String("host", "", "alternate address to host web ui on.")
|
HOST = flag.String("host", "", "alternate address to host web ui on.")
|
||||||
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
|
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
|
||||||
flag.IntVar(PORT, "p", 0, "SHORTHAND")
|
flag.IntVar(PORT, "p", 0, "SHORTHAND")
|
||||||
|
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
|
||||||
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
|
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
|
||||||
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
|
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
|
||||||
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
|
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
|
||||||
@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
|
|||||||
if *PPROF {
|
if *PPROF {
|
||||||
os.Setenv("PPROF", "1")
|
os.Setenv("PPROF", "1")
|
||||||
}
|
}
|
||||||
|
if *_LOADBAK != "" {
|
||||||
|
LOADBAK = *_LOADBAK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("SWAGGER") == "1" {
|
if os.Getenv("SWAGGER") == "1" {
|
||||||
|
180
backups.go
Normal file
180
backups.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BACKUP_PREFIX = "jfa-go-db-"
|
||||||
|
BACKUP_UPLOAD_PREFIX = "upload-"
|
||||||
|
BACKUP_DATEFMT = "2006-01-02T15-04-05"
|
||||||
|
BACKUP_SUFFIX = ".bak"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackupList struct {
|
||||||
|
files []os.DirEntry
|
||||||
|
dates []time.Time
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bl BackupList) Len() int { return len(bl.files) }
|
||||||
|
func (bl BackupList) Swap(i, j int) {
|
||||||
|
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
|
||||||
|
bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bl BackupList) Less(i, j int) bool {
|
||||||
|
// Push non-backup files to the end of the array,
|
||||||
|
// Since they didn't have a date parsed.
|
||||||
|
if bl.dates[i].IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if bl.dates[j].IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Sort by oldest first
|
||||||
|
return bl.dates[j].After(bl.dates[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 nil
|
||||||
|
}
|
||||||
|
items, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
backups := &BackupList{}
|
||||||
|
backups.files = items
|
||||||
|
backups.dates = make([]time.Time, len(items))
|
||||||
|
backups.count = 0
|
||||||
|
for i, item := range items {
|
||||||
|
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
|
||||||
|
if err != nil {
|
||||||
|
app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backups.dates[i] = t
|
||||||
|
backups.count++
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
|
||||||
|
err := os.Remove(fullpath)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fullpath := filepath.Join(path, fname)
|
||||||
|
f, err := os.Create(fullpath)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = app.storage.db.Badger().Backup(f, 0)
|
||||||
|
if err != nil {
|
||||||
|
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) loadPendingBackup() {
|
||||||
|
if LOADBAK == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
|
||||||
|
app.info.Printf("Moving existing database to \"%s\"\n", oldPath)
|
||||||
|
err := os.Rename(app.storage.db_path, oldPath)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Fatalf("Failed to move existing database: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.ConnectDB()
|
||||||
|
defer app.storage.db.Close()
|
||||||
|
|
||||||
|
f, err := os.Open(LOADBAK)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err)
|
||||||
|
}
|
||||||
|
err = app.storage.db.Badger().Load(f, 256)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err)
|
||||||
|
}
|
||||||
|
app.info.Printf("Restored backup \"%s\".", LOADBAK)
|
||||||
|
LOADBAK = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBackupDaemon(app *appContext) *housekeepingDaemon {
|
||||||
|
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
|
||||||
|
daemon := housekeepingDaemon{
|
||||||
|
Stopped: false,
|
||||||
|
ShutdownChannel: make(chan string),
|
||||||
|
Interval: interval,
|
||||||
|
period: interval,
|
||||||
|
app: app,
|
||||||
|
}
|
||||||
|
daemon.jobs = []func(app *appContext){
|
||||||
|
func(app *appContext) {
|
||||||
|
app.debug.Println("Backups: Creating backup")
|
||||||
|
app.makeBackup()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &daemon
|
||||||
|
}
|
@ -112,6 +112,10 @@ func (app *appContext) loadConfig() error {
|
|||||||
|
|
||||||
app.MustSetValue("telegram", "show_on_reg", "true")
|
app.MustSetValue("telegram", "show_on_reg", "true")
|
||||||
|
|
||||||
|
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("version").SetValue(version)
|
||||||
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||||
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||||
|
@ -1557,6 +1557,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backups": {
|
||||||
|
"order": [],
|
||||||
|
"meta": {
|
||||||
|
"name": "Backups",
|
||||||
|
"description": "Settings for database backups. Press the \"Backups\" button above to create, download and restore backups."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"enabled": {
|
||||||
|
"name": "Scheduled Backups",
|
||||||
|
"required": false,
|
||||||
|
"requires_restart": true,
|
||||||
|
"type": "bool",
|
||||||
|
"value": false,
|
||||||
|
"description": "Enable to generate database backups on a schedule."
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "Backup Path",
|
||||||
|
"required": false,
|
||||||
|
"requires_restart": true,
|
||||||
|
"type": "text",
|
||||||
|
"value": "",
|
||||||
|
"description": "Path to directory to store backups in. defaults to <data_directory>/backups."
|
||||||
|
},
|
||||||
|
"every_n_minutes": {
|
||||||
|
"name": "Backup frequency (Minutes)",
|
||||||
|
"required": false,
|
||||||
|
"requires_restart": true,
|
||||||
|
"depends_true": "enabled",
|
||||||
|
"type": "number",
|
||||||
|
"value": 1440,
|
||||||
|
"description": "Backup after this many minutes has passed since the last. Resets every restart."
|
||||||
|
},
|
||||||
|
"keep_n_backups": {
|
||||||
|
"name": "Number of backups to keep",
|
||||||
|
"required": false,
|
||||||
|
"requires_restart": true,
|
||||||
|
"type": "number",
|
||||||
|
"value": 20,
|
||||||
|
"description": "Number of most recent backups to keep. Once this is hit, the oldest backup will be deleted before doing a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"welcome_email": {
|
"welcome_email": {
|
||||||
"order": [],
|
"order": [],
|
||||||
"meta": {
|
"meta": {
|
||||||
|
@ -75,7 +75,7 @@ func (app *appContext) clearTelegram() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *appContext) clearActivities() {
|
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)
|
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
|
||||||
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
|
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
|
||||||
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
|
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||||
|
@ -328,6 +328,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modal-backups" class="modal">
|
||||||
|
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||||
|
<span class="heading">{{ .strings.backups }} <span class="modal-close">×</span></span>
|
||||||
|
<div class="content my-4">
|
||||||
|
{{ .strings.backupsDescription }}
|
||||||
|
<ul>
|
||||||
|
<li>{{ .strings.backupsCopy }}</li>
|
||||||
|
<li>{{ .strings.backupsFormatNote }}</li>
|
||||||
|
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row flex-wrap my-2">
|
||||||
|
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
|
||||||
|
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
|
||||||
|
<input id="backups-file" name="backups-file" type="file" hidden>
|
||||||
|
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ .strings.name }}</th>
|
||||||
|
<th>{{ .strings.date }}</th>
|
||||||
|
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="backups-list"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modal-backed-up" class="modal">
|
||||||
|
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||||
|
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">×</span></span>
|
||||||
|
<p class="content my-4" id="settings-backed-up-location"></p>
|
||||||
|
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
|
||||||
|
<div>
|
||||||
|
<button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="modal-refresh" class="modal">
|
<div id="modal-refresh" class="modal">
|
||||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||||
<span class="heading">{{ .strings.settingsApplied }}</span>
|
<span class="heading">{{ .strings.settingsApplied }}</span>
|
||||||
@ -810,7 +851,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span>
|
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
|
||||||
|
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
|
||||||
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
|
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
|
||||||
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
|
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -180,9 +180,23 @@
|
|||||||
"noMoreResults": "No more results.",
|
"noMoreResults": "No more results.",
|
||||||
"totalRecords": "{n} Total Records",
|
"totalRecords": "{n} Total Records",
|
||||||
"loadedRecords": "{n} Loaded",
|
"loadedRecords": "{n} Loaded",
|
||||||
"shownRecords": "{n} Shown"
|
"shownRecords": "{n} Shown",
|
||||||
|
"backups": "Backups",
|
||||||
|
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
|
||||||
|
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
|
||||||
|
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
|
||||||
|
"backupDownloadRestore": "Download / Restore",
|
||||||
|
"backupUpload": "Upload & Restore Backup",
|
||||||
|
"backupDownload": "Download Backup",
|
||||||
|
"backupRestore": "Restore Backup",
|
||||||
|
"backupNow": "Backup Now",
|
||||||
|
"backupCreated": "Backup created",
|
||||||
|
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
|
||||||
|
"backupCanDownload": "Alternatively, click below to download the backup.",
|
||||||
|
"wikiPage": "Wiki Page"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
"pathCopied": "Full path copied to clipboard.",
|
||||||
"changedEmailAddress": "Changed email address of {n}.",
|
"changedEmailAddress": "Changed email address of {n}.",
|
||||||
"userCreated": "User {n} created.",
|
"userCreated": "User {n} created.",
|
||||||
"createProfile": "Created profile {n}.",
|
"createProfile": "Created profile {n}.",
|
||||||
|
14
main.go
14
main.go
@ -56,6 +56,8 @@ var (
|
|||||||
commit string
|
commit string
|
||||||
buildTimeUnix string
|
buildTimeUnix string
|
||||||
builtBy string
|
builtBy string
|
||||||
|
_LOADBAK *string
|
||||||
|
LOADBAK = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
var temp = func() string {
|
var temp = func() string {
|
||||||
@ -355,8 +357,10 @@ func start(asDaemon, firstCall bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.storage.db_path = filepath.Join(app.dataPath, "db")
|
app.storage.db_path = filepath.Join(app.dataPath, "db")
|
||||||
|
app.loadPendingBackup()
|
||||||
app.ConnectDB()
|
app.ConnectDB()
|
||||||
defer app.storage.db.Close()
|
defer app.storage.db.Close()
|
||||||
|
|
||||||
// Read config-base for settings on web.
|
// Read config-base for settings on web.
|
||||||
app.configBasePath = "config-base.json"
|
app.configBasePath = "config-base.json"
|
||||||
configBase, _ := fs.ReadFile(localFS, app.configBasePath)
|
configBase, _ := fs.ReadFile(localFS, app.configBasePath)
|
||||||
@ -475,6 +479,13 @@ func start(asDaemon, firstCall bool) {
|
|||||||
go app.checkForUpdates()
|
go app.checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var backupDaemon *housekeepingDaemon
|
||||||
|
if app.config.Section("backups").Key("enabled").MustBool(false) {
|
||||||
|
backupDaemon = newBackupDaemon(app)
|
||||||
|
go backupDaemon.run()
|
||||||
|
defer backupDaemon.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
if telegramEnabled {
|
if telegramEnabled {
|
||||||
app.telegram, err = newTelegramDaemon(app)
|
app.telegram, err = newTelegramDaemon(app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -647,6 +658,9 @@ func flagPassed(name string) (found bool) {
|
|||||||
// @tag.name Ombi
|
// @tag.name Ombi
|
||||||
// @tag.description Ombi related operations.
|
// @tag.description Ombi related operations.
|
||||||
|
|
||||||
|
// @tag.name Backups
|
||||||
|
// @tag.description Database backup/restore operations.
|
||||||
|
|
||||||
// @tag.name Other
|
// @tag.name Other
|
||||||
// @tag.description Things that dont fit elsewhere.
|
// @tag.description Things that dont fit elsewhere.
|
||||||
|
|
||||||
|
11
models.go
11
models.go
@ -461,3 +461,14 @@ type GetActivitiesRespDTO struct {
|
|||||||
type GetActivityCountDTO struct {
|
type GetActivityCountDTO struct {
|
||||||
Count uint64 `json:"count"`
|
Count uint64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateBackupDTO struct {
|
||||||
|
Size string `json:"size"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Date int64 `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetBackupsDTO struct {
|
||||||
|
Backups []CreateBackupDTO `json:"backups"`
|
||||||
|
}
|
||||||
|
@ -208,6 +208,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
|||||||
api.POST(p+"/config", app.ModifyConfig)
|
api.POST(p+"/config", app.ModifyConfig)
|
||||||
api.POST(p+"/restart", app.restart)
|
api.POST(p+"/restart", app.restart)
|
||||||
api.GET(p+"/logs", app.GetLog)
|
api.GET(p+"/logs", app.GetLog)
|
||||||
|
api.POST(p+"/backups", app.CreateBackup)
|
||||||
|
api.GET(p+"/backups/:fname", app.GetBackup)
|
||||||
|
api.GET(p+"/backups", app.GetBackups)
|
||||||
|
api.POST(p+"/backups/restore/:fname", app.RestoreLocalBackup)
|
||||||
|
api.POST(p+"/backups/restore", app.RestoreBackup)
|
||||||
if telegramEnabled || discordEnabled || matrixEnabled {
|
if telegramEnabled || discordEnabled || matrixEnabled {
|
||||||
api.GET(p+"/telegram/pin", app.TelegramGetPin)
|
api.GET(p+"/telegram/pin", app.TelegramGetPin)
|
||||||
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
|
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
|
||||||
|
@ -68,6 +68,10 @@ window.availableProfiles = window.availableProfiles || [];
|
|||||||
|
|
||||||
window.modals.logs = new Modal(document.getElementById("modal-logs"));
|
window.modals.logs = new Modal(document.getElementById("modal-logs"));
|
||||||
|
|
||||||
|
window.modals.backedUp = new Modal(document.getElementById("modal-backed-up"));
|
||||||
|
|
||||||
|
window.modals.backups = new Modal(document.getElementById("modal-backups"));
|
||||||
|
|
||||||
if (window.telegramEnabled) {
|
if (window.telegramEnabled) {
|
||||||
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
|
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,31 @@ export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHtt
|
|||||||
req.send(JSON.stringify(data));
|
req.send(JSON.stringify(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const _download = (url: string, fname: string): void => {
|
||||||
|
let req = new XMLHttpRequest();
|
||||||
|
if (window.URLBase) { url = window.URLBase + url; }
|
||||||
|
req.open("GET", url, true);
|
||||||
|
req.responseType = 'blob';
|
||||||
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
||||||
|
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||||
|
req.onload = (e: Event) => {
|
||||||
|
let link = document.createElement("a") as HTMLAnchorElement;
|
||||||
|
link.href = URL.createObjectURL(req.response);
|
||||||
|
link.download = fname;
|
||||||
|
link.dispatchEvent(new MouseEvent("click"));
|
||||||
|
};
|
||||||
|
req.send();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _upload = (url: string, formData: FormData): void => {
|
||||||
|
let req = new XMLHttpRequest();
|
||||||
|
if (window.URLBase) { url = window.URLBase + url; }
|
||||||
|
req.open("POST", url, true);
|
||||||
|
req.setRequestHeader("Authorization", "Bearer " + window.token);
|
||||||
|
// req.setRequestHeader('Content-Type', 'multipart/form-data');
|
||||||
|
req.send(formData);
|
||||||
|
};
|
||||||
|
|
||||||
export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void): void => {
|
export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void): void => {
|
||||||
let req = new XMLHttpRequest();
|
let req = new XMLHttpRequest();
|
||||||
req.open("POST", window.URLBase + url, true);
|
req.open("POST", window.URLBase + url, true);
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, insertText } from "../modules/common.js";
|
import { _get, _post, _delete, _download, _upload, toggleLoader, addLoader, removeLoader, insertText, toClipboard, toDateString } from "../modules/common.js";
|
||||||
import { Marked } from "@ts-stack/markdown";
|
import { Marked } from "@ts-stack/markdown";
|
||||||
import { stripMarkdown } from "../modules/stripmd.js";
|
import { stripMarkdown } from "../modules/stripmd.js";
|
||||||
|
|
||||||
|
interface BackupDTO {
|
||||||
|
size: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
date: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface settingsBoolEvent extends Event {
|
interface settingsBoolEvent extends Event {
|
||||||
detail: boolean;
|
detail: boolean;
|
||||||
}
|
}
|
||||||
@ -635,6 +642,8 @@ export class settingsList {
|
|||||||
|
|
||||||
private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found");
|
private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found");
|
||||||
|
|
||||||
|
private _backupSortDirection = document.getElementById("settings-backups-sort-direction") as HTMLButtonElement;
|
||||||
|
private _backupSortAscending = true;
|
||||||
|
|
||||||
addSection = (name: string, s: Section, subButton?: HTMLElement) => {
|
addSection = (name: string, s: Section, subButton?: HTMLElement) => {
|
||||||
const section = new sectionPanel(s, name);
|
const section = new sectionPanel(s, name);
|
||||||
@ -736,6 +745,68 @@ export class settingsList {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setBackupSort = (ascending: boolean) => {
|
||||||
|
this._backupSortAscending = ascending;
|
||||||
|
this._backupSortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${ascending ? "up" : "down"}-s-line ml-2"></i>`;
|
||||||
|
this._getBackups();
|
||||||
|
};
|
||||||
|
|
||||||
|
private _backup = () => _post("/backups", null, (req: XMLHttpRequest) => {
|
||||||
|
if (req.readyState != 4 || req.status != 200) return;
|
||||||
|
const backupDTO = req.response as BackupDTO;
|
||||||
|
if (backupDTO.path == "") {
|
||||||
|
window.notifications.customError("backupError", window.lang.strings("errorFailureCheckLogs"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const location = document.getElementById("settings-backed-up-location");
|
||||||
|
const download = document.getElementById("settings-backed-up-download");
|
||||||
|
location.innerHTML = window.lang.strings("backupCanBeFound").replace("{filepath}", `<span class="text-black dark:text-white font-mono bg-inherit">"`+backupDTO.path+`"</span>`);
|
||||||
|
download.innerHTML = `
|
||||||
|
<i class="ri-download-line"></i>
|
||||||
|
<span class="ml-2">${window.lang.strings("download")}</span>
|
||||||
|
<span class="badge ~info @low ml-2">${backupDTO.size}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
download.parentElement.onclick = () => _download("/backups/" + backupDTO.name, backupDTO.name);
|
||||||
|
window.modals.backedUp.show();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
private _getBackups = () => _get("/backups", null, (req: XMLHttpRequest) => {
|
||||||
|
if (req.readyState != 4 || req.status != 200) return;
|
||||||
|
const backups = req.response["backups"] as BackupDTO[];
|
||||||
|
const table = document.getElementById("backups-list");
|
||||||
|
table.textContent = ``;
|
||||||
|
if (!this._backupSortAscending) {
|
||||||
|
backups.reverse();
|
||||||
|
}
|
||||||
|
for (let b of backups) {
|
||||||
|
const tr = document.createElement("tr") as HTMLTableRowElement;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="whitespace-nowrap"><span class="text-black dark:text-white font-mono bg-inherit">${b.name}</span> <span class="button ~info @low ml-2 backup-copy" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span></td>
|
||||||
|
<td>${toDateString(new Date(b.date*1000))}</td>
|
||||||
|
<td class="table-inline justify-center">
|
||||||
|
<span class="backup-download button ~positive @low" title="${window.lang.strings("backupDownload")}">
|
||||||
|
<i class="ri-download-line"></i>
|
||||||
|
<span class="badge ~positive @low ml-2">${b.size}</span>
|
||||||
|
</span>
|
||||||
|
<span class="backup-restore button ~critical @low ml-2 py-[inherit]" title="${window.lang.strings("backupRestore")}"><i class="icon ri-restart-line"></i></span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tr.querySelector(".backup-copy").addEventListener("click", () => {
|
||||||
|
toClipboard(b.path);
|
||||||
|
window.notifications.customPositive("pathCopied", "", window.lang.notif("pathCopied"));
|
||||||
|
});
|
||||||
|
tr.querySelector(".backup-download").addEventListener("click", () => _download("/backups/" + b.name, b.name));
|
||||||
|
tr.querySelector(".backup-restore").addEventListener("click", () => {
|
||||||
|
_post("/backups/restore/"+b.name, null, () => {});
|
||||||
|
window.modals.backups.close();
|
||||||
|
window.modals.settingsRefresh.modal.querySelector("span.heading").textContent = window.lang.strings("settingsRestarting");
|
||||||
|
window.modals.settingsRefresh.show();
|
||||||
|
});
|
||||||
|
table.appendChild(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._sections = {};
|
this._sections = {};
|
||||||
this._buttons = {};
|
this._buttons = {};
|
||||||
@ -748,7 +819,32 @@ export class settingsList {
|
|||||||
this._saveButton.onclick = this._save;
|
this._saveButton.onclick = this._save;
|
||||||
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
|
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
|
||||||
document.getElementById("settings-logs").onclick = this._showLogs;
|
document.getElementById("settings-logs").onclick = this._showLogs;
|
||||||
|
document.getElementById("settings-backups-backup").onclick = () => {
|
||||||
|
window.modals.backups.close();
|
||||||
|
this._backup();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-backups").onclick = () => {
|
||||||
|
this.setBackupSort(this._backupSortAscending);
|
||||||
|
window.modals.backups.show();
|
||||||
|
};
|
||||||
|
this._backupSortDirection.onclick = () => this.setBackupSort(!(this._backupSortAscending));
|
||||||
const advancedEnableToggle = document.getElementById("settings-advanced-enabled") as HTMLInputElement;
|
const advancedEnableToggle = document.getElementById("settings-advanced-enabled") as HTMLInputElement;
|
||||||
|
|
||||||
|
const filedlg = document.getElementById("backups-file") as HTMLInputElement;
|
||||||
|
document.getElementById("settings-backups-upload").onclick = () => {
|
||||||
|
filedlg.click();
|
||||||
|
};
|
||||||
|
filedlg.addEventListener("change", () => {
|
||||||
|
if (filedlg.files.length == 0) return;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("backups-file", filedlg.files[0], filedlg.files[0].name);
|
||||||
|
_upload("/backups/restore", form);
|
||||||
|
window.modals.backups.close();
|
||||||
|
window.modals.settingsRefresh.modal.querySelector("span.heading").textContent = window.lang.strings("settingsRestarting");
|
||||||
|
window.modals.settingsRefresh.show();
|
||||||
|
});
|
||||||
|
|
||||||
advancedEnableToggle.onchange = () => {
|
advancedEnableToggle.onchange = () => {
|
||||||
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
|
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
|
||||||
const parent = advancedEnableToggle.parentElement;
|
const parent = advancedEnableToggle.parentElement;
|
||||||
|
@ -117,6 +117,8 @@ declare interface Modals {
|
|||||||
email?: Modal;
|
email?: Modal;
|
||||||
enableReferralsUser?: Modal;
|
enableReferralsUser?: Modal;
|
||||||
enableReferralsProfile?: Modal;
|
enableReferralsProfile?: Modal;
|
||||||
|
backedUp?: Modal;
|
||||||
|
backups?: Modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Invite {
|
interface Invite {
|
||||||
|
@ -161,7 +161,6 @@ func newUpdater(buildroneURL, namespace, repo, version, commit, buildType string
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
binary += ".exe"
|
binary += ".exe"
|
||||||
}
|
}
|
||||||
fmt.Println("monitoring", tag)
|
|
||||||
return &Updater{
|
return &Updater{
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
timeoutHandler: common.NewTimeoutHandler("updater", buildroneURL, true),
|
timeoutHandler: common.NewTimeoutHandler("updater", buildroneURL, true),
|
||||||
|
Loading…
Reference in New Issue
Block a user