// Copyright (C) 2025 Marius Schellenberger package main import ( "cmp" "errors" "flag" "fmt" "image" "image/color" "image/png" "net/http" "os" "strconv" "sync" "time" "git.giftfish.de/ston1th/godrop/v2" ) var ( version string listen string ) const bfsURL = "https://uvi.bfs.de/Tagesgrafiken" var allowedFiles = []string{ "/etc/resolv.conf", "/etc/localtime", "/etc/hosts", "/etc/ssl/cert.pem", } func main() { flag.StringVar(&listen, "l", ":7878", "listen addr") flag.Parse() if len(cityList) != len(fileList) { fatal(errors.New("city and file list length differ")) } if err := godrop.PledgePromises("stdio inet rpath unveil"); err != nil { fatal(err) } for _, file := range allowedFiles { if err := godrop.Unveil(file, "r"); err != nil { fatal(err) } } if err := godrop.UnveilBlock(); err != nil { fatal(err) } ic := &ImageCrawler{ url: bfsURL, c: &http.Client{}, m: make(map[int]*Image), } http.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { r.ParseForm() loc := r.PostForm.Get("loc") _, ok := locFromString(loc) if !ok { http.Redirect(w, r, "/", http.StatusMovedPermanently) return } setCookie(w, loc) http.Redirect(w, r, "/", http.StatusMovedPermanently) return } w.Write([]byte(locPage)) }) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { cc := r.CookiesNamed("loc") var c *http.Cookie if len(cc) > 0 { c = cc[0] } else { c = setCookie(w, DefaultCity) } num, ok := locFromString(c.Value) if !ok { writeError(w, "Error", "Error") return } img, mod, err := ic.getImage(num) mod = mod.Local() location := cityList[num] if err != nil { writeError(w, mod.Format(time.RFC1123), location) return } vals := getUV(img) if len(vals) == 0 { writeError(w, mod.Format(time.RFC1123), location) return } last := vals[len(vals)-1] imgURL := bfsURL + "/" + fileList[num] fmt.Fprintf(w, indexPage, getColor(last), fmt.Sprintf("%.2f", last), mod.Format(time.RFC1123), time.Now().Format(time.RFC1123), location, imgURL, version, ) }) err := http.ListenAndServe(listen, nil) if err != nil { fatal(err) } } func setCookie(w http.ResponseWriter, loc string) *http.Cookie { c := &http.Cookie{ Name: "loc", Path: "/", Value: loc, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, } http.SetCookie(w, c) return c } func writeError(w http.ResponseWriter, mod, location string) { fmt.Fprintf(w, indexPage, "000000", "Error", mod, time.Now().Format(time.RFC1123), location, version, ) } func fatal(err error) { if err != nil { fmt.Println(err) os.Exit(1) } } type ImageCrawler struct { mu sync.RWMutex url string c *http.Client m map[int]*Image } type Image struct { mu sync.RWMutex ic *ImageCrawler file string img image.Image etag string modified time.Time lastGet time.Time } func (ic *ImageCrawler) getImage(loc int) (img image.Image, mod time.Time, err error) { file := fileList[loc] ic.mu.RLock() if i, ok := ic.m[loc]; ok { ic.mu.RUnlock() return i.getImage() } ic.mu.RUnlock() ic.mu.Lock() i := &Image{ ic: ic, file: file, } ic.m[loc] = i ic.mu.Unlock() return i.getImage() } func (i *Image) getImage() (img image.Image, mod time.Time, err error) { i.mu.RLock() if !i.lastGet.IsZero() && time.Now().Sub(i.lastGet) < time.Minute*3 { defer i.mu.RUnlock() return i.img, i.modified, nil } i.mu.RUnlock() req, err := http.NewRequest("GET", i.ic.url+"/"+i.file, nil) if err != nil { return } req.Header.Set("user-agent", "uvgo/"+version) i.mu.RLock() if !i.modified.IsZero() { req.Header.Set("if-modified-since", i.modified.Format(time.RFC1123)) } if i.etag != "" { req.Header.Set("if-none-match", i.etag) } i.mu.RUnlock() resp, err := i.ic.c.Do(req) if err != nil { return } defer resp.Body.Close() if resp.StatusCode == http.StatusNotModified { i.mu.RLock() defer i.mu.RUnlock() return i.img, i.modified, nil } if resp.StatusCode == http.StatusOK { img, err = png.Decode(resp.Body) if err != nil { return } i.mu.Lock() i.img = img modTime, err := time.Parse(time.RFC1123, resp.Header.Get("last-modified")) if err == nil { i.modified = modTime } i.etag = resp.Header.Get("etag") i.lastGet = time.Now() defer i.mu.Unlock() return i.img, i.modified, nil } i.mu.RLock() defer i.mu.RUnlock() if i.img != nil { return i.img, i.modified, nil } return nil, time.Time{}, errors.New("error getting image") } const ( X = 82 maxX = 574 maxStart = 150 Y = 431 ) func scale(img image.Image) int { startY := Y for y := Y; y > 0; y-- { col := img.At(X, y) if !isBlack(col) { startY = y break } } c := 0 for y := startY; y > 0; y-- { col := img.At(X, y) if isWhite(col) { c = startY - y startY = y break } } for y := startY; y > 0; y-- { col := img.At(X, y) if !isWhite(col) { return (startY + c) - y } } return -1 } func start(img image.Image) int { for x := X; x < maxStart; x++ { col := img.At(x, Y) if !isStart(col) { return x } } return -1 } func uv(img image.Image, x, s int) float64 { start := Y end := Y for y := start; y > 0; y-- { col := img.At(x, y) if isBlack(col) || isWhite(col) { end = y - 1 break } } v := start - end if v <= s { return float64(v) / float64(s) } return float64(v+s) / 2 / float64(s) } func getUV(img image.Image) (vals []float64) { s := scale(img) if s == -1 { return } x := start(img) if x == -1 { return } for ; x < maxX; x++ { col := img.At(x, Y) if isStop(col) { break } vals = append(vals, uv(img, x, s)) } return } func between[T cmp.Ordered](v, min, max T) bool { return v >= min && v <= max } func getColor(v float64) string { switch { case between(v, 0, 1): return "006300" case between(v, 1, 2): return "00a014" case between(v, 2, 3): return "81c600" case between(v, 3, 4): return "fff800" case between(v, 4, 5): return "ffd100" case between(v, 5, 6): return "ffa600" case between(v, 6, 7): return "ff7200" case between(v, 7, 8): return "ff4a00" case between(v, 8, 9): return "ff0000" case between(v, 9, 10): return "ff0078" case v >= 10: return "a80080" } return "000000" } func colorIs(c color.RGBA, v uint8) bool { return c.R == v && c.G == v && c.B == v } func isWhite(col color.Color) bool { col = color.RGBAModel.Convert(col) c, ok := col.(color.RGBA) if !ok { return false } return colorIs(c, 255) } func isBlack(col color.Color) bool { col = color.RGBAModel.Convert(col) c, ok := col.(color.RGBA) if !ok { return false } return colorIs(c, 0) } func isStop(col color.Color) bool { col = color.RGBAModel.Convert(col) c, ok := col.(color.RGBA) if !ok { return false } if colorIs(c, 241) || colorIs(c, 88) { return true } return false } func isStart(col color.Color) bool { col = color.RGBAModel.Convert(col) c, ok := col.(color.RGBA) if !ok { return false } if colorIs(c, 241) || colorIs(c, 214) || colorIs(c, 0) { return true } return false } // DefaultCity is Muenchen // Array index 24 const DefaultCity = "24" var fileList = []string{ "EEr_Andernach_today.png", "EEr_Berlin_Potsdamer_Platz_today.png", "EEr_Bonn_today.png", "EEr_Boesel_B_today.png", "EEr_Chieming_today.png", "EEr_Cuxhaven_B_today.png", "EEr_Dortmund_today.png", "EEr_Duderstadt_B_today.png", "EEr_Eckernfoerde_today.png", "EEr_Fichtelberg_today.png", "EEr_Friedrichshafen_today.png", "EEr_Genthin_today.png", "EEr_Giessen-Wettenberg_today.png", "EEr_Goerlitz_today.png", "EEr_Groemitz_today.png", "EEr_Hamburg_today.png", "EEr_Hohenpeissenberg_today.png", "EEr_Kassel_today.png", "EEr_Klippeneck_today.png", "EEr_Kulmbach_today.png", "EEr_Langen_today.png", "EEr_Lindenberg_today.png", "EEr_Lueneburg_today.png", "EEr_Melpitz_today.png", "EEr_Muenchen_today.png", "EEr_Norderney_today.png", "EEr_Belm-Osnabrueck_today.png", "EEr_Salzgitter_today.png", "EEr_Sankt Augustin_today.png", "EEr_Schauinsland_today.png", "EEr_Schweinfurt_Wasserlosen_today.png", "EEr_Stuttgart_today.png", "EEr_Sylt_Tinnum_today.png", "EEr_Tholey_today.png", "EEr_Todendorf_today.png", "EEr_Waldhof_Falkenstein_today.png", "EEr_Waldmuenchen_today.png", "EEr_Wasserkuppe bei Fulda_today.png", "EEr_Weissenburg_today.png", "EEr_Wurmberg_B_today.png", "EEr_Zingst_today.png", "EEr_Zirchow_today.png", "EEr_Schneefernerhaus_today.png", } func locFromString(s string) (i int, ok bool) { i, err := strconv.Atoi(s) if err != nil { return } return i, validLoc(i) } func validLoc(loc int) bool { return loc >= 0 && loc < len(cityList) } var cityList = []string{ "Andernach", "Berlin", "Bonn", "Bösel", "Chieming", "Cuxhaven", "Dortmund", "Duderstadt", "Eckernförde", "Fichtelberg", "Friedrichshafen", "Genthin", "Gießen", "Görlitz", "Grömitz", "Hamburg", "Hohenpeißenberg", "Kassel", "Klippeneck", "Kulmbach", "Langen", "Lindenberg", "Lüneburg", "Melpitz", "München", "Norderney", "Osnabrück", "Salzgitter", "Sankt Augustin", "Schauinsland", "Schweinfurt", "Stuttgart", "Sylt (Westerland)", "Tholey", "Todendorf", "Waldhof", "Waldmünchen", "Wasserkuppe", "Weißenburg", "Wurmberg", "Zingst", "Zirchow", "Zugspitze (Schneefernerhaus)", } func generateLocPage() string { loc := ` UV Index Change Location
Change Location

` return loc } var locPage = generateLocPage() var indexPage = ` UV Index
UV Index

%s

Updated: %s
Server: %s
Change Location: %s
View Image
`