From dda35baecaa03b78aa3d8155024695d876684de4 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Wed, 4 Mar 2026 11:59:28 +0300 Subject: [PATCH] Implement HTTP timeout configurations and enhance API documentation - Added optional HTTP server timeout configurations (`HNHMAP_READ_TIMEOUT`, `HNHMAP_WRITE_TIMEOUT`, `HNHMAP_IDLE_TIMEOUT`) to `.env.example` and updated the server initialization in `main.go` to utilize these settings. - Enhanced API documentation for the `rebuildZooms` endpoint to clarify its background processing and polling mechanism for status updates. - Updated `configuration.md` to include new timeout environment variables for better configuration guidance. - Improved error handling in the client for large request bodies, ensuring appropriate responses for oversized payloads. --- .env.example | 3 + cmd/hnh-map/main.go | 29 ++++++- docs/api.md | 3 +- docs/configuration.md | 3 + frontend-nuxt/components/MapView.vue | 13 ++- frontend-nuxt/components/UserAvatar.vue | 1 + frontend-nuxt/components/map/MapSearch.vue | 24 +++++- frontend-nuxt/composables/useGravatarUrl.ts | 12 ++- frontend-nuxt/composables/useMapApi.ts | 48 +++++++++-- frontend-nuxt/pages/admin/index.vue | 18 +++- internal/app/handlers/admin.go | 20 ++++- internal/app/handlers/client.go | 15 +++- internal/app/handlers/handlers_test.go | 36 ++++++++ internal/app/services/admin.go | 33 ++++++++ internal/app/services/export.go | 92 +++++++++++++++------ internal/app/services/map.go | 88 +++++++++++++++----- internal/app/store/db.go | 31 ++++++- 17 files changed, 396 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index 83a3666..8783fc9 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Backend (Go) # HNHMAP_PORT=8080 +# HNHMAP_READ_TIMEOUT=30s # HTTP read timeout (optional) +# HNHMAP_WRITE_TIMEOUT=60s # HTTP write timeout (optional) +# HNHMAP_IDLE_TIMEOUT=120s # HTTP idle timeout (optional) # HNHMAP_BOOTSTRAP_PASSWORD= # Set once for first run: login as admin with this password to create the first admin user (then unset or leave empty) # Grids directory (default: grids); in Docker often /map # HNHMAP_GRIDS=grids diff --git a/cmd/hnh-map/main.go b/cmd/hnh-map/main.go index 99ad216..a40a778 100644 --- a/cmd/hnh-map/main.go +++ b/cmd/hnh-map/main.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strconv" + "time" "github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app/handlers" @@ -79,6 +80,32 @@ func main() { publicDir := filepath.Join(workDir, "public") r := a.Router(publicDir, h) + readTimeout := 30 * time.Second + if v := os.Getenv("HNHMAP_READ_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + readTimeout = d + } + } + writeTimeout := 60 * time.Second + if v := os.Getenv("HNHMAP_WRITE_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + writeTimeout = d + } + } + idleTimeout := 120 * time.Second + if v := os.Getenv("HNHMAP_IDLE_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + idleTimeout = d + } + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", *port), + Handler: r, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } log.Printf("Listening on port %d", *port) - log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), r)) + log.Fatal(srv.ListenAndServe()) } diff --git a/docs/api.md b/docs/api.md index 34c37fb..645d4c4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,7 +40,8 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica - **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority). - **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility. - **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database. -- **POST /map/api/admin/rebuildZooms** — rebuild tile zoom levels from base tiles. The operation can take a long time (minutes) when there are many grids; the client should allow for request timeouts or show appropriate loading state. On success returns 200; on failure (e.g. store error) returns 500. +- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map. +- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress. - **GET /map/api/admin/export** — download data export (ZIP). - **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers). - **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`. diff --git a/docs/configuration.md b/docs/configuration.md index 5621f45..982d111 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,9 @@ |-----------------|-------------|---------| | `HNHMAP_PORT` | HTTP server port | 8080 | | `-port` | Same (command-line flag) | value of `HNHMAP_PORT` or 8080 | +| `HNHMAP_READ_TIMEOUT` | HTTP server read timeout (e.g. `30s`) | 30s | +| `HNHMAP_WRITE_TIMEOUT` | HTTP server write timeout (e.g. `60s`) | 60s | +| `HNHMAP_IDLE_TIMEOUT` | HTTP server idle timeout (e.g. `120s`) | 120s | | `HNHMAP_BOOTSTRAP_PASSWORD` | Password for initial setup: when no users exist, logging in as `admin` with this password creates the first admin user | — | | `HNHMAP_BASE_URL` | Full application URL for OAuth redirect_uri (e.g. `https://map.example.com`). If not set, derived from `Host` and `X-Forwarded-*` headers | — | | `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | — | diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index 3b2111c..bf04242 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -283,6 +283,7 @@ function updateSelectedMarkerForBookmark() { } } let intervalId: ReturnType | null = null +let characterPollFirstTickId: ReturnType | null = null let autoMode = false let mapContainer: HTMLElement | null = null let contextMenuHandler: ((ev: MouseEvent) => void) | null = null @@ -554,13 +555,20 @@ onMounted(async () => { } function startCharacterPoll() { + if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId) + characterPollFirstTickId = null if (intervalId) clearInterval(intervalId) + intervalId = null const ms = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? CHARACTER_POLL_MS_HIDDEN : CHARACTER_POLL_MS - pollCharacters() - intervalId = setInterval(pollCharacters, ms) + // First tick after delay to avoid double-fetch with initial getCharacters() in Promise.all + characterPollFirstTickId = setTimeout(() => { + characterPollFirstTickId = null + pollCharacters() + intervalId = setInterval(pollCharacters, ms) + }, ms) } startCharacterPoll() @@ -703,6 +711,7 @@ onBeforeUnmount(() => { if (contextMenuHandler) { document.removeEventListener('contextmenu', contextMenuHandler, true) } + if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId) if (intervalId) clearInterval(intervalId) updatesHandle?.cleanup() if (leafletMap) leafletMap.remove() diff --git a/frontend-nuxt/components/UserAvatar.vue b/frontend-nuxt/components/UserAvatar.vue index 46d1aba..4a83336 100644 --- a/frontend-nuxt/components/UserAvatar.vue +++ b/frontend-nuxt/components/UserAvatar.vue @@ -9,6 +9,7 @@ :src="useGravatarUrl(email, size)" alt="" class="w-full h-full object-cover" + loading="lazy" @error="gravatarError = true" >
(null) const query = ref('') +const queryDebounced = ref('') +let queryDebounceId: ReturnType | null = null +const SEARCH_DEBOUNCE_MS = 180 +watch( + query, + (v) => { + if (queryDebounceId) clearTimeout(queryDebounceId) + queryDebounceId = setTimeout(() => { + queryDebounced.value = v + queryDebounceId = null + }, SEARCH_DEBOUNCE_MS) + }, + { immediate: true } +) const showDropdown = ref(false) const highlightIndex = ref(0) let closeDropdownTimer: ReturnType | null = null @@ -113,7 +127,7 @@ interface Suggestion { } const suggestions = computed(() => { - const q = query.value.trim().toLowerCase() + const q = queryDebounced.value.trim().toLowerCase() if (!q) return [] const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/) @@ -193,10 +207,18 @@ const emit = defineEmits<{ 'jump-to-marker': [id: number] }>() +watch(suggestions, () => { + highlightIndex.value = 0 +}, { flush: 'sync' }) + onMounted(() => { useMapNavigate().setFocusSearch(() => inputRef.value?.focus()) }) +onBeforeUnmount(() => { + if (queryDebounceId) clearTimeout(queryDebounceId) +}) + onBeforeUnmount(() => { if (closeDropdownTimer) clearTimeout(closeDropdownTimer) useMapNavigate().setFocusSearch(null) diff --git a/frontend-nuxt/composables/useGravatarUrl.ts b/frontend-nuxt/composables/useGravatarUrl.ts index d908d7a..dc3854c 100644 --- a/frontend-nuxt/composables/useGravatarUrl.ts +++ b/frontend-nuxt/composables/useGravatarUrl.ts @@ -1,8 +1,11 @@ import md5 from 'md5' +const gravatarCache = new Map() + /** * Returns Gravatar avatar URL for the given email, or empty string if no email. * Gravatar expects: trim, lowercase, then MD5 hex. Use empty string to show a placeholder (e.g. initial letter) in the UI. + * Results are memoized by (email, size) to avoid recomputing MD5 on every render. * * @param email - User email (optional) * @param size - Avatar size in pixels (default 64; navbar 32, drawer 40, profile 64–80) @@ -10,7 +13,12 @@ import md5 from 'md5' export function useGravatarUrl(email: string | undefined, size?: number): string { const normalized = email?.trim().toLowerCase() if (!normalized) return '' - const hash = md5(normalized) const s = size ?? 64 - return `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon` + const key = `${normalized}\n${s}` + const cached = gravatarCache.get(key) + if (cached !== undefined) return cached + const hash = md5(normalized) + const url = `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon` + gravatarCache.set(key, url) + return url } diff --git a/frontend-nuxt/composables/useMapApi.ts b/frontend-nuxt/composables/useMapApi.ts index aa826d3..b42a342 100644 --- a/frontend-nuxt/composables/useMapApi.ts +++ b/frontend-nuxt/composables/useMapApi.ts @@ -13,6 +13,12 @@ export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeRespon // Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue) const onApiErrorCallbacks = new Map void>() +// In-flight dedup: one me() request at a time; concurrent callers share the same promise. +let mePromise: Promise | null = null + +// In-flight dedup for GET endpoints: same path + method shares one request across all callers. +const inFlightByKey = new Map>() + export function useMapApi() { const config = useRuntimeConfig() const apiBase = config.public.apiBase as string @@ -40,20 +46,31 @@ export function useMapApi() { return undefined as T } + function requestDeduped(path: string, opts?: RequestInit): Promise { + const key = path + (opts?.method ?? 'GET') + const existing = inFlightByKey.get(key) + if (existing) return existing as Promise + const p = request(path, opts).finally(() => { + inFlightByKey.delete(key) + }) + inFlightByKey.set(key, p) + return p + } + async function getConfig() { - return request('config') + return requestDeduped('config') } async function getCharacters() { - return request('v1/characters') + return requestDeduped('v1/characters') } async function getMarkers() { - return request('v1/markers') + return requestDeduped('v1/markers') } async function getMaps() { - return request>('maps') + return requestDeduped>('maps') } // Auth @@ -92,11 +109,17 @@ export function useMapApi() { } async function logout() { + mePromise = null + inFlightByKey.clear() await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' }) } async function me() { - return request('me') + if (mePromise) return mePromise + mePromise = request('me').finally(() => { + mePromise = null + }) + return mePromise } async function meUpdate(body: { email?: string }) { @@ -182,7 +205,19 @@ export function useMapApi() { } async function adminRebuildZooms() { - await request('admin/rebuildZooms', { method: 'POST' }) + const res = await fetch(`${apiBase}/admin/rebuildZooms`, { + method: 'POST', + credentials: 'include', + }) + if (res.status === 401 || res.status === 403) { + onApiErrorCallbacks.forEach((cb) => cb()) + throw new Error('Unauthorized') + } + if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`) + } + + async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> { + return request<{ running: boolean }>('admin/rebuildZooms/status') } function adminExportUrl() { @@ -250,6 +285,7 @@ export function useMapApi() { adminMapToggleHidden, adminWipe, adminRebuildZooms, + adminRebuildZoomsStatus, adminExportUrl, adminMerge, adminWipeTile, diff --git a/frontend-nuxt/pages/admin/index.vue b/frontend-nuxt/pages/admin/index.vue index e5c1f0a..6d669e5 100644 --- a/frontend-nuxt/pages/admin/index.vue +++ b/frontend-nuxt/pages/admin/index.vue @@ -60,11 +60,11 @@ aria-label="Search users" />
-
+
@@ -103,7 +103,7 @@ aria-label="Search maps" />
-
+
@@ -199,7 +199,7 @@
@@ -284,6 +284,7 @@ definePageMeta({ middleware: 'admin' }) useHead({ title: 'Admin – HnH Map' }) const api = useMapApi() +const exportUrl = computed(() => api.adminExportUrl()) const toast = useToast() const users = ref([]) const maps = ref([]) @@ -391,6 +392,15 @@ async function doRebuildZooms() { rebuilding.value = true try { await api.adminRebuildZooms() + // Rebuild runs in background; poll status until done + const poll = async (): Promise => { + const { running } = await api.adminRebuildZoomsStatus() + if (running) { + await new Promise((r) => setTimeout(r, 2000)) + return poll() + } + } + await poll() markRebuildDone() showRebuildModal.value = false toast.success('Zooms rebuilt.') diff --git a/internal/app/handlers/admin.go b/internal/app/handlers/admin.go index af71977..989e074 100644 --- a/internal/app/handlers/admin.go +++ b/internal/app/handlers/admin.go @@ -321,6 +321,7 @@ func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) } // 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 @@ -328,11 +329,22 @@ func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Reques if h.requireAdmin(rw, req) == nil { return } - if err := h.Admin.RebuildZooms(req.Context()); err != nil { - HandleServiceError(rw, err) + 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 } - rw.WriteHeader(http.StatusOK) + 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. @@ -426,6 +438,8 @@ func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path 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": diff --git a/internal/app/handlers/client.go b/internal/app/handlers/client.go index e808ac6..c844fac 100644 --- a/internal/app/handlers/client.go +++ b/internal/app/handlers/client.go @@ -12,6 +12,9 @@ import ( "github.com/andyleap/hnh-map/internal/app/services" ) +// maxClientBodySize is the maximum size for position and marker update request bodies. +const maxClientBodySize = 2 * 1024 * 1024 // 2 MB + var clientPath = regexp.MustCompile(`client/([^/]+)/(.*)`) // ClientRouter handles /client/* requests with token-based auth. @@ -112,12 +115,16 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Request) { defer req.Body.Close() - buf, err := io.ReadAll(req.Body) + buf, err := io.ReadAll(io.LimitReader(req.Body, maxClientBodySize+1)) if err != nil { slog.Error("error reading position update", "error", err) JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST") return } + if len(buf) > maxClientBodySize { + JSONError(rw, http.StatusRequestEntityTooLarge, "request body too large", "PAYLOAD_TOO_LARGE") + return + } if err := h.Client.UpdatePositions(req.Context(), buf); err != nil { slog.Error("position update failed", "error", err) HandleServiceError(rw, err) @@ -126,12 +133,16 @@ func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Reques func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request) { defer req.Body.Close() - buf, err := io.ReadAll(req.Body) + buf, err := io.ReadAll(io.LimitReader(req.Body, maxClientBodySize+1)) if err != nil { slog.Error("error reading marker update", "error", err) JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST") return } + if len(buf) > maxClientBodySize { + JSONError(rw, http.StatusRequestEntityTooLarge, "request body too large", "PAYLOAD_TOO_LARGE") + return + } if err := h.Client.UploadMarkers(req.Context(), buf); err != nil { slog.Error("marker update failed", "error", err) HandleServiceError(rw, err) diff --git a/internal/app/handlers/handlers_test.go b/internal/app/handlers/handlers_test.go index ce71f78..4320538 100644 --- a/internal/app/handlers/handlers_test.go +++ b/internal/app/handlers/handlers_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "bytes" "context" "encoding/json" "errors" @@ -677,3 +678,38 @@ func TestClientRouter_InvalidToken(t *testing.T) { t.Fatalf("expected 401, got %d", rr.Code) } } + +func TestClientRouter_PositionUpdate_BodyTooLarge(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + // Body larger than maxClientBodySize (2MB) + bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1) + req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/positionUpdate", bytes.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestClientRouter_MarkerUpdate_BodyTooLarge(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1) + req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/markerUpdate", bytes.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/app/services/admin.go b/internal/app/services/admin.go index bae283b..1320cd9 100644 --- a/internal/app/services/admin.go +++ b/internal/app/services/admin.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "strconv" + "sync" "github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app/store" @@ -17,6 +18,9 @@ import ( type AdminService struct { st *store.Store mapSvc *MapService + + rebuildMu sync.Mutex + rebuildRunning bool } // NewAdminService creates an AdminService with the given store and map service. @@ -372,3 +376,32 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error { 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 +} diff --git a/internal/app/services/export.go b/internal/app/services/export.go index 6ba4df9..633bcb1 100644 --- a/internal/app/services/export.go +++ b/internal/app/services/export.go @@ -35,14 +35,23 @@ func NewExportService(st *store.Store, mapSvc *MapService) *ExportService { return &ExportService{st: st, mapSvc: mapSvc} } -// Export writes all map data as a ZIP archive to the given writer. -func (s *ExportService) Export(ctx context.Context, w io.Writer) error { - zw := zip.NewWriter(w) - defer zw.Close() +// exportEntry describes a single grid PNG to copy into the ZIP (collected inside a read-only View). +type exportEntry struct { + ZipPath string // e.g. "1/grid1.png" + FilePath string // absolute path on disk +} - return s.st.Update(ctx, func(tx *bbolt.Tx) error { - maps := map[int]mapData{} - gridMap := map[string]int{} +// Export writes all map data as a ZIP archive to the given writer. +// It uses a read-only View to collect data, then builds the ZIP outside the transaction +// so that the write lock is not held during file I/O. +func (s *ExportService) Export(ctx context.Context, w io.Writer) error { + var maps map[int]mapData + var gridMap map[string]int + var filesToCopy []exportEntry + + if err := s.st.View(ctx, func(tx *bbolt.Tx) error { + maps = map[int]mapData{} + gridMap = map[string]int{} grids := tx.Bucket(store.BucketGrids) if grids == nil { @@ -54,6 +63,11 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error { } if err := grids.ForEach(func(k, v []byte) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } gd := app.GridData{} if err := json.Unmarshal(v, &gd); err != nil { return err @@ -84,17 +98,11 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error { if err := json.Unmarshal(tdraw, &td); err != nil { return err } - fw, err := zw.Create(fmt.Sprintf("%d/%s.png", gd.Map, gd.ID)) - if err != nil { - return err - } - f, err := os.Open(filepath.Join(s.mapSvc.GridStorage(), td.File)) - if err != nil { - return err - } - _, err = io.Copy(fw, f) - f.Close() - return err + filesToCopy = append(filesToCopy, exportEntry{ + ZipPath: fmt.Sprintf("%d/%s.png", gd.Map, gd.ID), + FilePath: filepath.Join(s.mapSvc.GridStorage(), td.File), + }) + return nil }); err != nil { return err } @@ -104,6 +112,11 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error { markersgrid := markersb.Bucket(store.BucketMarkersGrid) if markersgrid != nil { markersgrid.ForEach(func(k, v []byte) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } marker := app.Marker{} if json.Unmarshal(v, &marker) != nil { return nil @@ -115,16 +128,41 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error { }) } } - - for mapid, md := range maps { - fw, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid)) - if err != nil { - return err - } - json.NewEncoder(fw).Encode(md) - } return nil - }) + }); err != nil { + return err + } + + // Build ZIP outside the transaction so the write lock is not held during file I/O. + zw := zip.NewWriter(w) + defer zw.Close() + + for _, e := range filesToCopy { + fw, err := zw.Create(e.ZipPath) + if err != nil { + return err + } + f, err := os.Open(e.FilePath) + if err != nil { + return err + } + _, err = io.Copy(fw, f) + f.Close() + if err != nil { + return err + } + } + + for mapid, md := range maps { + fw, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid)) + if err != nil { + return err + } + if err := json.NewEncoder(fw).Encode(md); err != nil { + return err + } + } + return nil } // Merge imports map data from a ZIP file. diff --git a/internal/app/services/map.go b/internal/app/services/map.go index c332684..6f7549a 100644 --- a/internal/app/services/map.go +++ b/internal/app/services/map.go @@ -184,6 +184,38 @@ func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom i 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 { @@ -203,32 +235,28 @@ func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int }) } -// UpdateZoomLevel composes a zoom tile from 4 sub-tiles. +// 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 x := 0; x <= 1; x++ { - for y := 0; y <= 1; y++ { - subC := c - subC.X *= 2 - subC.Y *= 2 - subC.X += x - subC.Y += y - td := s.GetTile(ctx, mapid, subC, z-1) - if td == nil || td.File == "" { - continue - } - 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) + 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) @@ -266,6 +294,11 @@ func (s *MapService) RebuildZooms(ctx context.Context) error { return nil } 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 @@ -282,6 +315,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error { } 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 @@ -289,6 +325,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error { 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 { @@ -327,6 +366,11 @@ 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 diff --git a/internal/app/store/db.go b/internal/app/store/db.go index a463dda..97cd150 100644 --- a/internal/app/store/db.go +++ b/internal/app/store/db.go @@ -17,7 +17,9 @@ func New(db *bbolt.DB) *Store { return &Store{db: db} } -// View runs fn in a read-only transaction. Checks context before starting. +// View runs fn in a read-only transaction. It checks context before starting. +// Long-running callbacks (e.g. large ForEach or ForEachTile) should check ctx.Done() +// periodically and return ctx.Err() to abort early when the context is cancelled. func (s *Store) View(ctx context.Context, fn func(tx *bbolt.Tx) error) error { select { case <-ctx.Done(): @@ -27,7 +29,8 @@ func (s *Store) View(ctx context.Context, fn func(tx *bbolt.Tx) error) error { } } -// Update runs fn in a read-write transaction. Checks context before starting. +// Update runs fn in a read-write transaction. It checks context before starting. +// Long-running callbacks should check ctx.Done() periodically and return ctx.Err() to abort. func (s *Store) Update(ctx context.Context, fn func(tx *bbolt.Tx) error) error { select { case <-ctx.Done(): @@ -284,6 +287,30 @@ func (s *Store) GetTile(tx *bbolt.Tx, mapID, zoom int, coordKey string) []byte { return zoomB.Get([]byte(coordKey)) } +// GetTiles returns raw JSON for multiple tiles in the same map/zoom in one transaction. +// Keys that are not found are omitted from the result. Coord keys are in the form "x_y". +func (s *Store) GetTiles(tx *bbolt.Tx, mapID, zoom int, coordKeys []string) map[string][]byte { + out := make(map[string][]byte, len(coordKeys)) + tiles := tx.Bucket(BucketTiles) + if tiles == nil { + return out + } + mapB := tiles.Bucket([]byte(strconv.Itoa(mapID))) + if mapB == nil { + return out + } + zoomB := mapB.Bucket([]byte(strconv.Itoa(zoom))) + if zoomB == nil { + return out + } + for _, k := range coordKeys { + if v := zoomB.Get([]byte(k)); v != nil { + out[k] = v + } + } + return out +} + // PutTile stores a tile entry (creates nested buckets as needed). func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []byte) error { tiles, err := tx.CreateBucketIfNotExists(BucketTiles)