diff options
Diffstat (limited to 'nix/templates/goapp/frontend')
22 files changed, 1120 insertions, 89 deletions
diff --git a/nix/templates/goapp/frontend/default.nix b/nix/templates/goapp/frontend/default.nix index f4ed3a4..42ccb79 100644 --- a/nix/templates/goapp/frontend/default.nix +++ b/nix/templates/goapp/frontend/default.nix @@ -9,8 +9,8 @@ pkgs.buildGoModule { version = "${version}"; src = ./.; - subPackages = [ "" ]; - vendorHash = "sha256-tIk8lmyuVETrOW7fA7K7uNNXAAtJAYSM4uH+xZaMWqc="; + subPackages = [ "src" ]; + vendorHash = "sha256-VXuhsXejduIcthawj4qu7hruBEDegj27YY0ym5srMQY="; doCheck = true; } diff --git a/nix/templates/goapp/frontend/go.mod b/nix/templates/goapp/frontend/go.mod index 1532a66..fecf4ac 100644 --- a/nix/templates/goapp/frontend/go.mod +++ b/nix/templates/goapp/frontend/go.mod @@ -1,4 +1,4 @@ -module github.com/hanemile/goapp/frontend +module github.com/hanemile/goapp/backend go 1.23.5 @@ -7,18 +7,25 @@ require ( github.com/gorilla/mux v1.8.1 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 + github.com/mattn/go-sqlite3 v1.14.24 + golang.org/x/crypto v0.33.0 + golang.org/x/oauth2 v0.21.0 modernc.org/sqlite v1.34.5 ) require ( + github.com/coreos/go-oidc/v3 v3.12.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.22.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/sys v0.30.0 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect diff --git a/nix/templates/goapp/frontend/go.sum b/nix/templates/goapp/frontend/go.sum index 53d3f31..365e2c5 100644 --- a/nix/templates/goapp/frontend/go.sum +++ b/nix/templates/goapp/frontend/go.sum @@ -1,9 +1,14 @@ +github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo= +github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= @@ -18,6 +23,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= @@ -26,13 +33,24 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= diff --git a/nix/templates/goapp/frontend/main.db b/nix/templates/goapp/frontend/main.db new file mode 100644 index 0000000..da9d88e --- /dev/null +++ b/nix/templates/goapp/frontend/main.db Binary files differdiff --git a/nix/templates/goapp/frontend/main.go b/nix/templates/goapp/frontend/main.go deleted file mode 100644 index f6605be..0000000 --- a/nix/templates/goapp/frontend/main.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "net/http" - "os" - "time" - - "github.com/gorilla/mux" -) - -var ( - host string - port int - logFilePath string - databasePath string - sessiondbPath string - globalState *State -) - -func initFlags() { - flag.StringVar(&host, "host", "127.0.0.1", "The host to listen on") - flag.StringVar(&host, "h", "127.0.0.1", "The host to listen on (shorthand)") - - flag.IntVar(&port, "port", 8080, "The port to listen on") - flag.IntVar(&port, "p", 8080, "The port to listen on (shorthand)") - - flag.StringVar(&logFilePath, "logfilepath", "./server.log", "The path to the log file") - flag.StringVar(&databasePath, "databasepath", "./main.db", "The path to the main database") - flag.StringVar(&sessiondbPath, "sessiondbpath", "./sessions.db", "The path to the session database") -} - -func indexHandler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello World from the frontend") -} - -func main() { - initFlags() - flag.Parse() - - // log init - log.Println("[i] Setting up logging...") - logFile, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0664) - if err != nil { - log.Fatal("Error opening the server.log file: ", err) - } - logger := loggingMiddleware{logFile} - - // db init - log.Println("[i] Setting up Global State Struct...") - s, err := NewState() - if err != nil { - log.Fatal("Error creating the NewState(): ", err) - } - globalState = s - - // session init - log.Println("[i] Setting up Session Storage...") - store, err := NewSqliteStore(sessiondbPath, "sessions", "/", 3600, []byte(os.Getenv("SESSION_KEY"))) - if err != nil { - panic(err) - } - globalState.sessions = store - - r := mux.NewRouter() - r.Use(logger.Middleware) - r.HandleFunc("/", indexHandler) - - srv := &http.Server{ - Handler: r, - Addr: ":8080", - WriteTimeout: 15 * time.Second, - ReadTimeout: 15 * time.Second, - } - - log.Printf("[i] Running the server on %s", srv.Addr) - log.Fatal(srv.ListenAndServe()) -} diff --git a/nix/templates/goapp/frontend/run.sh b/nix/templates/goapp/frontend/run.sh new file mode 100755 index 0000000..fb3c7b3 --- /dev/null +++ b/nix/templates/goapp/frontend/run.sh @@ -0,0 +1,9 @@ +export CLIENT_ID=goapp +export CLIENT_SECRET=KGFO5LQnUxu1Zs.35gOem3MaG8odthg1U0v0.kScVPS6TPTWVRnAdT_nj4PYYSfuU6jdzTM6 +export CLIENT_CALLBACK_URL=http://localhost:8080/oauth2/callback +export VERSION=0.0.1 +export SESSION_KEY=aes1Itheich4aeQu9Ouz7ahcaiVoogh9 +go run ./... \ + --id goapp \ + --issuer "https://sso.emile.space" \ + --secret "KGFO5LQnUxu1Zs.35gOem3MaG8odthg1U0v0.kScVPS6TPTWVRnAdT_nj4PYYSfuU6jdzTM6" diff --git a/nix/templates/goapp/frontend/server.log b/nix/templates/goapp/frontend/server.log new file mode 100644 index 0000000..4b6cff5 --- /dev/null +++ b/nix/templates/goapp/frontend/server.log @@ -0,0 +1,179 @@ +::1 - - [16/Feb/2025:23:03:46 +0100] "GET / HTTP/1.1" 200 5445 +::1 - - [16/Feb/2025:23:04:16 +0100] "GET / HTTP/1.1" 200 5445 +::1 - - [16/Feb/2025:23:04:17 +0100] "GET / HTTP/1.1" 200 5445 +::1 - - [16/Feb/2025:23:04:58 +0100] "GET / HTTP/1.1" 200 5509 +::1 - - [16/Feb/2025:23:05:03 +0100] "GET / HTTP/1.1" 200 5509 +::1 - - [16/Feb/2025:23:05:23 +0100] "GET / HTTP/1.1" 200 5573 +::1 - - [16/Feb/2025:23:05:33 +0100] "GET / HTTP/1.1" 200 5573 +::1 - - [16/Feb/2025:23:06:07 +0100] "GET / HTTP/1.1" 200 5614 +::1 - - [16/Feb/2025:23:06:17 +0100] "GET / HTTP/1.1" 200 5630 +::1 - - [16/Feb/2025:23:06:37 +0100] "GET / HTTP/1.1" 200 5651 +::1 - - [16/Feb/2025:23:06:59 +0100] "GET / HTTP/1.1" 200 5652 +::1 - - [16/Feb/2025:23:07:28 +0100] "GET / HTTP/1.1" 200 5676 +::1 - - [16/Feb/2025:23:08:26 +0100] "GET / HTTP/1.1" 200 5662 +::1 - - [16/Feb/2025:23:08:30 +0100] "GET / HTTP/1.1" 200 5662 +::1 - - [16/Feb/2025:23:08:42 +0100] "GET / HTTP/1.1" 200 5662 +::1 - - [16/Feb/2025:23:08:43 +0100] "GET / HTTP/1.1" 200 5662 +::1 - - [16/Feb/2025:23:08:54 +0100] "GET / HTTP/1.1" 200 5677 +::1 - - [16/Feb/2025:23:09:25 +0100] "GET / HTTP/1.1" 200 5614 +::1 - - [16/Feb/2025:23:09:44 +0100] "GET / HTTP/1.1" 200 5639 +::1 - - [16/Feb/2025:23:10:13 +0100] "GET / HTTP/1.1" 200 5661 +::1 - - [16/Feb/2025:23:10:48 +0100] "GET / HTTP/1.1" 200 5672 +::1 - - [16/Feb/2025:23:10:49 +0100] "GET / HTTP/1.1" 200 5672 +::1 - - [16/Feb/2025:23:11:32 +0100] "GET / HTTP/1.1" 200 5648 +::1 - - [16/Feb/2025:23:11:32 +0100] "GET / HTTP/1.1" 200 5648 +::1 - - [16/Feb/2025:23:11:57 +0100] "GET / HTTP/1.1" 200 5655 +::1 - - [16/Feb/2025:23:12:11 +0100] "GET / HTTP/1.1" 200 5675 +::1 - - [16/Feb/2025:23:12:13 +0100] "GET / HTTP/1.1" 200 5675 +::1 - - [16/Feb/2025:23:12:46 +0100] "GET / HTTP/1.1" 200 5724 +::1 - - [16/Feb/2025:23:12:54 +0100] "GET / HTTP/1.1" 200 5722 +::1 - - [16/Feb/2025:23:13:03 +0100] "GET / HTTP/1.1" 200 5721 +::1 - - [16/Feb/2025:23:13:11 +0100] "GET / HTTP/1.1" 200 5721 +::1 - - [16/Feb/2025:23:13:42 +0100] "GET / HTTP/1.1" 200 5719 +::1 - - [17/Feb/2025:10:27:07 +0100] "GET / HTTP/1.1" 200 5719 +::1 - - [17/Feb/2025:10:27:09 +0100] "GET / HTTP/1.1" 200 5719 +::1 - - [17/Feb/2025:10:28:32 +0100] "GET / HTTP/1.1" 200 5854 +::1 - - [17/Feb/2025:10:28:40 +0100] "GET / HTTP/1.1" 200 5854 +::1 - - [17/Feb/2025:10:28:46 +0100] "GET / HTTP/1.1" 200 5854 +::1 - - [17/Feb/2025:10:28:52 +0100] "GET / HTTP/1.1" 200 5854 +::1 - - [17/Feb/2025:10:29:22 +0100] "GET / HTTP/1.1" 200 5858 +::1 - - [17/Feb/2025:10:29:54 +0100] "GET / HTTP/1.1" 200 5841 +::1 - - [17/Feb/2025:10:30:09 +0100] "GET / HTTP/1.1" 200 5802 +::1 - - [17/Feb/2025:10:30:10 +0100] "GET / HTTP/1.1" 200 5802 +::1 - - [17/Feb/2025:10:30:20 +0100] "GET / HTTP/1.1" 200 5866 +::1 - - [17/Feb/2025:10:31:14 +0100] "GET / HTTP/1.1" 200 5866 +::1 - - [17/Feb/2025:10:31:26 +0100] "GET / HTTP/1.1" 200 5866 +::1 - - [17/Feb/2025:10:31:31 +0100] "GET / HTTP/1.1" 200 5866 +::1 - - [17/Feb/2025:10:31:47 +0100] "GET / HTTP/1.1" 200 5786 +::1 - - [17/Feb/2025:10:34:25 +0100] "GET / HTTP/1.1" 200 5786 +::1 - - [17/Feb/2025:10:35:22 +0100] "GET / HTTP/1.1" 200 5786 +::1 - - [17/Feb/2025:10:36:10 +0100] "GET / HTTP/1.1" 200 5786 +::1 - - [17/Feb/2025:10:37:10 +0100] "POST /submit HTTP/1.1" 200 10 +::1 - - [17/Feb/2025:10:37:51 +0100] "POST /submit HTTP/1.1" 200 15 +::1 - - [17/Feb/2025:10:37:55 +0100] "GET / HTTP/1.1" 200 5786 +::1 - - [17/Feb/2025:10:37:59 +0100] "POST /submit HTTP/1.1" 200 15 +::1 - - [17/Feb/2025:10:38:26 +0100] "POST /submit HTTP/1.1" 200 35 +::1 - - [17/Feb/2025:10:38:46 +0100] "POST /submit HTTP/1.1" 200 21 +::1 - - [17/Feb/2025:10:40:03 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:10:40:28 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:10:41:20 +0100] "GET / HTTP/1.1" 200 5786 +::1 - - [17/Feb/2025:10:41:38 +0100] "GET / HTTP/1.1" 200 5806 +::1 - - [17/Feb/2025:10:41:44 +0100] "GET / HTTP/1.1" 200 5806 +::1 - - [17/Feb/2025:10:41:53 +0100] "GET / HTTP/1.1" 200 6331 +::1 - - [17/Feb/2025:10:42:24 +0100] "GET / HTTP/1.1" 200 6238 +::1 - - [17/Feb/2025:10:43:00 +0100] "GET / HTTP/1.1" 200 6268 +::1 - - [17/Feb/2025:10:43:10 +0100] "GET / HTTP/1.1" 200 6260 +::1 - - [17/Feb/2025:10:44:54 +0100] "GET / HTTP/1.1" 200 6260 +::1 - - [17/Feb/2025:10:46:05 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:49:07 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:51:15 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:51:16 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:51:26 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:51:27 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:51:45 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:51:59 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:52:56 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:52:58 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:52:59 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:53:11 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:53:13 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:53:29 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:53:30 +0100] "GET /login HTTP/1.1" 200 0 +::1 - - [17/Feb/2025:10:54:04 +0100] "GET /login HTTP/1.1" 200 6974 +::1 - - [17/Feb/2025:10:54:11 +0100] "GET /login HTTP/1.1" 200 6917 +::1 - - [17/Feb/2025:10:54:28 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:10:54:28 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:10:54:30 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:10:54:33 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:10:54:35 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:10:54:39 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:10:54:42 +0100] "GET / HTTP/1.1" 200 6398 +::1 - - [17/Feb/2025:10:54:55 +0100] "GET / HTTP/1.1" 200 6388 +::1 - - [17/Feb/2025:10:54:58 +0100] "POST /submit HTTP/1.1" 200 21 +::1 - - [17/Feb/2025:10:58:19 +0100] "GET / HTTP/1.1" 200 6388 +::1 - - [17/Feb/2025:10:58:42 +0100] "GET / HTTP/1.1" 200 6316 +::1 - - [17/Feb/2025:10:58:43 +0100] "GET / HTTP/1.1" 200 6316 +::1 - - [17/Feb/2025:10:58:43 +0100] "GET / HTTP/1.1" 200 6316 +::1 - - [17/Feb/2025:13:44:10 +0100] "GET / HTTP/1.1" 200 6316 +::1 - - [17/Feb/2025:13:44:11 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:13:44:23 +0100] "GET /login HTTP/1.1" 200 6724 +::1 - - [17/Feb/2025:14:37:43 +0100] "GET /login HTTP/1.1" 500 56 +::1 - - [17/Feb/2025:14:38:57 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:14:40:38 +0100] "GET / HTTP/1.1" 200 5683 +::1 - - [17/Feb/2025:14:43:24 +0100] "GET / HTTP/1.1" 200 5683 +::1 - - [17/Feb/2025:14:45:41 +0100] "GET / HTTP/1.1" 200 5683 +::1 - - [17/Feb/2025:14:46:07 +0100] "GET / HTTP/1.1" 200 5683 +::1 - - [17/Feb/2025:14:46:14 +0100] "GET / HTTP/1.1" 200 5894 +::1 - - [17/Feb/2025:14:46:23 +0100] "GET / HTTP/1.1" 200 5734 +::1 - - [17/Feb/2025:14:47:02 +0100] "GET / HTTP/1.1" 200 5835 +::1 - - [17/Feb/2025:14:47:14 +0100] "GET / HTTP/1.1" 200 5873 +::1 - - [17/Feb/2025:14:47:22 +0100] "GET / HTTP/1.1" 200 5899 +::1 - - [17/Feb/2025:14:48:10 +0100] "GET / HTTP/1.1" 200 5790 +::1 - - [17/Feb/2025:14:48:15 +0100] "GET / HTTP/1.1" 200 5899 +::1 - - [17/Feb/2025:14:48:47 +0100] "GET / HTTP/1.1" 200 5899 +::1 - - [17/Feb/2025:14:48:53 +0100] "GET / HTTP/1.1" 200 5790 +::1 - - [17/Feb/2025:14:49:07 +0100] "GET / HTTP/1.1" 200 5927 +::1 - - [17/Feb/2025:14:49:16 +0100] "GET / HTTP/1.1" 200 5790 +::1 - - [17/Feb/2025:14:49:24 +0100] "GET / HTTP/1.1" 200 5919 +::1 - - [17/Feb/2025:14:51:08 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:14:51:48 +0100] "GET / HTTP/1.1" 200 6285 +::1 - - [17/Feb/2025:14:52:24 +0100] "GET / HTTP/1.1" 200 6224 +::1 - - [17/Feb/2025:14:53:46 +0100] "GET / HTTP/1.1" 200 6242 +::1 - - [17/Feb/2025:14:54:34 +0100] "GET / HTTP/1.1" 200 6222 +::1 - - [17/Feb/2025:14:55:14 +0100] "GET / HTTP/1.1" 200 6284 +::1 - - [17/Feb/2025:14:55:18 +0100] "GET / HTTP/1.1" 200 6284 +::1 - - [17/Feb/2025:14:55:45 +0100] "GET / HTTP/1.1" 200 6360 +::1 - - [17/Feb/2025:14:57:06 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:14:57:43 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:14:58:27 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:15:01:47 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:15:02:03 +0100] "GET / HTTP/1.1" 500 33 +::1 - - [17/Feb/2025:15:03:49 +0100] "GET / HTTP/1.1" 200 6360 +::1 - - [17/Feb/2025:15:06:07 +0100] "GET / HTTP/1.1" 200 6412 +::1 - - [17/Feb/2025:15:07:19 +0100] "GET / HTTP/1.1" 200 6412 +::1 - - [17/Feb/2025:15:07:38 +0100] "GET / HTTP/1.1" 200 6412 +::1 - - [17/Feb/2025:15:07:46 +0100] "GET / HTTP/1.1" 200 6514 +::1 - - [17/Feb/2025:15:07:58 +0100] "GET / HTTP/1.1" 200 6491 +::1 - - [17/Feb/2025:15:08:04 +0100] "GET /login HTTP/1.1" 500 56 +::1 - - [17/Feb/2025:15:08:56 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [17/Feb/2025:15:08:56 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6491 +::1 - - [17/Feb/2025:15:08:58 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [17/Feb/2025:15:08:58 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6491 +::1 - - [17/Feb/2025:15:20:07 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6586 +::1 - - [17/Feb/2025:15:20:41 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [17/Feb/2025:15:20:45 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [17/Feb/2025:15:20:45 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [17/Feb/2025:15:20:53 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [17/Feb/2025:15:20:53 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [17/Feb/2025:15:24:15 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [17/Feb/2025:15:24:16 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [17/Feb/2025:15:24:16 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:18:57:18 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:18:57:19 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [19/Feb/2025:18:57:19 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:18:57:44 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [19/Feb/2025:18:57:44 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:18:58:54 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:18:58:55 +0100] "GET /login HTTP/1.1" 302 87 +::1 - - [19/Feb/2025:18:58:55 +0100] "GET /?client_id=&response_type=code&state=random-string-here HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:06:37 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:10:24 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:10:36 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:14:14 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:14:16 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:15:01 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:16:08 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:16:10 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:18:51 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:18:53 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:23:35 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:23:37 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:25:20 +0100] "GET /oauth2/callback HTTP/1.1" 500 314 +::1 - - [19/Feb/2025:19:25:37 +0100] "GET /oauth2/callback?code=authelia_ac_lA-8rLxGY4flmo-_DerONxfFPIVk2vpMiaCYZh_6ke0.FBoivMumLtPFauH9sWNVRz51S0FqWjwlFtqKO5sEA88&iss=https%3A%2F%2Fsso.emile.space&scope=openid+profile+email+groups&state=random-string-here HTTP/1.1" 500 314 +::1 - - [19/Feb/2025:19:27:27 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:27:28 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:43:04 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:43:05 +0100] "GET /login HTTP/1.1" 302 242 +::1 - - [19/Feb/2025:19:43:29 +0100] "GET /oauth2/callback?code=authelia_ac_8UdV__GJCN9gxJrYa629TC3FToyDDhsbacPbJzhvcJ4.uPw2-_N4jQr7xf7JNZ_IZBNHEq-eeOFoZup7Vwjx1Y0&iss=https%3A%2F%2Fsso.emile.space&scope=openid+profile+email+groups&state=random-string-here HTTP/1.1" 500 142 +::1 - - [19/Feb/2025:19:49:22 +0100] "GET / HTTP/1.1" 200 6587 +::1 - - [19/Feb/2025:19:49:23 +0100] "GET /login HTTP/1.1" 302 242 diff --git a/nix/templates/goapp/frontend/sessions.db b/nix/templates/goapp/frontend/sessions.db new file mode 100644 index 0000000..04d6727 --- /dev/null +++ b/nix/templates/goapp/frontend/sessions.db Binary files differdiff --git a/nix/templates/goapp/frontend/db.go b/nix/templates/goapp/frontend/src/db.go index fd3605a..fd3605a 100644 --- a/nix/templates/goapp/frontend/db.go +++ b/nix/templates/goapp/frontend/src/db.go diff --git a/nix/templates/goapp/frontend/src/handlers.go b/nix/templates/goapp/frontend/src/handlers.go new file mode 100644 index 0000000..8fdd325 --- /dev/null +++ b/nix/templates/goapp/frontend/src/handlers.go @@ -0,0 +1,236 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/sessions" + "golang.org/x/oauth2" +) + +func indexHandler(w http.ResponseWriter, r *http.Request) { + session, err := globalState.sessions.Get(r, "session") + if err != nil { + log.Println("error getting the session") + } + + tpl := indexTplData{ + Error: r.FormValue("error"), + } + + tpl.Breadcrumbs = []Breadcrumb{ + { + Link{"a", "b"}, + []Link{ + {"c", "d"}, + {"e", "f"}, + }, + }, + { + Link{"g", "h"}, + []Link{ + {"i", "j"}, + {"k", "l"}, + }, + }, + } + tpl.NextLinks = []Link{ + {"Login", "/login"}, + } + + if logged, ok := session.Values["logged"].(bool); ok && logged { + tpl.LoggedIn = true + tpl.Claims.IDToken = session.Values["id_token"].(Claims) + tpl.Claims.UserInfo = session.Values["userinfo"].(Claims) + + if len(options.GroupsFilter) >= 1 { + for _, group := range tpl.Claims.UserInfo.Groups { + if isStringInSlice(group, options.GroupsFilter) { + tpl.Groups = append(tpl.Groups, filterText(group, options.Filters)) + } + } + } else { + tpl.Groups = filterSliceOfText(tpl.Claims.UserInfo.Groups, options.Filters) + } + + tpl.Claims.IDToken.PreferredUsername = filterText(tpl.Claims.IDToken.PreferredUsername, options.Filters) + tpl.Claims.UserInfo.PreferredUsername = filterText(tpl.Claims.UserInfo.PreferredUsername, options.Filters) + tpl.Claims.IDToken.Audience = filterSliceOfText(tpl.Claims.IDToken.Audience, options.Filters) + tpl.Claims.UserInfo.Audience = filterSliceOfText(tpl.Claims.UserInfo.Audience, options.Filters) + tpl.Claims.IDToken.Issuer = filterText(tpl.Claims.IDToken.Issuer, options.Filters) + tpl.Claims.UserInfo.Issuer = filterText(tpl.Claims.UserInfo.Issuer, options.Filters) + tpl.Claims.IDToken.Email = filterText(tpl.Claims.IDToken.Email, options.Filters) + tpl.Claims.UserInfo.Email = filterText(tpl.Claims.UserInfo.Email, options.Filters) + tpl.Claims.IDToken.Name = filterText(tpl.Claims.IDToken.Name, options.Filters) + tpl.Claims.UserInfo.Name = filterText(tpl.Claims.UserInfo.Name, options.Filters) + tpl.RawToken = rawTokens[tpl.Claims.IDToken.JWTIdentifier] + tpl.AuthorizeCodeURL = acURLs[tpl.Claims.IDToken.JWTIdentifier].String() + } + + w.Header().Add("Content-Type", "text/html") + + // get the template + t, err := template.New("index").Funcs(templateFuncMap).ParseGlob(fmt.Sprintf("%s/*.html", options.TemplatesPath)) + if err != nil { + log.Printf("Error reading the template Path: %s/*.html", options.TemplatesPath) + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("500 - Error reading template file")) + return + } + + // exec! + err = t.ExecuteTemplate(w, "index", tpl) + if err != nil { + log.Println(err) + } +} + +func loginHandler(w http.ResponseWriter, r *http.Request) { + log.Println("[ ] Getting the global session from the session cookie:") + session, err := globalState.sessions.Get(r, options.CookieName) + if err != nil { + log.Println("[ ] Error getting the cookie") + writeErr(w, nil, "error getting cookie", http.StatusInternalServerError) + return + } + + log.Println("[ ] Setting the redirect URL") + session.Values["redirect-url"] = "/" + + log.Println("[ ] Saving the session") + if err = session.Save(r, w); err != nil { + writeErr(w, err, "error saving session", http.StatusInternalServerError) + return + } + + log.Printf("[ ] Redirecting to %s", oauth2Config.AuthCodeURL("random-string")) + http.Redirect(w, r, oauth2Config.AuthCodeURL("random-string-here"), http.StatusFound) +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + session, err := globalState.sessions.Get(r, options.CookieName) + if err != nil { + writeErr(w, err, "error getting cookie", http.StatusInternalServerError) + return + } + + // wet the session + session.Values = make(map[interface{}]interface{}) + + if err = session.Save(r, w); err != nil { + writeErr(w, err, "error saving session", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} + +func oauthCallbackHandler(res http.ResponseWriter, req *http.Request) { + log.Println("hit the oauth callback handler") + if req.FormValue("error") != "" { + log.Printf("got an error from the idp: %s", req.FormValue("error")) + http.Redirect(res, req, fmt.Sprintf("/error?%s", req.Form.Encode()), http.StatusFound) + + return + } + + var ( + token *oauth2.Token + idToken *oidc.IDToken + err error + idTokenRaw string + ok bool + ) + + // The state should be checked here in production + if token, err = oauth2Config.Exchange(req.Context(), req.URL.Query().Get("code")); err != nil { + log.Println("Unable to exchange authorization code for tokens") + writeErr(res, err, "unable to exchange authorization code for tokens", http.StatusInternalServerError) + return + } + + // Extract the ID Token from OAuth2 token. + if idTokenRaw, ok = token.Extra("id_token").(string); !ok { + log.Println("missing id token") + writeErr(res, nil, "missing id token", http.StatusInternalServerError) + return + } + + // Parse and verify ID Token payload. + if idToken, err = verifier.Verify(req.Context(), idTokenRaw); err != nil { + log.Printf("unable to verify id token or token is invalid: %+v", idTokenRaw) + writeErr(res, err, "unable to verify id token or token is invalid", http.StatusInternalServerError) + return + } + + // Extract custom claims + claimsIDToken := Claims{} + + if err = idToken.Claims(&claimsIDToken); err != nil { + log.Printf("unable to decode id token claims: %+v", &claimsIDToken) + writeErr(res, err, "unable to decode id token claims", http.StatusInternalServerError) + return + } + + var userinfo *oidc.UserInfo + + if userinfo, err = provider.UserInfo(req.Context(), oauth2.StaticTokenSource(token)); err != nil { + log.Printf("unable to retreive userinfo claims") + writeErr(res, err, "unable to retrieve userinfo claims", http.StatusInternalServerError) + return + } + + claimsUserInfo := Claims{} + + if err = userinfo.Claims(&claimsUserInfo); err != nil { + log.Printf("unable to decode userinfo claims") + writeErr(res, err, "unable to decode userinfo claims", http.StatusInternalServerError) + return + } + + var session *sessions.Session + + if session, err = globalState.sessions.Get(req, options.CookieName); err != nil { + log.Printf("unable to get session from cookie") + writeErr(res, err, "unable to get session from cookie", http.StatusInternalServerError) + return + } + + session.Values["id_token"] = claimsIDToken + session.Values["userinfo"] = claimsUserInfo + session.Values["logged"] = true + rawTokens[claimsIDToken.JWTIdentifier] = idTokenRaw + acURLs[claimsIDToken.JWTIdentifier] = req.URL + + if err = session.Save(req, res); err != nil { + log.Printf("unable to save session") + writeErr(res, err, "unable to save session", http.StatusInternalServerError) + return + } + + var redirectUrl string + + if redirectUrl, ok = session.Values["redirect-url"].(string); ok { + log.Printf("all fine!") + http.Redirect(res, req, redirectUrl, http.StatusFound) + return + } + + http.Redirect(res, req, "/", http.StatusFound) +} + +func writeErr(w http.ResponseWriter, err error, msg string, statusCode int) { + switch { + case err == nil: + log.Println(msg) + http.Error(w, msg, statusCode) + default: + log.Println(msg) + log.Println(err) + http.Error(w, fmt.Errorf("%s: %w", msg, err).Error(), statusCode) + } +} diff --git a/nix/templates/goapp/frontend/src/init.go b/nix/templates/goapp/frontend/src/init.go new file mode 100644 index 0000000..97e58f0 --- /dev/null +++ b/nix/templates/goapp/frontend/src/init.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/url" + "os" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func logInit() loggingMiddleware { + log.Println("[i] Setting up logging...") + logFile, err := os.OpenFile(options.LogFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0664) + if err != nil { + log.Fatal("Error opening the server.log file: ", err) + } + return loggingMiddleware{logFile} +} + +func dbInit() { + log.Println("[i] Setting up Global State Struct...") + s, err := NewState() + if err != nil { + log.Fatal("Error creating the NewState(): ", err) + } + globalState = s +} + +func sessionInit() { + log.Println("[i] Setting up Session Storage...") + store, err := NewSqliteStore( + sessiondbPath, + "sessions", + "/", + 3600, + []byte(os.Getenv("SESSION_KEY"))) + if err != nil { + panic(err) + } + globalState.sessions = store +} + +func oauth2Init() (err error) { + log.Println("[i] Setting up oauth2...") + var redirectURL *url.URL + if _, redirectURL, err = getURLs(options.PublicURL); err != nil { + return fmt.Errorf("could not parse public url: %w", err) + } + + log.Printf("[ ] provider_url: %s", options.Issuer) + log.Printf("[ ] redirect_url: %s", redirectURL.String()) + + if provider, err = oidc.NewProvider(context.Background(), options.Issuer); err != nil { + log.Println("Error init oidc provider: ", err) + return fmt.Errorf("error initializing oidc provider: %w", err) + } + + verifier = provider.Verifier(&oidc.Config{ClientID: options.ClientID}) + log.Printf("[ ] ClientID: %s", options.ClientID) + log.Printf("[ ] ClientSecret: %s", options.ClientSecret) + log.Printf("[ ] redirectURL: %s", redirectURL.String()) + log.Printf("[ ] providerEndpoint: %+v", provider.Endpoint()) + log.Printf("[ ] Scopes: %s", options.Scopes) + oauth2Config = oauth2.Config{ + ClientID: options.ClientID, + ClientSecret: options.ClientSecret, + RedirectURL: redirectURL.String(), + Endpoint: provider.Endpoint(), + Scopes: strings.Split(options.Scopes, ","), + } + return nil +} diff --git a/nix/templates/goapp/frontend/log.go b/nix/templates/goapp/frontend/src/log.go index 5af719a..5af719a 100644 --- a/nix/templates/goapp/frontend/log.go +++ b/nix/templates/goapp/frontend/src/log.go diff --git a/nix/templates/goapp/frontend/src/main.go b/nix/templates/goapp/frontend/src/main.go new file mode 100644 index 0000000..fcf4224 --- /dev/null +++ b/nix/templates/goapp/frontend/src/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log" + "net/http" + "net/url" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/mux" + "github.com/spf13/cobra" + "golang.org/x/oauth2" +) + +var ( + host string + port int + databasePath string + logFilePath string + sessiondbPath string + templatesPath string + globalState *State + + options Options + oauth2Config oauth2.Config + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + + rawTokens = make(map[string]string) + acURLs = make(map[string]*url.URL) +) + +func main() { + + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + rootCmd := &cobra.Command{Use: "goapp", RunE: root} + + rootCmd.Flags().StringVar(&options.Host, "host", "0.0.0.0", "Specifies the tcp host to listen on") + rootCmd.Flags().IntVar(&options.Port, "port", 8080, "Specifies the port to listen on") + rootCmd.Flags().StringVar(&options.PublicURL, "public-url", "http://localhost:8080/", "Specifies the root URL to generate the redirect URI") + rootCmd.Flags().StringVar(&options.ClientID, "id", "", "Specifies the OpenID Connect Client ID") + rootCmd.Flags().StringVarP(&options.ClientSecret, "secret", "s", "", "Specifies the OpenID Connect Client Secret") + rootCmd.Flags().StringVarP(&options.Issuer, "issuer", "i", "", "Specifies the URL for the OpenID Connect OP") + rootCmd.Flags().StringVar(&options.Scopes, "scopes", "openid,profile,email,groups", "Specifies the OpenID Connect scopes to request") + rootCmd.Flags().StringVar(&options.CookieName, "cookie-name", "oidc-client", "Specifies the storage cookie name to use") + rootCmd.Flags().StringSliceVar(&options.Filters, "filters", []string{}, "If specified filters the specified text from html output (not json) out of the email addresses, display names, audience, etc") + rootCmd.Flags().StringSliceVar(&options.GroupsFilter, "groups-filter", []string{}, "If specified only shows the groups in this list") + rootCmd.Flags().StringVar(&options.LogFilePath, "logpath", "./server.log", "Specifies the path to store the server logs at") + rootCmd.Flags().StringVar(&options.TemplatesPath, "templatespath", "./templates", "Specifies the path to where the templates are stored") + + _ = rootCmd.MarkFlagRequired("id") + _ = rootCmd.MarkFlagRequired("secret") + _ = rootCmd.MarkFlagRequired("issuer") + + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} + +func root(cmd *cobra.Command, args []string) (err error) { + + logger := logInit() + oauth2Init() + dbInit() + sessionInit() + + r := mux.NewRouter() + r.Use(logger.Middleware) + r.HandleFunc("/", indexHandler) + r.HandleFunc("/login", loginHandler) + // r.HandleFunc("/logout", ) + // r.HandleFunc("/error", loginHandler) + r.HandleFunc("/oauth2/callback", oauthCallbackHandler) + // r.HandleFunc("/json", loginHandler) + // r.HandleFunc("/jwt.json", loginHandler) + + // endpoints with auth needed + auth_needed := r.PathPrefix("/").Subrouter() + auth_needed.Use(authMiddleware) + auth_needed.HandleFunc("/logout", logoutHandler) + + serverAddress := fmt.Sprintf("%s:%d", options.Host, options.Port) + srv := &http.Server{ + Handler: r, + Addr: serverAddress, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + } + + log.Printf("[i] Running the server on %s", serverAddress) + log.Fatal(srv.ListenAndServe()) + return +} diff --git a/nix/templates/goapp/frontend/sqlitestore.go b/nix/templates/goapp/frontend/src/sqlitestore.go index 6f59d15..34e31e4 100644 --- a/nix/templates/goapp/frontend/sqlitestore.go +++ b/nix/templates/goapp/frontend/src/sqlitestore.go @@ -50,6 +50,7 @@ type DB interface { func init() { gob.Register(time.Time{}) + gob.Register(Claims{}) } func NewSqliteStore(endpoint string, tableName string, path string, maxAge int, keyPairs ...[]byte) (*SqliteStore, error) { diff --git a/nix/templates/goapp/frontend/src/templates.go b/nix/templates/goapp/frontend/src/templates.go new file mode 100644 index 0000000..5ae9397 --- /dev/null +++ b/nix/templates/goapp/frontend/src/templates.go @@ -0,0 +1,42 @@ +package main + +import ( + "html/template" + "strings" +) + +var ( + templateFuncMap = template.FuncMap{ + "stringsJoin": strings.Join, + "stringsEqualFold": strings.EqualFold, + "isStringInSlice": isStringInSlice, + } +) + +type indexTplData struct { + Title, Description, RawToken string + + Breadcrumbs []Breadcrumb + NextLinks []Link + + Error string + LoggedIn bool + Claims tplClaims + Groups []string + AuthorizeCodeURL string +} + +type Link struct { + Name string + Target string +} + +type Breadcrumb struct { + Main Link + Options []Link +} + +type tplClaims struct { + IDToken Claims + UserInfo Claims +} diff --git a/nix/templates/goapp/frontend/src/types.go b/nix/templates/goapp/frontend/src/types.go new file mode 100644 index 0000000..7efcc70 --- /dev/null +++ b/nix/templates/goapp/frontend/src/types.go @@ -0,0 +1,65 @@ +package main + +type Claims struct { + JWTIdentifier string `json:"jti"` + Issuer string `json:"iss"` + Subject string `json:"sub"` + Nonce string `json:"nonce"` + Expires int64 `json:"exp"` + IssueTime int64 `json:"iat"` + RequestedAt int64 `json:"rat"` + AuthorizeTime int64 `json:"auth_time"` + NotBefore int64 `json:"nbf"` + Audience []string `json:"aud"` + Scope []string `json:"scp"` + ScopeString string `json:"scope"` + AccessTokenHash string `json:"at_hash"` + CodeHash string `json:"c_hash"` + AuthenticationContextClassReference string `json:"acr"` + AuthenticationMethodsReference []string `json:"amr"` + + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + MiddleName string `json:"middle_name"` + Nickname string `json:"nickname"` + PreferredUsername string `json:"preferred_username"` + Profile string `jsoon:"profile"` + Picture string `json:"picture"` + Website string `json:"website"` + Gender string `json:"gender"` + Birthdate string `json:"birthdate"` + ZoneInfo string `json:"zoneinfo"` + Locale string `json:"locale"` + UpdatedAt int64 `json:"updated_at"` + Email string `json:"email"` + EmailAlts []string `json:"alt_emails"` + EmailVerified bool `json:"email_verified"` + PhoneNumber string `json:"phone_number"` + PhoneNumberVerified bool `json:"phone_number_verified"` + Address ClamsAddress `json:"address"` + Groups []string `json:"groups"` +} + +type ClamsAddress struct { + StreetAddress string `json:"street_address"` + Locality string `json:"locality"` + Region string `json:"region"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` +} + +type Options struct { + Host string + Port int + LogFilePath string + TemplatesPath string + ClientID string + ClientSecret string + Issuer string + PublicURL string + Scopes string + CookieName string + Filters []string + GroupsFilter []string +} diff --git a/nix/templates/goapp/frontend/src/util.go b/nix/templates/goapp/frontend/src/util.go new file mode 100644 index 0000000..89d28ba --- /dev/null +++ b/nix/templates/goapp/frontend/src/util.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +func isStringInSlice(s string, slice []string) bool { + for _, x := range slice { + if s == x { + return true + } + } + + return false +} + +func filterText(input string, filters []string) (output string) { + if len(filters) == 0 { + return input + } + + for _, filter := range filters { + input = strings.Replace(input, filter, strings.Repeat("*", len(filter)), -1) + } + + return input +} + +func filterSliceOfText(input []string, filters []string) (output []string) { + for _, item := range input { + output = append(output, filterText(item, filters)) + } + + return output +} + +func getURLs(rootURL string) (publicURL *url.URL, redirectURL *url.URL, err error) { + if publicURL, err = url.Parse(rootURL); err != nil { + return nil, nil, err + } + + if publicURL.Scheme != "http" && publicURL.Scheme != "https" { + return nil, nil, fmt.Errorf("scheme must be http or https but it is '%s'", publicURL.Scheme) + } + + if !strings.HasSuffix(publicURL.Path, "/") { + publicURL.Path += "/" + } + + redirectURL = &url.URL{} + *redirectURL = *publicURL + redirectURL.Path = path.Join(redirectURL.Path, "/oauth2/callback") + + return publicURL, redirectURL, nil +} diff --git a/nix/templates/goapp/frontend/templates/footer.html b/nix/templates/goapp/frontend/templates/footer.html new file mode 100644 index 0000000..1899096 --- /dev/null +++ b/nix/templates/goapp/frontend/templates/footer.html @@ -0,0 +1,8 @@ +{{ define "footer" }} +{{ if .asd }} +<br><br><hr><br> +{{ . }} +{{ end }} +</div> +{{ end }} + diff --git a/nix/templates/goapp/frontend/templates/head.html b/nix/templates/goapp/frontend/templates/head.html new file mode 100644 index 0000000..efc7ce3 --- /dev/null +++ b/nix/templates/goapp/frontend/templates/head.html @@ -0,0 +1,153 @@ +{{ define "head" }} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>goapp</title> + + <style> +* { word-wrap:break-word; font-family: monospace; margin: 0; padding: 0; } + +/* light/darktheme specific foo */ +@media (prefers-color-scheme: light) { + html { background: #fafafa; color: #040404; } + a:hover { color: #fafafa; background: #040404 } + a:not([href*="webring.xxiivv.com"]):hover, nav a:active { color: #fafafa; background: #040404 } + a { color: #040404; background: #fafafa; text-decoration: none;} + nav a:hover, a:active { color: #fafafa; background: #040404 } + nav { margin: 1ex 0; background: #eeeeee; } + nav a { display:block; background: #eeeeee; } + h1 { margin: 3ex 0 1ex 0; width: 100%; background-color: #eeeeee} + h2 { margin: 2ex 0 1ex 0; width: 100%; background-color: #eeeeee} + h3 { margin: 1ex 0 1ex 0; width: 100%; font-size: 1em; background-color: #eeeeee} + h4 { margin: 1ex 0 1ex 0; width: 100%; font-size: 1em; /*background-color: #fafafa*/} + h5 { margin: 1ex 0 1ex 0; width: 100%; font-size: 1em; /*background-color: #fafafa*/} + .code { border-left: 1px solid #040404; margin-left: 2ex; padding-left: 1ex; } + .codeline:hover { background: #eeeeee; color: #040404; } + .trhover:hover { background: #c0c0c0; color: #040404; } + + /* add an outline while hovering, the !important makes hovering on checked elements still visible */ + .check-with-label:checked + .label-for-check { background-color: #040404; color: #eeeeee !important; } + .check-with-label:hover + .label-for-check { outline: 1px solid #040404; color: #040404; } + + input, textarea { + outline: 1px solid #000000; + border: none; + background-color: #ffffff; + color: #000000; + } + button { + background-color: #ffffff; color: #000000; border: 1px solid #000000; border-style: solid; + margin-top: 1ex; + }; +} +@media (prefers-color-scheme: dark) { + html { background: #040404; color: #c0c0c0; } + a:hover { color: #040404; background: #c0c0c0 } + body nav a:not([href*="webring.xxiivv.com"]):hover, nav a:active { color: #c0c0c0; background: #040404 } + a { color: #c0c0c0; background: #040404; text-decoration: none; } + nav a:hover, a:active { color: #040404; background: #c0c0c0 } + nav { margin: 1ex 0; background: #c0c0c0; } + nav a { display:block; background: #c0c0c0; } + h1 { margin: 3ex 0 1ex 0; width: 100%; background-color: #c0c0c0} + h2 { margin: 2ex 0 1ex 0; width: 100%; background-color: #c0c0c0} + h3 { margin: 1ex 0 1ex 0; width: 100%; font-size: 1em; background-color: #c0c0c0} + h4 { margin: 1ex 0 1ex 0; width: 100%; font-size: 1em; /*background-color: #c0c0c0*/} + h5 { margin: 1ex 0 1ex 0; width: 100%; font-size: 1em; /*background-color: #c0c0c0*/} + .code { border-left: 1px solid #c0c0c0; margin-left: 2ex; padding-left: 1ex; } + .codeline:hover { background: #c0c0c0; color: #040404; } + .webring { -webkit-filter: invert(100%); filter: invert(100%); } + .trhover:hover { background: #c0c0c0; color: #040404; } + + /* add an outline while hovering, the !important makes hovering on checked elements still visible */ + .check-with-label:checked + .label-for-check { background-color: #c0c0c0; color: #040404 !important; } + .check-with-label:hover + .label-for-check { outline: 1px solid #c0c0c0; color: #c0c0c0; } + + input, textarea { + outline: 1px solid #ffffff; + border: none; + background-color: #000000; + color: #ffffff; + } + + button { + background-color: #000000; color: #ffffff; + border: 1px solid #ffffff; + border-style: solid; + margin-top: 1ex; + }; +} + +/* settings for mobile devices*/ +@media only screen and (max-width: 768px) { + body { margin: 1ex; width: calc(100% - 2ex) !important; } + img { max-width: 100% !important; max-height: 500px; } +} + +/* only display the hover dropdown on non-mobile devices */ +@media only screen and (min-width: 768px) { + nav ul li:hover a + ul { display: inherit; white-space: nowrap; } +} + +img { max-width: 100ex; max-height: 500px; } + +body { margin-left: auto; margin-right: auto; margin-top: 1ex; margin-bottom: 1ex; width: 100ex; } + +.webring { align: right; } +a .webring { float: right; } + +/* display local links using [] and external links using {} */ +body a:not(h1 a, h2 a, h3 a,h4 a):not([href*="webring.xxiivv.com"]):not([class*="local"]):before { content: "["; } +body a:not(h1 a, h2 a, h3 a,h4 a):not([href*="webring.xxiivv.com"]):not([class*="local"]):after { content: "]"; } +a[href*="//"]:not([href*="r2wa.rs"]):not([class*="icon"]):before { content: '{'; } +a[href*="//"]:not([href*="r2wa.rs"]):not([class*="icon"]):after { content: '}'; } + +/* table { width: 100ex; } */ +input, textarea { width: 100%; } +textarea { padding: 0.5ex; } + +ul { list-style-type: none; } + +/* navigation bar magic */ +nav * { color: #040404; } +nav ul { list-style: none; position: relative; display: inline-block; } +nav ul li { display:inline-block; } +nav ul ul { display: none; position: absolute; outline: 1px solid #040404; background-color: #ff0; } +nav ul ul li { width: 100%; padding-right: 1ex; float:none; display:list-item; position: relative; } +nav + ul li { display: inline-block;} + +/* nav bar spacing char */ +nav ul li > a::after { content: " /"; } +nav ul li > a:only-child::after { content: ""; } +nav ul li:last-of-type a::after { content: ""; } + +h1 a, h2 a, h3 a { padding-right: 1ex; } + +pre { white-space: pre-wrap; hyphens: auto; } +pre.code { white-space: pre-wrap; hyphens: none; } + +/* display the list of folders in the current one as a vertical list, if the + * .vert class is present */ +nav + ul.vert li { display: block; } + +.w-100 { width: 100%; } + +.check-with-label { display: none; } /* checkbox with a label */ + +/* In tables, make the first column fit the content and the reset be relaxed */ +body table tbody { width: 100%; word-wrap: break-word; } +/* body table tbody tr>td { padding: 0.5ex 0 0.5ex !important; } */ +body table tbody tr td:nth-child(1) { width: auto; white-space: nowrap; padding-right: 1ex; } +body table tbody tr td:not(:nth-child(1)) { width: 100%; max-width: 100%; word-wrap: anywhere; } + +tr { text-wrap: wrap;} + +input { padding-left: 0.5ex; } +input:focus { outline-offset: 0px; } +textarea:focus { outline-offset: 0px; } + + </style> +</head> +{{ end }} diff --git a/nix/templates/goapp/frontend/templates/index.html b/nix/templates/goapp/frontend/templates/index.html new file mode 100644 index 0000000..1d21f3d --- /dev/null +++ b/nix/templates/goapp/frontend/templates/index.html @@ -0,0 +1,81 @@ +{{ define "index" }} + +{{ template "head" . }} +{{ template "nav" . }} + +<h1>goapp</h1> + +{{- if .LoggedIn }} +<p id="welcome">Logged in as {{ or .Claims.UserInfo.PreferredUsername .Claims.IDToken.Subject "unknown" }}!</p> +<p><a href="/logout" id="log-out">Log out</a></p> +<p>Access Token Hash: <span id="claim-at_hash">{{ .Claims.IDToken.AccessTokenHash }}</span></p> +<p>Code Hash: <span id="claim-c_hash">{{ .Claims.IDToken.CodeHash }}</span></p> +<p>Authentication Context Class Reference: <span id="claim-acr">{{ .Claims.IDToken.AuthenticationContextClassReference }}</span></p> +<p>Authentication Methods Reference: <span id="claim-amr">{{ stringsJoin .Claims.IDToken.AuthenticationMethodsReference ", " }}</span></p> +<p>Audience: <span id="claim-aud">{{ stringsJoin .Claims.IDToken.Audience ", " }}</span></p> +<p>Expires: <span id="claim-exp">{{ .Claims.IDToken.Expires }}</span></p> +<p>Issue Time: <span id="claim-iat">{{ .Claims.IDToken.IssueTime }}</span></p> +<p>Requested At: <span id="claim-rat">{{ .Claims.IDToken.RequestedAt }}</span></p> +<p>Authorize Time: <span id="claim-auth_at">{{ .Claims.IDToken.AuthorizeTime }}</span></p> +<p>Not Before: <span id="claim-nbf">{{ .Claims.IDToken.NotBefore }}</span></p> +<p>Issuer: <span id="claim-iss">{{ .Claims.IDToken.Issuer }}</span></p> +<p>JWT ID: <span id="claim-jti">{{ .Claims.IDToken.JWTIdentifier }}</span></p> +<p>Subject: <span id="claim-sub">{{ .Claims.IDToken.Subject }}</span></p> +<p>Nonce: <span id="claim-nonce">{{ .Claims.IDToken.Nonce }}</span></p> +<p>Name: <span id="claim-name">{{ .Claims.UserInfo.Name }}</span></p> +<p>Name (ID Token): <span id="claim-id-token-name">{{ .Claims.IDToken.Name }}</span></p> +<p>Given Name: <span id="claim-given_name">{{ .Claims.UserInfo.GivenName }}</span></p> +<p>Given Name (ID Token): <span id="claim-id-token-given_name">{{ .Claims.IDToken.GivenName }}</span></p> +<p>Family Name: <span id="claim-family_name">{{ .Claims.UserInfo.FamilyName }}</span></p> +<p>Family Name (ID Token): <span id="claim-id-token-family_name">{{ .Claims.IDToken.FamilyName }}</span></p> +<p>Middle Name: <span id="claim-middle_name">{{ .Claims.UserInfo.MiddleName }}</span></p> +<p>Middle Name (ID Token): <span id="claim-id-token-middle_name">{{ .Claims.IDToken.MiddleName }}</span></p> +<p>Nickname: <span id="claim-nickname">{{ .Claims.UserInfo.Nickname }}</span></p> +<p>Nickname (ID Token): <span id="claim-id-token-nickname">{{ .Claims.IDToken.Nickname }}</span></p> +<p>Preferred Username: <span id="claim-preferred_username">{{ .Claims.UserInfo.PreferredUsername }}</span></p> +<p>Preferred Username (ID Token): <span id="claim-id-token-preferred_username">{{ .Claims.IDToken.PreferredUsername }}</span></p> +<p>Profile: <span id="claim-profile">{{ .Claims.UserInfo.Profile }}</span></p> +<p>Profile (ID Token): <span id="claim-id-token-profile">{{ .Claims.IDToken.Profile }}</span></p> +<p>Website: <span id="claim-website">{{ .Claims.UserInfo.Website }}</span></p> +<p>Website (ID Token): <span id="claim-id-token-website">{{ .Claims.IDToken.Website }}</span></p> +<p>Gender: <span id="claim-gender">{{ .Claims.UserInfo.Gender }}</span></p> +<p>Gender (ID Token): <span id="claim-id-token-gender">{{ .Claims.IDToken.Gender }}</span></p> +<p>Birthdate: <span id="claim-birthdate">{{ .Claims.UserInfo.Birthdate }}</span></p> +<p>Birthdate (ID Token): <span id="claim-id-token-birthdate">{{ .Claims.IDToken.Birthdate }}</span></p> +<p>ZoneInfo: <span id="claim-zoneinfo">{{ .Claims.UserInfo.ZoneInfo }}</span></p> +<p>ZoneInfo (ID Token): <span id="claim-id-token-zoneinfo">{{ .Claims.IDToken.ZoneInfo }}</span></p> +<p>Locale: <span id="claim-locale">{{ .Claims.UserInfo.Locale }}</span></p> +<p>Locale (ID Token): <span id="claim-id-token-locale">{{ .Claims.IDToken.Locale }}</span></p> +<p>Updated At: <span id="claim-updated_at">{{ .Claims.UserInfo.UpdatedAt }}</span></p> +<p>Updated At (ID Token): <span id="claim-id-token-updated_at">{{ .Claims.IDToken.UpdatedAt }}</span></p> +<p>Email: <span id="claim-email">{{ .Claims.UserInfo.Email }}</span></p> +<p>Email (ID Token): <span id="claim-id-token-email">{{ .Claims.IDToken.Email }}</span></p> +<p>Email Alts: <span id="claim-alt_emails">{{ .Claims.UserInfo.EmailAlts }}</span></p> +<p>Email Alts (ID Token): <span id="claim-id-token-alt_emails">{{ .Claims.IDToken.EmailAlts }}</span></p> +<p>Email Verified: <span id="claim-email_verified">{{ .Claims.UserInfo.EmailVerified }}</span></p> +<p>Email Verified (ID Token): <span id="claim-id-token-email_verified">{{ .Claims.IDToken.EmailVerified }}</span></p> +<p>Phone Number: <span id="claim-phone_number">{{ .Claims.UserInfo.PhoneNumber }}</span></p> +<p>Phone Number (ID Token): <span id="claim-id-token-phone_number">{{ .Claims.IDToken.PhoneNumber }}</span></p> +<p>Phone Number Verified: <span id="claim-phone_number_verified">{{ .Claims.UserInfo.PhoneNumberVerified }}</span></p> +<p>Phone Number Verified (ID Token): <span id="claim-id-token-phone_number_verified">{{ .Claims.IDToken.PhoneNumberVerified }}</span></p> +<p>Groups: <span id="claim-groups">{{ stringsJoin .Groups ", " }}</span></p> +<p>Groups (ID Token): <span id="claim-id-token-groups">{{ stringsJoin .Groups ", " }}</span></p> +<p>Raw: <span id="raw">{{ .RawToken }}</span></p> +<p>Authorize Code URL: <span id="auth-code-url">{{ .AuthorizeCodeURL }}</span></p> +{{- else }} +<p>Not logged yet...</p> <a id="login-link" href="/login">Log in</a> +{{- end }} + +<!-- +<body style="height: 100vh; position: relative;"> + <div style="position: absolute; width: 50%; transform: scale(2); + transform-origin: 0 0;"> + <form action="/submit" method="POST" id="sumbit"> + <input type="text" id="target" name="target" required size="20" /> + </form> + </div> +</body> +--> + +{{ end }} + diff --git a/nix/templates/goapp/frontend/templates/login.html b/nix/templates/goapp/frontend/templates/login.html new file mode 100644 index 0000000..6e54781 --- /dev/null +++ b/nix/templates/goapp/frontend/templates/login.html @@ -0,0 +1,41 @@ +{{ define "login" }} + +{{ template "head" . }} +<body> + {{ template "nav" . }} + + <span id="login"></span> + <h1><a href="#login">Login</a></h1> + + {{ if .err }}{{ .err }}{{ end }} + {{ if .logged_in }} + Already logged in! <a href="/">Return home</a> + {{ else }} + <form method="POST" action="/login"> + + <table> + <tr> + <td><label for="username">Name:</label></td> + <td><input class="border" type="text" id="username" name="username" autofocus></td> + </tr> + <tr> + <td><label for="password">Password:</label></td> + <td><input class="border" type="password" id="password" name="password"></td> + </tr> + <tr> + <td></td> + <td><input class="border" type="submit" value="Login"></td> + </tr> + <tr> + <td></td> + <td>{{ .res }}</td> + </tr> + </table> + </form> + <!-- Not registered yet? <a href="/register">Register Now!</a> --> + {{ end }} + + +</body> +{{ template "footer" . }} +{{end}} diff --git a/nix/templates/goapp/frontend/templates/nav.html b/nix/templates/goapp/frontend/templates/nav.html new file mode 100644 index 0000000..bf9820f --- /dev/null +++ b/nix/templates/goapp/frontend/templates/nav.html @@ -0,0 +1,41 @@ +{{ define "nav" }} + <header> + <p style="margin: 1ex 0; display: block; width: 100%; background-color: #ffaa00; color: white;"> + v0.0.1 EARLY BETA - Data can be deleted at random! + </p> + </header> + <nav> + + <ul> + {{ range .Breadcrumbs }} + <li> + <a class="local" href="{{ .Main.Target }}">{{ .Main.Name }}</a> + {{ if .Options }} + <ul> + {{ range $opt := .Options }} + <li><a class="local" href="{{ $opt.Target }}">{{ $opt.Name }}</a></li> + {{ end }} + </ul> + {{ end }} + </li> + {{ end }} + </ul> + + + <ul style="float: right"> + <li> + <a href="https://github.com/HanEmile/hefe/tree/main/nix/templates/goapp">src</a> + </li> + </ul> + </nav> + <ul> + {{ if .NextLinks }} + {{ range .NextLinks }} + <li><a class="local" href="{{ .Target }}">{{ .Name }}</a></li> + {{ end }} + {{ end }} + </ul> + <br> +{{ end }} + + |