initial commit
This commit is contained in:
commit
d14605d138
53
.drone.yml
Normal file
53
.drone.yml
Normal file
@ -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
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
*.env
|
||||
backup-notify
|
||||
*.db
|
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -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"]
|
58
api/backups/backups.go
Normal file
58
api/backups/backups.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
92
api/serve.go
Normal file
92
api/serve.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
73
api/template/template.go
Normal file
73
api/template/template.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
26
api/types/types.go
Normal file
26
api/types/types.go
Normal file
@ -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
|
52
args/args.go
Normal file
52
args/args.go
Normal file
@ -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
|
||||
}
|
51
database/backups.go
Normal file
51
database/backups.go
Normal file
@ -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
|
||||
}
|
17
database/conn.go
Normal file
17
database/conn.go
Normal file
@ -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
|
||||
}
|
42
database/migrate.go
Normal file
42
database/migrate.go
Normal file
@ -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
|
||||
}
|
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@ -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"
|
15
go.mod
Normal file
15
go.mod
Normal file
@ -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
|
||||
)
|
42
go.sum
Normal file
42
go.sum
Normal file
@ -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=
|
96
main.go
Normal file
96
main.go
Normal file
@ -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
|
||||
}
|
77
ntfy/watcher.go
Normal file
77
ntfy/watcher.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
17
scheduler/scheduler.go
Normal file
17
scheduler/scheduler.go
Normal file
@ -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()
|
||||
}
|
9
static/css/blinky.css
Normal file
9
static/css/blinky.css
Normal file
@ -0,0 +1,9 @@
|
||||
.blinky {
|
||||
animation: blinker 1s step-start infinite;
|
||||
}
|
||||
|
||||
@keyframes blinker {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
48
static/css/club.css
Normal file
48
static/css/club.css
Normal file
@ -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;
|
||||
}
|
51
static/css/colors.css
Normal file
51
static/css/colors.css
Normal file
@ -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;
|
||||
}
|
42
static/css/form.css
Normal file
42
static/css/form.css
Normal file
@ -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;
|
||||
}
|
16
static/css/guestbook.css
Normal file
16
static/css/guestbook.css
Normal file
@ -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;
|
||||
}
|
43
static/css/styles.css
Normal file
43
static/css/styles.css
Normal file
@ -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;
|
||||
}
|
31
static/css/table.css
Normal file
31
static/css/table.css
Normal file
@ -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;
|
||||
}
|
7
templates/404.html
Normal file
7
templates/404.html
Normal file
@ -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 }}
|
27
templates/backup_list.html
Normal file
27
templates/backup_list.html
Normal file
@ -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 }}
|
18
templates/base.html
Normal file
18
templates/base.html
Normal file
@ -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 }}
|
3
templates/base_empty.html
Normal file
3
templates/base_empty.html
Normal file
@ -0,0 +1,3 @@
|
||||
{{ define "base" }}
|
||||
{{ template "content" . }}
|
||||
{{ end }}
|
16
utils/random_id.go
Normal file
16
utils/random_id.go
Normal file
@ -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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user