commit 441423985424c439c432d5c1c570a78043bc31a1 Author: Knut Ahlers Date: Tue Jun 27 22:49:53 2017 +0200 Initial version Signed-off-by: Knut Ahlers diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38f0b9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +staticmap +cache diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..195007f --- /dev/null +++ b/cache.go @@ -0,0 +1,22 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "io" + "strings" + + "github.com/golang/geo/s2" +) + +type cacheFunction func(center s2.LatLng, zoom int, marker []marker, x, y int) (io.ReadCloser, error) + +func cacheKeyHelper(center s2.LatLng, zoom int, marker []marker, x, y int) string { + markerString := []string{} + for _, m := range marker { + markerString = append(markerString, m.String()) + } + hashString := fmt.Sprintf("%s|%d|%s|%dx%d", center.String(), zoom, strings.Join(markerString, "+"), x, y) + + return fmt.Sprintf("%x", sha256.Sum256([]byte(hashString))) +} diff --git a/cache_filesystem.go b/cache_filesystem.go new file mode 100644 index 0000000..a5bcb1e --- /dev/null +++ b/cache_filesystem.go @@ -0,0 +1,42 @@ +package main + +import ( + "bytes" + "io" + "io/ioutil" + "os" + "path" + "time" + + "github.com/golang/geo/s2" +) + +func filesystemCache(center s2.LatLng, zoom int, marker []marker, x, y int) (io.ReadCloser, error) { + cacheKey := cacheKeyHelper(center, zoom, marker, x, y) + cacheFileName := path.Join(cfg.CacheDir, cacheKey[0:2], cacheKey+".png") + + if info, err := os.Stat(cacheFileName); err == nil && info.ModTime().Add(cfg.ForceCache).After(time.Now()) { + return os.Open(cacheFileName) + } + + // No cache hit, generate a new map + mapReader, err := generateMap(center, zoom, marker, x, y) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + if _, err := io.Copy(buf, mapReader); err != nil { + return nil, err + } + + if err := os.MkdirAll(path.Dir(cacheFileName), 0755); err != nil { + return nil, err + } + + if err := ioutil.WriteFile(cacheFileName, buf.Bytes(), 0644); err != nil { + return nil, err + } + + return ioutil.NopCloser(buf), err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..58e0d37 --- /dev/null +++ b/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + httpHelper "github.com/Luzifer/go_helpers/http" + "github.com/Luzifer/rconfig" + log "github.com/Sirupsen/logrus" + "github.com/golang/geo/s2" + "github.com/gorilla/mux" + colorful "github.com/lucasb-eyer/go-colorful" +) + +var ( + cfg struct { + CacheDir string `flag:"cache-dir" default:"cache" env:"CACHE_DIR" description:"Directory to save the cached images to"` + ForceCache time.Duration `flag:"force-cache" default:"24h" description:"Force map to be cached for this duration"` + Listen string `flag:"listen" default:":3000" description:"IP/Port to listen on"` + MaxSize string `flag:"max-size" default:"1024x1024" description:"Maximum map size requestable"` + VersionAndExit bool `flag:"version" default:"false" description:"Print version information and exit"` + } + + mapMaxX, mapMaxY int + cacheFunc cacheFunction = filesystemCache // For now this is simply set and might be extended later + + version = "dev" +) + +func init() { + var err error + if err = rconfig.Parse(&cfg); err != nil { + log.Fatalf("Unable to parse CLI parameters") + } + + if mapMaxX, mapMaxY, err = parseSize(cfg.MaxSize, false); err != nil { + log.Fatalf("Unable to parse max-size: %s", err) + } + + if cfg.VersionAndExit { + fmt.Printf("staticmap %s\n", version) + } +} + +func main() { + r := mux.NewRouter() + r.HandleFunc("/status", func(res http.ResponseWriter, r *http.Request) { http.Error(res, "I'm fine", http.StatusOK) }) + r.HandleFunc("/map.png", handleMapRequest) + log.Fatalf("HTTP Server exitted: %s", http.ListenAndServe(cfg.Listen, httpHelper.NewHTTPLogHandler(r))) +} + +func handleMapRequest(res http.ResponseWriter, r *http.Request) { + var ( + center *s2.LatLng + err error + mapReader io.ReadCloser + markers []marker + x, y int + zoom int + ) + + if center, err = parseCoordinate(r.URL.Query().Get("center")); err != nil { + http.Error(res, fmt.Sprintf("Unable to parse 'center' parameter: %s", err), http.StatusBadRequest) + return + } + + if zoom, err = strconv.Atoi(r.URL.Query().Get("zoom")); err != nil { + http.Error(res, fmt.Sprintf("Unable to parse 'zoom' parameter: %s", err), http.StatusBadRequest) + return + } + + if x, y, err = parseSize(r.URL.Query().Get("size"), true); err != nil { + http.Error(res, fmt.Sprintf("Unable to parse 'size' parameter: %s", err), http.StatusBadRequest) + return + } + + if markers, err = parseMarkerLocations(r.URL.Query()["markers"]); err != nil { + http.Error(res, fmt.Sprintf("Unable to parse 'markers' parameter: %s", err), http.StatusBadRequest) + return + } + + if mapReader, err = cacheFunc(*center, zoom, markers, x, y); err != nil { + log.Error("Map render failed: %s (Request: %s)", err, r.URL.String()) + http.Error(res, fmt.Sprintf("I experienced difficulties rendering your map: %s", err), http.StatusInternalServerError) + return + } + defer mapReader.Close() + + res.Header().Set("Content-Type", "image/png") + res.Header().Set("Cache-Control", "public") + io.Copy(res, mapReader) +} + +func parseCoordinate(coord string) (*s2.LatLng, error) { + if coord == "" { + return nil, errors.New("No coordinate given") + } + + parts := strings.Split(coord, ",") + if len(parts) != 2 { + return nil, errors.New("Coordinate not in format lat,lon") + } + + var ( + lat, lon float64 + err error + ) + + if lat, err = strconv.ParseFloat(parts[0], 64); err != nil { + return nil, errors.New("Latitude not parseable as float") + } + + if lon, err = strconv.ParseFloat(parts[1], 64); err != nil { + return nil, errors.New("Longitude not parseable as float") + } + + pt := s2.LatLngFromDegrees(lat, lon) + return &pt, nil +} + +func parseSize(size string, validate bool) (x, y int, err error) { + if size == "" { + return 0, 0, errors.New("No size given") + } + + parts := strings.Split(size, "x") + if len(parts) != 2 { + return 0, 0, errors.New("Size not in format 600x300") + } + + if x, err = strconv.Atoi(parts[0]); err != nil { + return + } + + if y, err = strconv.Atoi(parts[1]); err != nil { + return + } + + if validate { + if x > mapMaxX || y > mapMaxY { + err = fmt.Errorf("Map size exceeds allowed bounds of %dx%d", mapMaxX, mapMaxY) + return + } + } + + return +} + +func parseMarkerLocations(markers []string) ([]marker, error) { + if markers == nil { + // No markers parameters passed, lets ignore this + return nil, nil + } + + result := []marker{} + + for _, markerInformation := range markers { + parts := strings.Split(markerInformation, "|") + + var ( + size = markerSizes["small"] + col = markerColors["red"] + ) + + for _, p := range parts { + switch { + case strings.HasPrefix(p, "size:"): + if s, ok := markerSizes[strings.TrimPrefix(p, "size:")]; ok { + size = s + } else { + return nil, fmt.Errorf("Bad marker size %q", strings.TrimPrefix(p, "size:")) + } + case strings.HasPrefix(p, "color:0x"): + if c, err := colorful.Hex("#" + strings.TrimPrefix(p, "color:0x")); err == nil { + col = c + } else { + return nil, fmt.Errorf("Unable to parse color %q: %s", strings.TrimPrefix(p, "color:"), err) + } + case strings.HasPrefix(p, "color:"): + if c, ok := markerColors[strings.TrimPrefix(p, "color:")]; ok { + col = c + } else { + return nil, fmt.Errorf("Bad color name %q", strings.TrimPrefix(p, "color:")) + } + default: + pos, err := parseCoordinate(p) + if err != nil { + return nil, fmt.Errorf("Unparsable chunk found in marker: %q", p) + } + result = append(result, marker{ + pos: *pos, + color: col, + size: size, + }) + } + } + } + + return result, nil +} diff --git a/map.go b/map.go new file mode 100644 index 0000000..421acfa --- /dev/null +++ b/map.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "fmt" + "image/color" + "io" + + staticMap "github.com/flopp/go-staticmaps" + "github.com/fogleman/gg" + "github.com/golang/geo/s2" +) + +var markerColors = map[string]color.Color{ + "black": color.RGBA{R: 145, G: 145, B: 145, A: 0xff}, + "brown": color.RGBA{R: 178, G: 154, B: 123, A: 0xff}, + "green": color.RGBA{R: 168, G: 196, B: 68, A: 0xff}, + "purple": color.RGBA{R: 177, G: 150, B: 191, A: 0xff}, + "yellow": color.RGBA{R: 237, G: 201, B: 107, A: 0xff}, + "blue": color.RGBA{R: 163, G: 196, B: 253, A: 0xff}, + "gray": color.RGBA{R: 204, G: 204, B: 204, A: 0xff}, + "orange": color.RGBA{R: 229, G: 165, B: 68, A: 0xff}, + "red": color.RGBA{R: 246, G: 118, B: 112, A: 0xff}, + "white": color.RGBA{R: 245, G: 244, B: 241, A: 0xff}, +} + +type markerSize float64 + +var markerSizes = map[string]markerSize{ + "tiny": 10, + "mid": 15, + "small": 20, +} + +type marker struct { + pos s2.LatLng + color color.Color + size markerSize +} + +func (m marker) String() string { + r, g, b, a := m.color.RGBA() + return fmt.Sprintf("%s|%.0f|%d,%d,%d,%d", m.pos.String(), m.size, r, g, b, a) +} + +func generateMap(center s2.LatLng, zoom int, marker []marker, x, y int) (io.Reader, error) { + ctx := staticMap.NewContext() + ctx.SetSize(x, y) + ctx.SetCenter(center) + ctx.SetZoom(zoom) + + if marker != nil { + for _, m := range marker { + ctx.AddMarker(staticMap.NewMarker(m.pos, m.color, float64(m.size))) + } + } + + staticMap.TileFetcherUserAgent = fmt.Sprintf("Mozilla/5.0+(compatible; staticmap/%s; https://github.com/Luzifer/staticmap)", version) + + img, err := ctx.Render() + if err != nil { + return nil, err + } + + pngCtx := gg.NewContextForImage(img) + pngBuf := new(bytes.Buffer) + return pngBuf, pngCtx.EncodePNG(pngBuf) +}