commit d14605d1388aaa7cc9ef1c230eae5ba14c9cef44 Author: Elizabeth Hunt Date: Sun Apr 21 18:46:40 2024 -0700 initial commit 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" }} +

page not found

+

but hey, at least you found our witty 404 page. that's something, right?

+ +

go back home

+ +{{ 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 }} +
{{ $hostname }}

+ + + {{ range $i, $_ := $backupList }} + + {{ end }} + + + {{ range $seen := $backupList }} + {{ if $seen }} + + {{ else }} + + {{ end }} + + {{ end }} + +
{{ $i }}
x!!
+


+ {{ 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" }} + + + + backup notify + + + + + +
+ {{ template "content" . }} +
+ + +{{ 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) +}