Refactor frontend components and enhance API integration

- Updated frontend-nuxt.mdc to specify usage of composables for API calls.
- Added new AuthCard and ConfirmModal components for improved UI consistency.
- Introduced UserAvatar component for user profile display, replacing previous Gravatar implementation.
- Implemented useFormSubmit composable for handling form submissions with loading and error states.
- Enhanced vitest.config.ts to include coverage reporting for composables and components.
- Removed deprecated useAdminApi and useAuth composables to streamline API interactions.
- Updated login and setup pages to utilize new components and composables for better user experience.
This commit is contained in:
2026-03-04 00:14:05 +03:00
parent f6375e7d0f
commit 8f769543f4
34 changed files with 878 additions and 379 deletions

View File

@@ -32,8 +32,7 @@ func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
JSON(rw, http.StatusOK, list)
return
}
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
s := h.requireAdmin(rw, req)
@@ -65,14 +64,17 @@ func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
// APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
return
}
auths, found := h.Admin.GetUser(req.Context(), name)
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"`
@@ -85,8 +87,7 @@ func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request,
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodDelete {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodDelete) {
return
}
s := h.requireAdmin(rw, req)
@@ -106,8 +107,7 @@ func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request,
// APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -127,8 +127,7 @@ func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request
// APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -152,8 +151,7 @@ func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Reques
// APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -178,8 +176,7 @@ func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, id
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -208,8 +205,7 @@ func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Req
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -230,8 +226,7 @@ func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Req
// APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -327,8 +322,7 @@ func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request)
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -343,8 +337,7 @@ func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Reques
// APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
if h.requireAdmin(rw, req) == nil {
@@ -359,8 +352,7 @@ func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
// APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
if h.requireAdmin(rw, req) == nil {

View File

@@ -31,8 +31,7 @@ type meUpdateRequest struct {
// APILogin handles POST /map/api/login.
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
ctx := req.Context()
@@ -73,8 +72,7 @@ func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
// APISetup handles GET /map/api/setup.
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
JSON(rw, http.StatusOK, struct {
@@ -84,8 +82,7 @@ func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
// APILogout handles POST /map/api/logout.
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
ctx := req.Context()
@@ -98,14 +95,12 @@ func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
// APIMe handles GET /map/api/me.
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
s := h.requireSession(rw, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
out := meResponse{Username: s.Username, Auths: s.Auths}
@@ -118,16 +113,14 @@ func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
// APIMeUpdate handles PATCH /map/api/me (update current user email).
func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPatch {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPatch) {
return
}
s := h.requireSession(rw, req)
if s == nil {
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body meUpdateRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
@@ -142,16 +135,14 @@ func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) {
// APIMeTokens handles POST /map/api/me/tokens.
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
s := h.requireSession(rw, req)
if s == nil {
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_UPLOAD) {
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
return
@@ -166,16 +157,14 @@ func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
// APIMePassword handles POST /map/api/me/password.
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodPost) {
return
}
s := h.requireSession(rw, req)
if s == nil {
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body passwordRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
@@ -190,8 +179,7 @@ func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
// APIOAuthProviders handles GET /map/api/oauth/providers.
func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
JSON(rw, http.StatusOK, services.OAuthProviders())
@@ -199,8 +187,7 @@ func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request)
// APIOAuthLogin handles GET /map/api/oauth/:provider/login.
func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
redirect := req.URL.Query().Get("redirect")
@@ -214,8 +201,7 @@ func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, prov
// APIOAuthCallback handles GET /map/api/oauth/:provider/callback.
func (h *Handlers) APIOAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
if !h.requireMethod(rw, req, http.MethodGet) {
return
}
code := req.URL.Query().Get("code")

View File

@@ -24,7 +24,7 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
username, err := h.Auth.ValidateClientToken(ctx, matches[1])
if err != nil {
rw.WriteHeader(http.StatusUnauthorized)
HandleServiceError(rw, err)
return
}
ctx = context.WithValue(ctx, app.ClientUsernameKey, username)
@@ -47,10 +47,10 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
if req.FormValue("version") == app.ClientVersion {
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusBadRequest)
JSONError(rw, http.StatusBadRequest, "version mismatch", "BAD_REQUEST")
}
default:
rw.WriteHeader(http.StatusNotFound)
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}
}
@@ -58,9 +58,11 @@ func (h *Handlers) clientLocate(rw http.ResponseWriter, req *http.Request) {
gridID := req.FormValue("gridID")
result, err := h.Client.Locate(req.Context(), gridID)
if err != nil {
rw.WriteHeader(http.StatusNotFound)
HandleServiceError(rw, err)
return
}
rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(result))
}
@@ -75,8 +77,11 @@ func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
result, err := h.Client.ProcessGridUpdate(req.Context(), grup)
if err != nil {
slog.Error("grid update failed", "error", err)
HandleServiceError(rw, err)
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(result.Response)
}
@@ -87,6 +92,7 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
}
if err := req.ParseMultipartForm(app.MultipartMaxMemory); err != nil {
slog.Error("multipart parse error", "error", err)
JSONError(rw, http.StatusBadRequest, "invalid multipart", "BAD_REQUEST")
return
}
id := req.FormValue("id")
@@ -94,11 +100,13 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
file, _, err := req.FormFile("file")
if err != nil {
slog.Error("form file error", "error", err)
JSONError(rw, http.StatusBadRequest, "missing file", "BAD_REQUEST")
return
}
defer file.Close()
if err := h.Client.ProcessGridUpload(req.Context(), id, extraData, file); err != nil {
slog.Error("grid upload failed", "error", err)
HandleServiceError(rw, err)
}
}
@@ -107,10 +115,12 @@ func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Reques
buf, err := io.ReadAll(req.Body)
if err != nil {
slog.Error("error reading position update", "error", err)
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
return
}
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
slog.Error("position update failed", "error", err)
HandleServiceError(rw, err)
}
}
@@ -119,9 +129,11 @@ func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request)
buf, err := io.ReadAll(req.Body)
if err != nil {
slog.Error("error reading marker update", "error", err)
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
return
}
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
slog.Error("marker update failed", "error", err)
HandleServiceError(rw, err)
}
}

