diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cbd2a0d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +cache.gob +prayercal +prayercal.ics +.git \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f3726f..ea90dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +.env cache.gob prayercal -.env prayercal.ics +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5adc7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM golang:1.27 AS build +ARG TARGETOS TARGETARCH TARGETVARIANT +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + GOARM=${TARGETVARIANT#v} \ + go build -o /prayercal . + +FROM gcr.io/distroless/static +COPY --from=build /prayercal /prayercal +EXPOSE 5543 +ENTRYPOINT ["/prayercal"] \ No newline at end of file diff --git a/go.mod b/go.mod index c9ccb3f..7b10349 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.27.0 require ( github.com/arran4/golang-ical v0.3.7 github.com/joho/godotenv v1.5.1 + golang.org/x/time v0.16.0 ) diff --git a/go.sum b/go.sum index c8dae03..d8e23ec 100644 --- a/go.sum +++ b/go.sum @@ -10,5 +10,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 99f9929..607a30b 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,11 @@ import ( _ "time/tzdata" ics "github.com/arran4/golang-ical" + + "net" + "strconv" + "strings" + "golang.org/x/time/rate" ) type prayerTimes struct { @@ -39,12 +44,19 @@ func main() { // example {1791571473 map[Asr:15:43 Dhuhr:12:53 Fajr:05:41 Isha:19:45 Maghrib:18:24 Sunrise:07:11]} loc, err := time.LoadLocation("Europe/London") - time.Local = loc if err != nil { log.Fatal(err) } + time.Local = loc - http.HandleFunc("/prayercal.ics", func(w http.ResponseWriter, r *http.Request) { + lim := newLimiter( + envFloat("RATE_LIMIT_RPS", 1), + envInt("RATE_LIMIT_BURST", 5), + ) + trustProxy := envBool("TRUST_PROXY", false) + + mux := http.NewServeMux() + mux.HandleFunc("/prayercal.ics", func(w http.ResponseWriter, r *http.Request) { mu.Lock() times, err := getTimes(url, "/data/cache.gob") mu.Unlock() @@ -64,7 +76,13 @@ func main() { w.Header().Set("Content-Type", "text/calendar; charset=utf-8") io.WriteString(w, out) }) - log.Fatal(http.ListenAndServe(":5543", nil)) + + srv := &http.Server{ + Addr: ":" + getenv("PORT", "5543"), + Handler: lim.middleware(trustProxy, mux), + ReadHeaderTimeout: 5 * time.Second, + } + log.Fatal(srv.ListenAndServe()) } func buildCal(times prayerTimes, loc *time.Location) (string, error) { @@ -189,4 +207,98 @@ func getTimes(url, cache string) (prayerTimes, error) { } return form, nil +} + +func envFloat(key string, def float64) float64 { + if v := os.Getenv(key); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + log.Printf("bad %s=%q, using %v", key, v, def) + } + return def +} + +func envInt(key string, def int) int { + return int(envFloat(key, float64(def))) +} + +func envBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return def +} + +type client struct { + lim *rate.Limiter + seen time.Time +} + +type limiter struct { + mu sync.Mutex + clients map[string]*client + rps rate.Limit + burst int +} + +func newLimiter(rps float64, burst int) *limiter { + l := &limiter{clients: map[string]*client{}, rps: rate.Limit(rps), burst: burst} + go func() { + for range time.Tick(time.Minute) { + l.mu.Lock() + for ip, c := range l.clients { + if time.Since(c.seen) > 10*time.Minute { + delete(l.clients, ip) + } + } + l.mu.Unlock() + } + }() + return l +} + +func (l *limiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + c, ok := l.clients[ip] + if !ok { + c = &client{lim: rate.NewLimiter(l.rps, l.burst)} + l.clients[ip] = c + } + c.seen = time.Now() + return c.lim.Allow() +} + +func clientIP(r *http.Request, trustProxy bool) string { + if trustProxy { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (l *limiter) middleware(trustProxy bool, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !l.allow(clientIP(r, trustProxy)) { + w.Header().Set("Retry-After", "1") + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def } \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..3dde148 --- /dev/null +++ b/readme.md @@ -0,0 +1,2 @@ +# Prayercal +