create a base template :) and use it for a new service
This commit is contained in:
parent
321cd40fba
commit
b19321bab5
163
create_service.sh
Executable file
163
create_service.sh
Executable file
@ -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
|
4
deploy-phoneof.yml
Normal file
4
deploy-phoneof.yml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
- name: deploy phoneof
|
||||||
|
hosts: phoneof
|
||||||
|
roles:
|
||||||
|
- phoneof
|
@ -21,6 +21,7 @@ raspberrypi ansible_user=root ansible_connection=ssh
|
|||||||
[webservers]
|
[webservers]
|
||||||
levi ansible_user=root ansible_connection=ssh
|
levi ansible_user=root ansible_connection=ssh
|
||||||
nijika ansible_user=root ansible_connection=ssh
|
nijika ansible_user=root ansible_connection=ssh
|
||||||
|
ryo ansible_user=root ansible_connection=ssh
|
||||||
|
|
||||||
[nameservers]
|
[nameservers]
|
||||||
ryo ansible_user=root ansible_connection=ssh
|
ryo ansible_user=root ansible_connection=ssh
|
||||||
@ -85,3 +86,6 @@ johan ansible_user=root ansible_connection=ssh
|
|||||||
|
|
||||||
[uptime]
|
[uptime]
|
||||||
raspberrypi ansible_user=root ansible_connection=ssh
|
raspberrypi ansible_user=root ansible_connection=ssh
|
||||||
|
|
||||||
|
[phoneof]
|
||||||
|
ryo ansible_user=root ansible_connection=ssh
|
||||||
|
@ -27,6 +27,7 @@ simponic.xyz. 1 IN A 23.95.214.176
|
|||||||
chesshbot.simponic.xyz. 1 IN A 129.123.76.14
|
chesshbot.simponic.xyz. 1 IN A 129.123.76.14
|
||||||
|
|
||||||
;; CNAME Records
|
;; CNAME Records
|
||||||
|
phoneof.simponic.xyz. 43200 IN CNAME ryo.simponic.xyz.
|
||||||
secure.tunnel.simponic.xyz. 1 IN CNAME simponic.xyz.
|
secure.tunnel.simponic.xyz. 1 IN CNAME simponic.xyz.
|
||||||
tunnel.simponic.xyz. 1 IN CNAME simponic.xyz.
|
tunnel.simponic.xyz. 1 IN CNAME simponic.xyz.
|
||||||
party.simponic.xyz. 1 IN CNAME simponic.xyz.
|
party.simponic.xyz. 1 IN CNAME simponic.xyz.
|
||||||
|
22
roles/phoneof/tasks/main.yml
Normal file
22
roles/phoneof/tasks/main.yml
Normal file
@ -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
|
18
roles/phoneof/templates/docker-compose.yml.j2
Normal file
18
roles/phoneof/templates/docker-compose.yml.j2
Normal file
@ -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"
|
13
roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf
Normal file
13
roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
33
roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf
Normal file
33
roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
5
template/.dockerignore
Normal file
5
template/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
{{ service }}
|
||||||
|
Dockerfile
|
||||||
|
*.db
|
||||||
|
.drone.yml
|
49
template/.drone.yml
Normal file
49
template/.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: {{ 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
|
3
template/.gitignore
vendored
Normal file
3
template/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.env
|
||||||
|
{{ service }}
|
||||||
|
*.db
|
1
template/.tool-versions
Normal file
1
template/.tool-versions
Normal file
@ -0,0 +1 @@
|
|||||||
|
golang 1.23.4
|
13
template/Dockerfile
Normal file
13
template/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/{{ 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"]
|
3
template/README.md
Normal file
3
template/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
## {{ service_title }}
|
||||||
|
|
||||||
|
this is a simponic service for {{ service }}
|
91
template/api/api.go
Normal file
91
template/api/api.go
Normal file
@ -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
|
||||||
|
}
|
89
template/api/api_test.go
Normal file
89
template/api/api_test.go
Normal file
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
73
template/api/template/template.go
Normal file
73
template/api/template/template.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
template/api/types/types.go
Normal file
26
template/api/types/types.go
Normal file
@ -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
|
82
template/args/args.go
Normal file
82
template/args/args.go
Normal file
@ -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
|
||||||
|
}
|
17
template/database/conn.go
Normal file
17
template/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
template/database/migrate.go
Normal file
39
template/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(`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
|
||||||
|
}
|
18
template/docker-compose.yml
Normal file
18
template/docker-compose.yml
Normal file
@ -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"
|
9
template/go.mod
Normal file
9
template/go.mod
Normal file
@ -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
|
||||||
|
)
|
63
template/main.go
Normal file
63
template/main.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
34
template/scheduler/scheduler.go
Normal file
34
template/scheduler/scheduler.go
Normal file
@ -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()
|
||||||
|
}
|
51
template/static/css/colors.css
Normal file
51
template/static/css/colors.css
Normal file
@ -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;
|
||||||
|
}
|
42
template/static/css/form.css
Normal file
42
template/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;
|
||||||
|
}
|
13
template/static/css/styles.css
Normal file
13
template/static/css/styles.css
Normal file
@ -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);
|
||||||
|
}
|
28
template/static/css/table.css
Normal file
28
template/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;
|
||||||
|
}
|
7
template/templates/404.html
Normal file
7
template/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 }}
|
16
template/templates/base.html
Normal file
16
template/templates/base.html
Normal file
@ -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 }}
|
3
template/templates/base_empty.html
Normal file
3
template/templates/base_empty.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "base" }}
|
||||||
|
{{ template "content" . }}
|
||||||
|
{{ end }}
|
3
template/templates/hello.html
Normal file
3
template/templates/hello.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
hello from {{ .Service }}!
|
||||||
|
{{ end }}
|
16
template/utils/random_id.go
Normal file
16
template/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…
x
Reference in New Issue
Block a user