Refactor Dockerignore and enhance Leaflet styles for improved map functionality

- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded.
- Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups.
- Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
This commit is contained in:
2026-03-04 18:16:41 +03:00
parent 3968bdc76f
commit 179357bc93
14 changed files with 3738 additions and 3738 deletions

View File

@@ -1,450 +1,450 @@
package handlers
import (
"archive/zip"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/andyleap/hnh-map/internal/app"
)
type mapInfoJSON struct {
ID int `json:"ID"`
Name string `json:"Name"`
Hidden bool `json:"Hidden"`
Priority bool `json:"Priority"`
}
// APIAdminUsers handles GET/POST /map/api/admin/users.
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
if req.Method == http.MethodGet {
if h.requireAdmin(rw, req) == nil {
return
}
list, err := h.Admin.ListUsers(ctx)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, list)
return
}
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
var body struct {
User string `json:"user"`
Pass string `json:"pass"`
Auths []string `json:"auths"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths)
if err != nil {
HandleServiceError(rw, err)
return
}
if body.User == s.Username {
s.Auths = body.Auths
}
if adminCreated && s.Username == "admin" {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
auths, found, err := h.Admin.GetUser(req.Context(), name)
if err != nil {
HandleServiceError(rw, err)
return
}
out := struct {
Username string `json:"username"`
Auths []string `json:"auths"`
}{Username: name}
if found {
out.Auths = auths
}
JSON(rw, http.StatusOK, out)
}
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodDelete) {
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
ctx := req.Context()
if err := h.Admin.DeleteUser(ctx, name); err != nil {
HandleServiceError(rw, err)
return
}
if name == s.Username {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, struct {
Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"`
Title string `json:"title"`
}{Prefix: prefix, DefaultHide: defaultHide, Title: title})
}
// APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Prefix *string `json:"prefix"`
DefaultHide *bool `json:"defaultHide"`
Title *string `json:"title"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
maps, err := h.Admin.ListMaps(req.Context())
if err != nil {
HandleServiceError(rw, err)
return
}
out := make([]mapInfoJSON, len(maps))
for i, m := range maps {
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
}
JSON(rw, http.StatusOK, out)
}
// APIAdminMapByID handles POST /map/api/admin/maps/:id.
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Name string `json:"name"`
Hidden bool `json:"hidden"`
Priority bool `json:"priority"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, mapInfoJSON{
ID: mi.ID,
Name: mi.Name,
Hidden: mi.Hidden,
Priority: mi.Priority,
})
}
// APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := h.Admin.Wipe(req.Context()); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminWipeTile handles POST /map/api/admin/wipeTile.
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
x, err := strconv.Atoi(req.FormValue("x"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
y, err := strconv.Atoi(req.FormValue("y"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSetCoords handles POST /map/api/admin/setCoords.
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
fx, err := strconv.Atoi(req.FormValue("fx"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
fy, err := strconv.Atoi(req.FormValue("fy"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
tx, err := strconv.Atoi(req.FormValue("tx"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
ty, err := strconv.Atoi(req.FormValue("ty"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminHideMarker handles POST /map/api/admin/hideMarker.
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
markerID := req.FormValue("id")
if markerID == "" {
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
return
}
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
// It starts the rebuild in the background and returns 202 Accepted immediately.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
h.Admin.StartRebuildZooms()
rw.WriteHeader(http.StatusAccepted)
}
// APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status.
// Returns {"running": true|false} so the client can poll until the rebuild finishes.
func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
running := h.Admin.RebuildZoomsRunning()
JSON(rw, http.StatusOK, map[string]bool{"running": running})
}
// APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
rw.Header().Set("Content-Type", "application/zip")
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
if err := h.Export.Export(req.Context(), rw); err != nil {
HandleServiceError(rw, err)
}
}
// APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
mergef, hdr, err := req.FormFile("merge")
if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
zr, err := zip.NewReader(mergef, hdr.Size)
if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
if err := h.Export.Merge(req.Context(), zr); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminRoute routes /map/api/admin/* sub-paths.
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
switch {
case path == "wipeTile":
h.APIAdminWipeTile(rw, req)
case path == "setCoords":
h.APIAdminSetCoords(rw, req)
case path == "hideMarker":
h.APIAdminHideMarker(rw, req)
case path == "users":
h.APIAdminUsers(rw, req)
case strings.HasPrefix(path, "users/"):
name := strings.TrimPrefix(path, "users/")
if name == "" {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return
}
if req.Method == http.MethodDelete {
h.APIAdminUserDelete(rw, req, name)
} else {
h.APIAdminUserByName(rw, req, name)
}
case path == "settings":
if req.Method == http.MethodGet {
h.APIAdminSettingsGet(rw, req)
} else {
h.APIAdminSettingsPost(rw, req)
}
case path == "maps":
h.APIAdminMaps(rw, req)
case strings.HasPrefix(path, "maps/"):
rest := strings.TrimPrefix(path, "maps/")
parts := strings.SplitN(rest, "/", 2)
idStr := parts[0]
if len(parts) == 2 && parts[1] == "toggle-hidden" {
h.APIAdminMapToggleHidden(rw, req, idStr)
return
}
if len(parts) == 1 {
h.APIAdminMapByID(rw, req, idStr)
return
}
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
case path == "wipe":
h.APIAdminWipe(rw, req)
case path == "rebuildZooms":
h.APIAdminRebuildZooms(rw, req)
case path == "rebuildZooms/status":
h.APIAdminRebuildZoomsStatus(rw, req)
case path == "export":
h.APIAdminExport(rw, req)
case path == "merge":
h.APIAdminMerge(rw, req)
default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}
}
package handlers
import (
"archive/zip"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/andyleap/hnh-map/internal/app"
)
type mapInfoJSON struct {
ID int `json:"ID"`
Name string `json:"Name"`
Hidden bool `json:"Hidden"`
Priority bool `json:"Priority"`
}
// APIAdminUsers handles GET/POST /map/api/admin/users.
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
if req.Method == http.MethodGet {
if h.requireAdmin(rw, req) == nil {
return
}
list, err := h.Admin.ListUsers(ctx)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, list)
return
}
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
var body struct {
User string `json:"user"`
Pass string `json:"pass"`
Auths []string `json:"auths"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths)
if err != nil {
HandleServiceError(rw, err)
return
}
if body.User == s.Username {
s.Auths = body.Auths
}
if adminCreated && s.Username == "admin" {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
auths, found, err := h.Admin.GetUser(req.Context(), name)
if err != nil {
HandleServiceError(rw, err)
return
}
out := struct {
Username string `json:"username"`
Auths []string `json:"auths"`
}{Username: name}
if found {
out.Auths = auths
}
JSON(rw, http.StatusOK, out)
}
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodDelete) {
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
ctx := req.Context()
if err := h.Admin.DeleteUser(ctx, name); err != nil {
HandleServiceError(rw, err)
return
}
if name == s.Username {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, struct {
Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"`
Title string `json:"title"`
}{Prefix: prefix, DefaultHide: defaultHide, Title: title})
}
// APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Prefix *string `json:"prefix"`
DefaultHide *bool `json:"defaultHide"`
Title *string `json:"title"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
maps, err := h.Admin.ListMaps(req.Context())
if err != nil {
HandleServiceError(rw, err)
return
}
out := make([]mapInfoJSON, len(maps))
for i, m := range maps {
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
}
JSON(rw, http.StatusOK, out)
}
// APIAdminMapByID handles POST /map/api/admin/maps/:id.
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Name string `json:"name"`
Hidden bool `json:"hidden"`
Priority bool `json:"priority"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, mapInfoJSON{
ID: mi.ID,
Name: mi.Name,
Hidden: mi.Hidden,
Priority: mi.Priority,
})
}
// APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := h.Admin.Wipe(req.Context()); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminWipeTile handles POST /map/api/admin/wipeTile.
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
x, err := strconv.Atoi(req.FormValue("x"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
y, err := strconv.Atoi(req.FormValue("y"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSetCoords handles POST /map/api/admin/setCoords.
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
fx, err := strconv.Atoi(req.FormValue("fx"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
fy, err := strconv.Atoi(req.FormValue("fy"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
tx, err := strconv.Atoi(req.FormValue("tx"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
ty, err := strconv.Atoi(req.FormValue("ty"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminHideMarker handles POST /map/api/admin/hideMarker.
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
markerID := req.FormValue("id")
if markerID == "" {
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
return
}
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
// It starts the rebuild in the background and returns 202 Accepted immediately.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
h.Admin.StartRebuildZooms()
rw.WriteHeader(http.StatusAccepted)
}
// APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status.
// Returns {"running": true|false} so the client can poll until the rebuild finishes.
func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
running := h.Admin.RebuildZoomsRunning()
JSON(rw, http.StatusOK, map[string]bool{"running": running})
}
// APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
rw.Header().Set("Content-Type", "application/zip")
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
if err := h.Export.Export(req.Context(), rw); err != nil {
HandleServiceError(rw, err)
}
}
// APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
mergef, hdr, err := req.FormFile("merge")
if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
zr, err := zip.NewReader(mergef, hdr.Size)
if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
if err := h.Export.Merge(req.Context(), zr); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminRoute routes /map/api/admin/* sub-paths.
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
switch {
case path == "wipeTile":
h.APIAdminWipeTile(rw, req)
case path == "setCoords":
h.APIAdminSetCoords(rw, req)
case path == "hideMarker":
h.APIAdminHideMarker(rw, req)
case path == "users":
h.APIAdminUsers(rw, req)
case strings.HasPrefix(path, "users/"):
name := strings.TrimPrefix(path, "users/")
if name == "" {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return
}
if req.Method == http.MethodDelete {
h.APIAdminUserDelete(rw, req, name)
} else {
h.APIAdminUserByName(rw, req, name)
}
case path == "settings":
if req.Method == http.MethodGet {
h.APIAdminSettingsGet(rw, req)
} else {
h.APIAdminSettingsPost(rw, req)
}
case path == "maps":
h.APIAdminMaps(rw, req)
case strings.HasPrefix(path, "maps/"):
rest := strings.TrimPrefix(path, "maps/")
parts := strings.SplitN(rest, "/", 2)
idStr := parts[0]
if len(parts) == 2 && parts[1] == "toggle-hidden" {
h.APIAdminMapToggleHidden(rw, req, idStr)
return
}
if len(parts) == 1 {
h.APIAdminMapByID(rw, req, idStr)
return
}
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
case path == "wipe":
h.APIAdminWipe(rw, req)
case path == "rebuildZooms":
h.APIAdminRebuildZooms(rw, req)
case path == "rebuildZooms/status":
h.APIAdminRebuildZoomsStatus(rw, req)
case path == "export":
h.APIAdminExport(rw, req)
case path == "merge":
h.APIAdminMerge(rw, req)
default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,423 +1,423 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"sync"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
// AdminService handles admin business logic (users, settings, maps, wipe, tile ops).
type AdminService struct {
st *store.Store
mapSvc *MapService
rebuildMu sync.Mutex
rebuildRunning bool
}
// NewAdminService creates an AdminService with the given store and map service.
// Uses direct args (two dependencies) rather than a deps struct.
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
return &AdminService{st: st, mapSvc: mapSvc}
}
// ListUsers returns all usernames.
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
var list []string
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachUser(tx, func(k, _ []byte) error {
list = append(list, string(k))
return nil
})
})
return list, err
}
// GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
}
var u app.User
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
auths = u.Auths
found = true
return nil
})
return auths, found, err
}
// CreateOrUpdateUser creates or updates a user.
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
existed := s.st.GetUser(tx, username) != nil
u := app.User{}
raw := s.st.GetUser(tx, username)
if raw != nil {
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
}
if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if e != nil {
return e
}
u.Pass = hash
}
u.Auths = auths
raw, _ = json.Marshal(u)
if e := s.st.PutUser(tx, username, raw); e != nil {
return e
}
if username == "admin" && !existed {
adminCreated = true
}
return nil
})
return adminCreated, err
}
// DeleteUser removes a user and their tokens.
func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
for _, tok := range u.Tokens {
if err := s.st.DeleteToken(tx, tok); err != nil {
return err
}
}
}
return s.st.DeleteUser(tx, username)
})
}
// GetSettings returns the current server settings.
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
if v := s.st.GetConfig(tx, "prefix"); v != nil {
prefix = string(v)
}
if v := s.st.GetConfig(tx, "defaultHide"); v != nil {
defaultHide = true
}
if v := s.st.GetConfig(tx, "title"); v != nil {
title = string(v)
}
return nil
})
return prefix, defaultHide, title, err
}
// UpdateSettings updates the specified server settings (nil fields are skipped).
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
if prefix != nil {
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
return err
}
}
if defaultHide != nil {
if *defaultHide {
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
return err
}
} else {
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
return err
}
}
}
if title != nil {
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
return err
}
}
return nil
})
}
// ListMaps returns all maps for the admin panel.
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
var maps []app.MapInfo
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{}
if err := json.Unmarshal(v, &mi); err != nil {
return err
}
if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id
}
maps = append(maps, mi)
return nil
})
})
return maps, err
}
// GetMap returns a map by ID.
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
var mi *app.MapInfo
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
if raw != nil {
mi = &app.MapInfo{}
return json.Unmarshal(raw, mi)
}
return nil
})
if err != nil {
return nil, false, err
}
if mi != nil {
mi.ID = id
}
return mi, mi != nil, nil
}
// UpdateMap updates a map's name, hidden, and priority fields.
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
mi := app.MapInfo{}
raw := s.st.GetMap(tx, id)
if raw != nil {
if err := json.Unmarshal(raw, &mi); err != nil {
return err
}
}
mi.ID = id
mi.Name = name
mi.Hidden = hidden
mi.Priority = priority
raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw)
})
}
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
var mi *app.MapInfo
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{}
if raw != nil {
if err := json.Unmarshal(raw, mi); err != nil {
return err
}
}
mi.ID = id
mi.Hidden = !mi.Hidden
raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw)
})
return mi, err
}
// Wipe deletes all grids, markers, tiles, and maps from the database.
func (s *AdminService) Wipe(ctx context.Context) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
for _, b := range [][]byte{
store.BucketGrids,
store.BucketMarkers,
store.BucketTiles,
store.BucketMaps,
} {
if s.st.BucketExists(tx, b) {
if err := s.st.DeleteBucket(tx, b); err != nil {
return err
}
}
}
return nil
})
}
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
c := app.Coord{X: x, Y: y}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
var ids [][]byte
err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil {
return err
}
if g.Coord == c && g.Map == mapid {
ids = append(ids, k)
}
return nil
})
if err != nil {
return err
}
for _, id := range ids {
if err := grids.Delete(id); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
zc := c
for z := 1; z <= app.MaxZoomLevel; z++ {
zc = zc.Parent()
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
}
return nil
}
// SetCoords shifts all grid and tile coordinates by a delta.
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
fc := app.Coord{X: fx, Y: fy}
tc := app.Coord{X: tx2, Y: ty}
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
var tds []*app.TileData
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapZooms == nil {
return nil
}
mapTiles := mapZooms.Bucket([]byte("0"))
if err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil {
return err
}
if g.Map == mapid {
g.Coord.X += diff.X
g.Coord.Y += diff.Y
raw, _ := json.Marshal(g)
if err := grids.Put(k, raw); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
if err := mapTiles.ForEach(func(k, v []byte) error {
td := &app.TileData{}
if err := json.Unmarshal(v, td); err != nil {
return err
}
td.Coord.X += diff.X
td.Coord.Y += diff.Y
tds = append(tds, td)
return nil
}); err != nil {
return err
}
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
}); err != nil {
return err
}
ops := make([]TileOp, len(tds))
for i, td := range tds {
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
}
s.mapSvc.ProcessZoomLevels(ctx, ops)
return nil
}
// HideMarker marks a marker as hidden.
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
_, idB, err := s.st.CreateMarkersBuckets(tx)
if err != nil {
return err
}
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return fmt.Errorf("markers grid bucket not found")
}
key := idB.Get([]byte(markerID))
if key == nil {
slog.Warn("marker not found", "id", markerID)
return nil
}
raw := grid.Get(key)
if raw == nil {
return nil
}
m := app.Marker{}
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
m.Hidden = true
raw, _ = json.Marshal(m)
if err := grid.Put(key, raw); err != nil {
return err
}
return nil
})
}
// RebuildZooms delegates to MapService.
func (s *AdminService) RebuildZooms(ctx context.Context) error {
return s.mapSvc.RebuildZooms(ctx)
}
// StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately.
// RebuildZoomsRunning returns true while the rebuild is in progress.
func (s *AdminService) StartRebuildZooms() {
s.rebuildMu.Lock()
if s.rebuildRunning {
s.rebuildMu.Unlock()
return
}
s.rebuildRunning = true
s.rebuildMu.Unlock()
go func() {
defer func() {
s.rebuildMu.Lock()
s.rebuildRunning = false
s.rebuildMu.Unlock()
}()
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil {
slog.Error("RebuildZooms background failed", "error", err)
}
}()
}
// RebuildZoomsRunning returns true if a rebuild is currently in progress.
func (s *AdminService) RebuildZoomsRunning() bool {
s.rebuildMu.Lock()
defer s.rebuildMu.Unlock()
return s.rebuildRunning
}
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"sync"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
// AdminService handles admin business logic (users, settings, maps, wipe, tile ops).
type AdminService struct {
st *store.Store
mapSvc *MapService
rebuildMu sync.Mutex
rebuildRunning bool
}
// NewAdminService creates an AdminService with the given store and map service.
// Uses direct args (two dependencies) rather than a deps struct.
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
return &AdminService{st: st, mapSvc: mapSvc}
}
// ListUsers returns all usernames.
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
var list []string
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachUser(tx, func(k, _ []byte) error {
list = append(list, string(k))
return nil
})
})
return list, err
}
// GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
}
var u app.User
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
auths = u.Auths
found = true
return nil
})
return auths, found, err
}
// CreateOrUpdateUser creates or updates a user.
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
existed := s.st.GetUser(tx, username) != nil
u := app.User{}
raw := s.st.GetUser(tx, username)
if raw != nil {
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
}
if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if e != nil {
return e
}
u.Pass = hash
}
u.Auths = auths
raw, _ = json.Marshal(u)
if e := s.st.PutUser(tx, username, raw); e != nil {
return e
}
if username == "admin" && !existed {
adminCreated = true
}
return nil
})
return adminCreated, err
}
// DeleteUser removes a user and their tokens.
func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
for _, tok := range u.Tokens {
if err := s.st.DeleteToken(tx, tok); err != nil {
return err
}
}
}
return s.st.DeleteUser(tx, username)
})
}
// GetSettings returns the current server settings.
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
if v := s.st.GetConfig(tx, "prefix"); v != nil {
prefix = string(v)
}
if v := s.st.GetConfig(tx, "defaultHide"); v != nil {
defaultHide = true
}
if v := s.st.GetConfig(tx, "title"); v != nil {
title = string(v)
}
return nil
})
return prefix, defaultHide, title, err
}
// UpdateSettings updates the specified server settings (nil fields are skipped).
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
if prefix != nil {
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
return err
}
}
if defaultHide != nil {
if *defaultHide {
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
return err
}
} else {
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
return err
}
}
}
if title != nil {
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
return err
}
}
return nil
})
}
// ListMaps returns all maps for the admin panel.
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
var maps []app.MapInfo
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{}
if err := json.Unmarshal(v, &mi); err != nil {
return err
}
if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id
}
maps = append(maps, mi)
return nil
})
})
return maps, err
}
// GetMap returns a map by ID.
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
var mi *app.MapInfo
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
if raw != nil {
mi = &app.MapInfo{}
return json.Unmarshal(raw, mi)
}
return nil
})
if err != nil {
return nil, false, err
}
if mi != nil {
mi.ID = id
}
return mi, mi != nil, nil
}
// UpdateMap updates a map's name, hidden, and priority fields.
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
mi := app.MapInfo{}
raw := s.st.GetMap(tx, id)
if raw != nil {
if err := json.Unmarshal(raw, &mi); err != nil {
return err
}
}
mi.ID = id
mi.Name = name
mi.Hidden = hidden
mi.Priority = priority
raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw)
})
}
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
var mi *app.MapInfo
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{}
if raw != nil {
if err := json.Unmarshal(raw, mi); err != nil {
return err
}
}
mi.ID = id
mi.Hidden = !mi.Hidden
raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw)
})
return mi, err
}
// Wipe deletes all grids, markers, tiles, and maps from the database.
func (s *AdminService) Wipe(ctx context.Context) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
for _, b := range [][]byte{
store.BucketGrids,
store.BucketMarkers,
store.BucketTiles,
store.BucketMaps,
} {
if s.st.BucketExists(tx, b) {
if err := s.st.DeleteBucket(tx, b); err != nil {
return err
}
}
}
return nil
})
}
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
c := app.Coord{X: x, Y: y}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
var ids [][]byte
err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil {
return err
}
if g.Coord == c && g.Map == mapid {
ids = append(ids, k)
}
return nil
})
if err != nil {
return err
}
for _, id := range ids {
if err := grids.Delete(id); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
zc := c
for z := 1; z <= app.MaxZoomLevel; z++ {
zc = zc.Parent()
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
}
return nil
}
// SetCoords shifts all grid and tile coordinates by a delta.
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
fc := app.Coord{X: fx, Y: fy}
tc := app.Coord{X: tx2, Y: ty}
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
var tds []*app.TileData
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapZooms == nil {
return nil
}
mapTiles := mapZooms.Bucket([]byte("0"))
if err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil {
return err
}
if g.Map == mapid {
g.Coord.X += diff.X
g.Coord.Y += diff.Y
raw, _ := json.Marshal(g)
if err := grids.Put(k, raw); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
if err := mapTiles.ForEach(func(k, v []byte) error {
td := &app.TileData{}
if err := json.Unmarshal(v, td); err != nil {
return err
}
td.Coord.X += diff.X
td.Coord.Y += diff.Y
tds = append(tds, td)
return nil
}); err != nil {
return err
}
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
}); err != nil {
return err
}
ops := make([]TileOp, len(tds))
for i, td := range tds {
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
}
s.mapSvc.ProcessZoomLevels(ctx, ops)
return nil
}
// HideMarker marks a marker as hidden.
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
_, idB, err := s.st.CreateMarkersBuckets(tx)
if err != nil {
return err
}
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return fmt.Errorf("markers grid bucket not found")
}
key := idB.Get([]byte(markerID))
if key == nil {
slog.Warn("marker not found", "id", markerID)
return nil
}
raw := grid.Get(key)
if raw == nil {
return nil
}
m := app.Marker{}
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
m.Hidden = true
raw, _ = json.Marshal(m)
if err := grid.Put(key, raw); err != nil {
return err
}
return nil
})
}
// RebuildZooms delegates to MapService.
func (s *AdminService) RebuildZooms(ctx context.Context) error {
return s.mapSvc.RebuildZooms(ctx)
}
// StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately.
// RebuildZoomsRunning returns true while the rebuild is in progress.
func (s *AdminService) StartRebuildZooms() {
s.rebuildMu.Lock()
if s.rebuildRunning {
s.rebuildMu.Unlock()
return
}
s.rebuildRunning = true
s.rebuildMu.Unlock()
go func() {
defer func() {
s.rebuildMu.Lock()
s.rebuildRunning = false
s.rebuildMu.Unlock()
}()
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil {
slog.Error("RebuildZooms background failed", "error", err)
}
}()
}
// RebuildZoomsRunning returns true if a rebuild is currently in progress.
func (s *AdminService) RebuildZoomsRunning() bool {
s.rebuildMu.Lock()
defer s.rebuildMu.Unlock()
return s.rebuildRunning
}

View File

@@ -1,308 +1,308 @@
package services_test
import (
"context"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
t.Helper()
db := newTestDB(t)
st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{},
})
return services.NewAdminService(st, mapSvc), st
}
func TestListUsers_Empty(t *testing.T) {
admin, _ := newTestAdmin(t)
users, err := admin.ListUsers(context.Background())
if err != nil {
t.Fatal(err)
}
if len(users) != 0 {
t.Fatalf("expected 0 users, got %d", len(users))
}
}
func TestListUsers_WithUsers(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", nil)
createUser(t, st, "bob", "pass", nil)
users, err := admin.ListUsers(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
}
func TestAdminGetUser_Found(t *testing.T) {
admin, st := newTestAdmin(t)
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
auths, found, err := admin.GetUser(context.Background(), "alice")
if err != nil || !found {
t.Fatalf("expected found, err=%v", err)
}
if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found, err := admin.GetUser(context.Background(), "ghost")
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}
}
func TestCreateOrUpdateUser_New(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
auths, found, err := admin.GetUser(ctx, "bob")
if err != nil || !found {
t.Fatalf("expected user to exist, err=%v", err)
}
if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestCreateOrUpdateUser_Update(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
auths, found, err := admin.GetUser(ctx, "alice")
if err != nil || !found {
t.Fatalf("expected user, err=%v", err)
}
if !auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth after update")
}
}
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if !adminCreated {
t.Fatal("expected adminCreated=true for new admin user")
}
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if adminCreated {
t.Fatal("expected adminCreated=false for existing admin user")
}
}
func TestDeleteUser(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
auth := services.NewAuthService(st)
auth.GenerateTokenForUser(ctx, "alice")
if err := admin.DeleteUser(ctx, "alice"); err != nil {
t.Fatal(err)
}
_, found, err := admin.GetUser(ctx, "alice")
if err != nil || found {
t.Fatalf("expected user to be deleted, err=%v", err)
}
}
func TestGetSettings_Defaults(t *testing.T) {
admin, _ := newTestAdmin(t)
prefix, defaultHide, title, err := admin.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if prefix != "" || defaultHide || title != "" {
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
}
}
func TestUpdateSettings(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
p := "pfx"
dh := true
ti := "My Map"
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
t.Fatal(err)
}
prefix, defaultHide, title, err := admin.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if prefix != "pfx" {
t.Fatalf("expected pfx, got %s", prefix)
}
if !defaultHide {
t.Fatal("expected defaultHide=true")
}
if title != "My Map" {
t.Fatalf("expected My Map, got %s", title)
}
dh2 := false
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
t.Fatal(err)
}
_, defaultHide2, _, _ := admin.GetSettings(ctx)
if defaultHide2 {
t.Fatal("expected defaultHide=false after update")
}
}
func TestListMaps_Empty(t *testing.T) {
admin, _ := newTestAdmin(t)
maps, err := admin.ListMaps(context.Background())
if err != nil {
t.Fatal(err)
}
if len(maps) != 0 {
t.Fatalf("expected 0 maps, got %d", len(maps))
}
}
func TestMapCRUD(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
t.Fatal(err)
}
mi, found, err := admin.GetMap(ctx, 1)
if err != nil || !found || mi == nil {
t.Fatalf("expected map, err=%v", err)
}
if mi.Name != "world" {
t.Fatalf("expected world, got %s", mi.Name)
}
maps, err := admin.ListMaps(ctx)
if err != nil {
t.Fatal(err)
}
if len(maps) != 1 {
t.Fatalf("expected 1 map, got %d", len(maps))
}
}
func TestToggleMapHidden(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
_ = admin.UpdateMap(ctx, 1, "world", false, false)
mi, err := admin.ToggleMapHidden(ctx, 1)
if err != nil {
t.Fatal(err)
}
if !mi.Hidden {
t.Fatal("expected hidden=true after toggle")
}
mi, err = admin.ToggleMapHidden(ctx, 1)
if err != nil {
t.Fatal(err)
}
if mi.Hidden {
t.Fatal("expected hidden=false after second toggle")
}
}
func TestWipe(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
return err
}
if err := st.PutMap(tx, 1, []byte("data")); err != nil {
return err
}
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
return err
}
_, _, err := st.CreateMarkersBuckets(tx)
return err
}); err != nil {
t.Fatal(err)
}
if err := admin.Wipe(ctx); err != nil {
t.Fatal(err)
}
if err := st.View(ctx, func(tx *bbolt.Tx) error {
if st.GetGrid(tx, "g1") != nil {
t.Fatal("expected grids wiped")
}
if st.GetMap(tx, 1) != nil {
t.Fatal("expected maps wiped")
}
if st.GetTile(tx, 1, 0, "0_0") != nil {
t.Fatal("expected tiles wiped")
}
if st.GetMarkersGridBucket(tx) != nil {
t.Fatal("expected markers wiped")
}
return nil
}); err != nil {
t.Fatal(err)
}
}
func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found, err := admin.GetMap(context.Background(), 999)
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}
}
package services_test
import (
"context"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
t.Helper()
db := newTestDB(t)
st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{},
})
return services.NewAdminService(st, mapSvc), st
}
func TestListUsers_Empty(t *testing.T) {
admin, _ := newTestAdmin(t)
users, err := admin.ListUsers(context.Background())
if err != nil {
t.Fatal(err)
}
if len(users) != 0 {
t.Fatalf("expected 0 users, got %d", len(users))
}
}
func TestListUsers_WithUsers(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", nil)
createUser(t, st, "bob", "pass", nil)
users, err := admin.ListUsers(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
}
func TestAdminGetUser_Found(t *testing.T) {
admin, st := newTestAdmin(t)
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
auths, found, err := admin.GetUser(context.Background(), "alice")
if err != nil || !found {
t.Fatalf("expected found, err=%v", err)
}
if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found, err := admin.GetUser(context.Background(), "ghost")
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}
}
func TestCreateOrUpdateUser_New(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
auths, found, err := admin.GetUser(ctx, "bob")
if err != nil || !found {
t.Fatalf("expected user to exist, err=%v", err)
}
if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestCreateOrUpdateUser_Update(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
auths, found, err := admin.GetUser(ctx, "alice")
if err != nil || !found {
t.Fatalf("expected user, err=%v", err)
}
if !auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth after update")
}
}
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if !adminCreated {
t.Fatal("expected adminCreated=true for new admin user")
}
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if adminCreated {
t.Fatal("expected adminCreated=false for existing admin user")
}
}
func TestDeleteUser(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
auth := services.NewAuthService(st)
auth.GenerateTokenForUser(ctx, "alice")
if err := admin.DeleteUser(ctx, "alice"); err != nil {
t.Fatal(err)
}
_, found, err := admin.GetUser(ctx, "alice")
if err != nil || found {
t.Fatalf("expected user to be deleted, err=%v", err)
}
}
func TestGetSettings_Defaults(t *testing.T) {
admin, _ := newTestAdmin(t)
prefix, defaultHide, title, err := admin.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if prefix != "" || defaultHide || title != "" {
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
}
}
func TestUpdateSettings(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
p := "pfx"
dh := true
ti := "My Map"
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
t.Fatal(err)
}
prefix, defaultHide, title, err := admin.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if prefix != "pfx" {
t.Fatalf("expected pfx, got %s", prefix)
}
if !defaultHide {
t.Fatal("expected defaultHide=true")
}
if title != "My Map" {
t.Fatalf("expected My Map, got %s", title)
}
dh2 := false
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
t.Fatal(err)
}
_, defaultHide2, _, _ := admin.GetSettings(ctx)
if defaultHide2 {
t.Fatal("expected defaultHide=false after update")
}
}
func TestListMaps_Empty(t *testing.T) {
admin, _ := newTestAdmin(t)
maps, err := admin.ListMaps(context.Background())
if err != nil {
t.Fatal(err)
}
if len(maps) != 0 {
t.Fatalf("expected 0 maps, got %d", len(maps))
}
}
func TestMapCRUD(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
t.Fatal(err)
}
mi, found, err := admin.GetMap(ctx, 1)
if err != nil || !found || mi == nil {
t.Fatalf("expected map, err=%v", err)
}
if mi.Name != "world" {
t.Fatalf("expected world, got %s", mi.Name)
}
maps, err := admin.ListMaps(ctx)
if err != nil {
t.Fatal(err)
}
if len(maps) != 1 {
t.Fatalf("expected 1 map, got %d", len(maps))
}
}
func TestToggleMapHidden(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
_ = admin.UpdateMap(ctx, 1, "world", false, false)
mi, err := admin.ToggleMapHidden(ctx, 1)
if err != nil {
t.Fatal(err)
}
if !mi.Hidden {
t.Fatal("expected hidden=true after toggle")
}
mi, err = admin.ToggleMapHidden(ctx, 1)
if err != nil {
t.Fatal(err)
}
if mi.Hidden {
t.Fatal("expected hidden=false after second toggle")
}
}
func TestWipe(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
return err
}
if err := st.PutMap(tx, 1, []byte("data")); err != nil {
return err
}
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
return err
}
_, _, err := st.CreateMarkersBuckets(tx)
return err
}); err != nil {
t.Fatal(err)
}
if err := admin.Wipe(ctx); err != nil {
t.Fatal(err)
}
if err := st.View(ctx, func(tx *bbolt.Tx) error {
if st.GetGrid(tx, "g1") != nil {
t.Fatal("expected grids wiped")
}
if st.GetMap(tx, 1) != nil {
t.Fatal("expected maps wiped")
}
if st.GetTile(tx, 1, 0, "0_0") != nil {
t.Fatal("expected tiles wiped")
}
if st.GetMarkersGridBucket(tx) != nil {
t.Fatal("expected markers wiped")
}
return nil
}); err != nil {
t.Fatal(err)
}
}
func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found, err := admin.GetMap(context.Background(), 999)
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}
}

View File

@@ -1,422 +1,422 @@
package services
import (
"context"
"encoding/json"
"fmt"
"image"
"image/png"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/image/draw"
)
type zoomproc struct {
c app.Coord
m int
}
// MapService handles map, markers, grids, tiles business logic.
type MapService struct {
st *store.Store
gridStorage string
gridUpdates *app.Topic[app.TileData]
mergeUpdates *app.Topic[app.Merge]
getChars func() []app.Character
}
// MapServiceDeps holds dependencies for MapService construction.
type MapServiceDeps struct {
Store *store.Store
GridStorage string
GridUpdates *app.Topic[app.TileData]
MergeUpdates *app.Topic[app.Merge]
GetChars func() []app.Character
}
// NewMapService creates a MapService with the given dependencies.
func NewMapService(d MapServiceDeps) *MapService {
return &MapService{
st: d.Store,
gridStorage: d.GridStorage,
gridUpdates: d.GridUpdates,
mergeUpdates: d.MergeUpdates,
getChars: d.GetChars,
}
}
// GridStorage returns the grid storage directory path.
func (s *MapService) GridStorage() string { return s.gridStorage }
// GetCharacters returns all current characters.
func (s *MapService) GetCharacters() []app.Character {
if s.getChars == nil {
return nil
}
return s.getChars()
}
// GetMarkers returns all markers with computed map positions.
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
var markers []app.FrontendMarker
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return nil
}
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{}
if err := json.Unmarshal(v, &marker); err != nil {
return err
}
graw := grids.Get([]byte(marker.GridID))
if graw == nil {
return nil
}
g := app.GridData{}
if err := json.Unmarshal(graw, &g); err != nil {
return err
}
markers = append(markers, app.FrontendMarker{
Image: marker.Image,
Hidden: marker.Hidden,
ID: marker.ID,
Name: marker.Name,
Map: g.Map,
Position: app.Position{
X: marker.Position.X + g.Coord.X*app.GridSize,
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
},
})
return nil
})
})
return markers, err
}
// GetMaps returns all maps, optionally including hidden ones.
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
maps := make(map[int]*app.MapInfo)
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k))
if err != nil {
return nil
}
mi := &app.MapInfo{}
if err := json.Unmarshal(v, mi); err != nil {
return err
}
if mi.Hidden && !showHidden {
return nil
}
maps[mapid] = mi
return nil
})
})
return maps, err
}
// GetConfig returns the application config for the frontend.
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
config := app.Config{Auths: auths}
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
config.Title = string(title)
}
return nil
})
return config, err
}
// GetPage returns page metadata (title).
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
p := app.Page{}
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
p.Title = string(title)
}
return nil
})
return p, err
}
// GetGrid returns a grid by its ID.
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
var gd *app.GridData
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id)
if raw == nil {
return nil
}
gd = &app.GridData{}
return json.Unmarshal(raw, gd)
})
return gd, err
}
// GetTile returns a tile by map ID, coordinate, and zoom level.
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil {
td = &app.TileData{}
return json.Unmarshal(raw, td)
}
return nil
}); err != nil {
return nil
}
return td
}
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
coords := []app.Coord{
{X: c.X*2 + 0, Y: c.Y*2 + 0},
{X: c.X*2 + 1, Y: c.Y*2 + 0},
{X: c.X*2 + 0, Y: c.Y*2 + 1},
{X: c.X*2 + 1, Y: c.Y*2 + 1},
}
keys := make([]string, len(coords))
for i := range coords {
keys[i] = coords[i].Name()
}
var rawMap map[string][]byte
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
return nil
}); err != nil {
return nil
}
result := make([]*app.TileData, 4)
for i, k := range keys {
if raw, ok := rawMap[k]; ok && len(raw) > 0 {
td := &app.TileData{}
if json.Unmarshal(raw, td) == nil {
result[i] = td
}
}
}
return result
}
// SaveTile persists a tile and broadcasts the update.
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
td := &app.TileData{
MapID: mapid,
Coord: c,
Zoom: z,
File: f,
Cache: t,
}
raw, err := json.Marshal(td)
if err != nil {
return err
}
s.gridUpdates.Send(td)
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
})
}
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
subTiles := s.getSubTiles(ctx, mapid, c, z)
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
for i := 0; i < 4; i++ {
td := subTiles[i]
if td == nil || td.File == "" {
continue
}
x := i % 2
y := i / 2
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
if err != nil {
continue
}
subimg, _, err := image.Decode(subf)
subf.Close()
if err != nil {
continue
}
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
}
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
slog.Error("failed to create zoom dir", "error", err)
return
}
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
f, err := os.Create(path)
if err != nil {
slog.Error("failed to create tile file", "path", path, "error", err)
return
}
if err := png.Encode(f, img); err != nil {
f.Close()
os.Remove(path)
slog.Error("failed to encode tile PNG", "path", path, "error", err)
return
}
if err := f.Close(); err != nil {
slog.Error("failed to close tile file", "path", path, "error", err)
return
}
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
}
// RebuildZooms rebuilds all zoom levels from base tiles.
// It can take a long time for many grids; the client should account for request timeouts.
func (s *MapService) RebuildZooms(ctx context.Context) error {
needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids)
if b == nil {
return nil
}
if err := b.ForEach(func(k, v []byte) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
grid := app.GridData{}
if err := json.Unmarshal(v, &grid); err != nil {
return err
}
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil
}); err != nil {
return err
}
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
return err
}
return nil
}); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err)
return err
}
for g, id := range saveGrid {
if ctx.Err() != nil {
return ctx.Err()
}
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
if _, err := os.Stat(f); err != nil {
continue
}
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
}
for z := 1; z <= app.MaxZoomLevel; z++ {
if ctx.Err() != nil {
return ctx.Err()
}
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
return nil
}
// ReportMerge sends a merge event.
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
s.mergeUpdates.Send(&app.Merge{
From: from,
To: to,
Shift: shift,
})
}
// WatchTiles creates a channel that receives tile updates.
func (s *MapService) WatchTiles() chan *app.TileData {
c := make(chan *app.TileData, app.SSETileChannelSize)
s.gridUpdates.Watch(c)
return c
}
// WatchMerges creates a channel that receives merge updates.
func (s *MapService) WatchMerges() chan *app.Merge {
c := make(chan *app.Merge, app.SSEMergeChannelSize)
s.mergeUpdates.Watch(c)
return c
}
// GetAllTileCache returns all tiles for the initial SSE cache dump.
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
var cache []TileCache
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
td := app.TileData{}
if err := json.Unmarshal(v, &td); err != nil {
return err
}
cache = append(cache, TileCache{
M: td.MapID,
X: td.Coord.X,
Y: td.Coord.Y,
Z: td.Zoom,
T: int(td.Cache),
})
return nil
})
})
return cache
}
// TileCache represents a minimal tile entry for SSE streaming.
type TileCache struct {
M, X, Y, Z, T int
}
// ProcessZoomLevels processes zoom levels for a set of tile operations.
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
needProcess := map[zoomproc]struct{}{}
for _, op := range ops {
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
}
for z := 1; z <= app.MaxZoomLevel; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
}
// TileOp represents a tile save operation.
type TileOp struct {
MapID int
X, Y int
File string
}
package services
import (
"context"
"encoding/json"
"fmt"
"image"
"image/png"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/image/draw"
)
type zoomproc struct {
c app.Coord
m int
}
// MapService handles map, markers, grids, tiles business logic.
type MapService struct {
st *store.Store
gridStorage string
gridUpdates *app.Topic[app.TileData]
mergeUpdates *app.Topic[app.Merge]
getChars func() []app.Character
}
// MapServiceDeps holds dependencies for MapService construction.
type MapServiceDeps struct {
Store *store.Store
GridStorage string
GridUpdates *app.Topic[app.TileData]
MergeUpdates *app.Topic[app.Merge]
GetChars func() []app.Character
}
// NewMapService creates a MapService with the given dependencies.
func NewMapService(d MapServiceDeps) *MapService {
return &MapService{
st: d.Store,
gridStorage: d.GridStorage,
gridUpdates: d.GridUpdates,
mergeUpdates: d.MergeUpdates,
getChars: d.GetChars,
}
}
// GridStorage returns the grid storage directory path.
func (s *MapService) GridStorage() string { return s.gridStorage }
// GetCharacters returns all current characters.
func (s *MapService) GetCharacters() []app.Character {
if s.getChars == nil {
return nil
}
return s.getChars()
}
// GetMarkers returns all markers with computed map positions.
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
var markers []app.FrontendMarker
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return nil
}
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{}
if err := json.Unmarshal(v, &marker); err != nil {
return err
}
graw := grids.Get([]byte(marker.GridID))
if graw == nil {
return nil
}
g := app.GridData{}
if err := json.Unmarshal(graw, &g); err != nil {
return err
}
markers = append(markers, app.FrontendMarker{
Image: marker.Image,
Hidden: marker.Hidden,
ID: marker.ID,
Name: marker.Name,
Map: g.Map,
Position: app.Position{
X: marker.Position.X + g.Coord.X*app.GridSize,
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
},
})
return nil
})
})
return markers, err
}
// GetMaps returns all maps, optionally including hidden ones.
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
maps := make(map[int]*app.MapInfo)
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k))
if err != nil {
return nil
}
mi := &app.MapInfo{}
if err := json.Unmarshal(v, mi); err != nil {
return err
}
if mi.Hidden && !showHidden {
return nil
}
maps[mapid] = mi
return nil
})
})
return maps, err
}
// GetConfig returns the application config for the frontend.
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
config := app.Config{Auths: auths}
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
config.Title = string(title)
}
return nil
})
return config, err
}
// GetPage returns page metadata (title).
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
p := app.Page{}
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
p.Title = string(title)
}
return nil
})
return p, err
}
// GetGrid returns a grid by its ID.
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
var gd *app.GridData
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id)
if raw == nil {
return nil
}
gd = &app.GridData{}
return json.Unmarshal(raw, gd)
})
return gd, err
}
// GetTile returns a tile by map ID, coordinate, and zoom level.
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil {
td = &app.TileData{}
return json.Unmarshal(raw, td)
}
return nil
}); err != nil {
return nil
}
return td
}
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
coords := []app.Coord{
{X: c.X*2 + 0, Y: c.Y*2 + 0},
{X: c.X*2 + 1, Y: c.Y*2 + 0},
{X: c.X*2 + 0, Y: c.Y*2 + 1},
{X: c.X*2 + 1, Y: c.Y*2 + 1},
}
keys := make([]string, len(coords))
for i := range coords {
keys[i] = coords[i].Name()
}
var rawMap map[string][]byte
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
return nil
}); err != nil {
return nil
}
result := make([]*app.TileData, 4)
for i, k := range keys {
if raw, ok := rawMap[k]; ok && len(raw) > 0 {
td := &app.TileData{}
if json.Unmarshal(raw, td) == nil {
result[i] = td
}
}
}
return result
}
// SaveTile persists a tile and broadcasts the update.
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
td := &app.TileData{
MapID: mapid,
Coord: c,
Zoom: z,
File: f,
Cache: t,
}
raw, err := json.Marshal(td)
if err != nil {
return err
}
s.gridUpdates.Send(td)
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
})
}
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
subTiles := s.getSubTiles(ctx, mapid, c, z)
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
for i := 0; i < 4; i++ {
td := subTiles[i]
if td == nil || td.File == "" {
continue
}
x := i % 2
y := i / 2
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
if err != nil {
continue
}
subimg, _, err := image.Decode(subf)
subf.Close()
if err != nil {
continue
}
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
}
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
slog.Error("failed to create zoom dir", "error", err)
return
}
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
f, err := os.Create(path)
if err != nil {
slog.Error("failed to create tile file", "path", path, "error", err)
return
}
if err := png.Encode(f, img); err != nil {
f.Close()
os.Remove(path)
slog.Error("failed to encode tile PNG", "path", path, "error", err)
return
}
if err := f.Close(); err != nil {
slog.Error("failed to close tile file", "path", path, "error", err)
return
}
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
}
// RebuildZooms rebuilds all zoom levels from base tiles.
// It can take a long time for many grids; the client should account for request timeouts.
func (s *MapService) RebuildZooms(ctx context.Context) error {
needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids)
if b == nil {
return nil
}
if err := b.ForEach(func(k, v []byte) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
grid := app.GridData{}
if err := json.Unmarshal(v, &grid); err != nil {
return err
}
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil
}); err != nil {
return err
}
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
return err
}
return nil
}); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err)
return err
}
for g, id := range saveGrid {
if ctx.Err() != nil {
return ctx.Err()
}
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
if _, err := os.Stat(f); err != nil {
continue
}
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
}
for z := 1; z <= app.MaxZoomLevel; z++ {
if ctx.Err() != nil {
return ctx.Err()
}
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
return nil
}
// ReportMerge sends a merge event.
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
s.mergeUpdates.Send(&app.Merge{
From: from,
To: to,
Shift: shift,
})
}
// WatchTiles creates a channel that receives tile updates.
func (s *MapService) WatchTiles() chan *app.TileData {
c := make(chan *app.TileData, app.SSETileChannelSize)
s.gridUpdates.Watch(c)
return c
}
// WatchMerges creates a channel that receives merge updates.
func (s *MapService) WatchMerges() chan *app.Merge {
c := make(chan *app.Merge, app.SSEMergeChannelSize)
s.mergeUpdates.Watch(c)
return c
}
// GetAllTileCache returns all tiles for the initial SSE cache dump.
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
var cache []TileCache
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
td := app.TileData{}
if err := json.Unmarshal(v, &td); err != nil {
return err
}
cache = append(cache, TileCache{
M: td.MapID,
X: td.Coord.X,
Y: td.Coord.Y,
Z: td.Zoom,
T: int(td.Cache),
})
return nil
})
})
return cache
}
// TileCache represents a minimal tile entry for SSE streaming.
type TileCache struct {
M, X, Y, Z, T int
}
// ProcessZoomLevels processes zoom levels for a set of tile operations.
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
needProcess := map[zoomproc]struct{}{}
for _, op := range ops {
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
}
for z := 1; z <= app.MaxZoomLevel; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
}
// TileOp represents a tile save operation.
type TileOp struct {
MapID int
X, Y int
File string
}