View File

@@ -35,6 +35,25 @@ func New(
}
}
// requireMethod writes 405 and returns false if req.Method != method; otherwise returns true.
func (h *Handlers) requireMethod(rw http.ResponseWriter, req *http.Request, method string) bool {
if req.Method != method {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return false
}
return true
}
// requireSession returns session or writes 401 and returns nil.
func (h *Handlers) requireSession(rw http.ResponseWriter, req *http.Request) *app.Session {
s := h.Auth.GetSession(req.Context(), req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return nil
}
return s
}
// requireAdmin returns session if admin, or writes 401 and returns nil.
func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.Session {
s := h.Auth.GetSession(req.Context(), req)

View File

@@ -601,3 +601,79 @@ func TestAdminUserDelete(t *testing.T) {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestClientRouter_Locate(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
ctx := context.Background()
tokens := env.auth.GenerateTokenForUser(ctx, "alice")
if len(tokens) == 0 {
t.Fatal("expected token")
}
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd)
env.st.Update(ctx, func(tx *bbolt.Tx) error {
return env.st.PutGrid(tx, "g1", raw)
})
req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=g1", nil)
rr := httptest.NewRecorder()
env.h.ClientRouter(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if body := strings.TrimSpace(rr.Body.String()); body != "1;2;3" {
t.Fatalf("expected body 1;2;3, got %q", body)
}
}
func TestClientRouter_Locate_NotFound(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")
}
req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=ghost", nil)
rr := httptest.NewRecorder()
env.h.ClientRouter(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rr.Code)
}
}
func TestClientRouter_CheckVersion(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")
}
req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/checkVersion?version="+app.ClientVersion, nil)
rr := httptest.NewRecorder()
env.h.ClientRouter(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for matching version, got %d", rr.Code)
}
req2 := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/checkVersion?version=other", nil)
rr2 := httptest.NewRecorder()
env.h.ClientRouter(rr2, req2)
if rr2.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for wrong version, got %d", rr2.Code)
}
}
func TestClientRouter_InvalidToken(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/client/badtoken/locate?gridID=g1", nil)
rr := httptest.NewRecorder()
env := newTestEnv(t)
env.h.ClientRouter(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}

View File

@@ -9,12 +9,11 @@ import (
// APIConfig handles GET /map/api/config.
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
s := h.requireSession(rw, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
ctx := req.Context()
config, err := h.Map.GetConfig(ctx, s.Auths)
if err != nil {
HandleServiceError(rw, err)
@@ -36,8 +35,10 @@ type CharacterResponse struct {
// APIGetChars handles GET /map/api/v1/characters.
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
s := h.requireSession(rw, req)
if s == nil {
return
}
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
@@ -64,12 +65,15 @@ func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
// APIGetMarkers handles GET /map/api/v1/markers.
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
s := h.requireSession(rw, req)
if s == nil {
return
}
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
ctx := req.Context()
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
JSON(rw, http.StatusOK, []interface{}{})
return

View File

@@ -73,7 +73,9 @@ var migrations = []func(tx *bbolt.Tx) error{
allTiles[string(k)] = zoomTiles
return zoom.ForEach(func(tk, tv []byte) error {
td := TileData{}
json.Unmarshal(tv, &td)
if err := json.Unmarshal(tv, &td); err != nil {
return err
}
zoomTiles[string(tk)] = td
return nil
})
@@ -161,7 +163,9 @@ var migrations = []func(tx *bbolt.Tx) error{
}
return users.ForEach(func(k, v []byte) error {
u := User{}
json.Unmarshal(v, &u)
if err := json.Unmarshal(v, &u); err != nil {
return err
}
if u.Auths.Has(AUTH_MAP) && !u.Auths.Has(AUTH_MARKERS) {
u.Auths = append(u.Auths, AUTH_MARKERS)
raw, err := json.Marshal(u)

View File

@@ -20,6 +20,7 @@ type AdminService struct {
}
// 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}
}
@@ -37,19 +38,21 @@ func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
}
// GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool) {
s.st.View(ctx, func(tx *bbolt.Tx) error {
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
json.Unmarshal(raw, &u)
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
auths = u.Auths
found = true
return nil
})
return auths, found
return auths, found, err
}
// CreateOrUpdateUser creates or updates a user.
@@ -60,7 +63,9 @@ func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string,
u := app.User{}
raw := s.st.GetUser(tx, username)
if raw != nil {
json.Unmarshal(raw, &u)
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
}
if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
@@ -88,7 +93,9 @@ func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
json.Unmarshal(uRaw, &u)
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
for _, tok := range u.Tokens {
s.st.DeleteToken(tx, tok)
}
@@ -140,7 +147,9 @@ func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{}
json.Unmarshal(v, &mi)
if err := json.Unmarshal(v, &mi); err != nil {
return err
}
if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id
}
@@ -152,18 +161,23 @@ func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
}
// GetMap returns a map by ID.
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool) {
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
var mi *app.MapInfo
s.st.View(ctx, func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
if raw != nil {
mi = &app.MapInfo{}
json.Unmarshal(raw, mi)
mi.ID = id
return json.Unmarshal(raw, mi)
}
return nil
})
return mi, mi != 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.
@@ -172,7 +186,9 @@ func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidde
mi := app.MapInfo{}
raw := s.st.GetMap(tx, id)
if raw != nil {
json.Unmarshal(raw, &mi)
if err := json.Unmarshal(raw, &mi); err != nil {
return err
}
}
mi.ID = id
mi.Name = name
@@ -190,7 +206,9 @@ func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInf
raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{}
if raw != nil {
json.Unmarshal(raw, mi)
if err := json.Unmarshal(raw, mi); err != nil {
return err
}
}
mi.ID = id
mi.Hidden = !mi.Hidden
@@ -340,7 +358,9 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
return nil
}
m := app.Marker{}
json.Unmarshal(raw, &m)
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
m.Hidden = true
raw, _ = json.Marshal(m)
grid.Put(key, raw)

View File

@@ -52,9 +52,9 @@ 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 := admin.GetUser(context.Background(), "alice")
if !found {
t.Fatal("expected found")
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")
@@ -63,7 +63,10 @@ func TestAdminGetUser_Found(t *testing.T) {
func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found := admin.GetUser(context.Background(), "ghost")
_, found, err := admin.GetUser(context.Background(), "ghost")
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}
@@ -78,9 +81,9 @@ func TestCreateOrUpdateUser_New(t *testing.T) {
t.Fatal(err)
}
auths, found := admin.GetUser(ctx, "bob")
if !found {
t.Fatal("expected user to exist")
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")
@@ -97,9 +100,9 @@ func TestCreateOrUpdateUser_Update(t *testing.T) {
t.Fatal(err)
}
auths, found := admin.GetUser(ctx, "alice")
if !found {
t.Fatal("expected user")
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")
@@ -139,9 +142,9 @@ func TestDeleteUser(t *testing.T) {
t.Fatal(err)
}
_, found := admin.GetUser(ctx, "alice")
if found {
t.Fatal("expected user to be deleted")
_, found, err := admin.GetUser(ctx, "alice")
if err != nil || found {
t.Fatalf("expected user to be deleted, err=%v", err)
}
}
@@ -210,9 +213,9 @@ func TestMapCRUD(t *testing.T) {
t.Fatal(err)
}
mi, found := admin.GetMap(ctx, 1)
if !found || mi == nil {
t.Fatal("expected map")
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)
@@ -285,7 +288,10 @@ func TestWipe(t *testing.T) {
func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found := admin.GetMap(context.Background(), 999)
_, found, err := admin.GetMap(context.Background(), 999)
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}

View File

@@ -41,6 +41,7 @@ type AuthService struct {
}
// NewAuthService creates an AuthService with the given store.
// Uses direct args (single dependency) rather than a deps struct.
func NewAuthService(st *store.Store) *AuthService {
return &AuthService{st: st}
}
@@ -121,12 +122,14 @@ func (s *AuthService) CreateSession(ctx context.Context, username string, tempAd
// GetUser returns user if username/password match.
func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User {
var u *app.User
s.st.View(ctx, func(tx *bbolt.Tx) error {
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
}
json.Unmarshal(raw, &u)
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
if u.Pass == nil {
u = nil
return nil
@@ -136,20 +139,26 @@ func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.U
return nil
}
return nil
})
}); err != nil {
return nil
}
return u
}
// GetUserByUsername returns user without password check (for OAuth-only check).
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User {
var u *app.User
s.st.View(ctx, func(tx *bbolt.Tx) error {
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw != nil {
json.Unmarshal(raw, &u)
if err := json.Unmarshal(raw, &u); err != nil {
return err
}
}
return nil
})
}); err != nil {
return nil
}
return u
}
@@ -205,11 +214,13 @@ func GetBootstrapPassword() string {
// GetUserTokensAndPrefix returns tokens and config prefix for a user.
func (s *AuthService) GetUserTokensAndPrefix(ctx context.Context, username string) (tokens []string, prefix string) {
s.st.View(ctx, func(tx *bbolt.Tx) error {
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
json.Unmarshal(uRaw, &u)
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
tokens = u.Tokens
}
if p := s.st.GetConfig(tx, "prefix"); p != nil {
@@ -232,7 +243,9 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
json.Unmarshal(uRaw, &u)
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
}
u.Tokens = append(u.Tokens, token)
tokens = u.Tokens
@@ -252,7 +265,9 @@ func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
json.Unmarshal(uRaw, &u)
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
@@ -297,7 +312,9 @@ func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (st
return apperr.ErrUnauthorized
}
var u app.User
json.Unmarshal(uRaw, &u)
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
if !u.Auths.Has(app.AUTH_UPLOAD) {
return apperr.ErrForbidden
}
@@ -401,7 +418,9 @@ func (s *AuthService) OAuthHandleCallback(ctx context.Context, provider, code, s
if raw == nil {
return apperr.ErrBadRequest
}
json.Unmarshal(raw, &st)
if err := json.Unmarshal(raw, &st); err != nil {
return err
}
return s.st.DeleteOAuthState(tx, state)
})
if err != nil || st.Provider == "" {
@@ -479,8 +498,8 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
_ = s.st.ForEachUser(tx, func(k, v []byte) error {
user := app.User{}
if json.Unmarshal(v, &user) != nil {
return nil
if err := json.Unmarshal(v, &user); err != nil {
return err
}
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
username = string(k)
@@ -491,7 +510,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
raw := s.st.GetUser(tx, username)
if raw != nil {
user := app.User{}
json.Unmarshal(raw, &user)
if err := json.Unmarshal(raw, &user); err != nil {
return err
}
if user.OAuthLinks == nil {
user.OAuthLinks = map[string]string{provider: sub}
} else {

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/apperr"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -63,7 +64,7 @@ func (s *ClientService) Locate(ctx context.Context, gridID string) (string, erro
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, gridID)
if raw == nil {
return fmt.Errorf("grid not found")
return apperr.ErrNotFound
}
cur := app.GridData{}
if err := json.Unmarshal(raw, &cur); err != nil {
@@ -110,7 +111,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
gridRaw := grids.Get([]byte(grid))
if gridRaw != nil {
gd := app.GridData{}
json.Unmarshal(gridRaw, &gd)
if err := json.Unmarshal(gridRaw, &gd); err != nil {
return err
}
maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y}
}
}
@@ -152,7 +155,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
mi := app.MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil {
json.Unmarshal(mraw, &mi)
if err := json.Unmarshal(mraw, &mi); err != nil {
return err
}
}
if mi.Priority {
mapid = id
@@ -171,7 +176,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
for y, grid := range row {
cur := app.GridData{}
if curRaw := grids.Get([]byte(grid)); curRaw != nil {
json.Unmarshal(curRaw, &cur)
if err := json.Unmarshal(curRaw, &cur); err != nil {
return err
}
if time.Now().After(cur.NextUpdate) {
greq.GridRequests = append(greq.GridRequests, grid)
}
@@ -192,7 +199,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 {
if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil {
cur := app.GridData{}
json.Unmarshal(curRaw, &cur)
if err := json.Unmarshal(curRaw, &cur); err != nil {
return err
}
greq.Map = cur.Map
greq.Coords = cur.Coord
}
@@ -200,7 +209,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
if len(maps) > 1 {
grids.ForEach(func(k, v []byte) error {
gd := app.GridData{}
json.Unmarshal(v, &gd)
if err := json.Unmarshal(v, &gd); err != nil {
return err
}
if gd.Map == mapid {
return nil
}
@@ -216,7 +227,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
}
tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil {
json.Unmarshal(tileraw, &td)
if err := json.Unmarshal(tileraw, &td); err != nil {
return err
}
}
gd.Map = mapid

View File

@@ -1,9 +1,14 @@
package services_test
import (
"context"
"encoding/json"
"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 TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
@@ -30,3 +35,57 @@ func TestFixMultipartContentType_Normal(t *testing.T) {
t.Fatalf("expected unchanged, got %q", got)
}
}
func newTestClientService(t *testing.T) (*services.ClientService, *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]{},
})
client := services.NewClientService(services.ClientServiceDeps{
Store: st,
MapSvc: mapSvc,
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
})
return client, st
}
func TestClientLocate_Found(t *testing.T) {
client, st := newTestClientService(t)
ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw)
})
result, err := client.Locate(ctx, "g1")
if err != nil {
t.Fatal(err)
}
if result != "1;2;3" {
t.Fatalf("expected 1;2;3, got %q", result)
}
}
func TestClientLocate_NotFound(t *testing.T) {
client, _ := newTestClientService(t)
_, err := client.Locate(context.Background(), "ghost")
if err == nil {
t.Fatal("expected error for unknown grid")
}
}
func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
client, _ := newTestClientService(t)
ctx := context.Background()
result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}

View File

@@ -30,6 +30,7 @@ type ExportService struct {
}
// NewExportService creates an ExportService with the given store and map service.
// Uses direct args (two dependencies) rather than a deps struct.
func NewExportService(st *store.Store, mapSvc *MapService) *ExportService {
return &ExportService{st: st, mapSvc: mapSvc}
}
@@ -190,7 +191,9 @@ func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
gridRaw := grids.Get([]byte(gid))
if gridRaw != nil {
gd := app.GridData{}
json.Unmarshal(gridRaw, &gd)
if err := json.Unmarshal(gridRaw, &gd); err != nil {
return err
}
ops = append(ops, TileOp{
MapID: gd.Map,
X: gd.Coord.X,
@@ -265,7 +268,9 @@ func (s *ExportService) processMergeJSON(
gridRaw := grids.Get([]byte(v))
if gridRaw != nil {
gd := app.GridData{}
json.Unmarshal(gridRaw, &gd)
if err := json.Unmarshal(gridRaw, &gd); err != nil {
return err
}
existingMaps[gd.Map] = struct{ X, Y int }{gd.Coord.X - c.X, gd.Coord.Y - c.Y}
}
}
@@ -301,7 +306,9 @@ func (s *ExportService) processMergeJSON(
mi := app.MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil {
json.Unmarshal(mraw, &mi)
if err := json.Unmarshal(mraw, &mi); err != nil {
return err
}
}
if mi.Priority {
mapid = id
@@ -333,7 +340,9 @@ func (s *ExportService) processMergeJSON(
if len(existingMaps) > 1 {
grids.ForEach(func(k, v []byte) error {
gd := app.GridData{}
json.Unmarshal(v, &gd)
if err := json.Unmarshal(v, &gd); err != nil {
return err
}
if gd.Map == mapid {
return nil
}
@@ -349,7 +358,9 @@ func (s *ExportService) processMergeJSON(
}
tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil {
json.Unmarshal(tileraw, &td)
if err := json.Unmarshal(tileraw, &td); err != nil {
return err
}
}
gd.Map = mapid

View File

@@ -0,0 +1,64 @@
package services_test
import (
"bytes"
"context"
"encoding/json"
"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 newTestExportService(t *testing.T) (*services.ExportService, *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.NewExportService(st, mapSvc), st
}
func TestExport_EmptyDB(t *testing.T) {
export, _ := newTestExportService(t)
var buf bytes.Buffer
err := export.Export(context.Background(), &buf)
if err != nil {
t.Fatal(err)
}
if buf.Len() == 0 {
t.Fatal("expected non-empty zip output")
}
// ZIP magic number
if buf.Len() < 4 || buf.Bytes()[0] != 0x50 || buf.Bytes()[1] != 0x4b {
t.Fatal("expected valid zip header")
}
}
func TestExport_WithGrid(t *testing.T) {
export, st := newTestExportService(t)
ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 0, Y: 0}}
gdRaw, _ := json.Marshal(gd)
mi := app.MapInfo{ID: 1, Name: "test", Hidden: false}
miRaw, _ := json.Marshal(mi)
st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
return err
}
return st.PutMap(tx, 1, miRaw)
})
var buf bytes.Buffer
err := export.Export(ctx, &buf)
if err != nil {
t.Fatal(err)
}
if buf.Len() < 4 {
t.Fatal("expected zip data")
}
}

View File

@@ -77,13 +77,17 @@ func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, erro
}
return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{}
json.Unmarshal(v, &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{}
json.Unmarshal(graw, &g)
if err := json.Unmarshal(graw, &g); err != nil {
return err
}
markers = append(markers, app.FrontendMarker{
Image: marker.Image,
Hidden: marker.Hidden,
@@ -111,7 +115,9 @@ func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app
return nil
}
mi := &app.MapInfo{}
json.Unmarshal(v, mi)
if err := json.Unmarshal(v, mi); err != nil {
return err
}
if mi.Hidden && !showHidden {
return nil
}
@@ -165,14 +171,16 @@ func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, 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
s.st.View(ctx, func(tx *bbolt.Tx) error {
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{}
json.Unmarshal(raw, td)
return json.Unmarshal(raw, td)
}
return nil
})
}); err != nil {
return nil
}
return td
}
@@ -259,7 +267,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
}
b.ForEach(func(k, v []byte) error {
grid := app.GridData{}
json.Unmarshal(v, &grid)
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
@@ -318,7 +328,9 @@ func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
td := app.TileData{}
json.Unmarshal(v, &td)
if err := json.Unmarshal(v, &td); err != nil {
return err
}
cache = append(cache, TileCache{
M: td.MapID,
X: td.Coord.X,

View File

@@ -32,6 +32,8 @@ func (t *Topic[T]) Send(b *T) {
// Close closes all subscriber channels.
func (t *Topic[T]) Close() {
t.mu.Lock()
defer t.mu.Unlock()
for _, c := range t.c {
close(c)
}