diff --git a/.gitignore b/.gitignore
index 8a318f2..dcbac4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ go.work
tmp/build-errors.log
tmp/main
watch.db
+backups
\ No newline at end of file
diff --git a/config.tmpl b/config.tmpl
index 02f6a7b..ec15c4f 100644
--- a/config.tmpl
+++ b/config.tmpl
@@ -38,6 +38,9 @@ notifiers:
database:
dsn: "/config/watch.db" # for docker usage
prune: "@every 1h"
+ backup:
+ schedule: "@every 1d"
+ path: "/backup/{{.Year}}_{{.Month}}_{{.Day}}T{{.Hour}}-{{.Minute}}-{{.Second}}.gzip" # https://pkg.go.dev/time available
proxy:
proxy_url: http://proxy.com:1234
browserless:
diff --git a/main.go b/main.go
index fed88e2..6ea6c37 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,8 @@
package main
import (
+ "bytes"
+ "compress/gzip"
"embed"
"encoding/json"
"errors"
@@ -13,6 +15,7 @@ import (
"net/http"
"net/url"
"os"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -191,6 +194,10 @@ func (web *Web) initRouter() {
web.router.GET("/notifiers/view", web.notifiersView)
web.router.POST("/notifiers/test", web.notifiersTest)
+ web.router.GET("/backup/view", web.backupView)
+ web.router.GET("/backup/create", web.backupCreate)
+ web.router.GET("/backup/test/:id", web.backupTest)
+
web.router.SetTrustedProxies(nil)
}
@@ -213,6 +220,9 @@ func (web *Web) initTemplates() {
web.templates.Add("notifiersView", template.Must(template.ParseFS(templatesFS, "base.html", "notifiers.html")))
+ web.templates.Add("backupView", template.Must(template.ParseFS(templatesFS, "base.html", "backup/view.html")))
+ web.templates.Add("backupTest", template.Must(template.ParseFS(templatesFS, "base.html", "backup/test.html")))
+
web.templates.Add("500", template.Must(template.ParseFS(templatesFS, "base.html", "500.html")))
}
@@ -228,6 +238,26 @@ func (web *Web) initCronJobs() {
web.cron = cron.New()
web.cron.Start()
+ // db prune job is started if there is a database.prune set
+ if viper.IsSet("database.prune") {
+ pruneSchedule := viper.GetString("database.prune")
+ _, err := web.cron.AddFunc(pruneSchedule, web.pruneDB)
+ if err != nil {
+ web.startupWarning("Could not parse database.prune:", err)
+ }
+ log.Println("Started DB prune cronjob:", pruneSchedule)
+ }
+
+ // backup job is started if there is a schedule and path
+ if viper.IsSet("database.backup.schedule") && viper.IsSet("database.backup.path") {
+ backupSchedule := viper.GetString("database.backup.schedule")
+ _, err := web.cron.AddFunc(backupSchedule, web.scheduledBackup)
+ if err != nil {
+ web.startupWarning("Could not parse database.backup.schedule:", err)
+ }
+ log.Println("Backup schedule set:", backupSchedule)
+ }
+
// add some delay to cron jobs, so watches with the same schedule don't
// 'burst' at the same time after restarting GoWatch
cronDelayStr := viper.GetString("schedule.delay")
@@ -246,16 +276,6 @@ func (web *Web) initCronJobs() {
time.Sleep(cronDelay)
}
}
-
- // db prune job is started if there is a database.prune set
- if viper.IsSet("database.prune") {
- pruneSchedule := viper.GetString("database.prune")
- _, err := web.cron.AddFunc(pruneSchedule, web.pruneDB)
- if err != nil {
- web.startupWarning("Could not parse database.prune:", err)
- }
- log.Println("Started DB prune cronjob:", pruneSchedule)
- }
}
// initNotifiers initializes the notifiers configured in the config
@@ -791,6 +811,170 @@ func (web *Web) cacheClear(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+// backupView (/backup/view) lists the stored backups
+func (web *Web) backupView(c *gin.Context) {
+ if !viper.IsSet("database.backup") {
+ c.HTML(http.StatusOK, "backupView", gin.H{"Error": "database.backup not set"})
+ return
+ }
+ if !viper.IsSet("database.backup.schedule") {
+ c.HTML(http.StatusOK, "backupView", gin.H{"Error": "database.backup.schedule not set"})
+ return
+ }
+ if !viper.IsSet("database.backup.path") {
+ c.HTML(http.StatusOK, "backupView", gin.H{"Error": "database.backup.path not set"})
+ return
+ }
+
+ backupPath := viper.GetString("database.backup.path")
+
+ backupDir, err := filepath.Abs(filepath.Dir(backupPath))
+ if err != nil {
+ c.HTML(http.StatusOK, "backupView", gin.H{"Error": err})
+ return
+ }
+
+ filesInBackupDir, err := ioutil.ReadDir(backupDir)
+ if err != nil {
+ c.HTML(http.StatusOK, "backupView", gin.H{"Error": err})
+ return
+ }
+
+ filePaths := make([]string, 0, len(filesInBackupDir))
+ for _, fileInBackupDir := range filesInBackupDir {
+ fullPath := filepath.Join(backupDir, fileInBackupDir.Name())
+ filePaths = append(filePaths, fullPath)
+ }
+
+ c.HTML(http.StatusOK, "backupView", gin.H{"Backups": filePaths})
+}
+
+// backupCreate (/backup/create) creates a new backup
+func (web *Web) backupCreate(c *gin.Context) {
+ if !viper.IsSet("database.backup") {
+ c.HTML(http.StatusBadRequest, "backupView", gin.H{"Error": "database.backup not set"})
+ return
+ }
+ if !viper.IsSet("database.backup.path") {
+ c.HTML(http.StatusBadRequest, "backupView", gin.H{"Error": "database.backup.path not set"})
+ return
+ }
+ backupDir := filepath.Dir(viper.GetString("database.backup.path"))
+ backupName := fmt.Sprintf("gowatch_%s.gzip", time.Now().Format(time.RFC3339))
+ backupName = strings.Replace(backupName, ":", "-", -1)
+
+ backupPath := filepath.Join(backupDir, backupName)
+ err := web.createBackup(backupPath)
+ if err != nil {
+ c.HTML(http.StatusBadRequest, "backupView", gin.H{"Error": err})
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/backup/view")
+}
+
+func (web *Web) scheduledBackup() {
+ log.Println("Starting scheduled backup")
+ backupPath := viper.GetString("database.backup.path")
+
+ // compare abs backup path to abs dir path, if they are the same, it's a dir
+ // avoids an Open(backupPath).Stat, which will fail if it's a file that doesn't exist
+ absBackupPath, err := filepath.Abs(backupPath)
+ if err != nil {
+ log.Println("Could not get abs path of database.backup.path")
+ return
+ }
+
+ backupDir, err := filepath.Abs(filepath.Dir(backupPath))
+ if err != nil {
+ log.Println("Could not get abs path of dir(database.backup.path)")
+ return
+ }
+ if absBackupPath == backupDir {
+ backupName := fmt.Sprintf("gowatch_%s.gzip", time.Now().Format(time.RFC3339))
+ backupPath = filepath.Join(backupPath, backupName)
+ log.Println(backupPath)
+ } else {
+ backupTemplate, err := template.New("backup").Parse(backupPath)
+ if err != nil {
+ log.Println("Could not parse backup path as template:", err)
+ return
+ }
+ var backupNameBytes bytes.Buffer
+ err = backupTemplate.Execute(&backupNameBytes, time.Now())
+ if err != nil {
+ log.Println("Could not execute backup template:", err)
+ return
+ }
+ backupPath = backupNameBytes.String()
+ }
+ err = web.createBackup(backupPath)
+ if err != nil {
+ log.Println("Could not create scheduled backup:", err)
+ return
+ }
+ log.Println("Backup succesful:", backupPath)
+}
+
+// createBackup is the function that actually creates the backup
+func (web *Web) createBackup(backupPath string) error {
+ backupFile, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY, 0660)
+ if err != nil {
+ return err
+ }
+ defer backupFile.Close()
+
+ backupWriter := gzip.NewWriter(backupFile)
+ defer backupWriter.Close()
+
+ var watches []Watch
+ tx := web.db.Find(&watches)
+ if tx.Error != nil {
+ return tx.Error
+ }
+
+ var filters []Filter
+ tx = web.db.Find(&filters)
+ if tx.Error != nil {
+ return tx.Error
+ }
+
+ var connections []FilterConnection
+ tx = web.db.Find(&connections)
+ if tx.Error != nil {
+ return tx.Error
+ }
+
+ var values []FilterOutput
+ tx = web.db.Find(&values)
+ if tx.Error != nil {
+ return tx.Error
+ }
+
+ backup := Backup{
+ Watches: watches,
+ Filters: filters,
+ Connections: connections,
+ Values: values,
+ }
+
+ jsn, err := json.Marshal(backup)
+ if err != nil {
+ return err
+ }
+ _, err = backupWriter.Write(jsn)
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// backupTest (/backup/test) tests the selected backup file
+func (web *Web) backupTest(c *gin.Context) {
+
+}
+
// exportWatch (/watch/export/:id) creates a json export of the current watch
func (web *Web) exportWatch(c *gin.Context) {
watchID := c.Param("id")
diff --git a/models.go b/models.go
index 36f13c3..3065783 100644
--- a/models.go
+++ b/models.go
@@ -54,3 +54,10 @@ type WatchExport struct {
Filters []Filter `json:"filters"`
Connections []FilterConnection `json:"connections"`
}
+
+type Backup struct {
+ Watches []Watch `json:"watches"`
+ Filters []Filter `json:"filters"`
+ Connections []FilterConnection `json:"connections"`
+ Values []FilterOutput `json:"values"`
+}
diff --git a/templates/backup/test.html b/templates/backup/test.html
new file mode 100644
index 0000000..728b63a
--- /dev/null
+++ b/templates/backup/test.html
@@ -0,0 +1,43 @@
+{{define "title"}}
+GoWatch Backups
+{{end}}
+{{define "content"}}
+
+
+
+ Backups
+
+ {{ if .Error }}
+
+ {{ .Error }}
+
+ {{ end }}
+
+
+
+ File |
+ Test |
+ Restore |
+
+
+
+ {{ range $i, $backup := .Backups }}
+
+ {{ $backup }} |
+
+
+ Test
+
+ |
+
+
+ Restore
+
+ |
+
+ {{ end }}
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/templates/backup/view.html b/templates/backup/view.html
new file mode 100644
index 0000000..9de6937
--- /dev/null
+++ b/templates/backup/view.html
@@ -0,0 +1,52 @@
+{{define "title"}}
+GoWatch Backups
+{{end}}
+{{define "content"}}
+
+
+
+ Backups
+
+ {{ if .Error }}
+
+ {{ .Error }}
+
+ {{ end }}
+
+
+
+ File |
+ Test |
+ Restore |
+ Download |
+
+
+
+ {{ range $i, $backup := .Backups }}
+
+ {{ $backup }} |
+
+
+ Test
+
+ |
+
+
+ Restore
+
+ |
+
+
+ Download
+
+ |
+
+ {{ end }}
+
+
+
+ Backup Now
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/templates/base.html b/templates/base.html
index d340980..2157374 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -29,6 +29,9 @@
New
+
+ Backups
+
{{ template "navbar" .}}