├── .air.toml ├── .gcloudignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── artifact-cleanup-policy.json ├── cloudbuild.json ├── cmd └── main.go ├── firebase.json ├── go.mod ├── go.sum ├── go.work ├── go.work.sum ├── gopher-batman.png ├── magefiles ├── cmd │ └── cmd.go ├── cmdAuth.go ├── cmdBuild.go ├── cmdDeploy.go ├── cmdDestroy.go ├── cmdInstall.go ├── cmdRelease.go ├── cmdRun.go ├── cmdTempl.go ├── cmdTidy.go ├── config.go ├── go.mod ├── go.sum ├── magefile.go └── tags.go ├── pkg ├── auth │ └── auth.go ├── handler │ ├── error.go │ ├── home.go │ └── util.go ├── log │ └── log.go ├── server │ ├── certs.go │ ├── router.go │ └── server.go └── version │ ├── version.go │ └── version_test.go └── web ├── components └── page │ └── page.templ ├── package-lock.json ├── package.json ├── pages └── home │ └── home.templ ├── public └── favicon.ico ├── tailwind.config.js ├── tailwind.css └── web.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "/tmp" 3 | 4 | [build] 5 | bin = "./dist/server" 6 | cmd = "go build -o ./dist/server cmd/main.go" 7 | delay = 100 8 | exclude_dir = ["web/node_modules"] 9 | exclude_file = [] 10 | exclude_regex = [".*_templ.go"] 11 | exclude_unchanged = false 12 | follow_symlink = false 13 | full_bin = "" 14 | include_dir = [] 15 | include_ext = ["go", "css"] 16 | kill_delay = "500ms" 17 | send_interrupt = true 18 | stop_on_error = true 19 | 20 | [color] 21 | app = "" 22 | build = "yellow" 23 | main = "magenta" 24 | runner = "green" 25 | watcher = "cyan" 26 | 27 | [log] 28 | time = false 29 | 30 | [misc] 31 | clean_on_exit = false 32 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # Ignore the installed node_modules 2 | node_modules 3 | 4 | # Ignore the artifacts directory 5 | dist 6 | 7 | # Ignore template files 8 | *.templ 9 | 10 | # Ignore sqlc files 11 | *.sql 12 | 13 | # Ignore keys 14 | *.crt 15 | *.cert 16 | *.key 17 | 18 | # Ignore secrets and config vars 19 | *.secrets 20 | *.env 21 | 22 | # Ignore tmp files 23 | tmp 24 | 25 | # Ignore git files 26 | .gitignore 27 | .git 28 | .firebase 29 | 30 | # Ignore meta files 31 | .firebaserc 32 | .gcloudignore 33 | LICENSE 34 | README.md 35 | *.png 36 | 37 | # Ignore config files 38 | *.json 39 | *.toml 40 | *.yml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Ignore the installed node_modules 4 | node_modules 5 | 6 | # Ignore the artifacts directory 7 | dist 8 | 9 | # Ignore keys 10 | *.crt 11 | *.cert 12 | *.key 13 | 14 | # Ignore secrets 15 | *.secrets 16 | 17 | # Ignore generated templ files 18 | *_templ.go 19 | *_templ.txt 20 | 21 | # Ignore generated minified styles 22 | *.min.css 23 | 24 | # Ignore terraform dependencies 25 | .terraform/ 26 | 27 | # Ignore tmp files 28 | tmp/ 29 | 30 | # Ignore firebase 31 | .firebaserc 32 | .firebase/ 33 | 34 | # Ignore env file 35 | *.env 36 | 37 | # Ignore version file 38 | version.txt 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest as builder 2 | 3 | WORKDIR /app 4 | 5 | # Copy go mod files 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | # Copy the entire project structure 10 | COPY . . 11 | 12 | # Build the application 13 | RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd 14 | 15 | # Final stage 16 | FROM alpine:latest 17 | 18 | WORKDIR /app 19 | 20 | # Copy the binary from builder 21 | COPY --from=builder /app/server . 22 | 23 | # Set environment variables 24 | ENV PORT=8080 25 | 26 | # Expose the port 27 | EXPOSE 8080 28 | 29 | # Run the application 30 | CMD ["./server"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Joel Holsteen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Description 3 |
4 | 5 |

