diff options
author | Elizabeth Hunt <elizabeth@simponic.xyz> | 2025-01-12 23:09:34 -0800 |
---|---|---|
committer | Elizabeth Hunt <elizabeth@simponic.xyz> | 2025-01-12 23:09:34 -0800 |
commit | 18a945aab9b3129b82076f633fab1d13ba28148e (patch) | |
tree | 09703cdc24992c42c5c14051e73e0b614280f5bd | |
download | phoneassistant-18a945aab9b3129b82076f633fab1d13ba28148e.tar.gz phoneassistant-18a945aab9b3129b82076f633fab1d13ba28148e.zip |
initial commit by simponic-infra
40 files changed, 1091 insertions, 0 deletions
diff --git a/.DS_Store b/.DS_Store Binary files differnew file mode 100644 index 0000000..c2ba3f0 --- /dev/null +++ b/.DS_Store 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" @@ -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 +) @@ -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= @@ -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 Binary files differnew file mode 100644 index 0000000..e36d3e1 --- /dev/null +++ b/static/.DS_Store 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 Binary files differnew file mode 100644 index 0000000..aa99597 --- /dev/null +++ b/static/fonts/.DS_Store diff --git a/static/fonts/GeistMono-Medium.ttf b/static/fonts/GeistMono-Medium.ttf Binary files differnew file mode 100644 index 0000000..4284eb4 --- /dev/null +++ b/static/fonts/GeistMono-Medium.ttf diff --git a/static/img/favicon.ico b/static/img/favicon.ico Binary files differnew file mode 100644 index 0000000..58c53c3 --- /dev/null +++ b/static/img/favicon.ico 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;i<e.length&&(!e[i]||!t(e[i],i,e));i+=1);}function eachReverse(e,t){var i;if(e)for(i=e.length-1;-1<i&&(!e[i]||!t(e[i],i,e));i-=1);}function hasProp(e,t){return hasOwn.call(e,t)}function getOwn(e,t){return hasProp(e,t)&&e[t]}function eachProp(e,t){var i;for(i in e)if(hasProp(e,i)&&t(e[i],i))break}function mixin(i,e,r,n){return e&&eachProp(e,function(e,t){!r&&hasProp(i,t)||(!n||"object"!=typeof e||!e||isArray(e)||isFunction(e)||e instanceof RegExp?i[t]=e:(i[t]||(i[t]={}),mixin(i[t],e,r,n)))}),i}function bind(e,t){return function(){return t.apply(e,arguments)}}function scripts(){return document.getElementsByTagName("script")}function defaultOnError(e){throw e}function getGlobal(e){if(!e)return e;var t=global;return each(e.split("."),function(e){t=t[e]}),t}function makeError(e,t,i,r){var n=new Error(t+"\nhttps://requirejs.org/docs/errors.html#"+e);return n.requireType=e,n.requireModules=r,i&&(n.originalError=i),n}if(void 0===define){if(void 0!==requirejs){if(isFunction(requirejs))return;cfg=requirejs,requirejs=void 0}void 0===require||isFunction(require)||(cfg=require,require=void 0),req=requirejs=function(e,t,i,r){var n,o,a=defContextName;return isArray(e)||"string"==typeof e||(o=e,isArray(t)?(e=t,t=i,i=r):e=[]),o&&o.context&&(a=o.context),(n=getOwn(contexts,a))||(n=contexts[a]=req.s.newContext(a)),o&&n.configure(o),n.require(e,t,i)},req.config=function(e){return req(e)},req.nextTick=void 0!==setTimeout?function(e){setTimeout(e,4)}:function(e){e()},require||(require=req),req.version=version,req.jsExtRegExp=/^\/|:|\?|\.js$/,req.isBrowser=isBrowser,s=req.s={contexts:contexts,newContext:newContext},req({}),each(["toUrl","undef","defined","specified"],function(t){req[t]=function(){var e=contexts[defContextName];return e.require[t].apply(e,arguments)}}),isBrowser&&(head=s.head=document.getElementsByTagName("head")[0],baseElement=document.getElementsByTagName("base")[0],baseElement&&(head=s.head=baseElement.parentNode)),req.onError=defaultOnError,req.createNode=function(e,t,i){var r=e.xhtml?document.createElementNS("http://www.w3.org/1999/xhtml","html:script"):document.createElement("script");return r.type=e.scriptType||"text/javascript",r.charset="utf-8",r.async=!0,r},req.load=function(t,i,r){var e,n=t&&t.config||{};if(isBrowser)return(e=req.createNode(n,i,r)).setAttribute("data-requirecontext",t.contextName),e.setAttribute("data-requiremodule",i),!e.attachEvent||e.attachEvent.toString&&e.attachEvent.toString().indexOf("[native code")<0||isOpera?(e.addEventListener("load",t.onScriptLoad,!1),e.addEventListener("error",t.onScriptError,!1)):(useInteractive=!0,e.attachEvent("onreadystatechange",t.onScriptLoad)),e.src=r,n.onNodeCreated&&n.onNodeCreated(e,n,i,r),currentlyAddingScript=e,baseElement?head.insertBefore(e,baseElement):head.appendChild(e),currentlyAddingScript=null,e;if(isWebWorker)try{setTimeout(function(){},0),importScripts(r),t.completeLoad(i)}catch(e){t.onError(makeError("importscripts","importScripts failed for "+i+" at "+r,e,[i]))}},isBrowser&&!cfg.skipDataMain&&eachReverse(scripts(),function(e){if(head||(head=e.parentNode),dataMain=e.getAttribute("data-main"))return mainScript=dataMain,cfg.baseUrl||-1!==mainScript.indexOf("!")||(mainScript=(src=mainScript.split("/")).pop(),subPath=src.length?src.join("/")+"/":"./",cfg.baseUrl=subPath),mainScript=mainScript.replace(jsSuffixRegExp,""),req.jsExtRegExp.test(mainScript)&&(mainScript=dataMain),cfg.deps=cfg.deps?cfg.deps.concat(mainScript):[mainScript],!0}),define=function(e,i,t){var r,n;"string"!=typeof e&&(t=i,i=e,e=null),isArray(i)||(t=i,i=null),!i&&isFunction(t)&&(i=[],t.length&&(t.toString().replace(commentRegExp,commentReplace).replace(cjsRequireRegExp,function(e,t){i.push(t)}),i=(1===t.length?["require"]:["require","exports","module"]).concat(i))),useInteractive&&(r=currentlyAddingScript||getInteractiveScript())&&(e||(e=r.getAttribute("data-requiremodule")),n=contexts[r.getAttribute("data-requirecontext")]),n?(n.defQueue.push([e,i,t]),n.defQueueMap[e]=!0):globalDefQueue.push([e,i,t])},define.amd={jQuery:!0},req.exec=function(text){return eval(text)},req(cfg)}function newContext(u){var i,e,l,c,d,g={waitSeconds:7,baseUrl:"./",paths:{},bundles:{},pkgs:{},shim:{},config:{}},p={},f={},r={},h=[],m={},n={},v={},x=1,b=1;function q(e,t,i){var r,n,o,a,s,u,c,d,p,f,l=t&&t.split("/"),h=g.map,m=h&&h["*"];if(e&&(u=(e=e.split("/")).length-1,g.nodeIdCompat&&jsSuffixRegExp.test(e[u])&&(e[u]=e[u].replace(jsSuffixRegExp,"")),"."===e[0].charAt(0)&&l&&(e=l.slice(0,l.length-1).concat(e)),function(e){var t,i;for(t=0;t<e.length;t++)if("."===(i=e[t]))e.splice(t,1),t-=1;else if(".."===i){if(0===t||1===t&&".."===e[2]||".."===e[t-1])continue;0<t&&(e.splice(t-1,2),t-=2)}}(e),e=e.join("/")),i&&h&&(l||m)){e:for(o=(n=e.split("/")).length;0<o;o-=1){if(s=n.slice(0,o).join("/"),l)for(a=l.length;0<a;a-=1)if((r=getOwn(h,l.slice(0,a).join("/")))&&(r=getOwn(r,s))){c=r,d=o;break e}!p&&m&&getOwn(m,s)&&(p=getOwn(m,s),f=o)}!c&&p&&(c=p,d=f),c&&(n.splice(0,d,c),e=n.join("/"))}return getOwn(g.pkgs,e)||e}function E(t){isBrowser&&each(scripts(),function(e){if(e.getAttribute("data-requiremodule")===t&&e.getAttribute("data-requirecontext")===l.contextName)return e.parentNode.removeChild(e),!0})}function w(e){var t=getOwn(g.paths,e);if(t&&isArray(t)&&1<t.length)return t.shift(),l.require.undef(e),l.makeRequire(null,{skipMap:!0})([e]),!0}function y(e){var t,i=e?e.indexOf("!"):-1;return-1<i&&(t=e.substring(0,i),e=e.substring(i+1,e.length)),[t,e]}function S(e,t,i,r){var n,o,a,s,u=null,c=t?t.name:null,d=e,p=!0,f="";return e||(p=!1,e="_@r"+(x+=1)),u=(s=y(e))[0],e=s[1],u&&(u=q(u,c,r),o=getOwn(m,u)),e&&(u?f=i?e:o&&o.normalize?o.normalize(e,function(e){return q(e,c,r)}):-1===e.indexOf("!")?q(e,c,r):e:(u=(s=y(f=q(e,c,r)))[0],f=s[1],i=!0,n=l.nameToUrl(f))),{prefix:u,name:f,parentMap:t,unnormalized:!!(a=!u||o||i?"":"_unnormalized"+(b+=1)),url:n,originalName:d,isDefine:p,id:(u?u+"!"+f:f)+a}}function k(e){var t=e.id,i=getOwn(p,t);return i||(i=p[t]=new l.Module(e)),i}function M(e,t,i){var r=e.id,n=getOwn(p,r);!hasProp(m,r)||n&&!n.defineEmitComplete?(n=k(e)).error&&"error"===t?i(n.error):n.on(t,i):"defined"===t&&i(m[r])}function O(i,e){var t=i.requireModules,r=!1;e?e(i):(each(t,function(e){var t=getOwn(p,e);t&&(t.error=i,t.events.error&&(r=!0,t.emit("error",i)))}),r||req.onError(i))}function j(){globalDefQueue.length&&(each(globalDefQueue,function(e){var t=e[0];"string"==typeof t&&(l.defQueueMap[t]=!0),h.push(e)}),globalDefQueue=[])}function P(e){delete p[e],delete f[e]}function R(){var e,r,t=1e3*g.waitSeconds,n=t&&l.startTime+t<(new Date).getTime(),o=[],a=[],s=!1,u=!0;if(!i){if(i=!0,eachProp(f,function(e){var t=e.map,i=t.id;if(e.enabled&&(t.isDefine||a.push(e),!e.error))if(!e.inited&&n)w(i)?s=r=!0:(o.push(i),E(i));else if(!e.inited&&e.fetched&&t.isDefine&&(s=!0,!t.prefix))return u=!1}),n&&o.length)return(e=makeError("timeout","Load timeout for modules: "+o,null,o)).contextName=l.contextName,O(e);u&&each(a,function(e){!function n(o,a,s){var e=o.map.id;o.error?o.emit("error",o.error):(a[e]=!0,each(o.depMaps,function(e,t){var i=e.id,r=getOwn(p,i);!r||o.depMatched[t]||s[i]||(getOwn(a,i)?(o.defineDep(t,m[i]),o.check()):n(r,a,s))}),s[e]=!0)}(e,{},{})}),n&&!r||!s||!isBrowser&&!isWebWorker||d||(d=setTimeout(function(){d=0,R()},50)),i=!1}}function a(e){hasProp(m,e[0])||k(S(e[0],null,!0)).init(e[1],e[2])}function o(e,t,i,r){e.detachEvent&&!isOpera?r&&e.detachEvent(r,t):e.removeEventListener(i,t,!1)}function s(e){var t=e.currentTarget||e.srcElement;return o(t,l.onScriptLoad,"load","onreadystatechange"),o(t,l.onScriptError,"error"),{node:t,id:t&&t.getAttribute("data-requiremodule")}}function T(){var e;for(j();h.length;){if(null===(e=h.shift())[0])return O(makeError("mismatch","Mismatched anonymous define() module: "+e[e.length-1]));a(e)}l.defQueueMap={}}return c={require:function(e){return e.require?e.require:e.require=l.makeRequire(e.map)},exports:function(e){if(e.usingExports=!0,e.map.isDefine)return e.exports?m[e.map.id]=e.exports:e.exports=m[e.map.id]={}},module:function(e){return e.module?e.module:e.module={id:e.map.id,uri:e.map.url,config:function(){return getOwn(g.config,e.map.id)||{}},exports:e.exports||(e.exports={})}}},(e=function(e){this.events=getOwn(r,e.id)||{},this.map=e,this.shim=getOwn(g.shim,e.id),this.depExports=[],this.depMaps=[],this.depMatched=[],this.pluginMaps={},this.depCount=0}).prototype={init:function(e,t,i,r){r=r||{},this.inited||(this.factory=t,i?this.on("error",i):this.events.error&&(i=bind(this,function(e){this.emit("error",e)})),this.depMaps=e&&e.slice(0),this.errback=i,this.inited=!0,this.ignore=r.ignore,r.enabled||this.enabled?this.enable():this.check())},defineDep:function(e,t){this.depMatched[e]||(this.depMatched[e]=!0,this.depCount-=1,this.depExports[e]=t)},fetch:function(){if(!this.fetched){this.fetched=!0,l.startTime=(new Date).getTime();var e=this.map;if(!this.shim)return e.prefix?this.callPlugin():this.load();l.makeRequire(this.map,{enableBuildCallback:!0})(this.shim.deps||[],bind(this,function(){return e.prefix?this.callPlugin():this.load()}))}},load:function(){var e=this.map.url;n[e]||(n[e]=!0,l.load(this.map.id,e))},check:function(){if(this.enabled&&!this.enabling){var t,e,i=this.map.id,r=this.depExports,n=this.exports,o=this.factory;if(this.inited){if(this.error)this.emit("error",this.error);else if(!this.defining){if(this.defining=!0,this.depCount<1&&!this.defined){if(isFunction(o)){if(this.events.error&&this.map.isDefine||req.onError!==defaultOnError)try{n=l.execCb(i,o,r,n)}catch(e){t=e}else n=l.execCb(i,o,r,n);if(this.map.isDefine&&void 0===n&&((e=this.module)?n=e.exports:this.usingExports&&(n=this.exports)),t)return t.requireMap=this.map,t.requireModules=this.map.isDefine?[this.map.id]:null,t.requireType=this.map.isDefine?"define":"require",O(this.error=t)}else n=o;if(this.exports=n,this.map.isDefine&&!this.ignore&&(m[i]=n,req.onResourceLoad)){var a=[];each(this.depMaps,function(e){a.push(e.normalizedMap||e)}),req.onResourceLoad(l,this.map,a)}P(i),this.defined=!0}this.defining=!1,this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else hasProp(l.defQueueMap,i)||this.fetch()}},callPlugin:function(){var u=this.map,c=u.id,e=S(u.prefix);this.depMaps.push(e),M(e,"defined",bind(this,function(e){var o,t,i,r=getOwn(v,this.map.id),n=this.map.name,a=this.map.parentMap?this.map.parentMap.name:null,s=l.makeRequire(u.parentMap,{enableBuildCallback:!0});return this.map.unnormalized?(e.normalize&&(n=e.normalize(n,function(e){return q(e,a,!0)})||""),M(t=S(u.prefix+"!"+n,this.map.parentMap,!0),"defined",bind(this,function(e){this.map.normalizedMap=t,this.init([],function(){return e},null,{enabled:!0,ignore:!0})})),void((i=getOwn(p,t.id))&&(this.depMaps.push(t),this.events.error&&i.on("error",bind(this,function(e){this.emit("error",e)})),i.enable()))):r?(this.map.url=l.nameToUrl(r),void this.load()):((o=bind(this,function(e){this.init([],function(){return e},null,{enabled:!0})})).error=bind(this,function(e){this.inited=!0,(this.error=e).requireModules=[c],eachProp(p,function(e){0===e.map.id.indexOf(c+"_unnormalized")&&P(e.map.id)}),O(e)}),o.fromText=bind(this,function(e,t){var i=u.name,r=S(i),n=useInteractive;t&&(e=t),n&&(useInteractive=!1),k(r),hasProp(g.config,c)&&(g.config[i]=g.config[c]);try{req.exec(e)}catch(e){return O(makeError("fromtexteval","fromText eval for "+c+" failed: "+e,e,[c]))}n&&(useInteractive=!0),this.depMaps.push(r),l.completeLoad(i),s([i],o)}),void e.load(u.name,s,o,g))})),l.enable(e,this),this.pluginMaps[e.id]=e},enable:function(){(f[this.map.id]=this).enabled=!0,this.enabling=!0,each(this.depMaps,bind(this,function(e,t){var i,r,n;if("string"==typeof e){if(e=S(e,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap),this.depMaps[t]=e,n=getOwn(c,e.id))return void(this.depExports[t]=n(this));this.depCount+=1,M(e,"defined",bind(this,function(e){this.undefed||(this.defineDep(t,e),this.check())})),this.errback?M(e,"error",bind(this,this.errback)):this.events.error&&M(e,"error",bind(this,function(e){this.emit("error",e)}))}i=e.id,r=p[i],hasProp(c,i)||!r||r.enabled||l.enable(e,this)})),eachProp(this.pluginMaps,bind(this,function(e){var t=getOwn(p,e.id);t&&!t.enabled&&l.enable(e,this)})),this.enabling=!1,this.check()},on:function(e,t){var i=this.events[e];i||(i=this.events[e]=[]),i.push(t)},emit:function(e,t){each(this.events[e],function(e){e(t)}),"error"===e&&delete this.events[e]}},(l={config:g,contextName:u,registry:p,defined:m,urlFetched:n,defQueue:h,defQueueMap:{},Module:e,makeModuleMap:S,nextTick:req.nextTick,onError:O,configure:function(e){if(e.baseUrl&&"/"!==e.baseUrl.charAt(e.baseUrl.length-1)&&(e.baseUrl+="/"),"string"==typeof e.urlArgs){var i=e.urlArgs;e.urlArgs=function(e,t){return(-1===t.indexOf("?")?"?":"&")+i}}var r=g.shim,n={paths:!0,bundles:!0,config:!0,map:!0};eachProp(e,function(e,t){n[t]?(g[t]||(g[t]={}),mixin(g[t],e,!0,!0)):g[t]=e}),e.bundles&&eachProp(e.bundles,function(e,t){each(e,function(e){e!==t&&(v[e]=t)})}),e.shim&&(eachProp(e.shim,function(e,t){isArray(e)&&(e={deps:e}),!e.exports&&!e.init||e.exportsFn||(e.exportsFn=l.makeShimExports(e)),r[t]=e}),g.shim=r),e.packages&&each(e.packages,function(e){var t;t=(e="string"==typeof e?{name:e}:e).name,e.location&&(g.paths[t]=e.location),g.pkgs[t]=e.name+"/"+(e.main||"main").replace(currDirRegExp,"").replace(jsSuffixRegExp,"")}),eachProp(p,function(e,t){e.inited||e.map.unnormalized||(e.map=S(t,null,!0))}),(e.deps||e.callback)&&l.require(e.deps||[],e.callback)},makeShimExports:function(t){return function(){var e;return t.init&&(e=t.init.apply(global,arguments)),e||t.exports&&getGlobal(t.exports)}},makeRequire:function(o,a){function s(e,t,i){var r,n;return a.enableBuildCallback&&t&&isFunction(t)&&(t.__requireJsBuild=!0),"string"==typeof e?isFunction(t)?O(makeError("requireargs","Invalid require call"),i):o&&hasProp(c,e)?c[e](p[o.id]):req.get?req.get(l,e,o,s):(r=S(e,o,!1,!0).id,hasProp(m,r)?m[r]:O(makeError("notloaded",'Module name "'+r+'" has not been loaded yet for context: '+u+(o?"":". Use require([])")))):(T(),l.nextTick(function(){T(),(n=k(S(null,o))).skipMap=a.skipMap,n.init(e,t,i,{enabled:!0}),R()}),s)}return a=a||{},mixin(s,{isBrowser:isBrowser,toUrl:function(e){var t,i=e.lastIndexOf("."),r=e.split("/")[0];return-1!==i&&(!("."===r||".."===r)||1<i)&&(t=e.substring(i,e.length),e=e.substring(0,i)),l.nameToUrl(q(e,o&&o.id,!0),t,!0)},defined:function(e){return hasProp(m,S(e,o,!1,!0).id)},specified:function(e){return e=S(e,o,!1,!0).id,hasProp(m,e)||hasProp(p,e)}}),o||(s.undef=function(i){j();var e=S(i,o,!0),t=getOwn(p,i);t.undefed=!0,E(i),delete m[i],delete n[e.url],delete r[i],eachReverse(h,function(e,t){e[0]===i&&h.splice(t,1)}),delete l.defQueueMap[i],t&&(t.events.defined&&(r[i]=t.events),P(i))}),s},enable:function(e){getOwn(p,e.id)&&k(e).enable()},completeLoad:function(e){var t,i,r,n=getOwn(g.shim,e)||{},o=n.exports;for(j();h.length;){if(null===(i=h.shift())[0]){if(i[0]=e,t)break;t=!0}else i[0]===e&&(t=!0);a(i)}if(l.defQueueMap={},r=getOwn(p,e),!t&&!hasProp(m,e)&&r&&!r.inited){if(!(!g.enforceDefine||o&&getGlobal(o)))return w(e)?void 0:O(makeError("nodefine","No define call for "+e,null,[e]));a([e,n.deps||[],n.exportsFn])}R()},nameToUrl:function(e,t,i){var r,n,o,a,s,u,c=getOwn(g.pkgs,e);if(c&&(e=c),u=getOwn(v,e))return l.nameToUrl(u,t,i);if(req.jsExtRegExp.test(e))a=e+(t||"");else{for(r=g.paths,o=(n=e.split("/")).length;0<o;o-=1)if(s=getOwn(r,n.slice(0,o).join("/"))){isArray(s)&&(s=s[0]),n.splice(0,o,s);break}a=n.join("/"),a=("/"===(a+=t||(/^data\:|^blob\:|\?/.test(a)||i?"":".js")).charAt(0)||a.match(/^[\w\+\.\-]+:/)?"":g.baseUrl)+a}return g.urlArgs&&!/^blob\:/.test(a)?a+g.urlArgs(e,a):a},load:function(e,t){req.load(l,e,t)},execCb:function(e,t,i,r){return t.apply(r,i)},onScriptLoad:function(e){if("load"===e.type||readyRegExp.test((e.currentTarget||e.srcElement).readyState)){interactiveScript=null;var t=s(e);l.completeLoad(t.id)}},onScriptError:function(e){var i=s(e);if(!w(i.id)){var r=[];return eachProp(p,function(e,t){0!==t.indexOf("_@r")&&each(e.depMaps,function(e){if(e.id===i.id)return r.push(t),!0})}),O(makeError("scripterror",'Script error for "'+i.id+(r.length?'", needed by: '+r.join(", "):'"'),e,[i.id]))}}}).require=l.makeRequire(),l}function getInteractiveScript(){return interactiveScript&&"interactive"===interactiveScript.readyState||eachReverse(scripts(),function(e){if("interactive"===e.readyState)return interactiveScript=e}),interactiveScript}}(this,"undefined"==typeof setTimeout?void 0:setTimeout);
\ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 0000000..b5e6249 --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,6 @@ +const scripts = [ + "/static/js/components/themeSwitcher.js", + "/static/js/components/formatDate.js", + "/static/js/components/infoBanners.js", +]; +requirejs(scripts); diff --git a/static/js/util/setThemeBeforeRender.js b/static/js/util/setThemeBeforeRender.js new file mode 100644 index 0000000..b025cce --- /dev/null +++ b/static/js/util/setThemeBeforeRender.js @@ -0,0 +1,11 @@ +const preferredMode = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "DARK" + : "LIGHT"; + +// sets theme before rendering & jquery loaded to prevent flashing of uninitialized theme +// (ugly white background) +localStorage.setItem("theme", localStorage.getItem("theme") || preferredMode); +document.documentElement.setAttribute( + "data-theme", + localStorage.getItem("theme"), +); diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..5210bfb --- /dev/null +++ b/templates/404.html @@ -0,0 +1,7 @@ +{{ define "content" }} +<h1>page not found</h1> +<p><em>but hey, at least you found our witty 404 page. that's something, right?</em></p> + +<p><a href="/">go back home</a></p> + +{{ end }} 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" }} +<!DOCTYPE html> +<html> + <head> + <title>phoneassistant</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"> + <link href="/static/img/favicon.ico" rel="icon" type="image/x-icon"> + <link rel="stylesheet" type="text/css" href="/static/css/styles.css"> + + <meta property="og:type" content="website"> + <meta property="og:url" content="https://phoneassistant.simponic.xyz"> + <meta property="og:title" content="phoneassistant"> + <meta property="og:description" content="phoneassistant"> + <meta property="og:image:secure_url" content="https://phoneassistant.simponic.xyz/static/img/favicon.ico"> + <meta property="og:image:secure" content="https://phoneassistant.simponic.xyz/static/img/favicon.ico"> + + <script src="/static/js/util/setThemeBeforeRender.js"></script> + </head> + <body> + <div id="content" class="container"> + <div> + <h1>phoneassistant</h1> + <a href="/">home.</a> + <span> | </span> + <a href="javascript:void(0);" id="theme-switcher">light mode.</a> + </div> + <hr> + {{ template "content" . }} + </div> + <script data-main="/static/js/script.js" src="/static/js/require.js"></script> + </body> +</html> +{{ end }} diff --git a/templates/base_empty.html b/templates/base_empty.html new file mode 100644 index 0000000..6191ab9 --- /dev/null +++ b/templates/base_empty.html @@ -0,0 +1,3 @@ +{{ define "base" }} + {{ template "content" . }} +{{ end }}
\ No newline at end of file diff --git a/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) +} |