304 lines
6.9 KiB
Go
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
|
|
} |