go version  license 7 | 8 | # gothem-stack 9 | 10 | An end to end [htmx](https://htmx.org) and [go-templ](https://templ.guide) template using [echo](https://echo.labstack.com/) for the web server and [mage](https://magefile.org/) for deployment. Other than that this is unopinionated so bring all your other favorite technologies :) 11 | 12 | **Go**\ 13 | **T**empl\ 14 | **H**TMX\ 15 | **E**cho\ 16 | **M**age 17 | 18 | For the frontend libraries it uses [tailwindcss](https://tailwindcss.com/) and [daisyUI](https://daisyui.com/) for styling. You can easily integrate [alpine-js](https://alpinejs.dev/) or [hyperscript](https://hyperscript.org/) if you desire. The example integrates with both. 19 | 20 | ## Quickstart 21 | 1. Install npm in your path `brew install node; brew install npm` 22 | 1. Install mage in your path `brew install mage`. See [https://magefile.org/](https://magefile.org/) for other installation instructions 23 | 1. Run `mage install` 24 | 1. Run `mage run` 25 | 1. Open http://localhost:7331 (the server is listening on :4433 but templ injects a watcher with an autoreload script) 26 | 27 | When you make changes to your templ files or any of your go code everything will regenerate and then autoreload your web page 28 | 29 | ## Basic Commands 30 | `mage run` - Run an interactive development environment that will automatically reload on any file change. Listens on port :4433 and has an autoreload page on :7331 31 | 32 | `mage install` - Install all the dependencies 33 | 34 | `mage templ` - Do a one time regeneration of your templ files 35 | 36 | `mage build` - Do a one time build of the go files 37 | 38 | `mage tidy` - Run `go mod tidy` 39 | 40 | ## Cloud Deployment (Optional) 41 | The project also includes optional support for deploying your code to Google Cloud Run and Firebase Hosting. **This is by no means required to use this project, if you choose to you can just ignore all these commands and use it without cloud integration**. To use these features, you'll need to set up Google Cloud and Firebase projects first. 42 | 43 | See the example running at [gothem-stack.web.app](https://gothem-stack.web.app/) 44 | 45 | ### Cloud Deployment Commands 46 | `mage auth [gcloud|firebase]` - Authenticate with Google Cloud or Firebase services 47 | 48 | `mage deploy [backend|frontend|all]` - Deploy your application 49 | - `backend` - Deploys to Google Cloud Run 50 | - `frontend` - Deploys to Firebase Hosting 51 | - `all` - Deploys both (default if no argument provided) 52 | 53 | `mage release [backend|frontend|all]` - Move to a specific version of the backend service 54 | 55 | `mage destroy` - Tear down all cloud infrastructure created during the deployment 56 | 57 | ### Required Environment Variables for Cloud Deployment 58 | If using cloud deployment, you'll need to set these environment variables: 59 | - `GOOGLE_CLOUD_PROJECT` - Your Google Cloud project ID 60 | - `GOOGLE_CLOUD_REGION` - The region to deploy to (defaults to us-central1) 61 | - `CLOUD_RUN_SERVICE_NAME` - The name of your Cloud Run service 62 | 63 | You can place these variables in a env file to declaratively use them. For example 64 | you could run 65 | 66 | ```bash 67 | echo "GOOGLE_CLOUD_PROJECT=foobar-123\nGOOGLE_CLOUD_REGION=us-central1\nCLOUD_RUN_SERVICE_NAME=gothem-stack" > gothem-stack.env 68 | ``` 69 | 70 | Then you can run `ENV=gothem-stack mage deploy` to use the configured environment variables. 71 | 72 | ### But Gotham is spelled with an 'a'.... 73 | Yea I know it's spelled with an 'a' :] I was trying to come up with a name that was easier to say and interact with than 'htmx-templ-template' and gothem is what I came up with. -------------------------------------------------------------------------------- /artifact-cleanup-policy.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "keep-recent-versions", 4 | "action": { 5 | "type": "Keep" 6 | }, 7 | "mostRecentVersions": { 8 | "keepCount": 5 9 | } 10 | }, 11 | { 12 | "name": "delete-old-untagged", 13 | "action": { 14 | "type": "Delete" 15 | }, 16 | "condition": { 17 | "tagState": "untagged", 18 | "olderThan": "30d" 19 | } 20 | } 21 | ] -------------------------------------------------------------------------------- /cloudbuild.json: -------------------------------------------------------------------------------- 1 | { 2 | "steps": [ 3 | { 4 | "name": "gcr.io/cloud-builders/docker", 5 | "args": [ 6 | "build", 7 | "--tag", 8 | "$_IMAGE_NAME", 9 | "." 10 | ], 11 | "env": [] 12 | } 13 | ], 14 | "images": [ 15 | "$_IMAGE_NAME" 16 | ], 17 | "substitutions": { 18 | "_IMAGE_NAME": "_IMAGE_NAME is required" 19 | } 20 | } -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | 10 | "github.com/grindlemire/gothem-stack/pkg/log" 11 | "github.com/grindlemire/gothem-stack/pkg/server" 12 | 13 | "github.com/urfave/cli/v2" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func main() { 18 | err := log.InitGlobal() 19 | if err != nil { 20 | fmt.Fprintf(os.Stderr, "failed to initalize logger: %v", err) 21 | os.Exit(1) 22 | } 23 | 24 | app := &cli.App{ 25 | Name: "serve", 26 | Usage: "serve an htmx api", 27 | Action: func(c *cli.Context) (err error) { 28 | ctx, cancel := context.WithCancel(c.Context) 29 | defer cancel() 30 | 31 | // Create a signal channel 32 | sigCh := make(chan os.Signal, 1) 33 | // Register a signal handler for SIGINT 34 | signal.Notify(sigCh, os.Interrupt) 35 | 36 | go func() { 37 | <-sigCh 38 | cancel() 39 | }() 40 | return server.Run(ctx) 41 | }, 42 | } 43 | 44 | err = app.Run(os.Args) 45 | if err != nil { 46 | // we don't care about context cancellation as that happens if we kill the process 47 | // while it is waiting for a request to finish 48 | if errors.Is(err, context.Canceled) { 49 | os.Exit(1) 50 | } 51 | zap.S().Fatal(err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "./dist/public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "/favicon.ico", 12 | "destination": "/dist/favicon.ico" 13 | }, 14 | { 15 | "source": "/dist/**", 16 | "destination": "/dist/**" 17 | }, 18 | { 19 | "source": "**", 20 | "run": { 21 | "serviceId": "gothem-stack", 22 | "region": "us-central1" 23 | } 24 | } 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grindlemire/gothem-stack 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/a-h/templ v0.2.747 7 | github.com/google/uuid v1.6.0 8 | github.com/kelseyhightower/envconfig v1.4.0 9 | github.com/labstack/echo/v4 v4.12.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/stretchr/testify v1.10.0 12 | github.com/urfave/cli/v2 v2.27.2 13 | go.uber.org/zap v1.27.0 14 | ) 15 | 16 | require ( 17 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 20 | github.com/labstack/gommon v0.4.2 // indirect 21 | github.com/mattn/go-colorable v0.1.13 // indirect 22 | github.com/mattn/go-isatty v0.0.20 // indirect 23 | github.com/pmezard/go-difflib v1.0.0 // indirect 24 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 25 | github.com/valyala/bytebufferpool v1.0.0 // indirect 26 | github.com/valyala/fasttemplate v1.2.2 // indirect 27 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect 28 | go.uber.org/multierr v1.11.0 // indirect 29 | golang.org/x/crypto v0.22.0 // indirect 30 | golang.org/x/net v0.24.0 // indirect 31 | golang.org/x/sys v0.21.0 // indirect 32 | golang.org/x/text v0.14.0 // indirect 33 | golang.org/x/time v0.5.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= 2 | github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 8 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 9 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 12 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 13 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 14 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 15 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= 16 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 17 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 18 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 19 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 20 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 21 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 22 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 23 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 24 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 25 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 29 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 30 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 31 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 32 | github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= 33 | github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= 34 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 35 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 36 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 37 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 38 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= 39 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= 40 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 41 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 42 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 43 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 44 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 45 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 46 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 47 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 48 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 49 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 50 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 53 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 54 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 55 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 56 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 57 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 61 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.22 2 | 3 | use ./magefiles 4 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= 2 | github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0= 3 | github.com/a-h/parse v0.0.0-20240121214402-3caf7543159a/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= 4 | github.com/a-h/pathvars v0.0.14/go.mod h1:7rLTtvDVyKneR/N65hC0lh2sZ2KRyAmWFaOvv00uxb0= 5 | github.com/a-h/protocol v0.0.0-20240704131721-1e461c188041/go.mod h1:Gm0KywveHnkiIhqFSMZglXwWZRQICg3KDWLYdglv/d8= 6 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 7 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 8 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 9 | github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 14 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 15 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 16 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 17 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 18 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 19 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 20 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= 21 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 22 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 23 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 24 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 25 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 26 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 27 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 28 | github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= 29 | github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 30 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 31 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 32 | github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 33 | github.com/segmentio/encoding v0.4.0/go.mod h1:/d03Cd8PoaDeceuhUUUQWjU0KhWjrmYrWPgtJHYZSnI= 34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 35 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 36 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 37 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 38 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 39 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= 43 | github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= 44 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 45 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 46 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 47 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 48 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= 49 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= 50 | go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= 51 | go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= 52 | go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= 53 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 54 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 55 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 56 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 57 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 58 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 59 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 60 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 62 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 64 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 65 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 66 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 67 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 68 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 69 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | -------------------------------------------------------------------------------- /gopher-batman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grindlemire/gothem-stack/cd0c89089d34a0c0b2e2c15a9d565022e631af79/gopher-batman.png -------------------------------------------------------------------------------- /magefiles/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "os/signal" 9 | "strings" 10 | 11 | "github.com/pkg/errors" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type cmdconf struct { 16 | name string 17 | args []string 18 | dir string 19 | env []string 20 | 21 | logCMD bool 22 | infoLog io.Writer 23 | errLog io.Writer 24 | in io.Reader 25 | } 26 | 27 | type cmdopt func(*cmdconf) error 28 | 29 | // WithDir specifies the directory to run the subprocess in 30 | func WithDir(dir string) cmdopt { 31 | return func(conf *cmdconf) (err error) { 32 | conf.dir = dir 33 | return nil 34 | } 35 | } 36 | 37 | // WithCMD specifies the command to run and the args to pass 38 | func WithCMD(name string, args ...string) cmdopt { 39 | return func(conf *cmdconf) (err error) { 40 | conf.name = name 41 | if conf.args == nil { 42 | conf.args = []string{} 43 | } 44 | if len(args) > 0 { 45 | // prepend the command args before the others 46 | conf.args = append(args, conf.args...) 47 | } 48 | return nil 49 | } 50 | } 51 | 52 | // WithSilent discards the subprocess stdout 53 | func WithSilent() cmdopt { 54 | return func(conf *cmdconf) (err error) { 55 | conf.infoLog = io.Discard 56 | conf.errLog = io.Discard 57 | return nil 58 | } 59 | } 60 | 61 | // WithArgs adds additional arguments for the command 62 | func WithArgs(args ...string) cmdopt { 63 | return func(conf *cmdconf) (err error) { 64 | if conf.args == nil { 65 | conf.args = []string{} 66 | } 67 | conf.args = append(conf.args, args...) 68 | return nil 69 | } 70 | } 71 | 72 | // WithEnv provides additional env vars for the subprocess 73 | func WithEnv(args ...string) cmdopt { 74 | return func(conf *cmdconf) (err error) { 75 | if conf.env == nil { 76 | conf.env = []string{} 77 | } 78 | conf.env = append(conf.env, args...) 79 | return nil 80 | } 81 | } 82 | 83 | // WithLog logs the command that is run and gives the ability to copy 84 | func WithLog() cmdopt { 85 | return func(conf *cmdconf) (err error) { 86 | conf.logCMD = true 87 | return nil 88 | } 89 | } 90 | 91 | // WithLogger hooks up the external zap logger with the stdout and stderr of the subprocess 92 | func WithLogger() cmdopt { 93 | return func(conf *cmdconf) (err error) { 94 | infoLog, err := zap.NewStdLogAt(zap.L(), zap.InfoLevel) 95 | if err != nil { 96 | return errors.Wrap(err, "wrapping info level zap logger") 97 | } 98 | 99 | errLog, err := zap.NewStdLogAt(zap.L(), zap.ErrorLevel) 100 | if err != nil { 101 | return errors.Wrap(err, "wrapping error level zap logger") 102 | } 103 | conf.infoLog = infoLog.Writer() 104 | conf.errLog = errLog.Writer() 105 | return nil 106 | } 107 | } 108 | 109 | // CMD creates a command but does not run it. Pass it to Run to run the command. 110 | func CMD(ctx context.Context, opts ...cmdopt) *exec.Cmd { 111 | conf := &cmdconf{ 112 | infoLog: os.Stdout, 113 | errLog: os.Stderr, 114 | in: os.Stdin, 115 | } 116 | for _, opt := range opts { 117 | err := opt(conf) 118 | if err != nil { 119 | zap.S().Fatalf("creating command config: %v", err) 120 | } 121 | } 122 | cmd := exec.CommandContext(ctx, conf.name, conf.args...) 123 | cmd.Dir = conf.dir 124 | 125 | cmd.Stderr = conf.errLog 126 | cmd.Stdout = conf.infoLog 127 | cmd.Stdin = conf.in 128 | cmd.Env = append(os.Environ(), conf.env...) 129 | 130 | if conf.logCMD { 131 | zap.S().Infof("ENV: %s", conf.env) 132 | zap.S().Infof("DIR: %s", cmd.Dir) 133 | zap.S().Infof("CMD: %s", cmd.String()) 134 | zap.S().Infof("COPY: pushd %s; %s %s; popd", cmd.Dir, strings.Join(conf.env, " "), cmd.String()) 135 | } 136 | return cmd 137 | } 138 | 139 | // Run a command and wait for it to return 140 | func Run(ctx context.Context, opts ...cmdopt) (err error) { 141 | cmd := CMD(ctx, opts...) 142 | // Create a signal channel 143 | sigCh := make(chan os.Signal, 1) 144 | 145 | // Register a signal handler for SIGINT 146 | signal.Notify(sigCh, os.Interrupt) 147 | 148 | // start the command and return the result 149 | if err := cmd.Start(); err != nil { 150 | return errors.Wrap(err, "starting subprocess") 151 | } 152 | done := make(chan error, 1) 153 | go func() { 154 | done <- cmd.Wait() 155 | }() 156 | 157 | for { 158 | select { 159 | case err = <-done: 160 | return err 161 | case <-sigCh: 162 | zap.S().Debugf("killing subprocess %s", cmd.String()) 163 | err := cmd.Process.Signal(os.Interrupt) 164 | if err != nil { 165 | return errors.Wrap(err, "sending cancel signal") 166 | } 167 | case <-ctx.Done(): 168 | zap.S().Infof("context cancelled, killing subprocess %s", cmd.String()) 169 | cmd.Process.Signal(os.Interrupt) 170 | _, err := cmd.Process.Wait() 171 | return err 172 | } 173 | } 174 | } 175 | 176 | // Output runs a command and returns its stdout output as bytes 177 | func Output(ctx context.Context, opts ...cmdopt) ([]byte, error) { 178 | // Create a config with default values 179 | conf := &cmdconf{ 180 | infoLog: os.Stdout, 181 | errLog: os.Stderr, 182 | in: os.Stdin, 183 | } 184 | 185 | // Apply all options 186 | for _, opt := range opts { 187 | err := opt(conf) 188 | if err != nil { 189 | return nil, errors.Wrap(err, "creating command config") 190 | } 191 | } 192 | 193 | // Create the command 194 | cmd := exec.CommandContext(ctx, conf.name, conf.args...) 195 | cmd.Dir = conf.dir 196 | cmd.Env = append(os.Environ(), conf.env...) 197 | cmd.Stdin = conf.in 198 | 199 | // Log the command if requested 200 | if conf.logCMD { 201 | zap.S().Infof("ENV: %s", conf.env) 202 | zap.S().Infof("DIR: %s", cmd.Dir) 203 | zap.S().Infof("CMD: %s", cmd.String()) 204 | zap.S().Infof("COPY: pushd %s; %s %s; popd", cmd.Dir, strings.Join(conf.env, " "), cmd.String()) 205 | } 206 | 207 | // Run the command and capture output 208 | output, err := cmd.Output() 209 | if err != nil { 210 | if exitErr, ok := err.(*exec.ExitError); ok { 211 | return nil, errors.Wrapf(err, "command failed with stderr: %s", string(exitErr.Stderr)) 212 | } 213 | return nil, errors.Wrap(err, "running command") 214 | } 215 | 216 | return output, nil 217 | } 218 | -------------------------------------------------------------------------------- /magefiles/cmdAuth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 7 | "github.com/pkg/errors" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // auth authenticates with cloud services 12 | func auth(ctx context.Context) error { 13 | config, err := GetConfig(ctx) 14 | if err != nil { 15 | return errors.Wrap(err, "failed to get config") 16 | } 17 | 18 | if len(config.Args) == 0 { 19 | return errors.New("no arguments passed. Please pass 'gcloud' or 'firebase' to authenticate with the respective service") 20 | } 21 | 22 | switch config.Args[0] { 23 | case "gcloud": 24 | return authGcloud(ctx) 25 | case "firebase": 26 | return authFirebase(ctx) 27 | default: 28 | return errors.Errorf("unknown auth target: %s", config.Args[0]) 29 | } 30 | } 31 | 32 | func authGcloud(ctx context.Context) error { 33 | zap.S().Info("Authenticating with Google Cloud...") 34 | 35 | // Check if gcloud is installed 36 | err := cmd.Run(ctx, cmd.WithCMD("gcloud", "version"), cmd.WithSilent()) 37 | if err != nil { 38 | return errors.Wrap(err, "gcloud is not installed. Run `mage install deploy` to install it") 39 | } 40 | 41 | // Run gcloud auth login 42 | err = cmd.Run(ctx, cmd.WithCMD( 43 | "gcloud", "auth", "login", 44 | )) 45 | if err != nil { 46 | return errors.Wrap(err, "failed to authenticate with gcloud") 47 | } 48 | 49 | zap.S().Info("Successfully authenticated with Google Cloud") 50 | return nil 51 | } 52 | 53 | func authFirebase(ctx context.Context) error { 54 | zap.S().Info("Authenticating with Firebase...") 55 | 56 | // Check if firebase is installed 57 | err := cmd.Run(ctx, cmd.WithCMD("firebase", "--version"), cmd.WithSilent()) 58 | if err != nil { 59 | return errors.Wrap(err, "firebase is not installed. Run `mage install deploy` to install it") 60 | } 61 | 62 | // Run firebase login 63 | err = cmd.Run(ctx, cmd.WithCMD( 64 | "firebase", "login", 65 | )) 66 | if err != nil { 67 | return errors.Wrap(err, "failed to authenticate with firebase") 68 | } 69 | 70 | zap.S().Info("Successfully authenticated with Firebase") 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /magefiles/cmdBuild.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 10 | "github.com/grindlemire/gothem-stack/web/pages/home" 11 | 12 | "github.com/magefile/mage/mg" 13 | "github.com/pkg/errors" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func build(ctx context.Context) error { 18 | config, err := GetConfig(ctx) 19 | if err != nil { 20 | return err 21 | } 22 | zap.S().Infof("mage running with configs: %+v", config) 23 | 24 | // Run dependencies in order 25 | mg.SerialCtxDeps(ctx, tidy, templ) 26 | 27 | // Ensure dist directory exists 28 | if err := os.MkdirAll("dist", 0755); err != nil { 29 | return errors.Wrap(err, "creating dist directory") 30 | } 31 | 32 | // Clean previous build 33 | if err := cmd.Run(ctx, cmd.WithCMD("rm", "-f", "dist/server")); err != nil { 34 | return errors.Wrap(err, "cleaning previous build") 35 | } 36 | 37 | // Build the server 38 | err = cmd.Run(ctx, 39 | cmd.WithCMD( 40 | "go", 41 | "build", 42 | "-o", "dist/server", 43 | "cmd/main.go", 44 | ), 45 | ) 46 | if err != nil { 47 | return errors.Wrap(err, "building server") 48 | } 49 | 50 | // Copy static assets to dist 51 | if err := generateStaticAssets(ctx); err != nil { 52 | return errors.Wrap(err, "handling static assets") 53 | } 54 | 55 | zap.S().Info("Build completed successfully") 56 | return nil 57 | } 58 | 59 | // generateStaticAssets generates CSS and copies static files to dist 60 | func generateStaticAssets(ctx context.Context) error { 61 | // Generate CSS using TailwindCSS 62 | err := cmd.Run(ctx, 63 | cmd.WithDir("./web"), 64 | cmd.WithCMD( 65 | "node_modules/.bin/tailwindcss", 66 | "-i", "tailwind.css", 67 | "-o", "public/styles.min.css", 68 | ), 69 | ) 70 | if err != nil { 71 | return errors.Wrap(err, "generating CSS") 72 | } 73 | 74 | // Create dist/public/dist directory 75 | if err := os.MkdirAll("dist/public/dist", 0755); err != nil { 76 | return errors.Wrap(err, "creating dist/public/dist directory") 77 | } 78 | 79 | // Generate the static HTML 80 | if err := generateHTML("dist/public/index.html"); err != nil { 81 | return errors.Wrap(err, "generating HTML") 82 | } 83 | 84 | // Copy static assets 85 | files := map[string]string{ 86 | "./web/public/favicon.ico": "dist/public/dist/favicon.ico", 87 | "./web/public/styles.min.css": "dist/public/dist/styles.min.css", 88 | } 89 | 90 | for src, dst := range files { 91 | if err := copyFile(src, dst); err != nil { 92 | return errors.Wrapf(err, "copying %s to %s", src, dst) 93 | } 94 | } 95 | 96 | return nil 97 | } 98 | 99 | // generateHTML renders the home page and writes it to the specified path 100 | func generateHTML(outputPath string) error { 101 | var s strings.Builder 102 | if err := home.Page().Render(context.Background(), &s); err != nil { 103 | return errors.Wrap(err, "rendering static page") 104 | } 105 | 106 | if err := os.WriteFile(outputPath, []byte(s.String()), 0644); err != nil { 107 | return errors.Wrap(err, "writing HTML to file") 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func copyFile(src, dst string) error { 114 | source, err := os.Open(src) 115 | if err != nil { 116 | return errors.Wrapf(err, "opening source file %s", src) 117 | } 118 | defer source.Close() 119 | 120 | destination, err := os.Create(dst) 121 | if err != nil { 122 | return errors.Wrapf(err, "creating destination file %s", dst) 123 | } 124 | defer destination.Close() 125 | 126 | _, err = io.Copy(destination, source) 127 | return err 128 | } 129 | -------------------------------------------------------------------------------- /magefiles/cmdDeploy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 10 | "github.com/grindlemire/gothem-stack/pkg/version" 11 | 12 | "github.com/pkg/errors" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func deploy(ctx context.Context) error { 17 | zap.S().Info("Starting deployment process...") 18 | config, err := GetConfig(ctx) 19 | if err != nil { 20 | return errors.Wrap(err, "failed to get config") 21 | } 22 | 23 | // If no arguments are passed, deploy both backend and frontend 24 | if len(config.Args) == 0 { 25 | config.Args = []string{"all"} 26 | } 27 | 28 | var ver version.Version 29 | // Only handle versioning for backend 30 | if config.Args[0] == "backend" || config.Args[0] == "all" { 31 | // Default to patch if no version increment specified 32 | versionLevel := "patch" 33 | if len(config.Args) > 1 { 34 | versionLevel = config.Args[1] 35 | } 36 | 37 | // Read current version 38 | ver, err = version.Read() 39 | if err != nil { 40 | return errors.Wrap(err, "failed to read version") 41 | } 42 | 43 | // Increment version 44 | if err := ver.Increment(versionLevel); err != nil { 45 | return errors.Wrap(err, "failed to increment version") 46 | } 47 | } 48 | 49 | // First, ensure we have the latest build 50 | if err := build(ctx); err != nil { 51 | return errors.Wrap(err, "failed to build static files") 52 | } 53 | 54 | if config.Args[0] == "backend" || config.Args[0] == "all" { 55 | err = deployBackend(ctx, ver) 56 | if err != nil { 57 | return errors.Wrap(err, "failed to deploy backend to Cloud Run") 58 | } 59 | // Save new version after successful backend deployment 60 | if err := version.Write(ver); err != nil { 61 | return errors.Wrap(err, "failed to save new version") 62 | } 63 | zap.S().Infof("Successfully deployed backend version %s", ver) 64 | } 65 | 66 | if config.Args[0] == "frontend" || config.Args[0] == "all" { 67 | err = deployFrontend(ctx) 68 | if err != nil { 69 | return errors.Wrap(err, "failed to deploy to firebase") 70 | } 71 | zap.S().Info("Successfully deployed frontend") 72 | } 73 | 74 | zap.S().Info("Successfully completed deployment") 75 | return nil 76 | } 77 | 78 | func deployBackend(ctx context.Context, ver version.Version) error { 79 | zap.S().Info("Deploying backend to Cloud Run...") 80 | // check if gcloud is installed 81 | err := cmd.Run(ctx, cmd.WithCMD("gcloud", "version"), cmd.WithSilent()) 82 | if err != nil { 83 | return errors.Wrap(err, "gcloud is not installed. Run `mage install backend` to install it") 84 | } 85 | 86 | projectID := os.Getenv("GOOGLE_CLOUD_PROJECT") 87 | if projectID == "" { 88 | return errors.New("GOOGLE_CLOUD_PROJECT environment variable not set") 89 | } 90 | 91 | region := os.Getenv("GOOGLE_CLOUD_REGION") 92 | if region == "" { 93 | region = "us-central1" // Default region 94 | } 95 | 96 | serviceName := os.Getenv("CLOUD_RUN_SERVICE_NAME") 97 | if serviceName == "" { 98 | return errors.New("CLOUD_RUN_SERVICE_NAME environment variable not set") 99 | } 100 | 101 | // Ensure the Artifact Registry is initialized 102 | err = ensureArtifactRegistry(ctx, projectID, region, serviceName) 103 | if err != nil { 104 | return errors.Wrap(err, "failed to initialize artifact registry") 105 | } 106 | 107 | err = ensureCloudBuild(ctx, projectID) 108 | if err != nil { 109 | return errors.Wrap(err, "failed to initialize cloud build") 110 | } 111 | 112 | err = ensureCloudRun(ctx, projectID) 113 | if err != nil { 114 | return errors.Wrap(err, "failed to initialize cloud run") 115 | } 116 | 117 | // create new tag name in accordance to the format gcr requires 118 | tagname, err := getImageTag( 119 | projectID, 120 | serviceName, 121 | "backend", 122 | fmt.Sprintf("v%s", ver), 123 | ) 124 | if err != nil { 125 | return errors.Wrap(err, "failed to get image tag") 126 | } 127 | 128 | err = cmd.Run(ctx, 129 | cmd.WithCMD( 130 | "gcloud", "builds", "submit", 131 | "--project", projectID, 132 | "--region", region, 133 | "--config", "./cloudbuild.json", 134 | "--substitutions", fmt.Sprintf("_IMAGE_NAME=%s", tagname), 135 | ), 136 | ) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | // Deploy to Cloud Run 142 | err = cmd.Run(ctx, cmd.WithCMD( 143 | "gcloud", "run", "deploy", serviceName, 144 | "--image", tagname, 145 | "--platform", "managed", 146 | "--region", region, 147 | "--project", projectID, 148 | "--allow-unauthenticated", 149 | )) 150 | if err != nil { 151 | return errors.Wrap(err, "failed to deploy to cloud run") 152 | } 153 | 154 | zap.S().Info("Successfully deployed to Cloud Run") 155 | return nil 156 | } 157 | 158 | func ensureCloudRun(ctx context.Context, projectID string) error { 159 | // Check if Cloud Run API is enabled 160 | err := cmd.Run(ctx, cmd.WithCMD( 161 | "gcloud", "services", "enable", "run.googleapis.com", 162 | "--project", projectID, 163 | )) 164 | if err != nil { 165 | return errors.Wrap(err, "failed to enable Cloud Run API") 166 | } 167 | return nil 168 | } 169 | 170 | func ensureArtifactRegistry(ctx context.Context, projectID, region, repoName string) error { 171 | // Check if Artifact Registry API is enabled 172 | err := cmd.Run(ctx, cmd.WithCMD( 173 | "gcloud", "services", "enable", "artifactregistry.googleapis.com", 174 | "--project", projectID, 175 | )) 176 | if err != nil { 177 | return errors.Wrap(err, "failed to enable Artifact Registry API") 178 | } 179 | 180 | // Check if repository exists 181 | repoExists := cmd.Run(ctx, 182 | cmd.WithCMD( 183 | "gcloud", "artifacts", "repositories", "describe", repoName, 184 | "--project", projectID, 185 | "--location", region, 186 | ), 187 | cmd.WithSilent(), 188 | ) == nil 189 | 190 | // Create repository if it doesn't exist 191 | if !repoExists { 192 | zap.S().Infof("Creating Artifact Registry repository %s in %s", repoName, region) 193 | err = cmd.Run(ctx, cmd.WithCMD( 194 | "gcloud", "artifacts", "repositories", "create", repoName, 195 | "--repository-format", "docker", 196 | "--location", region, 197 | "--project", projectID, 198 | )) 199 | if err != nil { 200 | return errors.Wrap(err, "failed to create Artifact Registry repository") 201 | } 202 | } 203 | 204 | // Set cleanup policy 205 | zap.S().Infof("Setting cleanup policy for repository %s", repoName) 206 | err = cmd.Run(ctx, cmd.WithCMD( 207 | "gcloud", "artifacts", "repositories", "set-cleanup-policies", repoName, 208 | "--location", region, 209 | "--project", projectID, 210 | "--policy=artifact-cleanup-policy.json", 211 | )) 212 | if err != nil { 213 | return errors.Wrap(err, "failed to set cleanup policy") 214 | } 215 | 216 | return nil 217 | } 218 | 219 | func ensureCloudBuild(ctx context.Context, projectID string) error { 220 | // Enable Cloud Build API 221 | err := cmd.Run(ctx, cmd.WithCMD( 222 | "gcloud", "services", "enable", "cloudbuild.googleapis.com", 223 | "--project", projectID, 224 | )) 225 | if err != nil { 226 | return errors.Wrap(err, "failed to enable Cloud Build API") 227 | } 228 | return nil 229 | } 230 | 231 | func deployFrontend(ctx context.Context) error { 232 | // Removed version parameter 233 | // if firebase is not installed, error out 234 | err := cmd.Run(ctx, cmd.WithCMD("firebase", "--version"), cmd.WithSilent()) 235 | if err != nil { 236 | return errors.Wrap(err, "firebase is not installed. Run `mage install frontend` to install it") 237 | } 238 | 239 | zap.S().Info("Deploying to Firebase hosting...") 240 | 241 | err = cmd.Run(ctx, 242 | cmd.WithDir("web"), 243 | cmd.WithCMD( 244 | "firebase", 245 | "deploy", 246 | "--only", "hosting", 247 | "--message", fmt.Sprintf("Build at %s", time.Now().Format(time.RFC3339)), 248 | ), 249 | ) 250 | if err != nil { 251 | return errors.Wrap(err, "failed to deploy to firebase") 252 | } 253 | 254 | zap.S().Info("Successfully deployed to Firebase hosting") 255 | return nil 256 | } 257 | -------------------------------------------------------------------------------- /magefiles/cmdDestroy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/pkg/errors" 9 | "go.uber.org/zap" 10 | 11 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 12 | ) 13 | 14 | func destroy(ctx context.Context) error { 15 | zap.S().Info("Starting destroy process...") 16 | config, err := GetConfig(ctx) 17 | if err != nil { 18 | return errors.Wrap(err, "failed to get config") 19 | } 20 | 21 | // Delete Firebase hosting 22 | if err := cmd.Run(ctx, 23 | cmd.WithCMD("firebase", "hosting:disable", "--project", config.Env, "--force"), 24 | ); err != nil { 25 | zap.S().Warnf("failed to disable firebase hosting (might already be disabled): %v", err) 26 | } 27 | 28 | // Delete Cloud Run service 29 | if err := cmd.Run(ctx, 30 | cmd.WithCMD("gcloud", "run", "services", "delete", "gothem-stack", 31 | "--platform", "managed", 32 | "--region", "us-central1", 33 | "--project", config.Env, 34 | "--quiet"), 35 | ); err != nil { 36 | zap.S().Warnf("failed to delete cloud run service: %v", err) 37 | } 38 | 39 | projectID := os.Getenv("GOOGLE_CLOUD_PROJECT") 40 | if projectID == "" { 41 | return errors.New("GOOGLE_CLOUD_PROJECT environment variable not set") 42 | } 43 | 44 | serviceName := os.Getenv("CLOUD_RUN_SERVICE_NAME") 45 | if serviceName == "" { 46 | return errors.New("CLOUD_RUN_SERVICE_NAME environment variable not set") 47 | } 48 | 49 | region := os.Getenv("GOOGLE_CLOUD_REGION") 50 | if region == "" { 51 | region = "us-central1" 52 | } 53 | 54 | // List and delete container images 55 | err = cmd.Run(ctx, 56 | cmd.WithCMD("gcloud", "artifacts", "repositories", "delete", serviceName, 57 | "--project", config.Env, 58 | "--location", region, 59 | "--quiet"), 60 | ) 61 | if err != nil { 62 | zap.S().Warnf("failed to delete artifacts repository: %v", err) 63 | } 64 | 65 | // Delete Cloud Build artifacts bucket 66 | bucketName := fmt.Sprintf("%s_cloudbuild", projectID) 67 | err = cmd.Run(ctx, 68 | cmd.WithCMD("gsutil", "rm", "-r", fmt.Sprintf("gs://%s", bucketName)), 69 | ) 70 | if err != nil { 71 | zap.S().Warnf("failed to delete cloudbuild bucket: %v", err) 72 | } 73 | 74 | zap.S().Info("successfully destroyed cloud infrastructure") 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /magefiles/cmdInstall.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | 7 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 8 | "github.com/magefile/mage/mg" 9 | "github.com/pkg/errors" 10 | 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // backend installs backend-specific dependencies 15 | func backend(ctx context.Context) error { 16 | zap.S().Info("Installing backend dependencies...") 17 | 18 | // Install gcloud CLI 19 | if runtime.GOOS == "darwin" { 20 | err := cmd.Run(ctx, cmd.WithCMD( 21 | "brew", "install", "google-cloud-sdk", 22 | )) 23 | if err != nil { 24 | return err 25 | } 26 | } else if runtime.GOOS == "linux" { 27 | return errors.New("Please install the gcloud CLI manually") 28 | } 29 | 30 | // Install Firebase CLI globally 31 | err := cmd.Run(ctx, cmd.WithCMD( 32 | "npm", "install", "-g", "firebase-tools", 33 | )) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | zap.S().Info("Deployment dependencies installed successfully") 39 | return nil 40 | } 41 | 42 | func install(ctx context.Context) error { 43 | config, err := GetConfig(ctx) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | zap.S().Infof("mage running with configs: %+v", config) 49 | 50 | // pin to a specific commit for now. See https://github.com/air-verse/air/issues/534 51 | zap.S().Info("installing air at pinned commit for #534") 52 | err = cmd.Run(ctx, 53 | cmd.WithCMD( 54 | "go", 55 | "install", 56 | "github.com/air-verse/air@360714a021b1b77e50a5656fefc4f8bb9312d328", 57 | ), 58 | ) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | // pin to a specific commit for now. See https://github.com/a-h/templ/pull/841 64 | zap.S().Info("installing templ at pinned commit for #841") 65 | err = cmd.Run(ctx, 66 | cmd.WithCMD( 67 | "go", 68 | "install", 69 | "github.com/a-h/templ/cmd/templ@v0.2.707", 70 | ), 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | zap.S().Info("running go mod tidy") 77 | mg.SerialCtxDeps(ctx, templ, tidy) 78 | 79 | zap.S().Info("installing frontend dependencies") 80 | err = cmd.Run(ctx, 81 | cmd.WithDir("web"), 82 | cmd.WithCMD( 83 | "npm", 84 | "install", 85 | ), 86 | ) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // install backend dependencies if the backend arg is passed 92 | if config.Args[0] == "deploy" { 93 | err = backend(ctx) 94 | if err != nil { 95 | return err 96 | } 97 | } 98 | 99 | mg.SerialCtxDeps(ctx, tidy) 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /magefiles/cmdRelease.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 9 | "github.com/grindlemire/gothem-stack/pkg/version" 10 | "github.com/pkg/errors" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | func release(ctx context.Context) error { 15 | config, err := GetConfig(ctx) 16 | if err != nil { 17 | return errors.Wrap(err, "failed to get config") 18 | } 19 | 20 | // Default to releasing both if no service specified 21 | if len(config.Args) < 1 { 22 | config.Args = []string{"all"} 23 | } 24 | 25 | // Only get version for backend releases 26 | var ver version.Version 27 | if config.Args[0] == "backend" || config.Args[0] == "all" { 28 | ver, err = version.Read() 29 | if err != nil { 30 | return errors.Wrap(err, "failed to read version") 31 | } 32 | } 33 | 34 | switch config.Args[0] { 35 | case "backend": 36 | err = releaseBackend(ctx, ver) 37 | case "frontend": 38 | err = releaseFrontend(ctx) 39 | case "all": 40 | // Release backend first 41 | if err = releaseBackend(ctx, ver); err != nil { 42 | return err 43 | } 44 | // Then release frontend 45 | err = releaseFrontend(ctx) 46 | default: 47 | return fmt.Errorf("invalid service: %s. Must be 'backend', 'frontend', or 'all'", config.Args[0]) 48 | } 49 | 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if config.Args[0] == "all" { 55 | zap.S().Info("Successfully released both services") 56 | } else if config.Args[0] == "backend" { 57 | zap.S().Infof("Successfully released backend version %s", ver) 58 | } else { 59 | zap.S().Infof("Successfully released frontend") 60 | } 61 | return nil 62 | } 63 | 64 | func releaseBackend(ctx context.Context, ver version.Version) error { 65 | projectID := os.Getenv("GOOGLE_CLOUD_PROJECT") 66 | if projectID == "" { 67 | return errors.New("GOOGLE_CLOUD_PROJECT environment variable not set") 68 | } 69 | 70 | region := os.Getenv("GOOGLE_CLOUD_REGION") 71 | if region == "" { 72 | region = "us-central1" 73 | } 74 | 75 | serviceName := os.Getenv("CLOUD_RUN_SERVICE_NAME") 76 | if serviceName == "" { 77 | return errors.New("CLOUD_RUN_SERVICE_NAME environment variable not set") 78 | } 79 | 80 | // Construct the image tag 81 | tagname, err := getImageTag( 82 | projectID, 83 | serviceName, 84 | "backend", 85 | fmt.Sprintf("v%s", ver), 86 | ) 87 | if err != nil { 88 | return errors.Wrap(err, "failed to get image tag") 89 | } 90 | 91 | // Deploy to Cloud Run 92 | err = cmd.Run(ctx, cmd.WithCMD( 93 | "gcloud", "run", "deploy", serviceName, 94 | "--image", tagname, 95 | "--platform", "managed", 96 | "--region", region, 97 | "--project", projectID, 98 | "--allow-unauthenticated", 99 | )) 100 | if err != nil { 101 | return errors.Wrap(err, "failed to deploy to cloud run") 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func releaseFrontend(ctx context.Context) error { 108 | // if firebase is not installed, error out 109 | err := cmd.Run(ctx, cmd.WithCMD("firebase", "--version"), cmd.WithSilent()) 110 | if err != nil { 111 | return errors.Wrap(err, "firebase is not installed. Run `mage install frontend` to install it") 112 | } 113 | 114 | zap.S().Info("Deploying frontend to Firebase hosting...") 115 | 116 | err = cmd.Run(ctx, 117 | cmd.WithDir("web"), 118 | cmd.WithCMD( 119 | "firebase", 120 | "deploy", 121 | "--only", "hosting", 122 | ), 123 | ) 124 | if err != nil { 125 | return errors.Wrap(err, "failed to deploy to firebase") 126 | } 127 | 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /magefiles/cmdRun.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func run(ctx context.Context) error { 12 | config, err := GetConfig(ctx) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | zap.S().Infof("mage running with config: %+v", config) 18 | 19 | // tailwindcss will recompute and compile the necessary styles if any of the 20 | // classes change in the templ files 21 | go func() { 22 | err = cmd.Run(ctx, 23 | cmd.WithDir("./web"), 24 | cmd.WithCMD( 25 | "node_modules/.bin/tailwindcss", 26 | "-i", "tailwind.css", 27 | "-o", "public/styles.min.css", 28 | "--watch", 29 | ), 30 | ) 31 | }() 32 | 33 | // templ watch will watch for changes to templ files and regenerate the code 34 | // as necessary 35 | go func() { 36 | err = cmd.Run(ctx, 37 | cmd.WithCMD( 38 | "templ", 39 | "generate", 40 | "--watch", 41 | `--proxy=http://localhost:4433`, 42 | "--open-browser=false", 43 | ), 44 | ) 45 | if err != nil { 46 | zap.S().Errorf("Error running templ watch: %v", err) 47 | } 48 | }() 49 | 50 | // air will restart the main binary when it detects a change 51 | // to a templ or go file. 52 | err = cmd.Run(ctx, 53 | cmd.WithCMD( 54 | "air", 55 | "-c", ".air.toml", 56 | ), 57 | ) 58 | if err != nil { 59 | zap.S().Errorf("Error running air server: %v", err) 60 | } 61 | return err 62 | 63 | } 64 | -------------------------------------------------------------------------------- /magefiles/cmdTempl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 7 | "github.com/pkg/errors" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func templ(ctx context.Context) error { 12 | config, err := GetConfig(ctx) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | zap.S().Infof("mage running with config: %+v", config) 18 | 19 | err = cmd.Run(ctx, 20 | cmd.WithCMD( 21 | "templ", 22 | "generate", 23 | ), 24 | ) 25 | if err != nil { 26 | return errors.Wrap(err, "generating templ files") 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /magefiles/cmdTidy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grindlemire/gothem-stack/magefiles/cmd" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func tidy(ctx context.Context) (err error) { 12 | err = cmd.Run(ctx, 13 | cmd.WithCMD( 14 | "go", 15 | "mod", "tidy", 16 | ), 17 | ) 18 | if err != nil { 19 | zap.S().Errorf("Error running go mod tidy: %v", err) 20 | } 21 | return err 22 | 23 | } 24 | -------------------------------------------------------------------------------- /magefiles/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/joho/godotenv" 8 | "github.com/kelseyhightower/envconfig" 9 | "github.com/pkg/errors" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type configKey string 14 | 15 | var key = configKey("mageconfigkey") 16 | 17 | // GetConfig retrieves the mage config from the context 18 | func GetConfig(ctx context.Context) (config Config, err error) { 19 | config, ok := ctx.Value(key).(Config) 20 | if !ok { 21 | return config, errors.Errorf("config not found in mage context") 22 | } 23 | return config, nil 24 | } 25 | 26 | // WithConfig adds the mage config to the context 27 | func WithConfig(ctx context.Context, args ...string) context.Context { 28 | var config Config 29 | err := envconfig.Process("", &config) 30 | if err != nil { 31 | zap.S().Fatalf("unable to parse environment config: %v", err) 32 | } 33 | 34 | godotenv.Load(fmt.Sprintf("%s.env", config.Env)) 35 | 36 | // we have to process the env config again to get the env vars loaded from the env file 37 | err = envconfig.Process("", &config) 38 | if err != nil { 39 | zap.S().Fatalf("unable to parse environment config: %v", err) 40 | } 41 | config.Args = args 42 | 43 | return context.WithValue(ctx, key, config) 44 | } 45 | -------------------------------------------------------------------------------- /magefiles/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grindlemire/gothem-stack/magefiles 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/grindlemire/gothem-stack v0.0.0-00010101000000-000000000000 7 | github.com/joho/godotenv v1.5.1 8 | github.com/kelseyhightower/envconfig v1.4.0 9 | github.com/magefile/mage v1.15.0 10 | github.com/pkg/errors v0.9.1 11 | go.uber.org/zap v1.27.0 12 | ) 13 | 14 | require ( 15 | github.com/a-h/templ v0.2.747 // indirect 16 | go.uber.org/multierr v1.11.0 // indirect 17 | ) 18 | 19 | replace github.com/grindlemire/gothem-stack => ./.. 20 | -------------------------------------------------------------------------------- /magefiles/go.sum: -------------------------------------------------------------------------------- 1 | github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= 2 | github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 8 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 9 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 10 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 11 | github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= 12 | github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 13 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 14 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 18 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 19 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 20 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 21 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 22 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 23 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 24 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /magefiles/magefile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | 8 | "github.com/grindlemire/gothem-stack/pkg/log" 9 | "github.com/pkg/errors" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func init() { 14 | log.InitGlobal() 15 | } 16 | 17 | // Config is the identifying information pulled out of the environment to execute 18 | // different mage commands 19 | type Config struct { 20 | Env string `envconfig:"env" default:"local"` 21 | Args []string `envconfig:"args" default:""` 22 | } 23 | 24 | func Install() (err error) { 25 | defer func(now time.Time) { 26 | if r := recover(); r != nil { 27 | err = errors.Errorf("%s", r) 28 | } 29 | finish(now, err) 30 | }(time.Now()) 31 | 32 | ctx, cancel := context.WithCancel(context.Background()) 33 | defer cancel() 34 | 35 | // ignore the first two args since they are "mage" and "init" 36 | return install(WithConfig(ctx, os.Args[2:]...)) 37 | } 38 | 39 | // Tidy will run go mod tidy 40 | func Tidy() (err error) { 41 | defer func(now time.Time) { 42 | if r := recover(); r != nil { 43 | err = errors.Errorf("%s", r) 44 | } 45 | finish(now, err) 46 | }(time.Now()) 47 | 48 | ctx, cancel := context.WithCancel(context.Background()) 49 | defer cancel() 50 | 51 | // ignore the first two args since they are "mage" and "tidy" 52 | return tidy(WithConfig(ctx, os.Args[2:]...)) 53 | } 54 | 55 | // Build will build a new binary 56 | func Build() (err error) { 57 | defer func(now time.Time) { 58 | if r := recover(); r != nil { 59 | err = errors.Errorf("%s", r) 60 | } 61 | finish(now, err) 62 | }(time.Now()) 63 | 64 | ctx, cancel := context.WithCancel(context.Background()) 65 | defer cancel() 66 | 67 | // ignore the first two args since they are "mage" and "build" 68 | return build(WithConfig(ctx, os.Args[2:]...)) 69 | } 70 | 71 | // Run will run a local dev server and UI 72 | func Run() (err error) { 73 | defer func(now time.Time) { 74 | if r := recover(); r != nil { 75 | err = errors.Errorf("%s", r) 76 | } 77 | finish(now, err) 78 | }(time.Now()) 79 | 80 | ctx, cancel := context.WithCancel(context.Background()) 81 | defer cancel() 82 | 83 | // ignore the first two args since they are "mage" and "run" 84 | return run(WithConfig(ctx, os.Args[2:]...)) 85 | } 86 | 87 | // Templ will run the templ command and generate the go code for the templates 88 | func Templ() (err error) { 89 | defer func(now time.Time) { 90 | if r := recover(); r != nil { 91 | err = errors.Errorf("%s", r) 92 | } 93 | finish(now, err) 94 | }(time.Now()) 95 | 96 | ctx, cancel := context.WithCancel(context.Background()) 97 | defer cancel() 98 | 99 | // ignore the first two args since they are "mage" and "templ" 100 | return templ(WithConfig(ctx, os.Args[2:]...)) 101 | } 102 | 103 | // Deploy will deploy the static site to Firebase hosting 104 | func Deploy() (err error) { 105 | defer func(now time.Time) { 106 | if r := recover(); r != nil { 107 | err = errors.Errorf("%s", r) 108 | } 109 | finish(now, err) 110 | }(time.Now()) 111 | 112 | ctx, cancel := context.WithCancel(context.Background()) 113 | defer cancel() 114 | 115 | // ignore the first two args since they are "mage" and "deploy" 116 | return deploy(WithConfig(ctx, os.Args[2:]...)) 117 | } 118 | 119 | // Auth will authenticate with cloud services (gcloud or firebase) 120 | func Auth() (err error) { 121 | defer func(now time.Time) { 122 | if r := recover(); r != nil { 123 | err = errors.Errorf("%s", r) 124 | } 125 | finish(now, err) 126 | }(time.Now()) 127 | 128 | ctx, cancel := context.WithCancel(context.Background()) 129 | defer cancel() 130 | 131 | // ignore the first two args since they are "mage" and "auth" 132 | return auth(WithConfig(ctx, os.Args[2:]...)) 133 | } 134 | 135 | // Release will deploy a specific version of the service to Cloud Run 136 | func Release() (err error) { 137 | defer func(now time.Time) { 138 | if r := recover(); r != nil { 139 | err = errors.Errorf("%s", r) 140 | } 141 | finish(now, err) 142 | }(time.Now()) 143 | 144 | ctx, cancel := context.WithCancel(context.Background()) 145 | defer cancel() 146 | 147 | // ignore the first two args since they are "mage" and "release" 148 | return release(WithConfig(ctx, os.Args[2:]...)) 149 | } 150 | 151 | // Destroy will delete all remote cloud infrastructure created during deploy 152 | func Destroy() (err error) { 153 | defer func(now time.Time) { 154 | if r := recover(); r != nil { 155 | err = errors.Errorf("%s", r) 156 | } 157 | finish(now, err) 158 | }(time.Now()) 159 | 160 | ctx, cancel := context.WithCancel(context.Background()) 161 | defer cancel() 162 | 163 | // ignore the first two args since they are "mage" and "destroy" 164 | return destroy(WithConfig(ctx, os.Args[2:]...)) 165 | } 166 | 167 | func finish(start time.Time, err error) { 168 | zap.S().Infof("elapsed time: %s", time.Since(start)) 169 | // This is a hack to get around the fact that mage treats command line args 170 | // as other targets. In a run we just want to interpret them as arugments to the binary, 171 | // not as other targets. So we just short circuit and tell mage to stop 172 | if err != nil { 173 | zap.S().Fatal(err) 174 | } 175 | os.Exit(0) 176 | } 177 | -------------------------------------------------------------------------------- /magefiles/tags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // getRepoName returns the fully qualified repository name for the given service 10 | func getRepoName(projectID, service, image string) string { 11 | return fmt.Sprintf("us-central1-docker.pkg.dev/%s/%s/gs-%s", projectID, service, image) 12 | } 13 | 14 | // getImageTag returns the fully qualified image tag for the given service 15 | func getImageTag(projectID, service, image, version string) (string, error) { 16 | repoName := getRepoName(projectID, service, image) 17 | if version == "" { 18 | return "", errors.New("version cannot be empty") 19 | } 20 | return fmt.Sprintf("%s:%s", repoName, version), nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | // Middleware is a simple middleware that checks the request for authentication 9 | func Middleware() echo.MiddlewareFunc { 10 | return func(next echo.HandlerFunc) echo.HandlerFunc { 11 | return func(c echo.Context) (err error) { 12 | // obviously this is not real authentication and is just illustrative of what you can do here 13 | username, _, ok := c.Request().BasicAuth() 14 | if ok && username == "reject" { 15 | return echo.ErrUnauthorized.SetInternal(errors.Errorf("user; %s is not authorized", username)) 16 | } 17 | return next(c) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/handler/error.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/pkg/errors" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func Error(err error, c echo.Context) { 13 | if c.Response().Committed { 14 | return 15 | } 16 | 17 | he, ok := err.(*echo.HTTPError) 18 | if ok { 19 | // If there is an internal error then use that for printing 20 | if he.Internal != nil { 21 | err = he.Internal 22 | } 23 | } else { 24 | he = &echo.HTTPError{ 25 | Code: http.StatusInternalServerError, 26 | Message: http.StatusText(http.StatusInternalServerError), 27 | Internal: err, 28 | } 29 | } 30 | 31 | code := he.Code 32 | message := he.Message 33 | 34 | // only log unauthorized errors at the debug level 35 | if !errors.Is(he, echo.ErrUnauthorized) { 36 | zap.S().Error(err) 37 | } else { 38 | zap.S().Debug(err) 39 | } 40 | 41 | switch m := he.Message.(type) { 42 | case string: 43 | message = echo.Map{"message": m} 44 | case json.Marshaler: 45 | // do nothing - this type knows how to format itself to JSON 46 | case error: 47 | message = echo.Map{"message": m.Error()} 48 | } 49 | 50 | // Send response 51 | if c.Request().Method == http.MethodHead { 52 | err = c.NoContent(he.Code) 53 | if err != nil { 54 | zap.S().Error(errors.Wrap(err, "sending no content")) 55 | } 56 | return 57 | } 58 | 59 | err = c.JSON(code, message) 60 | if err != nil { 61 | zap.S().Error(errors.Wrap(err, "marshalling json payload")) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/handler/home.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/grindlemire/gothem-stack/pkg/log" 8 | "github.com/grindlemire/gothem-stack/web/pages/home" 9 | "github.com/labstack/echo/v4" 10 | "github.com/pkg/errors" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type HomeHandler struct { 15 | // you could put a database handle here or any dependencies you want 16 | } 17 | 18 | func NewHomeHandler() (h *HomeHandler, err error) { 19 | return &HomeHandler{}, nil 20 | } 21 | 22 | // RegisterRoutes registers all the subroutes for the home handler to manage 23 | func (h *HomeHandler) RegisterRoutes(g *echo.Group) { 24 | g.GET("", h.RenderHomepage) 25 | g.GET("/random-string", h.GetRandomString) 26 | } 27 | 28 | func (h *HomeHandler) RenderHomepage(c echo.Context) error { 29 | return render(c, home.Page()) 30 | } 31 | 32 | func (h *HomeHandler) GetRandomString(c echo.Context) error { 33 | time.Sleep(750 * time.Millisecond) 34 | 35 | err := DoThing() 36 | if err != nil { 37 | zap.L().Info("example error", log.Callers(err)...) 38 | // zap.L().Info("example error with stacktrace", log.Callers(err, log.WithStack())...) 39 | } 40 | 41 | return render(c, home.RandomString(uuid.NewString())) 42 | } 43 | 44 | func DoThing() error { 45 | return DoSubThing() 46 | } 47 | 48 | func DoSubThing() error { 49 | // wrap third party errors at the callsite to get nice stack traces 50 | return errors.Wrap(ThirdPartyError(), "wrapped third party error") 51 | } 52 | 53 | func ThirdPartyError() error { 54 | return errors.New("third party error") 55 | } 56 | -------------------------------------------------------------------------------- /pkg/handler/util.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/a-h/templ" 5 | "github.com/labstack/echo/v4" 6 | ) 7 | 8 | func render(c echo.Context, component templ.Component) error { 9 | return component.Render(c.Request().Context(), c.Response().Writer) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/pkg/errors" 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | ) 13 | 14 | // Initializes the log depending on the environment 15 | func InitGlobal() error { 16 | var core zapcore.Core 17 | 18 | var logger *zap.Logger 19 | if strings.ToLower(os.Getenv("ENV")) == "prod" { 20 | zapconf := zap.NewProductionConfig() 21 | zapconf.EncoderConfig.FunctionKey = "func" 22 | core = zapcore.NewCore( 23 | zapcore.NewJSONEncoder(zapconf.EncoderConfig), 24 | zapcore.Lock(os.Stdout), 25 | zapcore.InfoLevel, 26 | ) 27 | 28 | logger = zap.New( 29 | core, 30 | zap.AddCaller(), 31 | ).Named("prod") 32 | } else { 33 | // if we are not in gcp use a console logger 34 | config := zap.NewDevelopmentConfig() 35 | config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 36 | config.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 37 | enc.AppendString(t.UTC().Format("2006-01-02T15:04:05.000Z")) 38 | } 39 | 40 | logLevel := zapcore.InfoLevel 41 | debug := strings.ToLower(os.Getenv("DEBUG")) 42 | if debug == "1" || debug == "true" { 43 | logLevel = zapcore.DebugLevel 44 | } 45 | 46 | core = zapcore.NewCore( 47 | zapcore.NewConsoleEncoder(config.EncoderConfig), 48 | zapcore.Lock(os.Stdout), 49 | logLevel, 50 | ) 51 | 52 | logger = zap.New( 53 | core, 54 | zap.AddCaller(), 55 | ).Named("dev") 56 | } 57 | 58 | zap.ReplaceGlobals(logger) 59 | return nil 60 | } 61 | 62 | type stackTracer interface { 63 | StackTrace() errors.StackTrace 64 | } 65 | 66 | type stackOpt struct { 67 | withStack bool 68 | } 69 | 70 | type opt func(*stackOpt) 71 | 72 | func WithStack() opt { 73 | return func(o *stackOpt) { 74 | o.withStack = true 75 | } 76 | } 77 | 78 | func Callers(err error, opts ...opt) []zap.Field { 79 | o := &stackOpt{} 80 | for _, opt := range opts { 81 | opt(o) 82 | } 83 | 84 | stack := "" 85 | caller := "" 86 | fun := "" 87 | if err, ok := err.(stackTracer); ok { 88 | for i, f := range err.StackTrace() { 89 | if i == 0 { 90 | caller = fmt.Sprintf("%s:%d", f, f) 91 | fun = fmt.Sprintf("%n", f) 92 | } 93 | stack = fmt.Sprintf("%s%+s:%d\n", stack, f, f) 94 | } 95 | } 96 | 97 | fields := []zap.Field{ 98 | zap.String("err_caller", caller), 99 | zap.String("err_func", fun), 100 | } 101 | 102 | if o.withStack { 103 | fields = append(fields, zap.String("err_stack", stack)) 104 | } 105 | 106 | return fields 107 | } 108 | -------------------------------------------------------------------------------- /pkg/server/certs.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "encoding/pem" 10 | "math/big" 11 | "os" 12 | "time" 13 | 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | const ( 18 | privateKeyFile = "./server.key" 19 | publicKeyFile = "./server.crt" 20 | ) 21 | 22 | func hasCerts() bool { 23 | _, privErr := os.Stat(privateKeyFile) 24 | _, pubErr := os.Stat(publicKeyFile) 25 | 26 | return privErr == nil && pubErr == nil 27 | } 28 | 29 | func generateCerts() (*rsa.PrivateKey, error) { 30 | bitSize := 4096 31 | 32 | privateKey, err := generatePrivateKey(bitSize) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | publicKeyBytes, err := generateX509Cert(privateKey) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | privateKeyBytes := encodePrivateKeyToPEM(privateKey) 43 | 44 | err = writeKeyToFile(privateKeyBytes, privateKeyFile) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | err = writeKeyToFile([]byte(publicKeyBytes), publicKeyFile) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | return privateKey, nil 55 | } 56 | 57 | // generatePrivateKey creates a RSA Private Key of specified byte size 58 | func generatePrivateKey(bitSize int) (*rsa.PrivateKey, error) { 59 | // Private Key generation 60 | privateKey, err := rsa.GenerateKey(rand.Reader, bitSize) 61 | if err != nil { 62 | return nil, errors.Wrap(err, "generating private key") 63 | } 64 | 65 | // Validate Private Key 66 | err = privateKey.Validate() 67 | if err != nil { 68 | return nil, errors.Wrap(err, "validating private key") 69 | } 70 | 71 | return privateKey, nil 72 | } 73 | 74 | // encodePrivateKeyToPEM encodes Private Key from RSA to PEM format 75 | func encodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte { 76 | // pem.Block 77 | privBlock := pem.Block{ 78 | Type: "RSA PRIVATE KEY", 79 | Bytes: x509.MarshalPKCS1PrivateKey(privateKey), 80 | } 81 | 82 | // Private key in PEM format 83 | privatePEM := pem.EncodeToMemory(&privBlock) 84 | 85 | return privatePEM 86 | } 87 | 88 | // generateX509Cert take a rsa.PublicKey and return bytes suitable for writing to .pub file 89 | // returns in the format "ssh-rsa ..." 90 | func generateX509Cert(privateKey *rsa.PrivateKey) (b []byte, err error) { 91 | template := x509.Certificate{ 92 | SerialNumber: big.NewInt(1), 93 | Subject: pkix.Name{ 94 | Organization: []string{"ACME corp"}, 95 | }, 96 | NotBefore: time.Now(), 97 | NotAfter: time.Now().Add(time.Hour * 24 * 180), 98 | 99 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 100 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 101 | BasicConstraintsValid: true, 102 | } 103 | 104 | certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) 105 | if err != nil { 106 | return b, errors.Wrap(err, "creating x509 certificate") 107 | } 108 | 109 | out := &bytes.Buffer{} 110 | err = pem.Encode(out, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) 111 | if err != nil { 112 | return b, errors.Wrap(err, "encoding certificate to pem") 113 | } 114 | 115 | return out.Bytes(), nil 116 | } 117 | 118 | // writePemToFile writes keys to a file 119 | func writeKeyToFile(keyBytes []byte, saveFileTo string) error { 120 | err := os.WriteFile(saveFileTo, keyBytes, 0o600) 121 | if err != nil { 122 | return errors.Wrap(err, "writing key to file") 123 | } 124 | 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /pkg/server/router.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/grindlemire/gothem-stack/pkg/auth" 8 | "github.com/grindlemire/gothem-stack/pkg/handler" 9 | "github.com/grindlemire/gothem-stack/web" 10 | 11 | "github.com/labstack/echo/v4" 12 | "github.com/labstack/echo/v4/middleware" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | func NewRouter(ctx context.Context) (h http.Handler, err error) { 17 | e := echo.New() 18 | 19 | e.Use( 20 | // recover from panics and create errors from them 21 | middleware.Recover(), 22 | // TODO: other global middleware goes here 23 | ) 24 | 25 | // register the customer pages and components 26 | homeHandler, err := handler.NewHomeHandler() 27 | if err != nil { 28 | return h, err 29 | } 30 | homeHandler.RegisterRoutes( 31 | e.Group("", auth.Middleware()), 32 | ) 33 | 34 | // register the static assets like the favicon and the css 35 | err = web.RegisterStaticAssets(e) 36 | if err != nil { 37 | return h, err 38 | } 39 | 40 | // all other routes should return not found. This should be the last registered route in the list 41 | e.HTTPErrorHandler = handler.Error 42 | e.Add(echo.RouteNotFound, "/*", echo.HandlerFunc(func(c echo.Context) error { 43 | return echo.ErrNotFound.SetInternal(errors.Errorf("not found | uri=[%s]", c.Request().RequestURI)) 44 | }), []echo.MiddlewareFunc{}...) 45 | 46 | return e.Server.Handler, nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/kelseyhightower/envconfig" 10 | "github.com/pkg/errors" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // ServerConfig is configuration for the server parsed from the env. 15 | type ServerConfig struct { 16 | Port int `envconfig:"PORT" default:"4433"` 17 | LocalCerts bool `envconfig:"LOCAL_CERTS" default:"false" split_words:"true"` 18 | } 19 | 20 | // Run runs the server. The context will be cancelled if we receive a SIGTERM (ctrl-c) 21 | func Run(ctx context.Context) error { 22 | // parse the env config 23 | var config ServerConfig 24 | err := envconfig.Process("", &config) 25 | if err != nil { 26 | return errors.Wrap(err, "loading environment") 27 | } 28 | addr := fmt.Sprintf(":%d", config.Port) 29 | 30 | if config.LocalCerts { 31 | if !hasCerts() { 32 | _, err := generateCerts() 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | } 38 | 39 | // create the top level http router 40 | httpRouter := http.NewServeMux() 41 | 42 | // create our echo router and match all routes to it 43 | webMux, err := NewRouter(ctx) 44 | if err != nil { 45 | return err 46 | } 47 | httpRouter.Handle("/", webMux) 48 | server := &http.Server{Addr: addr, Handler: httpRouter} 49 | 50 | // run the listeners in their own goroutine, this is so we can properly propagate signals 51 | // and cleanup everything since there may be other signals that need to be cleaned up. 52 | errCh := make(chan error, 1) 53 | go func() { 54 | zap.S().Infof("started listening on %s", addr) 55 | if config.LocalCerts { 56 | zap.S().Debug(ctx, "listening with tls") 57 | err := server.ListenAndServeTLS(publicKeyFile, privateKeyFile) 58 | errCh <- errors.Wrap(err, "starting server") 59 | return 60 | } 61 | err := server.ListenAndServe() 62 | errCh <- errors.Wrap(err, "starting server") 63 | }() 64 | 65 | // wait for either the context to be cancelled indicating we should shutdown 66 | // or for the servers to fail 67 | for { 68 | select { 69 | case <-ctx.Done(): 70 | shutdownCTX, cancel := context.WithTimeout(context.Background(), 5*time.Second) 71 | defer cancel() 72 | 73 | err = server.Shutdown(shutdownCTX) 74 | if err != nil { 75 | return err 76 | } 77 | return ctx.Err() 78 | case err := <-errCh: 79 | return err 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // DefaultVersionFile is the default path for the version file 13 | const DefaultVersionFile = "version.txt" 14 | 15 | // Version represents the version of a service 16 | type Version struct { 17 | Major int 18 | Minor int 19 | Patch int 20 | } 21 | 22 | // String returns the version as a string 23 | func (v Version) String() string { 24 | return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 25 | } 26 | 27 | // ReadFromFile reads the version from the specified file path 28 | func ReadFromFile(filepath string) (Version, error) { 29 | data, err := os.ReadFile(filepath) 30 | if err != nil { 31 | if os.IsNotExist(err) { 32 | // Start with 0.0.1 if file doesn't exist 33 | return Version{Major: 0, Minor: 0, Patch: 1}, nil 34 | } 35 | return Version{}, errors.Wrap(err, "failed to read version file") 36 | } 37 | return parseVersion(strings.TrimSpace(string(data))) 38 | } 39 | 40 | // WriteToFile writes the version to the specified file path 41 | func WriteToFile(v Version, filepath string) error { 42 | return os.WriteFile(filepath, []byte(v.String()), 0644) 43 | } 44 | 45 | // Read reads the version from the default version file 46 | func Read() (Version, error) { 47 | return ReadFromFile(DefaultVersionFile) 48 | } 49 | 50 | // Write writes the version to the default version file 51 | func Write(v Version) error { 52 | return WriteToFile(v, DefaultVersionFile) 53 | } 54 | 55 | // Increment increments the version 56 | func (v *Version) Increment(level string) error { 57 | switch level { 58 | case "major": 59 | v.Major++ 60 | v.Minor = 0 61 | v.Patch = 0 62 | case "minor": 63 | v.Minor++ 64 | v.Patch = 0 65 | case "patch": 66 | v.Patch++ 67 | default: 68 | return fmt.Errorf("invalid version increment level: %s", level) 69 | } 70 | return nil 71 | } 72 | 73 | // parseVersion parses the version from a string 74 | func parseVersion(ver string) (Version, error) { 75 | parts := strings.Split(strings.TrimPrefix(ver, "v"), ".") 76 | if len(parts) != 3 { 77 | return Version{}, fmt.Errorf("invalid version format: %s", ver) 78 | } 79 | 80 | major, err := strconv.Atoi(parts[0]) 81 | if err != nil { 82 | return Version{}, errors.Wrap(err, "invalid major version") 83 | } 84 | 85 | minor, err := strconv.Atoi(parts[1]) 86 | if err != nil { 87 | return Version{}, errors.Wrap(err, "invalid minor version") 88 | } 89 | 90 | patch, err := strconv.Atoi(parts[2]) 91 | if err != nil { 92 | return Version{}, errors.Wrap(err, "invalid patch version") 93 | } 94 | 95 | return Version{Major: major, Minor: minor, Patch: patch}, nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseVersion(t *testing.T) { 11 | type tc struct { 12 | name string 13 | input string 14 | expected Version 15 | expectError bool 16 | } 17 | 18 | tests := map[string]tc{ 19 | "valid version": { 20 | input: "1.2.3", 21 | expected: Version{Major: 1, Minor: 2, Patch: 3}, 22 | }, 23 | "version with v prefix": { 24 | input: "v2.3.4", 25 | expected: Version{Major: 2, Minor: 3, Patch: 4}, 26 | }, 27 | "invalid format": { 28 | input: "1.2", 29 | expectError: true, 30 | }, 31 | "invalid major": { 32 | input: "a.2.3", 33 | expectError: true, 34 | }, 35 | "invalid minor": { 36 | input: "1.b.3", 37 | expectError: true, 38 | }, 39 | "invalid patch": { 40 | input: "1.2.c", 41 | expectError: true, 42 | }, 43 | } 44 | 45 | for name, tc := range tests { 46 | t.Run(name, func(t *testing.T) { 47 | result, err := parseVersion(tc.input) 48 | if tc.expectError { 49 | assert.Error(t, err) 50 | } else { 51 | assert.NoError(t, err) 52 | assert.Equal(t, tc.expected, result) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestVersionString(t *testing.T) { 59 | tests := map[string]struct { 60 | version Version 61 | expected string 62 | }{ 63 | "standard version": { 64 | version: Version{Major: 1, Minor: 2, Patch: 3}, 65 | expected: "1.2.3", 66 | }, 67 | "zero version": { 68 | version: Version{Major: 0, Minor: 0, Patch: 0}, 69 | expected: "0.0.0", 70 | }, 71 | } 72 | 73 | for name, tc := range tests { 74 | t.Run(name, func(t *testing.T) { 75 | assert.Equal(t, tc.expected, tc.version.String()) 76 | }) 77 | } 78 | } 79 | 80 | func TestVersionIncrement(t *testing.T) { 81 | type tc struct { 82 | name string 83 | initial Version 84 | level string 85 | expected Version 86 | expectError bool 87 | } 88 | 89 | tests := map[string]tc{ 90 | "increment major": { 91 | initial: Version{Major: 1, Minor: 2, Patch: 3}, 92 | level: "major", 93 | expected: Version{Major: 2, Minor: 0, Patch: 0}, 94 | }, 95 | "increment minor": { 96 | initial: Version{Major: 1, Minor: 2, Patch: 3}, 97 | level: "minor", 98 | expected: Version{Major: 1, Minor: 3, Patch: 0}, 99 | }, 100 | "increment patch": { 101 | initial: Version{Major: 1, Minor: 2, Patch: 3}, 102 | level: "patch", 103 | expected: Version{Major: 1, Minor: 2, Patch: 4}, 104 | }, 105 | "invalid level": { 106 | initial: Version{Major: 1, Minor: 2, Patch: 3}, 107 | level: "invalid", 108 | expectError: true, 109 | }, 110 | } 111 | 112 | for name, tc := range tests { 113 | t.Run(name, func(t *testing.T) { 114 | v := tc.initial 115 | err := v.Increment(tc.level) 116 | if tc.expectError { 117 | assert.Error(t, err) 118 | } else { 119 | assert.NoError(t, err) 120 | assert.Equal(t, tc.expected, v) 121 | } 122 | }) 123 | } 124 | } 125 | 126 | func TestReadWrite(t *testing.T) { 127 | // Create temp file path 128 | tmpFile, err := os.CreateTemp("", "version_test_*.txt") 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | tmpPath := tmpFile.Name() 133 | tmpFile.Close() 134 | 135 | // Clean up after test 136 | defer os.Remove(tmpPath) 137 | 138 | tests := map[string]struct { 139 | initialVersion Version 140 | expectedError bool 141 | }{ 142 | "write and read version": { 143 | initialVersion: Version{Major: 1, Minor: 2, Patch: 3}, 144 | }, 145 | "read non-existent file": { 146 | expectedError: false, // Should return 0.0.1 147 | }, 148 | } 149 | 150 | for name, tc := range tests { 151 | t.Run(name, func(t *testing.T) { 152 | // Remove any existing file 153 | os.Remove(tmpPath) 154 | 155 | if !tc.expectedError { 156 | if err := WriteToFile(tc.initialVersion, tmpPath); err != nil { 157 | t.Fatal(err) 158 | } 159 | 160 | readVersion, err := ReadFromFile(tmpPath) 161 | assert.NoError(t, err) 162 | if tc.initialVersion.Major == 0 && tc.initialVersion.Minor == 0 && tc.initialVersion.Patch == 0 { 163 | // Expect default version for non-existent file 164 | assert.Equal(t, Version{Major: 0, Minor: 0, Patch: 1}, readVersion) 165 | } else { 166 | assert.Equal(t, tc.initialVersion, readVersion) 167 | } 168 | } 169 | }) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /web/components/page/page.templ: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | templ Base(name string) { 4 | 5 | 12 | 13 | 14 | { name } 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 39 | 40 | 41 | { children... } 42 | 43 | 44 | } 45 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@egoist/tailwindcss-icons": "^1.7.4", 4 | "@iconify-json/mdi": "^1.1.66", 5 | "daisyui": "4.6.1", 6 | "tailwindcss": "^3.3.1" 7 | }, 8 | "dependencies": { 9 | "@iconify-json/ic": "^1.1.17" 10 | } 11 | } -------------------------------------------------------------------------------- /web/pages/home/home.templ: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import "github.com/grindlemire/gothem-stack/web/components/page" 4 | 5 | templ Page() { 6 | @page.Base("home") { 7 |

