initial commit by simponic-infra
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
18a945aab9
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
phoneassistant
|
||||||
|
Dockerfile
|
||||||
|
*.db
|
||||||
|
.drone.yml
|
49
.drone.yml
Normal file
49
.drone.yml
Normal file
@ -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
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.env
|
||||||
|
phoneassistant
|
||||||
|
*.db
|
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@ -0,0 +1 @@
|
|||||||
|
golang 1.23.4
|
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@ -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"]
|
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
## phoneassistant
|
||||||
|
|
||||||
|
this is a simponic service for phoneassistant
|
91
api/api.go
Normal file
91
api/api.go
Normal file
@ -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
|
||||||
|
}
|
89
api/api_test.go
Normal file
89
api/api_test.go
Normal file
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
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/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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/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
|
94
args/args.go
Normal file
94
args/args.go
Normal file
@ -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
|
||||||
|
}
|
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
|
||||||
|
}
|
39
database/migrate.go
Normal file
39
database/migrate.go
Normal file
@ -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
|
||||||
|
}
|
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@ -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"
|
16
go.mod
Normal file
16
go.mod
Normal file
@ -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
|
||||||
|
)
|
24
go.sum
Normal file
24
go.sum
Normal file
@ -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=
|
76
main.go
Normal file
76
main.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
16
ntfy/publisher.go
Normal file
16
ntfy/publisher.go
Normal file
@ -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
|
||||||
|
}
|
96
ntfy/watcher.go
Normal file
96
ntfy/watcher.go
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
34
scheduler/scheduler.go
Normal file
34
scheduler/scheduler.go
Normal file
@ -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()
|
||||||
|
}
|
BIN
static/.DS_Store
vendored
Normal file
BIN
static/.DS_Store
vendored
Normal file
Binary file not shown.
55
static/css/colors.css
Normal file
55
static/css/colors.css
Normal file
@ -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;
|
||||||
|
}
|
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;
|
||||||
|
}
|
60
static/css/styles.css
Normal file
60
static/css/styles.css
Normal file
@ -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;
|
||||||
|
}
|
28
static/css/table.css
Normal file
28
static/css/table.css
Normal file
@ -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;
|
||||||
|
}
|
BIN
static/fonts/.DS_Store
vendored
Normal file
BIN
static/fonts/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
static/fonts/GeistMono-Medium.ttf
Normal file
BIN
static/fonts/GeistMono-Medium.ttf
Normal file
Binary file not shown.
BIN
static/img/favicon.ico
Normal file
BIN
static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 591 B |
7
static/js/components/formatDate.js
Normal file
7
static/js/components/formatDate.js
Normal file
@ -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();
|
||||||
|
});
|
6
static/js/components/infoBanners.js
Normal file
6
static/js/components/infoBanners.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const infoBanners = document.querySelectorAll(".info");
|
||||||
|
Array.from(infoBanners).forEach((infoBanner) => {
|
||||||
|
infoBanner.addEventListener("click", () => {
|
||||||
|
infoBanner.remove();
|
||||||
|
});
|
||||||
|
});
|
27
static/js/components/themeSwitcher.js
Normal file
27
static/js/components/themeSwitcher.js
Normal file
@ -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);
|
5
static/js/require.js
Normal file
5
static/js/require.js
Normal file
File diff suppressed because one or more lines are too long
6
static/js/script.js
Normal file
6
static/js/script.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const scripts = [
|
||||||
|
"/static/js/components/themeSwitcher.js",
|
||||||
|
"/static/js/components/formatDate.js",
|
||||||
|
"/static/js/components/infoBanners.js",
|
||||||
|
];
|
||||||
|
requirejs(scripts);
|
11
static/js/util/setThemeBeforeRender.js
Normal file
11
static/js/util/setThemeBeforeRender.js
Normal file
@ -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"),
|
||||||
|
);
|
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 }}
|
34
templates/base.html
Normal file
34
templates/base.html
Normal file
@ -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 }}
|
3
templates/base_empty.html
Normal file
3
templates/base_empty.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "base" }}
|
||||||
|
{{ template "content" . }}
|
||||||
|
{{ end }}
|
3
templates/hello.html
Normal file
3
templates/hello.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
hello from {{ .Service }}!
|
||||||
|
{{ 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