diff --git a/create_service.sh b/create_service.sh new file mode 100755 index 0000000..92bb4b1 --- /dev/null +++ b/create_service.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +set -x +set -e + +DNS_ENDPOINT="https://hatecomputers.club/dns" +BIND_FILE="roles/nameservers/templates/db.simponic.xyz.j2" + +SERVICE_TITLE="phoneof simponic." +SERVICE="phoneof" +SERVICE_PORT="19191" +SERVICE_REPO="git.simponic.xyz/simponic/$SERVICE" +SERVICE_ORIGIN="git@git.simponic.xyz:simponic/$SERVICE" +INTERNAL="no" +SERVICE_HOST="ryo" +PACKAGE_PATH="$HOME/git/simponic/$SERVICE" +HATECOMPUTERS_API_KEY="$(pbaste)" + + +function render_template() { + cp -r template $PACKAGE_PATH + ggrep -rlZ "{{ service }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service }}/$SERVICE/g" + ggrep -rlZ "{{ service_host }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_host }}/$SERVICE_HOST/g" + ggrep -rlZ "{{ service_repo }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_repo }}/$(echo $SERVICE_REPO | sed 's/\//\\\//g')/g" + ggrep -rlZ "{{ service_port }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_port }}/$SERVICE_PORT/g" + ggrep -rlZ "{{ service_title }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_title }}/$SERVICE_TITLE/g" +} + +function test_and_commit_code() { + cd $PACKAGE_PATH + + go get + go mod tidy + go build + go test -v ./... + + echo "everything looks good, can you make a repo at https://$SERVICE_REPO (press enter when done)" + read + echo "cool. now, please sync it with drone (https://drone.internal.simponic.xyz/simponic/$SERVICE). (press enter when done)" + read + + git init + git add . + git commit -m "initial commit by simponic-infra" + git checkout -B main + git remote add origin $SERVICE_ORIGIN + git push -u origin main + cd - +} + +function add_dns_records() { + if [[ "$INTERNAL" = "yes" ]]; then + name="$SERVICE.internal.simponic.xyz." + content="$SERVICE_HOST.internal.simponic.xyz." + curl -H "Authorization: Bearer $HATECOMPUTERS_API_KEY" \ + -F "type=CNAME&name=$name&content=$content.internal.simponic.xyz.&ttl=43200&internal=on" \ + $DNS_ENDPOINT + else + name="$SERVICE.simponic.xyz." + content="$SERVICE_HOST.simponic.xyz." + gsed -i "s|;; CNAME Records|;; CNAME Records\n$name\t43200\tIN\tCNAME\t$content|" $BIND_FILE + fi +} + +function add_nginx_config() { + endpoint="$SERVICE.simponic.xyz" + destination="roles/webservers/files/$SERVICE_HOST" + if [[ $INTERNAL = "yes" ]]; then + ednpoint="$SERVICE.internal.simponic.xyz" + destination="roles/private/files/$SERVICE_HOST" + else + mkdir -p $destination + + echo "server { + listen 443 ssl; + server_name headscale.simponic.xyz; + + ssl_certificate /etc/letsencrypt/live/$endpoint/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/$endpoint/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/$endpoint/fullchain.pem; + + ssl_session_cache shared:SSL:50m; + ssl_session_timeout 5m; + ssl_stapling on; + ssl_stapling_verify on; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers \"ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4\"; + + ssl_dhparam /etc/nginx/dhparams.pem; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass https://127.0.0.1:$SERVICE_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection \"upgrade\"; + proxy_set_header Host \$server_name; + proxy_redirect http:// https://; + proxy_buffering off; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$http_x_forwarded_proto; + add_header Strict-Transport-Security \"max-age=15552000; includeSubDomains\" always; + } +}" > "$destination/https.$endpoint.conf" + echo "server { + listen 80; + server_name $endpoint; + + location /.well-known/acme-challenge { + root /var/www/letsencrypt; + try_files \$uri \$uri/ =404; + } + + location / { + rewrite ^ https://$endpoint\$request_uri? permanent; + } +}" > "$destination/http.$endpoint.conf" + fi +} + +function create_role() { + printf "\n[$SERVICE]\n$SERVICE_HOST ansible_user=root ansible_connection=ssh" >> inventory + mkdir -p roles/$SERVICE/tasks + mkdir -p roles/$SERVICE/templates + cp $PACKAGE_PATH/docker-compose.yml roles/$SERVICE/templates/docker-compose.yml.j2 + + echo "--- +- name: ensure $SERVICE docker/compose exist + file: + path: /etc/docker/compose/$SERVICE + state: directory + owner: root + group: root + mode: 0700 + +- name: build $SERVICE docker-compose.yml.j2 + template: + src: ../templates/docker-compose.yml.j2 + dest: /etc/docker/compose/$SERVICE/docker-compose.yml + owner: root + group: root + mode: u=rw,g=r,o=r + +- name: daemon-reload and enable $SERVICE + ansible.builtin.systemd_service: + state: restarted + enabled: true + name: docker-compose@$SERVICE" > roles/$SERVICE/tasks/main.yml + + echo "- name: deploy $SERVICE + hosts: $SERVICE + roles: + - $SERVICE" > deploy-$SERVICE.yml +} + +render_template +test_and_commit_code + +add_dns_records +add_nginx_config +create_role diff --git a/deploy-phoneof.yml b/deploy-phoneof.yml new file mode 100644 index 0000000..b1f0f6d --- /dev/null +++ b/deploy-phoneof.yml @@ -0,0 +1,4 @@ +- name: deploy phoneof + hosts: phoneof + roles: + - phoneof diff --git a/inventory b/inventory index 5f9f6c1..05c0d23 100644 --- a/inventory +++ b/inventory @@ -21,6 +21,7 @@ raspberrypi ansible_user=root ansible_connection=ssh [webservers] levi ansible_user=root ansible_connection=ssh nijika ansible_user=root ansible_connection=ssh +ryo ansible_user=root ansible_connection=ssh [nameservers] ryo ansible_user=root ansible_connection=ssh @@ -85,3 +86,6 @@ johan ansible_user=root ansible_connection=ssh [uptime] raspberrypi ansible_user=root ansible_connection=ssh + +[phoneof] +ryo ansible_user=root ansible_connection=ssh diff --git a/roles/nameservers/templates/db.simponic.xyz.j2 b/roles/nameservers/templates/db.simponic.xyz.j2 index d257346..8b6b429 100644 --- a/roles/nameservers/templates/db.simponic.xyz.j2 +++ b/roles/nameservers/templates/db.simponic.xyz.j2 @@ -27,6 +27,7 @@ simponic.xyz. 1 IN A 23.95.214.176 chesshbot.simponic.xyz. 1 IN A 129.123.76.14 ;; CNAME Records +phoneof.simponic.xyz. 43200 IN CNAME ryo.simponic.xyz. secure.tunnel.simponic.xyz. 1 IN CNAME simponic.xyz. tunnel.simponic.xyz. 1 IN CNAME simponic.xyz. party.simponic.xyz. 1 IN CNAME simponic.xyz. diff --git a/roles/phoneof/tasks/main.yml b/roles/phoneof/tasks/main.yml new file mode 100644 index 0000000..082e87e --- /dev/null +++ b/roles/phoneof/tasks/main.yml @@ -0,0 +1,22 @@ +--- +- name: ensure phoneof docker/compose exist + file: + path: /etc/docker/compose/phoneof + state: directory + owner: root + group: root + mode: 0700 + +- name: build phoneof docker-compose.yml.j2 + template: + src: ../templates/docker-compose.yml.j2 + dest: /etc/docker/compose/phoneof/docker-compose.yml + owner: root + group: root + mode: u=rw,g=r,o=r + +- name: daemon-reload and enable phoneof + ansible.builtin.systemd_service: + state: restarted + enabled: true + name: docker-compose@phoneof diff --git a/roles/phoneof/templates/docker-compose.yml.j2 b/roles/phoneof/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..ca17e85 --- /dev/null +++ b/roles/phoneof/templates/docker-compose.yml.j2 @@ -0,0 +1,18 @@ +version: "3" + +services: + api: + restart: always + image: git.simponic.xyz/simponic/phoneof + 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:19191:8080" diff --git a/roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf b/roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf new file mode 100644 index 0000000..c849a26 --- /dev/null +++ b/roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name phoneof.simponic.xyz; + + location /.well-known/acme-challenge { + root /var/www/letsencrypt; + try_files $uri $uri/ =404; + } + + location / { + rewrite ^ https://phoneof.simponic.xyz$request_uri? permanent; + } +} diff --git a/roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf b/roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf new file mode 100644 index 0000000..2290c4a --- /dev/null +++ b/roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf @@ -0,0 +1,33 @@ +server { + listen 443 ssl; + server_name headscale.simponic.xyz; + + ssl_certificate /etc/letsencrypt/live/phoneof.simponic.xyz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/phoneof.simponic.xyz/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/phoneof.simponic.xyz/fullchain.pem; + + ssl_session_cache shared:SSL:50m; + ssl_session_timeout 5m; + ssl_stapling on; + ssl_stapling_verify on; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; + + ssl_dhparam /etc/nginx/dhparams.pem; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass https://127.0.0.1:19191; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $server_name; + proxy_redirect http:// https://; + proxy_buffering off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; + } +} 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" }} +
but hey, at least you found our witty 404 page. that's something, right?
+ + + +{{ 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" }} + + + +