summaryrefslogtreecommitdiff
path: root/template
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2025-01-02 16:23:43 -0800
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2025-01-02 16:23:57 -0800
commitb19321bab542de35564127dc77781af44252bcb9 (patch)
tree37d12f7867124e8440ee46ae9f903b242e8d62af /template
parent321cd40fba0956e3aa697f9e7c7006159a032f58 (diff)
downloadoldinfra-b19321bab542de35564127dc77781af44252bcb9.tar.gz
oldinfra-b19321bab542de35564127dc77781af44252bcb9.zip
create a base template :) and use it for a new service
Diffstat (limited to 'template')
-rw-r--r--template/.dockerignore5
-rw-r--r--template/.drone.yml49
-rw-r--r--template/.gitignore3
-rw-r--r--template/.tool-versions1
-rw-r--r--template/Dockerfile13
-rw-r--r--template/README.md3
-rw-r--r--template/api/api.go91
-rw-r--r--template/api/api_test.go89
-rw-r--r--template/api/template/template.go73
-rw-r--r--template/api/types/types.go26
-rw-r--r--template/args/args.go82
-rw-r--r--template/database/conn.go17
-rw-r--r--template/database/migrate.go39
-rw-r--r--template/docker-compose.yml18
-rw-r--r--template/go.mod9
-rw-r--r--template/main.go63
-rw-r--r--template/scheduler/scheduler.go34
-rw-r--r--template/static/css/colors.css51
-rw-r--r--template/static/css/form.css42
-rw-r--r--template/static/css/styles.css13
-rw-r--r--template/static/css/table.css28
-rw-r--r--template/templates/404.html7
-rw-r--r--template/templates/base.html16
-rw-r--r--template/templates/base_empty.html3
-rw-r--r--template/templates/hello.html3
-rw-r--r--template/utils/random_id.go16
26 files changed, 794 insertions, 0 deletions
diff --git a/template/.dockerignore b/template/.dockerignore
new file mode 100644
index 0000000..7dddab3
--- /dev/null
+++ b/template/.dockerignore
@@ -0,0 +1,5 @@
+.env
+{{ service }}
+Dockerfile
+*.db
+.drone.yml
diff --git a/template/.drone.yml b/template/.drone.yml
new file mode 100644
index 0000000..92378f4
--- /dev/null
+++ b/template/.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: {{ service_repo }}
+ - name: ssh
+ image: appleboy/drone-ssh
+ settings:
+ host: {{ service_host }}.simponic.xyz
+ username: root
+ key:
+ from_secret: cd_ssh_key
+ port: 22
+ command_timeout: 2m
+ script:
+ - systemctl restart docker-compose@{{ service }}
+
+trigger:
+ branch:
+ - main
+ event:
+ - push
diff --git a/template/.gitignore b/template/.gitignore
new file mode 100644
index 0000000..059b6c1
--- /dev/null
+++ b/template/.gitignore
@@ -0,0 +1,3 @@
+*.env
+{{ service }}
+*.db
diff --git a/template/.tool-versions b/template/.tool-versions
new file mode 100644
index 0000000..db5d8ee
--- /dev/null
+++ b/template/.tool-versions
@@ -0,0 +1 @@
+golang 1.23.4
diff --git a/template/Dockerfile b/template/Dockerfile
new file mode 100644
index 0000000..87a2422
--- /dev/null
+++ b/template/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/{{ service }}
+
+EXPOSE 8080
+
+CMD ["/app/{{ service }}", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/{{ service }}.db", "--static-path", "/app/static", "--scheduler"]
diff --git a/template/README.md b/template/README.md
new file mode 100644
index 0000000..c7477e4
--- /dev/null
+++ b/template/README.md
@@ -0,0 +1,3 @@
+## {{ service_title }}
+
+this is a simponic service for {{ service }}
diff --git a/template/api/api.go b/template/api/api.go
new file mode 100644
index 0000000..4d278da
--- /dev/null
+++ b/template/api/api.go
@@ -0,0 +1,91 @@
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "{{ service_repo }}/api/template"
+ "{{ service_repo }}/api/types"
+ "{{ service_repo }}/args"
+ "{{ service_repo }}/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"] = "{{ service }}"
+ templateFile := "hello.html"
+ LogRequestContinuation(requestContext, r, w)(template.TemplateContinuation(templateFile, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ return mux
+}
diff --git a/template/api/api_test.go b/template/api/api_test.go
new file mode 100644
index 0000000..9ad8f92
--- /dev/null
+++ b/template/api/api_test.go
@@ -0,0 +1,89 @@
+package api_test
+
+import (
+ "database/sql"
+ "io"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "{{ service_repo }}/api"
+ "{{ service_repo }}/args"
+ "{{ service_repo }}/database"
+ "{{ service_repo }}/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/template/api/template/template.go b/template/api/template/template.go
new file mode 100644
index 0000000..9190f29
--- /dev/null
+++ b/template/api/template/template.go
@@ -0,0 +1,73 @@
+package template
+
+import (
+ "bytes"
+ "errors"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+
+ "{{ service_repo }}/api/types"
+)
+
+func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
+ templatePath := context.Args.TemplatePath
+ basePath := templatePath + "/base_empty.html"
+ if showBaseHtml {
+ basePath = templatePath + "/base.html"
+ }
+
+ templateLocation := templatePath + "/" + templateName
+ tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+
+ dataPtr := context.TemplateData
+ if dataPtr == nil {
+ dataPtr = &map[string]interface{}{}
+ }
+
+ data := *dataPtr
+
+ var buffer bytes.Buffer
+ err = tmpl.ExecuteTemplate(&buffer, "base", data)
+
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+ return buffer, nil
+}
+
+func TemplateContinuation(path string, showBase bool) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ html, err := renderTemplate(context, path, true)
+ if errors.Is(err, os.ErrNotExist) {
+ resp.WriteHeader(404)
+ html, err = renderTemplate(context, "404.html", true)
+ if err != nil {
+ log.Println("error rendering 404 template", err)
+ resp.WriteHeader(500)
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return failure(context, req, resp)
+ }
+
+ if err != nil {
+ log.Println("error rendering template", err)
+ resp.WriteHeader(500)
+ resp.Write([]byte("error rendering template"))
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/template/api/types/types.go b/template/api/types/types.go
new file mode 100644
index 0000000..d2a91a3
--- /dev/null
+++ b/template/api/types/types.go
@@ -0,0 +1,26 @@
+package types
+
+import (
+ "database/sql"
+ "net/http"
+ "time"
+
+ "{{ service_repo }}/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/template/args/args.go b/template/args/args.go
new file mode 100644
index 0000000..6e4aff1
--- /dev/null
+++ b/template/args/args.go
@@ -0,0 +1,82 @@
+package args
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "sync"
+)
+
+type Arguments struct {
+ DatabasePath string
+ TemplatePath string
+ StaticPath string
+
+ Migrate bool
+ Scheduler 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", "./{{ service }}.db", "Path to the SQLite database")
+
+ templatePath := flag.String("template-path", "./templates", "Path to the template directory")
+ staticPath := flag.String("static-path", "./static", "Path to the static directory")
+
+ scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron")
+ migrate := flag.Bool("migrate", false, "Run the migrations")
+
+ 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,
+ }
+ err := validateArgs(args)
+ if err != nil {
+ return nil, err
+ }
+
+ return args, nil
+}
diff --git a/template/database/conn.go b/template/database/conn.go
new file mode 100644
index 0000000..be27586
--- /dev/null
+++ b/template/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/template/database/migrate.go b/template/database/migrate.go
new file mode 100644
index 0000000..8b8712f
--- /dev/null
+++ b/template/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(`DO NOTHING;`)
+ 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/template/docker-compose.yml b/template/docker-compose.yml
new file mode 100644
index 0000000..655159d
--- /dev/null
+++ b/template/docker-compose.yml
@@ -0,0 +1,18 @@
+version: "3"
+
+services:
+ api:
+ restart: always
+ image: {{ service_repo }}
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"]
+ interval: 5s
+ timeout: 10s
+ retries: 5
+ env_file: .env
+ volumes:
+ - ./db:/app/db
+ - ./templates:/app/templates
+ - ./static:/app/static
+ ports:
+ - "127.0.0.1:{{ service_port }}:8080"
diff --git a/template/go.mod b/template/go.mod
new file mode 100644
index 0000000..006e357
--- /dev/null
+++ b/template/go.mod
@@ -0,0 +1,9 @@
+module {{ service_repo }}
+
+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
+)
diff --git a/template/main.go b/template/main.go
new file mode 100644
index 0000000..6d2b657
--- /dev/null
+++ b/template/main.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+
+ "{{ service_repo }}/api"
+ "{{ service_repo }}/args"
+ "{{ service_repo }}/database"
+ "{{ service_repo }}/scheduler"
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
+
+ err := godotenv.Load()
+ if err != nil {
+ log.Println("could not load .env file:", err)
+ }
+
+ argv, err := args.GetArgs()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ dbConn := database.MakeConn(&argv.DatabasePath)
+ defer dbConn.Close()
+
+ if argv.Migrate {
+ _, err = database.Migrate(dbConn)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Println("database migrated successfully")
+ }
+
+ if argv.Scheduler {
+ go func() {
+ scheduler.StartScheduler(dbConn, argv)
+ }()
+ }
+
+ if argv.Server {
+ mux := api.MakeMux(argv, dbConn)
+ log.Println("🚀🚀 {{ service }} 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 {
+ select {} // block forever
+ }
+}
diff --git a/template/scheduler/scheduler.go b/template/scheduler/scheduler.go
new file mode 100644
index 0000000..7b4487a
--- /dev/null
+++ b/template/scheduler/scheduler.go
@@ -0,0 +1,34 @@
+package scheduler
+
+import (
+ "database/sql"
+ "log"
+ "time"
+
+ "{{ service_repo }}/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/template/static/css/colors.css b/template/static/css/colors.css
new file mode 100644
index 0000000..46357d9
--- /dev/null
+++ b/template/static/css/colors.css
@@ -0,0 +1,51 @@
+:root {
+ --background-color-light: #f4e8e9;
+ --background-color-light-2: #f5e6f3;
+ --text-color-light: #333;
+ --confirm-color-light: #91d9bb;
+ --link-color-light: #d291bc;
+ --container-bg-light: #fff7f87a;
+ --border-color-light: #692fcc;
+ --error-color-light: #a83254;
+
+ --background-color-dark: #333;
+ --background-color-dark-2: #2c2c2c;
+ --text-color-dark: #f4e8e9;
+ --confirm-color-dark: #4d8f73;
+ --link-color-dark: #b86b77;
+ --container-bg-dark: #424242ea;
+ --border-color-dark: #956ade;
+ --error-color-dark: #851736;
+}
+
+[data-theme="DARK"] {
+ --background-color: var(--background-color-dark);
+ --background-color-2: var(--background-color-dark-2);
+ --text-color: var(--text-color-dark);
+ --link-color: var(--link-color-dark);
+ --container-bg: var(--container-bg-dark);
+ --border-color: var(--border-color-dark);
+ --error-color: var(--error-color-dark);
+ --confirm-color: var(--confirm-color-dark);
+}
+
+[data-theme="LIGHT"] {
+ --background-color: var(--background-color-light);
+ --background-color-2: var(--background-color-light-2);
+ --text-color: var(--text-color-light);
+ --link-color: var(--link-color-light);
+ --container-bg: var(--container-bg-light);
+ --border-color: var(--border-color-light);
+ --error-color: var(--error-color-light);
+ --confirm-color: var(--confirm-color-light);
+}
+
+.error {
+ background-color: var(--error-color);
+ padding: 1rem;
+}
+
+.success {
+ background-color: var(--confirm-color);
+ padding: 1rem;
+}
diff --git a/template/static/css/form.css b/template/static/css/form.css
new file mode 100644
index 0000000..7ccd8db
--- /dev/null
+++ b/template/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/template/static/css/styles.css b/template/static/css/styles.css
new file mode 100644
index 0000000..6252898
--- /dev/null
+++ b/template/static/css/styles.css
@@ -0,0 +1,13 @@
+@import "/static/css/colors.css";
+@import "/static/css/form.css";
+@import "/static/css/table.css";
+
+* {
+ box-sizing: border-box;
+ color: var(--text-color);
+}
+
+body {
+ font-family: "Roboto", sans-serif;
+ background-color: var(--background-color);
+}
diff --git a/template/static/css/table.css b/template/static/css/table.css
new file mode 100644
index 0000000..16da86d
--- /dev/null
+++ b/template/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/template/templates/404.html b/template/templates/404.html
new file mode 100644
index 0000000..5210bfb
--- /dev/null
+++ b/template/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/template/templates/base.html b/template/templates/base.html
new file mode 100644
index 0000000..30a9c53
--- /dev/null
+++ b/template/templates/base.html
@@ -0,0 +1,16 @@
+{{ define "base" }}
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>{{ service_title }}</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+ <link rel="stylesheet" type="text/css" href="/static/css/styles.css">
+ </head>
+ <body data-theme="DARK">
+ <div id="content" class="container">
+ {{ template "content" . }}
+ </div>
+ </body>
+</html>
+{{ end }}
diff --git a/template/templates/base_empty.html b/template/templates/base_empty.html
new file mode 100644
index 0000000..6191ab9
--- /dev/null
+++ b/template/templates/base_empty.html
@@ -0,0 +1,3 @@
+{{ define "base" }}
+ {{ template "content" . }}
+{{ end }} \ No newline at end of file
diff --git a/template/templates/hello.html b/template/templates/hello.html
new file mode 100644
index 0000000..d2311f5
--- /dev/null
+++ b/template/templates/hello.html
@@ -0,0 +1,3 @@
+{{ define "content" }}
+hello from {{ .Service }}!
+{{ end }}
diff --git a/template/utils/random_id.go b/template/utils/random_id.go
new file mode 100644
index 0000000..1b03ec8
--- /dev/null
+++ b/template/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)
+}