summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-04-21 18:46:40 -0700
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-04-21 18:46:40 -0700
commitd14605d1388aaa7cc9ef1c230eae5ba14c9cef44 (patch)
tree59fcda0fae7899ca577eed1f72d89bff17d5ad5d
downloadbackup-notify-d14605d1388aaa7cc9ef1c230eae5ba14c9cef44.tar.gz
backup-notify-d14605d1388aaa7cc9ef1c230eae5ba14c9cef44.zip
initial commit
-rw-r--r--.drone.yml53
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile14
-rw-r--r--api/backups/backups.go58
-rw-r--r--api/serve.go92
-rw-r--r--api/template/template.go73
-rw-r--r--api/types/types.go26
-rw-r--r--args/args.go52
-rw-r--r--database/backups.go51
-rw-r--r--database/conn.go17
-rw-r--r--database/migrate.go42
-rw-r--r--docker-compose.yml18
-rw-r--r--go.mod15
-rw-r--r--go.sum42
-rw-r--r--main.go96
-rw-r--r--ntfy/watcher.go77
-rw-r--r--scheduler/scheduler.go17
-rw-r--r--static/css/blinky.css9
-rw-r--r--static/css/club.css48
-rw-r--r--static/css/colors.css51
-rw-r--r--static/css/form.css42
-rw-r--r--static/css/guestbook.css16
-rw-r--r--static/css/styles.css43
-rw-r--r--static/css/table.css31
-rw-r--r--templates/404.html7
-rw-r--r--templates/backup_list.html27
-rw-r--r--templates/base.html18
-rw-r--r--templates/base_empty.html3
-rw-r--r--utils/random_id.go16
29 files changed, 1057 insertions, 0 deletions
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..d056e69
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,53 @@
+---
+kind: pipeline
+type: docker
+name: build
+
+steps:
+ - name: run tests
+ image: golang
+ commands:
+ - go build
+ - go test -p 1 -v ./...
+
+trigger:
+ event:
+ - pull_request
+
+---
+kind: pipeline
+type: docker
+name: deploy
+
+steps:
+ - name: run tests
+ image: golang
+ commands:
+ - go build
+ - go test -p 1 -v ./...
+ - name: docker
+ image: plugins/docker
+ settings:
+ username:
+ from_secret: gitea_packpub_username
+ password:
+ from_secret: gitea_packpub_password
+ registry: git.hatecomputers.club
+ repo: git.hatecomputers.club/hatecomputers/hatecomputers.club
+ - name: ssh
+ image: appleboy/drone-ssh
+ settings:
+ host: hatecomputers.club
+ username: root
+ key:
+ from_secret: cd_ssh_key
+ port: 22
+ command_timeout: 2m
+ script:
+ - systemctl restart docker-compose@hatecomputers-club
+
+trigger:
+ branch:
+ - main
+ event:
+ - push
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f5a75bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.env
+backup-notify
+*.db
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..000f87d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,14 @@
+FROM golang:1.22
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN go build -o /app/backupnotify
+
+EXPOSE 8080
+
+CMD ["/app/nackupnotify", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/nackupnotify.db", "--static-path", "/app/static", "--scheduler", "--ntfy-endpoint", "https://ntfy.internal.simponic.xyz", "--ntfy-topics", "server-backups"]
diff --git a/api/backups/backups.go b/api/backups/backups.go
new file mode 100644
index 0000000..d389582
--- /dev/null
+++ b/api/backups/backups.go
@@ -0,0 +1,58 @@
+package backups
+
+import (
+ "log"
+ "net/http"
+ "time"
+
+ "git.simponic.xyz/simponic/backup-notify/api/types"
+ "git.simponic.xyz/simponic/backup-notify/database"
+)
+
+func getHostStatusOverTime(backups []database.Backup) map[string][]bool {
+ hostnameBackups := make(map[string][]database.Backup)
+ statusOverTime := make(map[string][]bool)
+
+ if len(backups) == 0 {
+ return statusOverTime
+ }
+
+ firstReceivedBackup := backups[0].ReceivedOn
+ for _, backup := range backups {
+ if backup.ReceivedOn.Before(firstReceivedBackup) {
+ firstReceivedBackup = backup.ReceivedOn
+ }
+
+ if _, ok := hostnameBackups[backup.Hostname]; !ok {
+ hostnameBackups[backup.Hostname] = []database.Backup{}
+ }
+ hostnameBackups[backup.Hostname] = append(hostnameBackups[backup.Hostname], backup)
+ }
+
+ daysSinceFirstBackup := int(time.Since(firstReceivedBackup).Hours()/24) + 1
+
+ for hostname := range hostnameBackups {
+ statusOverTime[hostname] = make([]bool, daysSinceFirstBackup)
+ for _, backup := range hostnameBackups[hostname] {
+ dayReceivedOn := int(time.Since(backup.ReceivedOn).Hours() / 24)
+ statusOverTime[hostname][dayReceivedOn] = true
+ }
+ }
+
+ return statusOverTime
+}
+
+func ListBackupsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ backups, err := database.ListBackups(context.DBConn)
+ if err != nil {
+ log.Println(err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ hostStatusOverTime := getHostStatusOverTime(backups)
+ (*context.TemplateData)["HostStatusOverTime"] = hostStatusOverTime
+ return success(context, req, resp)
+ }
+}
diff --git a/api/serve.go b/api/serve.go
new file mode 100644
index 0000000..5772373
--- /dev/null
+++ b/api/serve.go
@@ -0,0 +1,92 @@
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "git.simponic.xyz/simponic/backup-notify/api/backups"
+ "git.simponic.xyz/simponic/backup-notify/api/template"
+ "git.simponic.xyz/simponic/backup-notify/api/types"
+ "git.simponic.xyz/simponic/backup-notify/args"
+ "git.simponic.xyz/simponic/backup-notify/utils"
+)
+
+func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ context.Start = time.Now()
+ context.Id = utils.RandomId()
+
+ log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id)
+ return success(context, req, resp)
+ }
+}
+
+func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ end := time.Now()
+ log.Println(context.Id, "took", end.Sub(context.Start))
+
+ return success(context, req, resp)
+ }
+}
+
+func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.WriteHeader(200)
+ resp.Write([]byte("healthy"))
+ return success(context, req, resp)
+ }
+}
+
+func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ return failure(context, req, resp)
+ }
+}
+
+func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ return success(context, req, resp)
+ }
+}
+
+func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ header := fmt.Sprintf("public, max-age=%d", maxAge)
+ w.Header().Set("Cache-Control", header)
+ next.ServeHTTP(w, r)
+ })
+}
+
+func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
+ mux := http.NewServeMux()
+
+ staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
+ mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
+
+ makeRequestContext := func() *types.RequestContext {
+ return &types.RequestContext{
+ DBConn: dbConn,
+ Args: argv,
+ TemplateData: &map[string]interface{}{},
+ }
+ }
+
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(backups.ListBackupsContinuation, backups.ListBackupsContinuation)(template.TemplateContinuation("backup_list.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ return &http.Server{
+ Addr: ":" + fmt.Sprint(argv.Port),
+ Handler: mux,
+ }
+}
diff --git a/api/template/template.go b/api/template/template.go
new file mode 100644
index 0000000..81cb9e2
--- /dev/null
+++ b/api/template/template.go
@@ -0,0 +1,73 @@
+package template
+
+import (
+ "bytes"
+ "errors"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+
+ "git.simponic.xyz/simponic/backup-notify/api/types"
+)
+
+func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
+ templatePath := context.Args.TemplatePath
+ basePath := templatePath + "/base_empty.html"
+ if showBaseHtml {
+ basePath = templatePath + "/base.html"
+ }
+
+ templateLocation := templatePath + "/" + templateName
+ tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+
+ dataPtr := context.TemplateData
+ if dataPtr == nil {
+ dataPtr = &map[string]interface{}{}
+ }
+
+ data := *dataPtr
+
+ var buffer bytes.Buffer
+ err = tmpl.ExecuteTemplate(&buffer, "base", data)
+
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+ return buffer, nil
+}
+
+func TemplateContinuation(path string, showBase bool) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ html, err := renderTemplate(context, path, true)
+ if errors.Is(err, os.ErrNotExist) {
+ resp.WriteHeader(404)
+ html, err = renderTemplate(context, "404.html", true)
+ if err != nil {
+ log.Println("error rendering 404 template", err)
+ resp.WriteHeader(500)
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return failure(context, req, resp)
+ }
+
+ if err != nil {
+ log.Println("error rendering template", err)
+ resp.WriteHeader(500)
+ resp.Write([]byte("error rendering template"))
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/api/types/types.go b/api/types/types.go
new file mode 100644
index 0000000..1898867
--- /dev/null
+++ b/api/types/types.go
@@ -0,0 +1,26 @@
+package types
+
+import (
+ "database/sql"
+ "net/http"
+ "time"
+
+ "git.simponic.xyz/simponic/backup-notify/args"
+)
+
+type RequestContext struct {
+ DBConn *sql.DB
+ Args *args.Arguments
+
+ Id string
+ Start time.Time
+
+ TemplateData *map[string]interface{}
+}
+
+type BannerMessages struct {
+ Messages []string
+}
+
+type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
+type ContinuationChain func(Continuation, Continuation) ContinuationChain
diff --git a/args/args.go b/args/args.go
new file mode 100644
index 0000000..dd080cb
--- /dev/null
+++ b/args/args.go
@@ -0,0 +1,52 @@
+package args
+
+import (
+ "flag"
+ "strings"
+)
+
+type Arguments struct {
+ DatabasePath string
+ TemplatePath string
+ StaticPath string
+
+ Migrate bool
+ Scheduler bool
+
+ NtfyEndpoint string
+ NtfyTopics []string
+
+ Port int
+ Server bool
+}
+
+func GetArgs() (*Arguments, error) {
+ databasePath := flag.String("database-path", "./backupnotify.db", "Path to the SQLite database")
+
+ templatePath := flag.String("template-path", "./templates", "Path to the template directory")
+ staticPath := flag.String("static-path", "./static", "Path to the static directory")
+
+ scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron")
+ migrate := flag.Bool("migrate", false, "Run the migrations")
+ ntfyEndpoint := flag.String("ntfy-endpoint", "https://ntfy.sh", "")
+ ntfyTopics := flag.String("ntfy-topics", "server-backup", "")
+
+ port := flag.Int("port", 8080, "Port to listen on")
+ server := flag.Bool("server", false, "Run the server")
+
+ flag.Parse()
+
+ arguments := &Arguments{
+ DatabasePath: *databasePath,
+ TemplatePath: *templatePath,
+ StaticPath: *staticPath,
+ Port: *port,
+ Server: *server,
+ Migrate: *migrate,
+ Scheduler: *scheduler,
+ NtfyEndpoint: *ntfyEndpoint,
+ NtfyTopics: strings.Split(*ntfyTopics, ","),
+ }
+
+ return arguments, nil
+}
diff --git a/database/backups.go b/database/backups.go
new file mode 100644
index 0000000..ab2155f
--- /dev/null
+++ b/database/backups.go
@@ -0,0 +1,51 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+ "time"
+)
+
+type Backup struct {
+ Hostname string
+ ReceivedOn time.Time
+}
+
+func ListBackups(dbConn *sql.DB) ([]Backup, error) {
+ log.Println("listing backups")
+
+ rows, err := dbConn.Query(`SELECT hostname, received_on FROM backups;`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ backups := []Backup{}
+ for rows.Next() {
+ var backup Backup
+ err := rows.Scan(&backup.Hostname, &backup.ReceivedOn)
+ if err != nil {
+ return nil, err
+ }
+ backups = append(backups, backup)
+ }
+
+ return backups, nil
+}
+
+func DeleteOldBackups(dbConn *sql.DB, days int) error {
+ log.Println("deleting old backups")
+
+ duration := time.Duration(days) * 24 * time.Hour
+ _, err := dbConn.Exec(`DELETE FROM backups WHERE received_on < ?;`, time.Now().Add(-duration))
+
+ return err
+}
+
+func ReceivedBackup(dbConn *sql.DB, hostname string) error {
+ log.Println("received backup for", hostname)
+
+ _, err := dbConn.Exec(`INSERT INTO backups (hostname) VALUES (?);`, hostname)
+
+ return err
+}
diff --git a/database/conn.go b/database/conn.go
new file mode 100644
index 0000000..be27586
--- /dev/null
+++ b/database/conn.go
@@ -0,0 +1,17 @@
+package database
+
+import (
+ "database/sql"
+ _ "github.com/mattn/go-sqlite3"
+ "log"
+)
+
+func MakeConn(databasePath *string) *sql.DB {
+ log.Println("opening database at", *databasePath, "with foreign keys enabled")
+ dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on")
+ if err != nil {
+ panic(err)
+ }
+
+ return dbConn
+}
diff --git a/database/migrate.go b/database/migrate.go
new file mode 100644
index 0000000..f2087cc
--- /dev/null
+++ b/database/migrate.go
@@ -0,0 +1,42 @@
+package database
+
+import (
+ "log"
+
+ "database/sql"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type Migrator func(*sql.DB) (*sql.DB, error)
+
+func MigrateBackups(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating backups table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS backups (
+ hostname TEXT NOT NULL,
+ received_on TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ return dbConn, nil
+}
+
+func Migrate(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating database")
+
+ migrations := []Migrator{
+ MigrateBackups,
+ }
+
+ for _, migration := range migrations {
+ dbConn, err := migration(dbConn)
+ if err != nil {
+ return dbConn, err
+ }
+ }
+
+ return dbConn, nil
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0dbf90f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+version: "3"
+
+services:
+ api:
+ restart: always
+ image: git.simponic.xyz/simponic/backup-notify
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"]
+ interval: 5s
+ timeout: 10s
+ retries: 5
+ env_file: .env
+ volumes:
+ - ./db:/app/db
+ - ./templates:/app/templates
+ - ./static:/app/static
+ ports:
+ - "127.0.0.1:4455:8080"
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..05fc817
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,15 @@
+module git.simponic.xyz/simponic/backup-notify
+
+go 1.22.1
+
+require (
+ github.com/go-co-op/gocron v1.37.0
+ github.com/joho/godotenv v1.5.1
+ github.com/mattn/go-sqlite3 v1.14.22
+)
+
+require (
+ github.com/google/uuid v1.4.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..bc178fa
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,42 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
+github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
+github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
+github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..b66bac6
--- /dev/null
+++ b/main.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+
+ "git.simponic.xyz/simponic/backup-notify/api"
+ "git.simponic.xyz/simponic/backup-notify/args"
+ "git.simponic.xyz/simponic/backup-notify/database"
+ "git.simponic.xyz/simponic/backup-notify/ntfy"
+ "git.simponic.xyz/simponic/backup-notify/scheduler"
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
+
+ err := godotenv.Load()
+ if err != nil {
+ log.Println("could not load .env file:", err)
+ }
+
+ argv, err := args.GetArgs()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ dbConn := database.MakeConn(&argv.DatabasePath)
+ defer dbConn.Close()
+
+ if argv.Migrate {
+ _, err = database.Migrate(dbConn)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Println("database migrated successfully")
+ }
+
+ if argv.Scheduler {
+ scheduler.StartScheduler(dbConn)
+ }
+
+ if argv.NtfyEndpoint != "" {
+ ntfy := ntfy.MakeNtfyWatcher(argv.NtfyEndpoint, argv.NtfyTopics)
+ notifications := ntfy.Watch()
+
+ go func() {
+ for notification := range notifications {
+ // message type is a struct, so we can marshal it to JSON
+ message := notification.Text
+ messageStruct := struct {
+ Id string `json:"id"`
+ Time int `json:"time"`
+ Message string `json:"message"`
+ Event string `json:"event"`
+ }{}
+
+ err := json.Unmarshal([]byte(message), &messageStruct)
+ if err != nil {
+ log.Println("could not unmarshal message:", err)
+ continue
+ }
+
+ if messageStruct.Event == "keepalive" {
+ log.Println("received keepalive message")
+ continue
+ }
+
+ if messageStruct.Event != "message" {
+ log.Println("received unknown event:", messageStruct.Event)
+ continue
+ }
+
+ log.Println("received backup host:", messageStruct.Message)
+ err = database.ReceivedBackup(dbConn, messageStruct.Message)
+ if err != nil {
+ log.Println("could not record backup:", err)
+ }
+ }
+ }()
+ }
+
+ if argv.Server {
+ server := api.MakeServer(argv, dbConn)
+ log.Println("🚀🚀 API listening on port", argv.Port)
+
+ go func() {
+ err = server.ListenAndServe()
+ if err != nil {
+ log.Fatal(err)
+ }
+ }()
+ }
+
+ select {} // block forever
+}
diff --git a/ntfy/watcher.go b/ntfy/watcher.go
new file mode 100644
index 0000000..af4dd55
--- /dev/null
+++ b/ntfy/watcher.go
@@ -0,0 +1,77 @@
+package ntfy
+
+import (
+ "bufio"
+ "log"
+ "net/http"
+ "net/url"
+ "path"
+ "time"
+)
+
+type Message struct {
+ Topic string
+ Text string
+}
+
+type NtfyWatcher struct {
+ Endpoint string
+ Topics []string
+}
+
+func (w *NtfyWatcher) Watch() chan Message {
+ notifications := make(chan Message)
+
+ for _, topic := range w.Topics {
+ log.Println("subscribing to topic:", topic)
+
+ go func() {
+ retryCount := 5
+ retryTime := 5 * time.Second
+ retries := retryCount
+
+ retry := func() {
+ log.Println("waiting 5 seconds before reconnecting. retries left:", retries, "topic:", topic, "endpoint:", w.Endpoint)
+ time.Sleep(retryTime)
+ retries--
+ }
+
+ for true {
+ if retries == 0 {
+ log.Fatal("too many retries, exiting")
+ }
+
+ endpoint, _ := url.JoinPath(w.Endpoint, path.Join(topic, "json"))
+ resp, err := http.Get(endpoint)
+ if err != nil {
+ log.Println("error connecting to endpoint:", err)
+ retry()
+ continue
+ }
+
+ defer resp.Body.Close()
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ text := scanner.Text()
+ log.Println("received notification:", text)
+ notifications <- Message{Topic: topic, Text: text}
+ retries = retryCount // reset retries
+ }
+
+ if err := scanner.Err(); err != nil {
+ log.Println("error reading response body:", err)
+ retry()
+ }
+ }
+ }()
+ }
+
+ return notifications
+}
+
+func MakeNtfyWatcher(endpoint string, topics []string) *NtfyWatcher {
+ return &NtfyWatcher{
+ Endpoint: endpoint,
+ Topics: topics,
+ }
+}
diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go
new file mode 100644
index 0000000..f276ac7
--- /dev/null
+++ b/scheduler/scheduler.go
@@ -0,0 +1,17 @@
+package scheduler
+
+import (
+ "database/sql"
+ "time"
+
+ "git.simponic.xyz/simponic/backup-notify/database"
+ "github.com/go-co-op/gocron"
+)
+
+func StartScheduler(dbConn *sql.DB) {
+ scheduler := gocron.NewScheduler(time.Local)
+ scheduler.Every(1).Minute().Do(func() {
+ database.DeleteOldBackups(dbConn, 31)
+ })
+ scheduler.StartAsync()
+}
diff --git a/static/css/blinky.css b/static/css/blinky.css
new file mode 100644
index 0000000..8bd636e
--- /dev/null
+++ b/static/css/blinky.css
@@ -0,0 +1,9 @@
+.blinky {
+ animation: blinker 1s step-start infinite;
+}
+
+@keyframes blinker {
+ 50% {
+ opacity: 0;
+ }
+}
diff --git a/static/css/club.css b/static/css/club.css
new file mode 100644
index 0000000..747f2d0
--- /dev/null
+++ b/static/css/club.css
@@ -0,0 +1,48 @@
+.club-members {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: left;
+ gap: 20px;
+ padding: 20px;
+}
+
+.club-member {
+ flex: 1;
+ background-color: var(--background-color-2);
+ border: 1px solid var(--border-color);
+ padding: 10px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-around;
+ gap: 10px;
+ max-width: 600px;
+ min-width: 400px;
+ line-break: anywhere;
+}
+
+.club-bio {
+ white-space: pre-wrap;
+ border-top: 1px solid var(--border-color);
+}
+
+.avatar {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.avatar div {
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ width: 120px;
+ height: 120px;
+ border-radius: 25%;
+}
+
+.about {
+ flex: 2;
+}
diff --git a/static/css/colors.css b/static/css/colors.css
new file mode 100644
index 0000000..46357d9
--- /dev/null
+++ b/static/css/colors.css
@@ -0,0 +1,51 @@
+:root {
+ --background-color-light: #f4e8e9;
+ --background-color-light-2: #f5e6f3;
+ --text-color-light: #333;
+ --confirm-color-light: #91d9bb;
+ --link-color-light: #d291bc;
+ --container-bg-light: #fff7f87a;
+ --border-color-light: #692fcc;
+ --error-color-light: #a83254;
+
+ --background-color-dark: #333;
+ --background-color-dark-2: #2c2c2c;
+ --text-color-dark: #f4e8e9;
+ --confirm-color-dark: #4d8f73;
+ --link-color-dark: #b86b77;
+ --container-bg-dark: #424242ea;
+ --border-color-dark: #956ade;
+ --error-color-dark: #851736;
+}
+
+[data-theme="DARK"] {
+ --background-color: var(--background-color-dark);
+ --background-color-2: var(--background-color-dark-2);
+ --text-color: var(--text-color-dark);
+ --link-color: var(--link-color-dark);
+ --container-bg: var(--container-bg-dark);
+ --border-color: var(--border-color-dark);
+ --error-color: var(--error-color-dark);
+ --confirm-color: var(--confirm-color-dark);
+}
+
+[data-theme="LIGHT"] {
+ --background-color: var(--background-color-light);
+ --background-color-2: var(--background-color-light-2);
+ --text-color: var(--text-color-light);
+ --link-color: var(--link-color-light);
+ --container-bg: var(--container-bg-light);
+ --border-color: var(--border-color-light);
+ --error-color: var(--error-color-light);
+ --confirm-color: var(--confirm-color-light);
+}
+
+.error {
+ background-color: var(--error-color);
+ padding: 1rem;
+}
+
+.success {
+ background-color: var(--confirm-color);
+ padding: 1rem;
+}
diff --git a/static/css/form.css b/static/css/form.css
new file mode 100644
index 0000000..7ccd8db
--- /dev/null
+++ b/static/css/form.css
@@ -0,0 +1,42 @@
+.form {
+ max-width: 600px;
+ padding: 1em;
+ background: var(--background-color-2);
+ border: 1px solid #ccc;
+}
+
+label {
+ display: block;
+ margin: 0 0 1em;
+ font-weight: bold;
+}
+
+input {
+ display: block;
+ width: 100%;
+ padding: 0.5em;
+ margin: 0 0 1em;
+ border: 1px solid var(--border-color);
+ background: var(--container-bg);
+}
+
+button,
+input[type="submit"] {
+ padding: 0.5em 1em;
+ background: var(--link-color);
+ color: var(--text-color);
+ border: 0;
+ cursor: pointer;
+}
+
+textarea {
+ display: block;
+ width: 100%;
+ padding: 0.5em;
+ margin: 0 0 1em;
+ border: 1px solid var(--border-color);
+ background: var(--container-bg);
+
+ resize: vertical;
+ min-height: 100px;
+}
diff --git a/static/css/guestbook.css b/static/css/guestbook.css
new file mode 100644
index 0000000..6241717
--- /dev/null
+++ b/static/css/guestbook.css
@@ -0,0 +1,16 @@
+.entry {
+ margin-bottom: 10px;
+ border: 1px solid var(--border-color);
+
+ padding: 10px;
+ max-width: 700px;
+}
+
+.entry-name {
+ font-weight: bold;
+}
+
+.entry-message {
+ margin-left: 20px;
+ white-space: pre-wrap;
+}
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..0d8d1ba
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,43 @@
+@import "/static/css/colors.css";
+
+* {
+ box-sizing: border-box;
+ color: var(--text-color);
+}
+
+body {
+ font-family: "Roboto", sans-serif;
+ background-color: var(--background-color);
+}
+
+table {
+ width: auto;
+ border-collapse: collapse;
+ border: 1px solid var(--border-color);
+}
+
+th,
+td {
+ padding: 12px 20px;
+ text-align: center;
+ border-bottom: 1px solid var(--border-color);
+}
+
+th,
+thead {
+ background-color: var(--background-color-2);
+}
+
+tbody tr:nth-child(odd) {
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+tbody tr {
+ transition: background-color 0.3s ease;
+}
+
+tbody tr:hover {
+ background-color: #ff47daa0;
+ color: #2a2a2a;
+}
diff --git a/static/css/table.css b/static/css/table.css
new file mode 100644
index 0000000..75a961d
--- /dev/null
+++ b/static/css/table.css
@@ -0,0 +1,31 @@
+table {
+ width: auto;
+ border-collapse: collapse;
+ border: 1px solid var(--border-color);
+}
+
+th,
+td {
+ padding: 12px 20px;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+th,
+thead {
+ background-color: var(--background-color-2);
+}
+
+tbody tr:nth-child(odd) {
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+tbody tr {
+ transition: background-color 0.3s ease;
+}
+
+tbody tr:hover {
+ background-color: #ff47daa0;
+ color: #2a2a2a;
+}
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 0000000..35b43bb
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,7 @@
+{{ define "content" }}
+<h1>page not found</h1>
+<p><em>but hey, at least you found our witty 404 page. that's something, right?</em></p>
+
+<p><a href="/">go back home</a></p>
+
+{{ end }} \ No newline at end of file
diff --git a/templates/backup_list.html b/templates/backup_list.html
new file mode 100644
index 0000000..82256b1
--- /dev/null
+++ b/templates/backup_list.html
@@ -0,0 +1,27 @@
+{{ define "content" }}
+ {{ if (eq (len .HostStatusOverTime) 0) }}
+ no backups yet!
+ {{ end }}
+
+ {{ range $hostname, $backupList := .HostStatusOverTime }}
+ <div>{{ $hostname }}</div><br>
+ <table>
+ <tr>
+ {{ range $i, $_ := $backupList }}
+ <th>{{ $i }}</th>
+ {{ end }}
+ </tr>
+ <tr>
+ {{ range $seen := $backupList }}
+ {{ if $seen }}
+ <td class="success">x</td>
+ {{ else }}
+ <td class="error">!!</td>
+ {{ end }}
+ </td>
+ {{ end }}
+ </tr>
+ </table>
+ <br><hr><br>
+ {{ end }}
+{{ end }}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..c8766c0
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,18 @@
+{{ define "base" }}
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>backup notify</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content=
+ "width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+ <link rel="stylesheet" type="text/css" href=
+ "/static/css/styles.css">
+ </head>
+ <body data-theme="DARK">
+ <div id="content" class="container">
+ {{ template "content" . }}
+ </div>
+ </body>
+</html>
+{{ end }}
diff --git a/templates/base_empty.html b/templates/base_empty.html
new file mode 100644
index 0000000..6191ab9
--- /dev/null
+++ b/templates/base_empty.html
@@ -0,0 +1,3 @@
+{{ define "base" }}
+ {{ template "content" . }}
+{{ end }} \ No newline at end of file
diff --git a/utils/random_id.go b/utils/random_id.go
new file mode 100644
index 0000000..1b03ec8
--- /dev/null
+++ b/utils/random_id.go
@@ -0,0 +1,16 @@
+package utils
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+func RandomId() string {
+ id := make([]byte, 16)
+ _, err := rand.Read(id)
+ if err != nil {
+ panic(err)
+ }
+
+ return fmt.Sprintf("%x", id)
+}