1
0
Fork 0
mirror of https://github.com/Luzifer/staticmap.git synced 2024-12-20 21:01:18 +00:00
staticmap/main.go
Knut Ahlers aea881c15c
Lint: Fix linter errors
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-11-30 12:41:20 +01:00

276 lines
7.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
httpHelper "github.com/Luzifer/go_helpers/v2/http"
"github.com/Luzifer/rconfig/v2"
"github.com/didip/tollbooth"
"github.com/didip/tollbooth/limiter"
"github.com/golang/geo/s2"
"github.com/gorilla/mux"
colorful "github.com/lucasb-eyer/go-colorful"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
var (
cfg struct {
CacheDir string `flag:"cache-dir" default:"cache" 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"`
RateLimit float64 `flag:"rate-limit" default:"1" description:"How many requests to allow per time"`
RateLimitTime time.Duration `flag:"rate-limit-time" default:"1s" description:"Time interval to allow N requests in"`
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 initApp() (err error) {
rconfig.AutoEnv(true)
if err = rconfig.ParseAndValidate(&cfg); err != nil {
return errors.Wrap(err, "parsing CLI parameters")
}
if mapMaxX, mapMaxY, err = parseSize(cfg.MaxSize); err != nil {
return errors.Wrap(err, "parsing max-size")
}
return nil
}
func main() {
var err error
if err = initApp(); err != nil {
logrus.WithError(err).Fatal("initializing app")
}
if cfg.VersionAndExit {
fmt.Printf("staticmap %s\n", version) //nolint:forbidigo
return
}
rateLimit := tollbooth.NewLimiter(cfg.RateLimit, &limiter.ExpirableOptions{
DefaultExpirationTTL: cfg.RateLimitTime,
})
rateLimit.SetIPLookups([]string{"X-Forwarded-For", "RemoteAddr", "X-Real-IP"})
r := mux.NewRouter()
r.HandleFunc("/status", func(res http.ResponseWriter, _ *http.Request) { http.Error(res, "I'm fine", http.StatusOK) })
r.Handle("/map.png", tollbooth.LimitFuncHandler(rateLimit, handleMapRequest)).Methods("GET")
r.Handle("/map.png", tollbooth.LimitFuncHandler(rateLimit, handlePostMapRequest)).Methods("POST")
server := &http.Server{
Addr: cfg.Listen,
Handler: httpHelper.NewHTTPLogHandlerWithLogger(r, logrus.StandardLogger()),
ReadHeaderTimeout: time.Second,
}
logrus.WithField("version", version).WithField("addr", cfg.Listen).Info("staticmap started")
if err = server.ListenAndServe(); err != nil {
logrus.WithError(err).Fatal("running HTTP server")
}
}
func handleMapRequest(res http.ResponseWriter, r *http.Request) {
var (
err error
mapReader io.ReadCloser
opts = generateMapConfig{
DisableAttribution: r.URL.Query().Get("no-attribution") == "true",
}
)
if opts.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 opts.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 opts.Width, opts.Height, err = parseSize(r.URL.Query().Get("size")); err != nil {
http.Error(res, fmt.Sprintf("Unable to parse 'size' parameter: %s", err), http.StatusBadRequest)
return
}
if opts.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(opts); err != nil {
logrus.Errorf("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 func() {
if err := mapReader.Close(); err != nil {
logrus.WithError(err).Error("closing map cache reader (leaked fd)")
}
}()
res.Header().Set("Content-Type", "image/png")
res.Header().Set("Cache-Control", "public")
if _, err = io.Copy(res, mapReader); err != nil {
logrus.WithError(err).Debug("writing image to HTTP client")
}
}
func handlePostMapRequest(res http.ResponseWriter, r *http.Request) {
var (
body = postMapEnvelope{}
mapReader io.ReadCloser
)
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(res, fmt.Sprintf("Unable to parse input: %s", err), http.StatusBadRequest)
return
}
opts, err := body.toGenerateMapConfig()
if err != nil {
http.Error(res, fmt.Sprintf("Unable to process input: %s", err), http.StatusBadRequest)
return
}
if mapReader, err = cacheFunc(opts); err != nil {
logrus.Errorf("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 func() {
if err := mapReader.Close(); err != nil {
logrus.WithError(err).Error("closing map cache reader (leaked fd)")
}
}()
res.Header().Set("Content-Type", "image/png")
res.Header().Set("Cache-Control", "public")
if _, err = io.Copy(res, mapReader); err != nil {
logrus.WithError(err).Debug("writing image to HTTP client")
}
}
func parseCoordinate(coord string) (s2.LatLng, error) {
if coord == "" {
return s2.LatLng{}, errors.New("No coordinate given")
}
parts := strings.Split(coord, ",")
if len(parts) != 2 { //nolint:mnd
return s2.LatLng{}, 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 s2.LatLng{}, errors.New("Latitude not parseable as float")
}
if lon, err = strconv.ParseFloat(parts[1], 64); err != nil {
return s2.LatLng{}, errors.New("Longitude not parseable as float")
}
pt := s2.LatLngFromDegrees(lat, lon)
return pt, nil
}
func parseSize(size string) (x, y int, err error) {
if size == "" {
return 0, 0, errors.New("No size given")
}
parts := strings.Split(size, "x")
if len(parts) != 2 { //nolint:mnd
return 0, 0, errors.New("Size not in format 600x300")
}
if x, err = strconv.Atoi(parts[0]); err != nil {
return 0, 0, errors.Wrap(err, "parsing width")
}
if y, err = strconv.Atoi(parts[1]); err != nil {
return 0, 0, errors.Wrap(err, "parsing height")
}
if (x > mapMaxX || y > mapMaxY) && mapMaxX > 0 && mapMaxY > 0 {
return 0, 0, errors.Errorf("map size exceeds allowed bounds of %dx%d", mapMaxX, mapMaxY)
}
return x, y, nil
}
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:"):
s, ok := markerSizes[strings.TrimPrefix(p, "size:")]
if !ok {
return nil, errors.Errorf("bad marker size %q", strings.TrimPrefix(p, "size:"))
}
size = s
case strings.HasPrefix(p, "color:0x"):
c, err := colorful.Hex("#" + strings.TrimPrefix(p, "color:0x"))
if err != nil {
return nil, errors.Wrapf(err, "parsing color %q", strings.TrimPrefix(p, "color:"))
}
col = c
case strings.HasPrefix(p, "color:"):
c, ok := markerColors[strings.TrimPrefix(p, "color:")]
if !ok {
return nil, errors.Errorf("bad color name %q", strings.TrimPrefix(p, "color:"))
}
col = c
default:
pos, err := parseCoordinate(p)
if err != nil {
return nil, errors.Errorf("unparsable chunk found in marker: %q", p)
}
result = append(result, marker{
pos: pos,
color: col,
size: size,
})
}
}
}
return result, nil
}