From 31f27c50925f6fdd8440b595917e5b7ae54e5627 Mon Sep 17 00:00:00 2001 From: Dakota Walsh Date: Tue, 20 Jan 2026 10:20:19 +1300 Subject: [PATCH] Initial commit --- config.toml | 1 + go.mod | 11 ++++ go.sum | 18 ++++++ main.go | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 config.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..899f359 --- /dev/null +++ b/config.toml @@ -0,0 +1 @@ +Addr = ":1112" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad31e4f --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.nilsu.org/kota/donation-notifications + +go 1.25.5 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/justinas/alice v1.2.0 + github.com/throttled/throttled/v2 v2.15.0 +) + +require github.com/hashicorp/golang-lru v0.5.4 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dca8d8c --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= +github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= +github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= +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/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/throttled/throttled/v2 v2.15.0 h1:7XLCECtmEx+Yz/e5opBNff9cPGpH0ia0xEj5kyDPotI= +github.com/throttled/throttled/v2 v2.15.0/go.mod h1:JlfSSSYoM/bjFoW2sCATGxJJXggjO67DFQu9xduGAWE= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8abed1d --- /dev/null +++ b/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "runtime/debug" + + "github.com/BurntSushi/toml" + "github.com/justinas/alice" + "github.com/throttled/throttled/v2" + throttledstore "github.com/throttled/throttled/v2/store/memstore" +) + +type application struct { + infoLog *log.Logger + errLog *log.Logger + rateLimiter *throttled.HTTPRateLimiterCtx +} + +func main() { + cfgPath := flag.String( + "config", + "/etc/donation-notification/config.toml", + "Path to configuration file", + ) + flag.Parse() + + infoLog := log.New(os.Stdout, "INFO ", log.Ldate|log.Ltime) + errLog := log.New(os.Stdout, "ERROR ", log.Ldate|log.Ltime|log.Lshortfile) + + cfg, err := loadConfig(*cfgPath) + if err != nil { + errLog.Fatalln(err) + } + + // Set up HTTP request throttling. + tstore, err := throttledstore.NewCtx(65536) + if err != nil { + errLog.Fatal(err) + } + quota := throttled.RateQuota{ + MaxRate: throttled.PerMin(1), + MaxBurst: 2, + } + throttler, err := throttled.NewGCRARateLimiterCtx(tstore, quota) + if err != nil { + errLog.Fatal(err) + } + rateLimiter := &throttled.HTTPRateLimiterCtx{ + RateLimiter: throttler, + VaryBy: &throttled.VaryBy{ + Path: true, + Method: true, + Headers: []string{"X-Forwarded-For"}, + }, + } + + app := &application{ + infoLog: infoLog, + errLog: errLog, + rateLimiter: rateLimiter, + } + + srv := &http.Server{ + Addr: cfg.Addr, + ErrorLog: errLog, + Handler: app.routes(), + } + + infoLog.Println("listening on", cfg.Addr) + err = srv.ListenAndServe() + errLog.Fatalln(err) +} + +type config struct { + Addr string +} + +func loadConfig(path string) (config, error) { + cfg := config{ + Addr: ":1112", + } + _, err := toml.DecodeFile(path, &cfg) + if err != nil { + return config{}, fmt.Errorf("failed loading config: %v", err) + } + return cfg, nil +} + +func (app *application) routes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/webhook/stripe", app.stripe) + mux.HandleFunc("/webhook/paypal", app.paypal) + + standard := alice.New( + app.recoverPanic, + app.rateLimiter.RateLimit, + app.logRequest, + app.secureHeaders, + ) + return standard.Then(mux) +} + +func (app *application) stripe(w http.ResponseWriter, r *http.Request) {} + +func (app *application) paypal(w http.ResponseWriter, r *http.Request) {} + +// logRequest is a middleware that prints each request to the info log. +func (app *application) logRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addr := r.RemoteAddr + + // Use correct address if behind proxy. + if proxyAddr := r.Header.Get("X-Forwarded-For"); proxyAddr != "" { + addr = proxyAddr + } + + app.infoLog.Printf( + "%s - %s %s %s", + addr, + r.Proto, + r.Method, + r.URL.RequestURI(), + ) + next.ServeHTTP(w, r) + }) +} + +// recoverPanic is a middleware which recovers from a panic and logs the error. +func (app *application) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.Header().Set("Connection", "close") + app.serverError(w, fmt.Errorf("%s", err)) + } + }() + + next.ServeHTTP(w, r) + }) +} + +func (app *application) clientError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} + +func (app *application) serverError(w http.ResponseWriter, err error) { + trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack()) + _ = app.errLog.Output(2, trace) // Ignore failed error logging. + http.Error( + w, + http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError, + ) +} + +// secureHeaders is a middleware which adds strict CSP and other headers. +// A CSP nonce is stored in the request's context which can be retrieved with +// the nonce helper function. +func (app *application) secureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set( + "Content-Security-Policy", + "default-src 'none'; script-src 'none' style-src 'none' img-src 'none' https: data:", + ) + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "deny") + w.Header().Set("X-XSS-Protection", "0") + + next.ServeHTTP(w, r) + }) +}