├── .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 |
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 |