prayercal/main.go
2026-10-10 18:16:24 +01:00

304 lines
6.9 KiB
Go

package main
import (
"io"
"log"
"net/http"
"os"
"encoding/json"
"github.com/joho/godotenv"
"time"
"encoding/gob"
"bytes"
"fmt"
"sync"
_ "time/tzdata"
ics "github.com/arran4/golang-ical"
"net"
"strconv"
"strings"
"golang.org/x/time/rate"
)
type prayerTimes struct {
FetchedAt int64
Times map[string]string
}
func main() {
// load the env and check for API key, but its ok if there is no .env as there is (hopefully) env variables in the envrionment itself or whatever
_ = godotenv.Load()
var mu sync.Mutex
apiKey := os.Getenv("LPT_API_KEY")
if apiKey == "" {
log.Fatal("LPT_API_KEY is not set")
}
url := "https://www.londonprayertimes.com/api/times/?format=json&24hours=true&key=" + apiKey
// times := getTimes(url, "./cache.gob")
// 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")
if err != nil {
log.Fatal(err)
}
time.Local = loc
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()
if err != nil {
log.Println(err)
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
out, err := buildCal(times, loc)
if err != nil {
log.Println(err)
http.Error(w, "bad prayer data", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
io.WriteString(w, out)
})
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) {
cal := ics.NewCalendar()
cal.SetMethod(ics.MethodPublish)
for _, name := range prayerNames {
start, err := parseToday(times.Times[name], loc)
if err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
ev := cal.AddEvent(name + "-" + start.Format("20060102") + "@prayercal")
ev.SetCreatedTime(time.Now())
ev.SetDtStampTime(time.Now())
ev.SetModifiedAt(time.Now())
ev.SetStartAt(start)
ev.SetEndAt(start.Add(20 * time.Minute))
ev.SetSummary(name)
ev.SetDescription(name + " prayer time")
for _, a := range []struct{ trigger, desc string }{
{"-PT15M", name + " in 15 minutes"},
{"PT0S", name + " time now"},
} {
alarm := ev.AddAlarm()
alarm.SetAction(ics.ActionDisplay)
alarm.SetTrigger(a.trigger)
alarm.SetDescription(a.desc)
}
}
return cal.Serialize(), nil
}
var prayerNames = []string{"Fajr", "Sunrise", "Dhuhr", "Asr", "Maghrib", "Isha"}
var httpClient = &http.Client{Timeout: 10 * time.Second}
func parseToday(hhmm string, loc *time.Location) (time.Time, error) {
t, err := time.Parse("15:04", hhmm)
if err != nil {
return time.Time{}, fmt.Errorf("bad time %q: %w", hhmm, err)
}
now := time.Now().In(loc)
return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, loc), nil
}
func readCache(path string) (prayerTimes, bool) {
file, err := os.Open(path)
if err != nil {
if !os.IsNotExist(err) {
log.Println("cache open error:", err)
}
return prayerTimes{}, false
}
defer file.Close()
var cached prayerTimes
if err := gob.NewDecoder(file).Decode(&cached); err != nil {
log.Println("cache decode error, refetching:", err)
return prayerTimes{}, false
}
y1, m1, d1 := time.Unix(cached.FetchedAt, 0).Date()
y2, m2, d2 := time.Now().Date()
return cached, y1 == y2 && m1 == m2 && d1 == d2
}
func getTimes(url, cache string) (prayerTimes, error) {
if cached, ok := readCache(cache); ok {
log.Println("cache hit")
return cached, nil
}
log.Println("cache miss")
resp, err := httpClient.Get(url)
if err != nil {
return prayerTimes{}, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return prayerTimes{}, fmt.Errorf("fetch: status %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return prayerTimes{}, fmt.Errorf("read body: %w", err)
}
var raw map[string]string
if err := json.Unmarshal(body, &raw); err != nil {
return prayerTimes{}, fmt.Errorf("unmarshal: %w", err)
}
form := prayerTimes{
FetchedAt: time.Now().Unix(),
Times: map[string]string{
"Fajr": raw["fajr"],
"Sunrise": raw["sunrise"],
"Dhuhr": raw["dhuhr"],
"Asr": raw["asr"],
"Maghrib": raw["magrib"],
"Isha": raw["isha"],
},
}
// don't cache a response with missing fields
for _, name := range prayerNames {
if form.Times[name] == "" {
return prayerTimes{}, fmt.Errorf("api response missing %s", name)
}
}
var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(form); err != nil {
log.Println("cache encode error:", err) // still serve the data
return form, nil
}
if err := os.WriteFile(cache, buf.Bytes(), 0644); err != nil {
log.Println("cache write error:", err)
}
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
}