package handlers import ( "encoding/json" "net/http" "strconv" "strings" "github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app/services" ) type loginRequest struct { User string `json:"user"` Pass string `json:"pass"` } type meResponse struct { Username string `json:"username"` Auths []string `json:"auths"` Tokens []string `json:"tokens,omitempty"` Prefix string `json:"prefix,omitempty"` } // APILogin handles POST /map/api/login. func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } var body loginRequest if err := json.NewDecoder(req.Body).Decode(&body); err != nil { JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") return } if u := h.Auth.GetUserByUsername(body.User); u != nil && u.Pass == nil { JSONError(rw, http.StatusUnauthorized, "Use OAuth to sign in", "OAUTH_ONLY") return } u := h.Auth.GetUser(body.User, body.Pass) if u == nil { if boot := h.Auth.BootstrapAdmin(body.User, body.Pass, services.GetBootstrapPassword()); boot != nil { u = boot } else { JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") return } } sessionID := h.Auth.CreateSession(body.User, u.Auths.Has("tempadmin")) if sessionID == "" { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } http.SetCookie(rw, &http.Cookie{ Name: "session", Value: sessionID, Path: "/", MaxAge: 24 * 7 * 3600, HttpOnly: true, Secure: req.TLS != nil, SameSite: http.SameSiteLaxMode, }) JSON(rw, http.StatusOK, meResponse{Username: body.User, Auths: u.Auths}) } // APISetup handles GET /map/api/setup. func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } JSON(rw, http.StatusOK, struct { SetupRequired bool `json:"setupRequired"` }{SetupRequired: h.Auth.SetupRequired()}) } // APILogout handles POST /map/api/logout. func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := h.Auth.GetSession(req) if s != nil { h.Auth.DeleteSession(s) } rw.WriteHeader(http.StatusOK) } // APIMe handles GET /map/api/me. func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := h.Auth.GetSession(req) if s == nil { JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") return } out := meResponse{Username: s.Username, Auths: s.Auths} out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(s.Username) JSON(rw, http.StatusOK, out) } // APIMeTokens handles POST /map/api/me/tokens. func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := h.Auth.GetSession(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 } tokens := h.Auth.GenerateTokenForUser(s.Username) if tokens == nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } JSON(rw, http.StatusOK, map[string][]string{"tokens": tokens}) } type passwordRequest struct { Pass string `json:"pass"` } // APIMePassword handles POST /map/api/me/password. func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := h.Auth.GetSession(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") return } if err := h.Auth.SetUserPassword(s.Username, body.Pass); err != nil { JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") return } rw.WriteHeader(http.StatusOK) } // APIConfig handles GET /map/api/config. func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) { s := h.Auth.GetSession(req) if s == nil { JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") return } config, err := h.Map.GetConfig(s.Auths) if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } JSON(rw, http.StatusOK, config) } // APIGetChars handles GET /map/api/v1/characters. func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) { s := h.Auth.GetSession(req) if !h.canAccessMap(s) { JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") return } if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) { JSON(rw, http.StatusOK, []interface{}{}) return } chars := h.Map.GetCharacters() JSON(rw, http.StatusOK, chars) } // APIGetMarkers handles GET /map/api/v1/markers. func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) { s := h.Auth.GetSession(req) if !h.canAccessMap(s) { JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") return } if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) { JSON(rw, http.StatusOK, []interface{}{}) return } markers, err := h.Map.GetMarkers() if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } JSON(rw, http.StatusOK, markers) } // APIGetMaps handles GET /map/api/maps. func (h *Handlers) APIGetMaps(rw http.ResponseWriter, req *http.Request) { s := h.Auth.GetSession(req) if !h.canAccessMap(s) { JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") return } showHidden := s.Auths.Has(app.AUTH_ADMIN) maps, err := h.Map.GetMaps(showHidden) if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } JSON(rw, http.StatusOK, maps) } // --- Admin API --- // APIAdminUsers handles GET/POST /map/api/admin/users. func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { if h.requireAdmin(rw, req) == nil { return } list, err := h.Admin.ListUsers() if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } JSON(rw, http.StatusOK, list) return } if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) 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(body.User, body.Pass, body.Auths) if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } if body.User == s.Username { s.Auths = body.Auths } if adminCreated && s.Username == "admin" { h.Auth.DeleteSession(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 req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if h.requireAdmin(rw, req) == nil { return } auths, found := h.Admin.GetUser(name) 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 req.Method != http.MethodDelete { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := h.requireAdmin(rw, req) if s == nil { return } if err := h.Admin.DeleteUser(name); err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } if name == s.Username { h.Auth.DeleteSession(s) } rw.WriteHeader(http.StatusOK) } // APIAdminSettingsGet handles GET /map/api/admin/settings. func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if h.requireAdmin(rw, req) == nil { return } prefix, defaultHide, title, err := h.Admin.GetSettings() if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") 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 req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) 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(body.Prefix, body.DefaultHide, body.Title); err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } rw.WriteHeader(http.StatusOK) } type mapInfoJSON struct { ID int `json:"ID"` Name string `json:"Name"` Hidden bool `json:"Hidden"` Priority bool `json:"Priority"` } // APIAdminMaps handles GET /map/api/admin/maps. func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if h.requireAdmin(rw, req) == nil { return } maps, err := h.Admin.ListMaps() if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") 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 req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) 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(id, body.Name, body.Hidden, body.Priority); err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") 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 req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if h.requireAdmin(rw, req) == nil { return } mi, err := h.Admin.ToggleMapHidden(id) if err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") 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 req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if h.requireAdmin(rw, req) == nil { return } if err := h.Admin.Wipe(); err != nil { JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR") return } rw.WriteHeader(http.StatusOK) } // APIRouter routes /map/api/* requests. func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) { path := strings.TrimPrefix(req.URL.Path, "/map/api") path = strings.TrimPrefix(path, "/") switch path { case "config": h.APIConfig(rw, req) return case "v1/characters": h.APIGetChars(rw, req) return case "v1/markers": h.APIGetMarkers(rw, req) return case "maps": h.APIGetMaps(rw, req) return } if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" { switch path { case "admin/wipeTile": h.App.WipeTile(rw, req) case "admin/setCoords": h.App.SetCoords(rw, req) case "admin/hideMarker": h.App.HideMarker(rw, req) } return } switch { case path == "oauth/providers": h.App.APIOAuthProviders(rw, req) return case strings.HasPrefix(path, "oauth/"): rest := strings.TrimPrefix(path, "oauth/") parts := strings.SplitN(rest, "/", 2) if len(parts) != 2 { JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") return } provider := parts[0] action := parts[1] switch action { case "login": h.App.OAuthLogin(rw, req, provider) case "callback": h.App.OAuthCallback(rw, req, provider) default: JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") } return case path == "setup": h.APISetup(rw, req) return case path == "login": h.APILogin(rw, req) return case path == "logout": h.APILogout(rw, req) return case path == "me": h.APIMe(rw, req) return case path == "me/tokens": h.APIMeTokens(rw, req) return case path == "me/password": h.APIMePassword(rw, req) return case path == "admin/users": if req.Method == http.MethodPost { h.APIAdminUsers(rw, req) } else { h.APIAdminUsers(rw, req) } return case strings.HasPrefix(path, "admin/users/"): name := strings.TrimPrefix(path, "admin/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) } return case path == "admin/settings": if req.Method == http.MethodGet { h.APIAdminSettingsGet(rw, req) } else { h.APIAdminSettingsPost(rw, req) } return case path == "admin/maps": h.APIAdminMaps(rw, req) return case strings.HasPrefix(path, "admin/maps/"): rest := strings.TrimPrefix(path, "admin/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") return case path == "admin/wipe": h.APIAdminWipe(rw, req) return case path == "admin/rebuildZooms": h.App.APIAdminRebuildZooms(rw, req) return case path == "admin/export": h.App.APIAdminExport(rw, req) return case path == "admin/merge": h.App.APIAdminMerge(rw, req) return } JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") }