From 18a945aab9b3129b82076f633fab1d13ba28148e Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 12 Jan 2025 23:09:34 -0800 Subject: [PATCH] initial commit by simponic-infra --- .DS_Store | Bin 0 -> 6148 bytes .dockerignore | 5 ++ .drone.yml | 49 +++++++++++++ .gitignore | 3 + .tool-versions | 1 + Dockerfile | 13 ++++ README.md | 3 + api/api.go | 91 +++++++++++++++++++++++ api/api_test.go | 89 +++++++++++++++++++++++ api/template/template.go | 73 +++++++++++++++++++ api/types/types.go | 26 +++++++ args/args.go | 94 ++++++++++++++++++++++++ database/conn.go | 17 +++++ database/migrate.go | 39 ++++++++++ docker-compose.yml | 16 +++++ go.mod | 16 +++++ go.sum | 24 +++++++ main.go | 76 ++++++++++++++++++++ ntfy/publisher.go | 16 +++++ ntfy/watcher.go | 96 +++++++++++++++++++++++++ scheduler/scheduler.go | 34 +++++++++ static/.DS_Store | Bin 0 -> 6148 bytes static/css/colors.css | 55 ++++++++++++++ static/css/form.css | 42 +++++++++++ static/css/styles.css | 60 ++++++++++++++++ static/css/table.css | 28 ++++++++ static/fonts/.DS_Store | Bin 0 -> 6148 bytes static/fonts/GeistMono-Medium.ttf | Bin 0 -> 78292 bytes static/img/favicon.ico | Bin 0 -> 591 bytes static/js/components/formatDate.js | 7 ++ static/js/components/infoBanners.js | 6 ++ static/js/components/themeSwitcher.js | 27 +++++++ static/js/require.js | 5 ++ static/js/script.js | 6 ++ static/js/util/setThemeBeforeRender.js | 11 +++ templates/404.html | 7 ++ templates/base.html | 34 +++++++++ templates/base_empty.html | 3 + templates/hello.html | 3 + utils/random_id.go | 16 +++++ 40 files changed, 1091 insertions(+) create mode 100644 .DS_Store create mode 100644 .dockerignore create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 .tool-versions create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api/api.go create mode 100644 api/api_test.go create mode 100644 api/template/template.go create mode 100644 api/types/types.go create mode 100644 args/args.go create mode 100644 database/conn.go create mode 100644 database/migrate.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 ntfy/publisher.go create mode 100644 ntfy/watcher.go create mode 100644 scheduler/scheduler.go create mode 100644 static/.DS_Store create mode 100644 static/css/colors.css create mode 100644 static/css/form.css create mode 100644 static/css/styles.css create mode 100644 static/css/table.css create mode 100644 static/fonts/.DS_Store create mode 100644 static/fonts/GeistMono-Medium.ttf create mode 100644 static/img/favicon.ico create mode 100644 static/js/components/formatDate.js create mode 100644 static/js/components/infoBanners.js create mode 100644 static/js/components/themeSwitcher.js create mode 100644 static/js/require.js create mode 100644 static/js/script.js create mode 100644 static/js/util/setThemeBeforeRender.js create mode 100644 templates/404.html create mode 100644 templates/base.html create mode 100644 templates/base_empty.html create mode 100644 templates/hello.html create mode 100644 utils/random_id.go diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c2ba3f0046147f9330aa21efdbe82d983e574cbc GIT binary patch literal 6148 zcmeHKO>fgc5S?w(#AyYJ5JhT>qC$(rC4}_AflGtn04Uh8X{|cmD0YZY6e(x; z5BvqL{1W~PCwQ~FmEsM(A%vnGY4`2No7tTg+nXgK(VImbq9ze}D2&xTRDUp@=dxit zX5=PNa11#LQyHSi;hZ@IoC5#50{re)D4+=?&(qTT`xG;Wzm%yYs$fm&oN|lz&Ek0! zfmxu0C^^MtzPM}gZ-FmqM3)pJ{(ZD0jjBLk;P?ouH}aFd}6-R-@{3owL@_9DUnf~4=RG;gqFnh7^n};& z8pGznV$nJ2v|E1XXxZ`?kB?4T{=-Md%Vop6bML{Ev*Ber&g5t8A0}|7D7#_s3OK(~ z{vQX^ER`AmYV6a1Dlt@_KuK08wx3zS&%(MwyHK5#Ol_um64se;+!{0bdiz_GkxRrW z;1u{<3h?*AM`7$5TxnFF4piz20IXtI8QSX4KhWm^VAtSEBSv6Cy8^YVFjov=?hZn` zW4>$nD~;NngjyMK%*w*tP=vX92w%}jbTzu#Dc}?+D^SthCZGSO*WdrkPOj$^a0+ad z0;1X<^m|CjoUI$l@mcGmyhmYU-b$l#L1m6()!?J}07V(*d~N`{23Hy}0&{-^lnkzN J3j9+Aegg=~=?wq? literal 0 HcmV?d00001 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc5713c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +phoneassistant +Dockerfile +*.db +.drone.yml diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..cc25270 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,49 @@ +--- +kind: pipeline +type: docker +name: build + +steps: + - name: run tests + image: golang + commands: + - go get + - go test -p 1 -v ./... + +trigger: + event: + - pull_request + + +--- +kind: pipeline +type: docker +name: cicd + +steps: + - name: ci + image: plugins/docker + settings: + username: + from_secret: gitea_packpub_username + password: + from_secret: gitea_packpub_password + registry: git.simponic.xyz + repo: git.simponic.xyz/simponic/phoneassistant + - name: ssh + image: appleboy/drone-ssh + settings: + host: johan.simponic.xyz + username: root + key: + from_secret: cd_ssh_key + port: 22 + command_timeout: 2m + script: + - systemctl restart docker-compose@phoneassistant + +trigger: + branch: + - main + event: + - push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d1e583 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.env +phoneassistant +*.db diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..db5d8ee --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.23.4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f82bdb6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23 +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o /app/phoneassistant + +EXPOSE 8080 + +CMD ["/app/phoneassistant", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/phoneassistant.db", "--static-path", "/app/static", "--scheduler", "--ntfy-topics", "whois", "--ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cab75b1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +## phoneassistant + +this is a simponic service for phoneassistant diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..17d317c --- /dev/null +++ b/api/api.go @@ -0,0 +1,91 @@ +package api + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "time" + + "git.simponic.xyz/simponic/phoneassistant/api/template" + "git.simponic.xyz/simponic/phoneassistant/api/types" + "git.simponic.xyz/simponic/phoneassistant/args" + "git.simponic.xyz/simponic/phoneassistant/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 MakeMux(argv *args.Arguments, dbConn *sql.DB) *http.ServeMux { + 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() + + (*requestContext.TemplateData)["Service"] = "phoneassistant" + templateFile := "hello.html" + LogRequestContinuation(requestContext, r, w)(template.TemplateContinuation(templateFile, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + return mux +} diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 0000000..945a488 --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,89 @@ +package api_test + +import ( + "database/sql" + "io" + "net/http/httptest" + "os" + "strings" + "testing" + + "git.simponic.xyz/simponic/phoneassistant/api" + "git.simponic.xyz/simponic/phoneassistant/args" + "git.simponic.xyz/simponic/phoneassistant/database" + "git.simponic.xyz/simponic/phoneassistant/utils" +) + +func setup(t *testing.T) (*sql.DB, *httptest.Server) { + randomDb := utils.RandomId() + + testDb := database.MakeConn(&randomDb) + database.Migrate(testDb) + + arguments := &args.Arguments{ + TemplatePath: "../templates", + StaticPath: "../static", + } + + mux := api.MakeMux(arguments, testDb) + testServer := httptest.NewServer(mux) + + t.Cleanup(func() { + testServer.Close() + testDb.Close() + os.Remove(randomDb) + }) + return testDb, testServer +} + +func assertResponseCode(t *testing.T, resp *httptest.ResponseRecorder, statusCode int) { + if resp.Code != statusCode { + t.Errorf("code is unexpected: %d, expected %d", resp.Code, statusCode) + } +} + +func assertResponseBody(t *testing.T, resp *httptest.ResponseRecorder, body string) { + buf := new(strings.Builder) + _, err := io.Copy(buf, resp.Body) + if err != nil { + panic("could not read response body") + } + bodyStr := buf.String() + if bodyStr != body { + t.Errorf("body is unexpected: %s, expected %s", bodyStr, body) + } +} + +func TestHealthcheck(t *testing.T) { + _, testServer := setup(t) + + req := httptest.NewRequest("GET", "/health", nil) + resp := httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(resp, req) + + assertResponseCode(t, resp, 200) + assertResponseBody(t, resp, "healthy") +} + +func TestHello(t *testing.T) { + _, testServer := setup(t) + + req := httptest.NewRequest("GET", "/", nil) + resp := httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(resp, req) + + assertResponseCode(t, resp, 200) +} + +func TestCachingStaticFiles(t *testing.T) { + _, testServer := setup(t) + + req := httptest.NewRequest("GET", "/static/css/styles.css", nil) + resp := httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(resp, req) + + assertResponseCode(t, resp, 200) + if resp.Header().Get("Cache-Control") != "public, max-age=3600" { + t.Errorf("client cache will live indefinitely for static files, which is probably not great! %s", resp.Header().Get("Cache-Control")) + } +} diff --git a/api/template/template.go b/api/template/template.go new file mode 100644 index 0000000..bbf667d --- /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/phoneassistant/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, showBase) + 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..da581c9 --- /dev/null +++ b/api/types/types.go @@ -0,0 +1,26 @@ +package types + +import ( + "database/sql" + "net/http" + "time" + + "git.simponic.xyz/simponic/phoneassistant/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..a0b930f --- /dev/null +++ b/args/args.go @@ -0,0 +1,94 @@ +package args + +import ( + "flag" + "fmt" + "os" + "strings" + "sync" +) + +type Arguments struct { + DatabasePath string + TemplatePath string + StaticPath string + + Migrate bool + Scheduler bool + + NtfyEndpoint string + NtfyTopics []string + NtfyListener bool + + Port int + Server bool +} + +func isDirectory(path string) (bool, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return false, err + } + + return fileInfo.IsDir(), err +} + +func validateArgs(args *Arguments) error { + templateIsDir, err := isDirectory(args.TemplatePath) + if err != nil || !templateIsDir { + return fmt.Errorf("template path is not an accessible directory %s", err) + } + staticPathIsDir, err := isDirectory(args.StaticPath) + if err != nil || !staticPathIsDir { + return fmt.Errorf("static path is not an accessible directory %s", err) + } + return nil +} + +var lock = &sync.Mutex{} +var args *Arguments + +func GetArgs() (*Arguments, error) { + lock.Lock() + defer lock.Unlock() + + if args != nil { + return args, nil + } + + databasePath := flag.String("database-path", "./phoneassistant.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") + + ntfyEndpoint := flag.String("ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club", "NTFY Endpoint") + ntfyTopics := flag.String("ntfy-topics", "testtopic", "Comma-separated NTFY Topics") + ntfyListener := flag.Bool("ntfy-listener", false, "Listen to NTFY Topic and propagate messages") + + scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron") + migrate := flag.Bool("migrate", false, "Run the migrations") + + port := flag.Int("port", 8080, "Port to listen on") + server := flag.Bool("server", false, "Run the server") + + flag.Parse() + + args = &Arguments{ + DatabasePath: *databasePath, + TemplatePath: *templatePath, + StaticPath: *staticPath, + Port: *port, + Server: *server, + Migrate: *migrate, + Scheduler: *scheduler, + NtfyEndpoint: *ntfyEndpoint, + NtfyTopics: strings.Split(*ntfyTopics, ","), + NtfyListener: *ntfyListener, + } + err := validateArgs(args) + if err != nil { + return nil, err + } + + return args, nil +} 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..64457bc --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,39 @@ +package database + +import ( + "log" + + "database/sql" + + _ "github.com/mattn/go-sqlite3" +) + +type Migrator func(*sql.DB) (*sql.DB, error) + +func DoNothing(dbConn *sql.DB) (*sql.DB, error) { + log.Println("doing nothing") + + _, err := dbConn.Exec(`SELECT 0;`) + if err != nil { + return dbConn, err + } + + return dbConn, nil +} + +func Migrate(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating database") + + migrations := []Migrator{ + DoNothing, + } + + 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..38b6101 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3" + +services: + api: + restart: always + image: git.simponic.xyz/simponic/phoneassistant + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"] + interval: 5s + timeout: 10s + retries: 5 + env_file: .env + volumes: + - ./db:/app/db + ports: + - "127.0.0.1:9082:8080" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e7802f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.simponic.xyz/simponic/phoneassistant + +go 1.23.4 + +require ( + github.com/go-co-op/gocron/v2 v2.14.0 + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.24 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..129e134 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +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/v2 v2.14.0 h1:bWPJeIdd4ioqiEpLLD1BVSTrtae7WABhX/WaVJbKVqg= +github.com/go-co-op/gocron/v2 v2.14.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.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/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +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..b721f90 --- /dev/null +++ b/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "git.simponic.xyz/simponic/phoneassistant/api" + "git.simponic.xyz/simponic/phoneassistant/args" + "git.simponic.xyz/simponic/phoneassistant/database" + "git.simponic.xyz/simponic/phoneassistant/ntfy" + "git.simponic.xyz/simponic/phoneassistant/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.NtfyListener { + ntfy := ntfy.MakeNtfyWatcher(argv.NtfyEndpoint, argv.NtfyTopics) + notifications := ntfy.Watch() + + go func() { + for notification := range notifications { + message := notification.Message + log.Println("got message", message) + } + }() + } + + if argv.Scheduler { + go func() { + scheduler.StartScheduler(dbConn, argv) + }() + } + + if argv.Server { + mux := api.MakeMux(argv, dbConn) + log.Println("🚀🚀 phoneassistant API listening on port", argv.Port) + go func() { + server := &http.Server{ + Addr: ":" + fmt.Sprint(argv.Port), + Handler: mux, + } + err = server.ListenAndServe() + if err != nil { + log.Fatal(err) + } + }() + } + + if argv.Server || argv.Scheduler || argv.NtfyListener { + select {} // block forever + } +} diff --git a/ntfy/publisher.go b/ntfy/publisher.go new file mode 100644 index 0000000..68f8e49 --- /dev/null +++ b/ntfy/publisher.go @@ -0,0 +1,16 @@ +package ntfy + +import ( + "net/http" + "strings" +) + +func SendMessage(message string, endpoint string, topics []string) error { + for _, topic := range topics { + _, err := http.Post(endpoint+"/"+topic, "text/plain", strings.NewReader(message)) + if err != nil { + return err + } + } + return nil +} diff --git a/ntfy/watcher.go b/ntfy/watcher.go new file mode 100644 index 0000000..2b3b5eb --- /dev/null +++ b/ntfy/watcher.go @@ -0,0 +1,96 @@ +package ntfy + +import ( + "bufio" + "encoding/json" + "log" + "net/http" + "net/url" + "path" + "time" +) + +type Message struct { + Id string `json:"id"` + Time int `json:"time"` + Message string `json:"message"` + Event string `json:"event"` +} + +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 + + sleepAndDecrementRetry := 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) + sleepAndDecrementRetry() + continue + } + + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + bytes := scanner.Bytes() + var msg Message + err := json.Unmarshal(bytes, &msg) + if err != nil { + log.Println("could not unmarshal message:", err) + continue + } + + if msg.Event == "keepalive" { + log.Println("received keepalive message") + continue + } + if msg.Event != "message" { + log.Println("received unknown event:", msg.Event) + continue + } + + log.Println("received notification:", msg) + notifications <- msg + retries = retryCount // reset retries + } + + if err := scanner.Err(); err != nil { + log.Println("error reading response body:", err) + sleepAndDecrementRetry() + } + } + }() + } + + 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..106b28b --- /dev/null +++ b/scheduler/scheduler.go @@ -0,0 +1,34 @@ +package scheduler + +import ( + "database/sql" + "log" + "time" + + "git.simponic.xyz/simponic/phoneassistant/args" + "github.com/go-co-op/gocron/v2" +) + +func StartScheduler(_dbConn *sql.DB, argv *args.Arguments) { + scheduler, err := gocron.NewScheduler() + if err != nil { + panic("could not create scheduler") + } + + _, err = scheduler.NewJob( + gocron.DurationJob( + 24*time.Hour, + ), + gocron.NewTask( + func(msg string) { + log.Println(msg) + }, + "it's a beautiful new day!", + ), + ) + if err != nil { + panic("could not create job") + } + + scheduler.Start() +} diff --git a/static/.DS_Store b/static/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e36d3e1899ddb1bda874d73e842ea8d32f77eedc GIT binary patch literal 6148 zcmeHK%}&BV5T4~nN{9&uO*l63;(;gx6%#KN@!-`OJ*YtnsExHtXpsXU=~$*$$BoK>)H-A6fc<90lZ8tuU~jV`1NylKDfUHZLItGIC@!?^8-y@5*byFCoK zx$?uV95v)H>_#fr)eb9fto>jtOrhMiw)e{lz4Aj1c=PBW&FjLZNtzznPk17`oS3aj$M{GQAJGq5xU zXn(L#2_1upMzwWdqe=iox<)F&zPkJ)+GId<3?>@Uf+BP(qD~d2#1N)-5IPU0q5$vBUBvM?2jFr|mERX7MoBe%=|Gce0QS`UkK{-6F`|Ia4zh#6o8{uKiv({!5+ zT#`9k=N3n2t&MtzNW Ca$w>B literal 0 HcmV?d00001 diff --git a/static/css/colors.css b/static/css/colors.css new file mode 100644 index 0000000..e40f80c --- /dev/null +++ b/static/css/colors.css @@ -0,0 +1,55 @@ +/* Colors inspired by "day/night-fox" schemes */ +:root { + /* Light mode colors */ + --background-color-light: #f6f2ee; /* base00 */ + --background-color-light-2: #dbd1dd; /* base01 */ + --text-color-light: #3d2b5a; /* base05 */ + --confirm-color-light: #396847; /* base0B */ + --link-color-light: #6e33ce; /* base0E */ + --container-bg-light: #f4ece6; /* base07 */ + --border-color-light: #2848a9; /* base0D */ + --error-color-light: #a5222f; /* base08 */ + + /* Dark mode colors */ + --background-color-dark: #192330; /* base00 */ + --background-color-dark-2: #212e3f; /* base01 */ + --text-color-dark: #cdcecf; /* base05 */ + --confirm-color-dark: #81b29a; /* base0B */ + --link-color-dark: #9d79d6; /* base0E */ + --container-bg-dark: #29394f; /* base02 */ + --border-color-dark: #719cd6; /* base0D */ + --error-color-dark: #c94f6d; /* base08 */ +} + + +[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/styles.css b/static/css/styles.css new file mode 100644 index 0000000..2ec823a --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,60 @@ +@import "/static/css/colors.css"; +@import "/static/css/form.css"; +@import "/static/css/table.css"; +@import "/static/css/chat.css"; + +@font-face { + font-family: 'GeistMono'; + src: url('/static/fonts/GeistMono-Medium.ttf') format('truetype'); +} + +* { + box-sizing: border-box; + font-family: GeistMono; +} + +html { + margin: 0; + padding: 0; + color: var(--text-color); +} + +body { + background-color: var(--background-color); + min-height: 100vh; +} + +hr { + border: 0; + border-top: 1px solid var(--text-color); + + margin: 20px 0; +} + +.container { + max-width: 1600px; + margin: auto; + background-color: var(--container-bg); + padding: 1rem; +} + + +a { + color: var(--link-color); + text-decoration: none; + font-weight: bold; +} +a:hover { + text-decoration: underline; +} + +.info { + margin-bottom: 1rem; + max-width: 600px; + + transition: opacity 0.3s; +} + +.info:hover { + opacity: 0.8; +} diff --git a/static/css/table.css b/static/css/table.css new file mode 100644 index 0000000..16da86d --- /dev/null +++ b/static/css/table.css @@ -0,0 +1,28 @@ +@import "/static/css/colors.css"; + +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; +} diff --git a/static/fonts/.DS_Store b/static/fonts/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..aa995978aad991b037b822f1fbbc5cf9cbece17b GIT binary patch literal 6148 zcmeHKyG{c^44j1&k!Vs-Q0@;9i9c9HL5Lz1KY&6&LJGNpq`!{O;S*u}0C75ys3_1_ zvS)YJzRgW>jscX#VRHe@08HtIc(E`xJ2xNMQ8I-{=QH+Lp~H@TKFa={u-a2x;U158 z9@MYMZO9(*ggH<4c(~qnck6ApW{v&eSNiA!>4q(_5A;t5gO32j328U3eU>0rBZw`r4JzT{O~^SmYYfnHAD%Zd6UV7kbpz+Wiv4uhf| A$p8QV literal 0 HcmV?d00001 diff --git a/static/fonts/GeistMono-Medium.ttf b/static/fonts/GeistMono-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4284eb47df050fe3c30f986bb249556208162140 GIT binary patch literal 78292 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�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 literal 0 HcmV?d00001 diff --git a/static/img/favicon.ico b/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..58c53c33bf7a06bcfd0a1044c7ea6fe5a0ee11dc GIT binary patch literal 591 zcmZ8dJ5Iwu5PjZ{WkQM-N)bAzL_)|;1)|w}%&$QFEK!04cSxBaxIqp;!vVMfh>CNV zDkU8fQkdD@I4G~!oj3DlH1iIR4|ex%4Gg})7>!2r&&{f;91Bn9N3GpKp)+4UEv_yH zy|bdblgWt6+brRB$l1|wNW>B?n7!2?b>@6PtQQ&e39Y1T;fR?~as?YP~qyQ5xf2BNIRkHFG|ZgzohM3VcVc_w(uwU)Gvz Op%qN}Y$rmKE&G32-84b~ literal 0 HcmV?d00001 diff --git a/static/js/components/formatDate.js b/static/js/components/formatDate.js new file mode 100644 index 0000000..a12f04f --- /dev/null +++ b/static/js/components/formatDate.js @@ -0,0 +1,7 @@ +const timeElements = document.querySelectorAll(".time"); +timeElements.forEach((timeElement) => { + const dateStr = timeElement.textContent.split(" ").slice(0, 3).join(" "); + const date = new Date(dateStr); + + timeElement.textContent = date.toLocaleString(); +}); diff --git a/static/js/components/infoBanners.js b/static/js/components/infoBanners.js new file mode 100644 index 0000000..6a19864 --- /dev/null +++ b/static/js/components/infoBanners.js @@ -0,0 +1,6 @@ +const infoBanners = document.querySelectorAll(".info"); +Array.from(infoBanners).forEach((infoBanner) => { + infoBanner.addEventListener("click", () => { + infoBanner.remove(); + }); +}); diff --git a/static/js/components/themeSwitcher.js b/static/js/components/themeSwitcher.js new file mode 100644 index 0000000..e5497f0 --- /dev/null +++ b/static/js/components/themeSwitcher.js @@ -0,0 +1,27 @@ +const THEMES = { + DARK: "DARK", + LIGHT: "LIGHT", +}; + +const flipFlopTheme = (theme) => + THEMES[theme] === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK; + +const themePickerText = { + DARK: "light mode.", + LIGHT: "dark mode.", +}; + +const themeSwitcher = document.getElementById("theme-switcher"); + +const setTheme = (theme) => { + themeSwitcher.textContent = `${themePickerText[theme]}`; + + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); +}; + +themeSwitcher.addEventListener("click", () => + setTheme(flipFlopTheme(document.documentElement.getAttribute("data-theme"))), +); + +setTheme(localStorage.getItem("theme") || THEMES.LIGHT); diff --git a/static/js/require.js b/static/js/require.js new file mode 100644 index 0000000..a4203f0 --- /dev/null +++ b/static/js/require.js @@ -0,0 +1,5 @@ +/** vim: et:ts=4:sw=4:sts=4 + * @license RequireJS 2.3.6 Copyright jQuery Foundation and other contributors. + * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE + */ +var requirejs,require,define;!function(global,setTimeout){var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.6",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){var i;if(e)for(i=0;ipage not found +

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

+ +

go back home

+ +{{ end }} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..4cd6b4c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ +{{ define "base" }} + + + + phoneassistant + + + + + + + + + + + + + + + +
+
+

phoneassistant

+ home. + | + light mode. +
+
+ {{ 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/templates/hello.html b/templates/hello.html new file mode 100644 index 0000000..d2311f5 --- /dev/null +++ b/templates/hello.html @@ -0,0 +1,3 @@ +{{ define "content" }} +hello from {{ .Service }}! +{{ end }} 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) +}