package app import ( "encoding/json" "fmt" "log" "net/http" "path/filepath" "regexp" "strconv" "time" "github.com/andyleap/hnh-map/internal/app/store" "go.etcd.io/bbolt" ) 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, } type TileData struct { MapID int Coord Coord Zoom int File string Cache int64 } func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) { a.db.View(func(tx *bbolt.Tx) error { tiles := tx.Bucket(store.BucketTiles) if tiles == nil { return nil } mapb := tiles.Bucket([]byte(strconv.Itoa(mapid))) if mapb == nil { return nil } zoom := mapb.Bucket([]byte(strconv.Itoa(z))) if zoom == nil { return nil } tileraw := zoom.Get([]byte(c.Name())) if tileraw == nil { return nil } json.Unmarshal(tileraw, &td) return nil }) return } func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) { a.db.Update(func(tx *bbolt.Tx) error { tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles) if err != nil { return err } mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapid))) if err != nil { return err } zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(z))) if err != nil { return err } td := &TileData{ MapID: mapid, Coord: c, Zoom: z, File: f, Cache: t, } raw, err := json.Marshal(td) if err != nil { return err } a.gridUpdates.Send(td) return zoom.Put([]byte(c.Name()), raw) }) return } func (a *App) reportMerge(from, to int, shift Coord) { a.mergeUpdates.Send(&Merge{ From: from, To: to, Shift: shift, }) } type TileCache struct { M, X, Y, Z, T int } func (a *App) watchGridUpdates(rw http.ResponseWriter, req *http.Request) { s := a.getSession(req) if !a.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 { http.Error(rw, "Streaming unsupported!", http.StatusInternalServerError) return } c := make(chan *TileData, 1000) mc := make(chan *Merge, 5) a.gridUpdates.Watch(c) a.mergeUpdates.Watch(mc) tileCache := make([]TileCache, 0, 100) a.db.View(func(tx *bbolt.Tx) error { tiles := tx.Bucket(store.BucketTiles) if tiles == nil { return nil } return tiles.ForEach(func(mk, mv []byte) error { mapb := tiles.Bucket(mk) if mapb == nil { return nil } return mapb.ForEach(func(k, v []byte) error { zoom := mapb.Bucket(k) if zoom == nil { return nil } return zoom.ForEach(func(tk, tv []byte) error { td := TileData{} json.Unmarshal(tv, &td) tileCache = append(tileCache, TileCache{ M: td.MapID, X: td.Coord.X, Y: td.Coord.Y, Z: td.Zoom, T: int(td.Cache), }) return nil }) }) }) }) raw, _ := json.Marshal(tileCache) fmt.Fprint(rw, "data: ") rw.Write(raw) fmt.Fprint(rw, "\n\n") tileCache = tileCache[:0] flusher.Flush() ticker := time.NewTicker(5 * time.Second) for { select { 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, TileCache{ M: e.MapID, X: e.Coord.X, Y: e.Coord.Y, Z: e.Zoom, T: int(e.Cache), }) } case e, ok := <-mc: log.Println(e, ok) if !ok { return } raw, err := json.Marshal(e) if err != nil { log.Println(err) } log.Println(string(raw)) 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: ") rw.Write(raw) fmt.Fprint(rw, "\n\n") tileCache = tileCache[:0] flusher.Flush() } } } var tileRegex = regexp.MustCompile("([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+).png") func (a *App) gridTile(rw http.ResponseWriter, req *http.Request) { s := a.getSession(req) if !a.canAccessMap(s) { rw.WriteHeader(http.StatusUnauthorized) return } tile := tileRegex.FindStringSubmatch(req.URL.Path) if tile == nil || len(tile) < 5 { http.Error(rw, "invalid path", http.StatusBadRequest) return } mapid, err := strconv.Atoi(tile[1]) if err != nil { http.Error(rw, "request parsing error", http.StatusInternalServerError) return } z, err := strconv.Atoi(tile[2]) if err != nil { http.Error(rw, "request parsing error", http.StatusInternalServerError) return } x, err := strconv.Atoi(tile[3]) if err != nil { http.Error(rw, "request parsing error", http.StatusInternalServerError) return } y, err := strconv.Atoi(tile[4]) if err != nil { http.Error(rw, "request parsing error", http.StatusInternalServerError) return } // Map frontend Leaflet zoom (1…6) to storage level (0…5): z=6 → 0 (max detail), z=1..5 → same storageZ := z if storageZ == 6 { storageZ = 0 } if storageZ < 0 || storageZ > 5 { storageZ = 0 } td := a.GetTile(mapid, 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(a.gridStorage, td.File)) }