Add project scaffolding with fx DI, SQLite migrations, and healthcheck
- go.mod with git.eeqj.de/sneak/chat module - internal packages: globals, logger, config, db, healthcheck, middleware, handlers, server - SQLite database with embedded migration system (schema_migrations tracking) - Migration 001: schema_migrations table - Migration 002: channels table - Config with chat-specific vars (MAX_HISTORY, SESSION_TIMEOUT, MAX_MESSAGE_SIZE, MOTD, SERVER_NAME, FEDERATION_KEY) - Healthcheck endpoint at /.well-known/healthcheck.json - Makefile, .gitignore - cmd/chatd/main.go entry point
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
chatd
|
||||
data.db
|
||||
.env
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
debug.log
|
||||
48
Makefile
Normal file
48
Makefile
Normal file
@@ -0,0 +1,48 @@
|
||||
FN := chat
|
||||
|
||||
VERSION := $(shell git describe --always --dirty=-dirty)
|
||||
ARCH := $(shell uname -m)
|
||||
UNAME_S := $(shell uname -s)
|
||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||
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 .)"'
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
check: lint test
|
||||
|
||||
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
|
||||
cd ./cmd/$(FN)d && \
|
||||
go build -o ../../$(FN)d $(GOFLAGS) .
|
||||
|
||||
tools:
|
||||
go get -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.31.0
|
||||
go get -v mvdan.cc/gofumpt/gofumports
|
||||
51
go.mod
Normal file
51
go.mod
Normal file
@@ -0,0 +1,51 @@
|
||||
module git.eeqj.de/sneak/chat
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/getsentry/sentry-go v0.42.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/slok/go-http-metrics v0.13.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
go.uber.org/fx v1.24.0
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
150
go.sum
Normal file
150
go.sum
Normal file
@@ -0,0 +1,150 @@
|
||||
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/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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
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.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
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/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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8=
|
||||
github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
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=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
96
internal/config/config.go
Normal file
96
internal/config/config.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
|
||||
_ "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
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
MaxHistory int
|
||||
SessionTimeout int
|
||||
MaxMessageSize int
|
||||
MOTD string
|
||||
ServerName string
|
||||
FederationKey string
|
||||
params *ConfigParams
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
log := params.Logger.Get()
|
||||
name := params.Globals.Appname
|
||||
|
||||
viper.SetConfigName(name)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(fmt.Sprintf("/etc/%s", name))
|
||||
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", name))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
viper.SetDefault("DEBUG", "false")
|
||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||
viper.SetDefault("PORT", "8080")
|
||||
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("MAX_HISTORY", "10000")
|
||||
viper.SetDefault("SESSION_TIMEOUT", "86400")
|
||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||
viper.SetDefault("MOTD", "")
|
||||
viper.SetDefault("SERVER_NAME", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
// Config file not found is OK
|
||||
} else {
|
||||
log.Error("config file malformed", "error", err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
s := &Config{
|
||||
DBURL: viper.GetString("DBURL"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
Port: viper.GetInt("PORT"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
|
||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||
MOTD: viper.GetString("MOTD"),
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
s.log = params.Logger.Get()
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
161
internal/db/db.go
Normal file
161
internal/db/db.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed schema/*.sql
|
||||
var SchemaFiles embed.FS
|
||||
|
||||
type DatabaseParams struct {
|
||||
fx.In
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
DB *sql.DB
|
||||
log *slog.Logger
|
||||
params *DatabaseParams
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
||||
s := new(Database)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
|
||||
s.log.Info("Database instantiated")
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
s.log.Info("Database OnStart Hook")
|
||||
return s.connect(ctx)
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
s.log.Info("Database OnStop Hook")
|
||||
if s.DB != nil {
|
||||
return s.DB.Close()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Database) connect(ctx context.Context) error {
|
||||
dbURL := s.params.Config.DBURL
|
||||
if dbURL == "" {
|
||||
dbURL = "file:./data.db?_journal_mode=WAL"
|
||||
}
|
||||
|
||||
s.log.Info("connecting to database", "url", dbURL)
|
||||
|
||||
d, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
s.log.Error("failed to open database", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.PingContext(ctx); err != nil {
|
||||
s.log.Error("failed to ping database", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.DB = d
|
||||
s.log.Info("database connected")
|
||||
|
||||
return s.runMigrations(ctx)
|
||||
}
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
sql string
|
||||
}
|
||||
|
||||
func (s *Database) runMigrations(ctx context.Context) error {
|
||||
// Bootstrap: create schema_migrations table directly (migration 001 also does this,
|
||||
// but we need it to exist before we can check which migrations have run)
|
||||
_, err := s.DB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Read all migration files
|
||||
entries, err := fs.ReadDir(SchemaFiles, "schema")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read schema dir: %w", err)
|
||||
}
|
||||
|
||||
var migrations []migration
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse version from filename (e.g. "001_initial.sql" -> 1)
|
||||
parts := strings.SplitN(entry.Name(), "_", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
version, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := SchemaFiles.ReadFile("schema/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
migrations = append(migrations, migration{
|
||||
version: version,
|
||||
name: entry.Name(),
|
||||
sql: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].version < migrations[j].version
|
||||
})
|
||||
|
||||
for _, m := range migrations {
|
||||
var exists int
|
||||
err := s.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&exists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check migration %d: %w", m.version, err)
|
||||
}
|
||||
if exists > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
s.log.Info("applying migration", "version", m.version, "name", m.name)
|
||||
if _, err := s.DB.ExecContext(ctx, m.sql); err != nil {
|
||||
return fmt.Errorf("failed to apply migration %d (%s): %w", m.version, m.name, err)
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", m.version); err != nil {
|
||||
return fmt.Errorf("failed to record migration %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("database migrations complete")
|
||||
return nil
|
||||
}
|
||||
4
internal/db/schema/001_initial.sql
Normal file
4
internal/db/schema/001_initial.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
8
internal/db/schema/002_tables.sql
Normal file
8
internal/db/schema/002_tables.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
modes TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
23
internal/globals/globals.go
Normal file
23
internal/globals/globals.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package globals
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
var (
|
||||
Appname string
|
||||
Version string
|
||||
)
|
||||
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
56
internal/handlers/handlers.go
Normal file
56
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
type HandlersParams struct {
|
||||
fx.In
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Database *db.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
type Handlers struct {
|
||||
params *HandlersParams
|
||||
log *slog.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 {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
||||
w.WriteHeader(status)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if data != nil {
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
s.log.Error("json encode error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
12
internal/handlers/healthcheck.go
Normal file
12
internal/handlers/healthcheck.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
resp := s.hc.Healthcheck()
|
||||
s.respondJSON(w, req, resp, 200)
|
||||
}
|
||||
}
|
||||
70
internal/healthcheck/healthcheck.go
Normal file
70
internal/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
type HealthcheckParams struct {
|
||||
fx.In
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *db.Database
|
||||
}
|
||||
|
||||
type Healthcheck struct {
|
||||
StartupTime time.Time
|
||||
log *slog.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 {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
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) uptime() time.Duration {
|
||||
return time.Since(s.StartupTime)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
65
internal/logger/logger.go
Normal file
65
internal/logger/logger.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
type LoggerParams struct {
|
||||
fx.In
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
log *slog.Logger
|
||||
level *slog.LevelVar
|
||||
params LoggerParams
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||
l := new(Logger)
|
||||
l.level = new(slog.LevelVar)
|
||||
l.level.Set(slog.LevelInfo)
|
||||
|
||||
tty := false
|
||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||
tty = true
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
if tty {
|
||||
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: l.level,
|
||||
AddSource: true,
|
||||
})
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: l.level,
|
||||
AddSource: true,
|
||||
})
|
||||
}
|
||||
|
||||
l.log = slog.New(handler)
|
||||
l.params = params
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Logger) EnableDebugLogging() {
|
||||
l.level.Set(slog.LevelDebug)
|
||||
l.log.Debug("debug logging enabled", "debug", true)
|
||||
}
|
||||
|
||||
func (l *Logger) Get() *slog.Logger {
|
||||
return l.log
|
||||
}
|
||||
|
||||
func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
)
|
||||
}
|
||||
131
internal/middleware/middleware.go
Normal file
131
internal/middleware/middleware.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
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 *slog.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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
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.InfoContext(ctx, "request",
|
||||
"request_start", start,
|
||||
"method", r.Method,
|
||||
"url", r.URL.String(),
|
||||
"useragent", r.UserAgent(),
|
||||
"request_id", ctx.Value(middleware.RequestIDKey).(string),
|
||||
"referer", r.Referer(),
|
||||
"proto", r.Proto,
|
||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(lrw, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Middleware) Auth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("AUTH: before request")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
32
internal/server/http.go
Normal file
32
internal/server/http.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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,
|
||||
}
|
||||
|
||||
s.SetupRoutes()
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.log.Error("listen error", "error", err)
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
45
internal/server/routes.go
Normal file
45
internal/server/routes.go
Normal file
@@ -0,0 +1,45 @@
|
||||
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()
|
||||
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
if viper.GetString("METRICS_USERNAME") != "" {
|
||||
s.router.Use(s.mw.Metrics())
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
if s.sentryEnabled {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
}
|
||||
|
||||
// Health check
|
||||
s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
|
||||
|
||||
// Protected metrics endpoint
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
142
internal/server/server.go
Normal file
142
internal/server/server.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
_ "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 *slog.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()
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Run() {
|
||||
s.configure()
|
||||
s.enableSentry()
|
||||
s.serve()
|
||||
}
|
||||
|
||||
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.Error("sentry init failure", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
s.log.Info("sentry error reporting activated")
|
||||
s.sentryEnabled = true
|
||||
}
|
||||
|
||||
func (s *Server) serve() int {
|
||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
sig := <-c
|
||||
s.log.Info("signal received", "signal", sig)
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}()
|
||||
|
||||
go s.serveUntilShutdown()
|
||||
|
||||
for range s.ctx.Done() {
|
||||
}
|
||||
s.cleanShutdown()
|
||||
return s.exitCode
|
||||
}
|
||||
|
||||
func (s *Server) cleanupForExit() {
|
||||
s.log.Info("cleaning up")
|
||||
}
|
||||
|
||||
func (s *Server) cleanShutdown() {
|
||||
s.exitCode = 0
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
||||
s.log.Error("server clean shutdown failed", "error", err)
|
||||
}
|
||||
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() {
|
||||
}
|
||||
Reference in New Issue
Block a user