diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb1adff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +debug.log +/.env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db963cb --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +FN := directory + +VERSION := $(shell git describe --always --dirty=-dirty) +ARCH := $(shell uname -m) +UNAME_S := $(shell uname -s) +GOLDFLAGS += -X main.Version=$(VERSION) +GOLDFLAGS += -X main.Buildarch=$(ARCH) +GOFLAGS := -ldflags "$(GOLDFLAGS)" + +default: clean debug + +commit: fmt lint + git commit -a + +fmt: + gofumpt -l -w . + golangci-lint run --fix + +lint: + golangci-lint run + sh -c 'test -z "$$(gofmt -l .)"' + +debug: ./$(FN)d + DEBUG=1 GOTRACEBACK=all ./$(FN)d + +debugger: + cd cmd/$(FN)d && dlv debug main.go + +run: ./$(FN)d + ./$(FN)d + +clean: + -rm -f ./$(FN)d debug.log + +docker: + docker build --progress plain . + +./$(FN)d: cmd/$(FN)d/main.go internal/*/*.go assets/*/* + cd ./cmd/$(FN)d && \ + go build -o ../../$(FN)d $(GOFLAGS) . diff --git a/cmd/httpd/main.go b/cmd/httpd/main.go new file mode 100644 index 0000000..7fad905 --- /dev/null +++ b/cmd/httpd/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "go.uber.org/fx" + "sneak.berlin/go/directory/internal/config" + "sneak.berlin/go/directory/internal/database" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/handlers" + "sneak.berlin/go/directory/internal/healthcheck" + "sneak.berlin/go/directory/internal/logger" + "sneak.berlin/go/directory/internal/middleware" + "sneak.berlin/go/directory/internal/server" +) + +var ( + Appname string = "directory" + Version string + Buildarch string +) + +func main() { + globals.Appname = Appname + globals.Version = Version + globals.Buildarch = Buildarch + + fx.New( + fx.Provide( + config.New, + database.New, + globals.New, + handlers.New, + logger.New, + server.New, + middleware.New, + healthcheck.New, + ), + fx.Invoke(func(*server.Server) {}), + ).Run() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6f09dd --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module sneak.berlin/go/directory + +go 1.22.2 + +require ( + github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 + github.com/getsentry/sentry-go v0.28.0 + github.com/go-chi/chi v1.5.5 + github.com/go-chi/cors v1.2.1 + github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.19.1 + github.com/rs/zerolog v1.33.0 + github.com/slok/go-http-metrics v0.12.0 + github.com/spf13/viper v1.18.2 + go.uber.org/fx v1.22.0 + gorm.io/driver/sqlite v1.5.5 + gorm.io/gorm v1.25.10 + sneak.berlin/go/util v0.0.0-20240601223751-9302c14a6cdc +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.53.0 // indirect + github.com/prometheus/procfs v0.13.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/dig v1.17.1 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..19baebb --- /dev/null +++ b/go.sum @@ -0,0 +1,143 @@ +github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= +github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/getsentry/sentry-go v0.28.0 h1:7Rqx9M3ythTKy2J6uZLHmc8Sz9OGgIlseuO1iBX/s0M= +github.com/getsentry/sentry-go v0.28.0/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hako/durafmt v0.0.0-20191009132224-3f39dc1ed9f4 h1:60gBOooTSmNtrqNaRvrDbi8VAne0REaek2agjnITKSw= +github.com/hako/durafmt v0.0.0-20191009132224-3f39dc1ed9f4/go.mod h1:5Scbynm8dF1XAPwIwkGPqzkM/shndPm79Jd1003hTjE= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= +github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= +github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= +github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= +github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/slok/go-http-metrics v0.12.0 h1:mAb7hrX4gB4ItU6NkFoKYdBslafg3o60/HbGBRsKaG8= +github.com/slok/go-http-metrics v0.12.0/go.mod h1:Ee/mdT9BYvGrlGzlClkK05pP2hRHmVbRF9dtUVS8LNA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.22.0 h1:pApUK7yL0OUHMd8vkunWSlLxZVFFk70jR2nKde8X2NM= +go.uber.org/fx v1.22.0/go.mod h1:HT2M7d7RHo+ebKGh9NRcrsrHHfpZ60nW3QRubMRfv48= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= +gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +sneak.berlin/go/util v0.0.0-20240522214643-bef8d7776867 h1:Ukf/4BjHwMLJHxF0Wbne+c2o+1MokssxHerIWgiVvfQ= +sneak.berlin/go/util v0.0.0-20240522214643-bef8d7776867/go.mod h1:CJ0oxokKpYpvL+PpKXQwoUOGU1wQOPY2lVLsHPvV2Vk= +sneak.berlin/go/util v0.0.0-20240601223751-9302c14a6cdc h1:w9MgdtDE5zRPmni8joT1+tyut9lmY+xlWy9PZiE3Z1Q= +sneak.berlin/go/util v0.0.0-20240601223751-9302c14a6cdc/go.mod h1:Y8fzvsIyVr1RlWgFj6tL93VNSEBjD2ghhlLpRrV1w6Y= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..52b8b68 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "fmt" + + "github.com/rs/zerolog" + "github.com/spf13/viper" + "go.uber.org/fx" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/logger" + + // spooky action at a distance! + // this populates the environment + // from a ./.env file automatically + // for development configuration. + // .env contents should be things like + // `DBURL=postgres://user:pass@.../` + // (without the backticks, of course) + _ "github.com/joho/godotenv/autoload" +) + +type ConfigParams struct { + fx.In + Globals *globals.Globals + Logger *logger.Logger +} + +type Config struct { + DBURL string + Debug bool + MaintenanceMode bool + DevelopmentMode bool + DevAdminUsername string + DevAdminPassword string + MetricsPassword string + MetricsUsername string + Port int + SentryDSN string + params *ConfigParams + log *zerolog.Logger +} + +func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { + log := params.Logger.Get() + name := params.Globals.Appname + + viper.SetConfigName(name) + viper.SetConfigType("yaml") + // path to look for the config file in: + viper.AddConfigPath(fmt.Sprintf("/etc/%s", name)) + // call multiple times to add many search paths: + viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", name)) + // viper.SetEnvPrefix(strings.ToUpper(s.appname)) + viper.AutomaticEnv() + + viper.SetDefault("DEBUG", "false") + viper.SetDefault("MAINTENANCE_MODE", "false") + viper.SetDefault("DEVELOPMENT_MODE", "false") + viper.SetDefault("DEV_ADMIN_USERNAME", "") + viper.SetDefault("DEV_ADMIN_PASSWORD", "") + viper.SetDefault("PORT", "8080") + viper.SetDefault("DBURL", "") + viper.SetDefault("SENTRY_DSN", "") + viper.SetDefault("METRICS_USERNAME", "") + viper.SetDefault("METRICS_PASSWORD", "") + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Config file not found; ignore error if desired + } else { + // Config file was found but another error was produced + log.Panic(). + Err(err). + Msg("config file malformed") + } + } + + s := &Config{ + DBURL: viper.GetString("DBURL"), + Debug: viper.GetBool("debug"), + Port: viper.GetInt("PORT"), + SentryDSN: viper.GetString("SENTRY_DSN"), + MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), + DevelopmentMode: viper.GetBool("DEVELOPMENT_MODE"), + DevAdminUsername: viper.GetString("DEV_ADMIN_USERNAME"), + DevAdminPassword: viper.GetString("DEV_ADMIN_PASSWORD"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsPassword: viper.GetString("METRICS_PASSWORD"), + log: log, + params: ¶ms, + } + + if s.Debug { + params.Logger.EnableDebugLogging() + s.log = params.Logger.Get() + } + + return s, nil +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..225012e --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,115 @@ +package database + +import ( + "context" + "os" + + "database/sql" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "go.uber.org/fx" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "sneak.berlin/go/directory/internal/config" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/logger" + "sneak.berlin/go/util" + + // spooky action at a distance! + // this populates the environment + // from a ./.env file automatically + // for development configuration. + // .env contents should be things like + // `DBURL=postgres://user:pass@.../` + // (without the backticks, of course) + _ "github.com/joho/godotenv/autoload" +) + +type DatabaseParams struct { + fx.In + Logger *logger.Logger + Config *config.Config + Globals *globals.Globals +} + +type Database struct { + URL string + log *zerolog.Logger + params *DatabaseParams + DB *gorm.DB + SQLDB *sql.DB + dbdir string + dbfn string +} + +func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) { + s := new(Database) + s.params = ¶ms + s.log = params.Logger.Get() + + s.log.Info().Msg("Database instantiated") + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.log.Info().Msg("Database OnStart Hook") + // FIXME connect to db + return nil + }, + OnStop: func(ctx context.Context) error { + // FIXME disconnect from db + return nil + }, + }) + return s, nil +} + +func (d *Database) Close() { + d.log.Info().Msg("Database Close()") +} + +func (d *Database) Get() (*gorm.DB, error) { + if d.DB == nil { + err := d.Connect() + if err != nil { + d.log.Error().Err(err).Msg("failed to connect to database") + return nil, err + } + } + return d.DB, nil +} + +func (d *Database) Connect() error { + // FIXME make this get the data dir path from config + d.dbdir = os.Getenv("HOME") + err := util.Mkdirp(d.dbdir) + if err != nil { + d.log.Error(). + Err(err). + Str("dbdir", d.dbdir). + Msg("unable to create directory") + return err + } + d.dbfn = d.dbdir + "/" + d.params.Globals.Appname + ".db" + d.log.Info(). + Str("file", d.dbfn). + Msg("opening store db") + + // Open the database using the pure Go SQLite driver + sqlDB, err := sql.Open("sqlite", d.dbfn+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)") + if err != nil { + d.log.Error().Err(err).Msg("failed to open database") + return err + } + d.SQLDB = sqlDB + + // Use the generic Gorm SQL driver to integrate it + db, err := gorm.Open(sqlite.Dialector{Conn: d.SQLDB}, &gorm.Config{}) + if err != nil { + log.Error().Err(err).Msg("failed to connect database") + return err + } + d.DB = db + + return nil +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go new file mode 100644 index 0000000..e2ed2c8 --- /dev/null +++ b/internal/globals/globals.go @@ -0,0 +1,27 @@ +package globals + +import ( + "go.uber.org/fx" +) + +// these get populated from main() and copied into the Globals object. +var ( + Appname string + Version string + Buildarch string +) + +type Globals struct { + Appname string + Version string + Buildarch string +} + +func New(lc fx.Lifecycle) (*Globals, error) { + n := &Globals{ + Appname: Appname, + Buildarch: Buildarch, + Version: Version, + } + return n, nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..00d4c7a --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/rs/zerolog" + "go.uber.org/fx" + "sneak.berlin/go/directory/internal/database" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/healthcheck" + "sneak.berlin/go/directory/internal/logger" +) + +type HandlersParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Database *database.Database + Healthcheck *healthcheck.Healthcheck +} + +type Handlers struct { + params *HandlersParams + log *zerolog.Logger + hc *healthcheck.Healthcheck +} + +func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { + s := new(Handlers) + s.params = ¶ms + s.log = params.Logger.Get() + s.hc = params.Healthcheck + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // FIXME compile some templates here or something + return nil + }, + }) + return s, nil +} + +const jsonContentType = "application/json; charset=utf-8" + +func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) { + w.WriteHeader(status) + w.Header().Set("Content-Type", jsonContentType) + if data != nil { + err := json.NewEncoder(w).Encode(data) + if err != nil { + s.log.Error().Err(err).Msg("json encode error") + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { // nolint + return json.NewDecoder(r.Body).Decode(v) +} diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go new file mode 100644 index 0000000..63cf82a --- /dev/null +++ b/internal/healthcheck/healthcheck.go @@ -0,0 +1,82 @@ +package healthcheck + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/rs/zerolog" + "go.uber.org/fx" + "sneak.berlin/go/directory/internal/config" + "sneak.berlin/go/directory/internal/database" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/logger" +) + +type HealthcheckParams struct { + fx.In + Globals *globals.Globals + Config *config.Config + Logger *logger.Logger + Database *database.Database +} + +type Healthcheck struct { + StartupTime time.Time + log *zerolog.Logger + params *HealthcheckParams +} + +func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) { + s := new(Healthcheck) + s.params = ¶ms + s.log = params.Logger.Get() + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.StartupTime = time.Now() + return nil + }, + OnStop: func(ctx context.Context) error { + // FIXME do server shutdown here + return nil + }, + }) + return s, nil +} + +func (s *Healthcheck) uptime() time.Duration { + return time.Since(s.StartupTime) +} + +type HealthcheckResponse struct { + Status string `json:"status"` + Now string `json:"now"` + UptimeSeconds int64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + Version string `json:"version"` + Appname string `json:"appname"` + Maintenance bool `json:"maintenance_mode"` +} + +func (s *Healthcheck) Healthcheck() *HealthcheckResponse { + + resp := &HealthcheckResponse{ + Status: "ok", + Now: time.Now().UTC().Format(time.RFC3339Nano), + UptimeSeconds: int64(s.uptime().Seconds()), + UptimeHuman: s.uptime().String(), + Appname: s.params.Globals.Appname, + Version: s.params.Globals.Version, + } + return resp +} + +func (s *Healthcheck) HealthcheckHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := s.Healthcheck() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(resp) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..90508a0 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,97 @@ +package logger + +import ( + "io" + "os" + "time" + + "github.com/rs/zerolog" + "go.uber.org/fx" + "sneak.berlin/go/directory/internal/globals" +) + +type LoggerParams struct { + fx.In + Globals *globals.Globals +} + +type Logger struct { + log *zerolog.Logger + params LoggerParams +} + +func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) { + l := new(Logger) + + // always log in UTC + zerolog.TimestampFunc = func() time.Time { + return time.Now().UTC() + } + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + tty := false + if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { + tty = true + } + + var writers []io.Writer + + if tty { + // this does cool colorization for console/dev + consoleWriter := zerolog.NewConsoleWriter( + func(w *zerolog.ConsoleWriter) { + // Customize time format + w.TimeFormat = time.RFC3339Nano + }, + ) + + writers = append(writers, consoleWriter) + } else { + // log json in prod for the machines + writers = append(writers, os.Stdout) + } + + /* + // this is how you log to a file, if you do that + // sort of thing still + logfile := viper.GetString("Logfile") + if logfile != "" { + logfileDir := filepath.Dir(logfile) + err := goutil.Mkdirp(logfileDir) + if err != nil { + log.Error().Err(err).Msg("unable to create log dir") + } + + hp.logfh, err = os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + panic("unable to open logfile: " + err.Error()) + } + + writers = append(writers, hp.logfh) + */ + + multi := zerolog.MultiLevelWriter(writers...) + logger := zerolog.New(multi).With().Timestamp().Logger().With().Caller().Logger() + + l.log = &logger + // log.Logger = logger + + return l, nil +} + +func (l *Logger) EnableDebugLogging() { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + l.log.Debug().Bool("debug", true).Send() +} + +func (l *Logger) Get() *zerolog.Logger { + return l.log +} + +func (l *Logger) Identify() { + l.log.Info(). + Str("appname", l.params.Globals.Appname). + Str("version", l.params.Globals.Version). + Str("buildarch", l.params.Globals.Buildarch). + Msg("starting") +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..2c50cde --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,134 @@ +package middleware + +import ( + "net" + "net/http" + "time" + + basicauth "github.com/99designs/basicauth-go" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" + "github.com/rs/zerolog" + "sneak.berlin/go/directory/internal/config" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/logger" + + metrics "github.com/slok/go-http-metrics/metrics/prometheus" + ghmm "github.com/slok/go-http-metrics/middleware" + "github.com/slok/go-http-metrics/middleware/std" + "github.com/spf13/viper" + "go.uber.org/fx" +) + +type MiddlewareParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config +} + +type Middleware struct { + log *zerolog.Logger + params *MiddlewareParams +} + +func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) { + s := new(Middleware) + s.params = ¶ms + s.log = params.Logger.Get() + return s, nil +} + +// the following is from +// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/ + +func ipFromHostPort(hp string) string { + h, _, err := net.SplitHostPort(hp) + if err != nil { + return "" + } + if len(h) > 0 && h[0] == '[' { + return h[1 : len(h)-1] + } + return h +} + +type loggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{w, http.StatusOK} +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +// type Middleware func(http.Handler) http.Handler +// this returns a Middleware that is designed to do every request through the +// mux, note the signature: +func (s *Middleware) Logging() func(http.Handler) http.Handler { + // FIXME this should use https://github.com/google/go-cloud/blob/master/server/requestlog/requestlog.go + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lrw := NewLoggingResponseWriter(w) + ctx := r.Context() + defer func() { + latency := time.Since(start) + s.log.Info(). + Time("request_start", start). + Str("method", r.Method). + Str("url", r.URL.String()). + Str("useragent", r.UserAgent()). + Str("request_id", ctx.Value(middleware.RequestIDKey).(string)). + Str("referer", r.Referer()). + Str("proto", r.Proto). + Str("remoteIP", ipFromHostPort(r.RemoteAddr)). + Int("status", lrw.statusCode). + Int("latency_ms", int(latency.Milliseconds())). + Send() + }() + + next.ServeHTTP(lrw, r) + }) + } +} + +func (s *Middleware) CORS() func(http.Handler) http.Handler { + return cors.Handler(cors.Options{ + // CHANGEME! these are defaults, change them to suit your needs or + // read from environment/viper. + // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts + AllowedOrigins: []string{"*"}, + // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: false, + MaxAge: 300, // Maximum value not ignored by any of major browsers + }) +} + +func (s *Middleware) Metrics() func(http.Handler) http.Handler { + mdlw := ghmm.New(ghmm.Config{ + Recorder: metrics.NewRecorder(metrics.Config{}), + }) + return func(next http.Handler) http.Handler { + return std.Handler("", mdlw, next) + } +} + +func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { + return basicauth.New( + "metrics", + map[string][]string{ + viper.GetString("METRICS_USERNAME"): { + viper.GetString("METRICS_PASSWORD"), + }, + }, + ) +} diff --git a/internal/server/http.go b/internal/server/http.go new file mode 100644 index 0000000..6c25ffc --- /dev/null +++ b/internal/server/http.go @@ -0,0 +1,34 @@ +package server + +import ( + "fmt" + "net/http" + "time" +) + +func (s *Server) serveUntilShutdown() { + listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) + s.httpServer = &http.Server{ + Addr: listenAddr, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + Handler: s, + } + + // add routes + // this does any necessary setup in each handler + s.SetupRoutes() + + s.log.Info().Str("listenaddr", listenAddr).Msg("http begin listen") + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.log.Error().Msgf("listen:%+s\n", err) + if s.cancelFunc != nil { + s.cancelFunc() + } + } +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..3387816 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,104 @@ +package server + +import ( + "net/http" + "time" + + sentryhttp "github.com/getsentry/sentry-go/http" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/spf13/viper" +) + +func (s *Server) SetupRoutes() { + s.router = chi.NewRouter() + + // the mux .Use() takes a http.Handler wrapper func, like most + // things that deal with "middlewares" like alice et c, and will + // call ServeHTTP on it. These middlewares applied by the mux (you + // can .Use() more than one) will be applied to every request into + // the service. + + s.router.Use(middleware.Recoverer) + s.router.Use(middleware.RequestID) + s.router.Use(s.mw.Logging()) + + // add metrics middleware only if we can serve them behind auth + if viper.GetString("METRICS_USERNAME") != "" { + s.router.Use(s.mw.Metrics()) + } + + // set up CORS headers. you'll probably want to configure that + // in middlewares.go. + s.router.Use(s.mw.CORS()) + + // CHANGEME to suit your needs, or pull from config. + // timeout for request context; your handlers must finish within + // this window: + s.router.Use(middleware.Timeout(60 * time.Second)) + + // this adds a sentry reporting middleware if and only if sentry is + // enabled via setting of SENTRY_DSN in env. + if s.sentryEnabled { + // Options docs at + // https://docs.sentry.io/platforms/go/guides/http/ + // we set sentry to repanic so that all panics bubble up to the + // Recoverer chi middleware above. + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + s.router.Use(sentryHandler.Handle) + } + + //////////////////////////////////////////////////////////////////////// + // ROUTES + // complete docs: https://github.com/go-chi/chi + //////////////////////////////////////////////////////////////////////// + + s.router.Get("/", s.h.HandleIndex()) + + s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) + + s.router.Route("/api/v1", func(r chi.Router) { + r.Get("/now", s.h.HandleNow()) + }) + + // if you want to use a general purpose middleware (http.Handler + // wrapper) on a specific HandleFunc route, you need to take the + // .ServeHTTP of the http.Handler to get its HandleFunc, viz: + auth := s.mw.Auth() + s.router.Get( + "/login", + auth(s.h.HandleLoginGET()).ServeHTTP, + ) + + s.router.Get( + "/signup", + auth(s.h.HandleSignupGET()).ServeHTTP, + ) + + s.router.Post( + "/signup", + auth(s.h.HandleSignupPOST()).ServeHTTP, + ) + // route that panics for testing + // CHANGEME remove this + s.router.Get( + "/panic", + s.h.HandlePanic(), + ) + + s.router.Get( + "/.well-known/healthcheck.json", + s.h.HandleHealthCheck(), + ) + + // set up authenticated /metrics route: + if viper.GetString("METRICS_USERNAME") != "" { + s.router.Group(func(r chi.Router) { + r.Use(s.mw.MetricsAuth()) + r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..69b3f56 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,177 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/rs/zerolog" + "go.uber.org/fx" + "sneak.berlin/go/directory/internal/config" + "sneak.berlin/go/directory/internal/globals" + "sneak.berlin/go/directory/internal/handlers" + "sneak.berlin/go/directory/internal/logger" + "sneak.berlin/go/directory/internal/middleware" + + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi" + + // spooky action at a distance! + // this populates the environment + // from a ./.env file automatically + // for development configuration. + // .env contents should be things like + // `DBURL=postgres://user:pass@.../` + // (without the backticks, of course) + _ "github.com/joho/godotenv/autoload" +) + +type ServerParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config + Middleware *middleware.Middleware + Handlers *handlers.Handlers +} + +type Server struct { + startupTime time.Time + exitCode int + sentryEnabled bool + log *zerolog.Logger + ctx context.Context + cancelFunc context.CancelFunc + httpServer *http.Server + router *chi.Mux + params ServerParams + mw *middleware.Middleware + h *handlers.Handlers +} + +func New(lc fx.Lifecycle, params ServerParams) (*Server, error) { + s := new(Server) + s.params = params + s.mw = params.Middleware + s.h = params.Handlers + s.log = params.Logger.Get() + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.startupTime = time.Now() + go s.Run() // background FIXME + return nil + }, + OnStop: func(ctx context.Context) error { + // FIXME do server shutdown here + return nil + }, + }) + return s, nil +} + +// FIXME change this to use uber/fx DI and an Invoke() +// this is where we come in from package main. +func (s *Server) Run() { + // this does nothing if SENTRY_DSN is unset in env. + // TODO remove: + if s.sentryEnabled { + sentry.CaptureMessage("It works!") + } + + s.configure() + + // logging before sentry, because sentry logs + s.enableSentry() + + s.serve() // FIXME deal with return value +} + +func (s *Server) enableSentry() { + s.sentryEnabled = false + + if s.params.Config.SentryDSN == "" { + return + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: s.params.Config.SentryDSN, + Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version), + }) + if err != nil { + s.log.Fatal().Err(err).Msg("sentry init failure") + return + } + s.log.Info().Msg("sentry error reporting activated") + s.sentryEnabled = true +} + +func (s *Server) serve() int { + // FIXME fx will handle this for us + s.ctx, s.cancelFunc = context.WithCancel(context.Background()) + + // signal watcher + go func() { + c := make(chan os.Signal, 1) + signal.Ignore(syscall.SIGPIPE) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + // block and wait for signal + sig := <-c + s.log.Info().Msgf("signal received: %+v", sig) + if s.cancelFunc != nil { + // cancelling the main context will trigger a clean + // shutdown. + s.cancelFunc() + } + }() + + go s.serveUntilShutdown() + + for range s.ctx.Done() { + // aforementioned clean shutdown upon main context + // cancellation + } + s.cleanShutdown() + return s.exitCode +} + +func (s *Server) cleanupForExit() { + s.log.Info().Msg("cleaning up") + // FIXME unimplemented + // close database connections or whatever +} + +func (s *Server) cleanShutdown() { + // initiate clean shutdown + s.exitCode = 0 + ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := s.httpServer.Shutdown(ctxShutdown); err != nil { + s.log.Error(). + Err(err). + Msg("server clean shutdown failed") + } + if shutdownCancel != nil { + shutdownCancel() + } + + s.cleanupForExit() + + if s.sentryEnabled { + sentry.Flush(2 * time.Second) + } +} + +func (s *Server) MaintenanceMode() bool { + return s.params.Config.MaintenanceMode +} + +func (s *Server) configure() { + // FIXME move most of this to dedicated places + // if viper.GetBool("DEBUG") { + // pp.Print(viper.AllSettings()) + // } +}