8 |
9 |
10 |

Generate Random Strings using an HTMX call:

11 |
12 | 25 |
26 |
27 |
31 |
35 |
36 | 37 |
38 |

Alpine.js Counter:

39 |
40 |
41 |
42 | 48 | 54 |
55 | 56 |
57 | 65 |
66 |
67 |
68 |
69 |
70 |
71 | } 72 | } 73 | 74 | templ RandomString(s string) { 75 |
80 | GS2-{ s } 81 |
82 | } 83 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grindlemire/gothem-stack/cd0c89089d34a0c0b2e2c15a9d565022e631af79/web/public/favicon.ico -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | iconsPlugin, 3 | getIconCollections, 4 | } = require("@egoist/tailwindcss-icons"); 5 | 6 | /** @type {import('tailwindcss').Config} */ 7 | module.exports = { 8 | content: ["**/*.templ"], 9 | darkMode: "class", 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | mono: ["Courier Prime", "monospace"], 14 | }, 15 | }, 16 | }, 17 | plugins: [ 18 | require("daisyui"), 19 | iconsPlugin({ 20 | collections: getIconCollections(["ic", "mdi"]), 21 | }), 22 | ], 23 | daisyui: {}, 24 | }; 25 | -------------------------------------------------------------------------------- /web/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | //go:embed public/* 13 | var public embed.FS 14 | 15 | // RegisterStaticAssets will register the static css, js, and html assets in the public 16 | // directory under the /dist url path in the echo server. 17 | func RegisterStaticAssets(e *echo.Echo) error { 18 | // embed and register the static files (css, favicon, js, etc.) 19 | assets, err := fs.Sub(public, "public") 20 | if err != nil { 21 | return errors.Wrap(err, "processing public assets") 22 | } 23 | e.StaticFS("/dist", assets) 24 | 25 | // independently return the favicon because some robots like to pull from this path 26 | e.GET("/favicon.ico", func(c echo.Context) error { 27 | favicon, err := public.ReadFile("public/favicon.ico") 28 | if err != nil { 29 | return errors.Wrap(err, "reading favicon") 30 | } 31 | 32 | return c.Blob(http.StatusOK, "image/x-icon", favicon) 33 | }) 34 | 35 | return nil 36 | } 37 | --------------------------------------------------------------------------------