package handlers import ( "encoding/json" "fmt" "log/slog" "net/http" "path/filepath" "regexp" "strconv" "time" "github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app/services" ) var transparentPNG = []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, } var tileRegex = regexp.MustCompile(`([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+)\.png`) // WatchGridUpdates is the SSE endpoint for tile updates. func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() s := h.Auth.GetSession(ctx, req) if !h.canAccessMap(s) { rw.WriteHeader(http.StatusUnauthorized) return } rw.Header().Set("Content-Type", "text/event-stream") rw.Header().Set("Access-Control-Allow-Origin", "*") rw.Header().Set("X-Accel-Buffering", "no") flusher, ok := rw.(http.Flusher) if !ok { JSONError(rw, http.StatusInternalServerError, "streaming unsupported", "INTERNAL_ERROR") return } c := h.Map.WatchTiles() mc := h.Map.WatchMerges() // Option 1A: do not send full cache on connect; client requests tiles with cache=0 when missing. // This avoids a huge JSON dump and slow parse on connect when the DB has many tiles. tileCache := []services.TileCache{} raw, _ := json.Marshal(tileCache) fmt.Fprint(rw, "data: ") _, _ = rw.Write(raw) fmt.Fprint(rw, "\n\n") flusher.Flush() ticker := time.NewTicker(app.SSETickInterval) defer ticker.Stop() keepaliveTicker := time.NewTicker(app.SSEKeepaliveInterval) defer keepaliveTicker.Stop() for { select { case <-ctx.Done(): return case <-keepaliveTicker.C: if _, err := fmt.Fprint(rw, ": keepalive\n\n"); err != nil { return } flusher.Flush() case e, ok := <-c: if !ok { return } found := false for i := range tileCache { if tileCache[i].M == e.MapID && tileCache[i].X == e.Coord.X && tileCache[i].Y == e.Coord.Y && tileCache[i].Z == e.Zoom { tileCache[i].T = int(e.Cache) found = true } } if !found { tileCache = append(tileCache, services.TileCache{ M: e.MapID, X: e.Coord.X, Y: e.Coord.Y, Z: e.Zoom, T: int(e.Cache), }) } case e, ok := <-mc: if !ok { return } raw, err := json.Marshal(e) if err != nil { slog.Error("failed to marshal merge event", "error", err) } fmt.Fprint(rw, "event: merge\n") fmt.Fprint(rw, "data: ") _, _ = rw.Write(raw) fmt.Fprint(rw, "\n\n") flusher.Flush() case <-ticker.C: raw, _ := json.Marshal(tileCache) fmt.Fprint(rw, "data: ") if _, err := rw.Write(raw); err != nil { return } fmt.Fprint(rw, "\n\n") tileCache = tileCache[:0] flusher.Flush() } } } // GridTile serves tile images. func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() s := h.Auth.GetSession(ctx, req) if !h.canAccessMap(s) { rw.WriteHeader(http.StatusUnauthorized) return } tile := tileRegex.FindStringSubmatch(req.URL.Path) if tile == nil || len(tile) < 5 { JSONError(rw, http.StatusBadRequest, "invalid path", "BAD_REQUEST") return } mapid, err := strconv.Atoi(tile[1]) if err != nil { JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST") return } z, err := strconv.Atoi(tile[2]) if err != nil { JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST") return } x, err := strconv.Atoi(tile[3]) if err != nil { JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST") return } y, err := strconv.Atoi(tile[4]) if err != nil { JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST") return } storageZ := z if storageZ == 6 { storageZ = 0 } if storageZ < 0 || storageZ > app.MaxZoomLevel { storageZ = 0 } td := h.Map.GetTile(ctx, mapid, app.Coord{X: x, Y: y}, storageZ) if td == nil { rw.Header().Set("Content-Type", "image/png") rw.Header().Set("Cache-Control", "private, max-age=3600") rw.WriteHeader(http.StatusOK) _, _ = rw.Write(transparentPNG) return } rw.Header().Set("Content-Type", "image/png") rw.Header().Set("Cache-Control", "private immutable") http.ServeFile(rw, req, filepath.Join(h.Map.GridStorage(), td.File)) }