Add configuration files and update project documentation

- Introduced .editorconfig for consistent coding styles across the project.
- Added .golangci.yml for Go linting configuration.
- Updated AGENTS.md to clarify project structure and components.
- Enhanced CONTRIBUTING.md with Makefile usage for common tasks.
- Updated Dockerfiles to use Go 1.24 and improved build instructions.
- Refined README.md and deployment documentation for clarity.
- Added testing documentation in testing.md for backend and frontend tests.
- Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
2026-03-01 01:51:47 +03:00
parent 0466ff3087
commit 6529d7370e
92 changed files with 13411 additions and 8438 deletions

View File

@@ -0,0 +1,441 @@
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 req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
auths, found := h.Admin.GetUser(req.Context(), 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 {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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.
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")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
h.Admin.RebuildZooms(req.Context())
rw.WriteHeader(http.StatusOK)
}
// 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")
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 req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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 == "export":
h.APIAdminExport(rw, req)
case path == "merge":
h.APIAdminMerge(rw, req)
default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}
}

View File

@@ -1,458 +1,11 @@
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.
// APIRouter routes /map/api/* requests to the appropriate handler.
func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/map/api")
path = strings.TrimPrefix(path, "/")
@@ -470,22 +23,29 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
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)
}
case "setup":
h.APISetup(rw, req)
return
case "login":
h.APILogin(rw, req)
return
case "logout":
h.APILogout(rw, req)
return
case "me":
h.APIMe(rw, req)
return
case "me/tokens":
h.APIMeTokens(rw, req)
return
case "me/password":
h.APIMePassword(rw, req)
return
}
switch {
case path == "oauth/providers":
h.App.APIOAuthProviders(rw, req)
h.APIOAuthProviders(rw, req)
return
case strings.HasPrefix(path, "oauth/"):
rest := strings.TrimPrefix(path, "oauth/")
@@ -498,85 +58,16 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
action := parts[1]
switch action {
case "login":
h.App.OAuthLogin(rw, req, provider)
h.APIOAuthLogin(rw, req, provider)
case "callback":
h.App.OAuthCallback(rw, req, provider)
h.APIOAuthCallback(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)
case strings.HasPrefix(path, "admin/"):
adminPath := strings.TrimPrefix(path, "admin/")
h.APIAdminRoute(rw, req, adminPath)
return
}

View File

@@ -0,0 +1,224 @@
package handlers
import (
"encoding/json"
"net/http"
"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"`
}
type passwordRequest struct {
Pass string `json:"pass"`
}
// 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")
return
}
ctx := req.Context()
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(ctx, body.User); u != nil && u.Pass == nil {
JSONError(rw, http.StatusUnauthorized, "Use OAuth to sign in", "OAUTH_ONLY")
return
}
u := h.Auth.GetUser(ctx, body.User, body.Pass)
if u == nil {
if boot := h.Auth.BootstrapAdmin(ctx, body.User, body.Pass, services.GetBootstrapPassword()); boot != nil {
u = boot
} else {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
}
sessionID := h.Auth.CreateSession(ctx, 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: app.SessionMaxAge,
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 {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
JSON(rw, http.StatusOK, struct {
SetupRequired bool `json:"setupRequired"`
}{SetupRequired: h.Auth.SetupRequired(req.Context())})
}
// 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")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s != nil {
h.Auth.DeleteSession(ctx, 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 {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, 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(ctx, 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 {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
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
}
tokens := h.Auth.GenerateTokenForUser(ctx, s.Username)
if tokens == nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, map[string][]string{"tokens": tokens})
}
// 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")
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")
return
}
if err := h.Auth.SetUserPassword(ctx, s.Username, body.Pass); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
rw.WriteHeader(http.StatusOK)
}
// 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")
return
}
JSON(rw, http.StatusOK, services.OAuthProviders())
}
// 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")
return
}
redirect := req.URL.Query().Get("redirect")
authURL, err := h.Auth.OAuthInitLogin(req.Context(), provider, redirect, req)
if err != nil {
HandleServiceError(rw, err)
return
}
http.Redirect(rw, req, authURL, http.StatusFound)
}
// 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")
return
}
code := req.URL.Query().Get("code")
state := req.URL.Query().Get("state")
if code == "" || state == "" {
JSONError(rw, http.StatusBadRequest, "missing code or state", "BAD_REQUEST")
return
}
sessionID, redirectTo, err := h.Auth.OAuthHandleCallback(req.Context(), provider, code, state, req)
if err != nil {
HandleServiceError(rw, err)
return
}
http.SetCookie(rw, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
MaxAge: app.SessionMaxAge,
HttpOnly: true,
Secure: req.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(rw, req, redirectTo, http.StatusFound)
}
// RedirectLogout handles GET /logout.
func (h *Handlers) RedirectLogout(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/logout" {
http.NotFound(rw, req)
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s != nil {
h.Auth.DeleteSession(ctx, s)
}
http.Redirect(rw, req, "/login", http.StatusFound)
}

View File

@@ -0,0 +1,125 @@
package handlers
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"regexp"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
)
var clientPath = regexp.MustCompile(`client/([^/]+)/(.*)`)
// ClientRouter handles /client/* requests with token-based auth.
func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
matches := clientPath.FindStringSubmatch(req.URL.Path)
if matches == nil {
JSONError(rw, http.StatusBadRequest, "Client token not found", "BAD_REQUEST")
return
}
ctx := req.Context()
username, err := h.Auth.ValidateClientToken(ctx, matches[1])
if err != nil {
rw.WriteHeader(http.StatusUnauthorized)
return
}
_ = username
switch matches[2] {
case "locate":
h.clientLocate(rw, req)
case "gridUpdate":
h.clientGridUpdate(rw, req)
case "gridUpload":
h.clientGridUpload(rw, req)
case "positionUpdate":
h.clientPositionUpdate(rw, req)
case "markerUpdate":
h.clientMarkerUpdate(rw, req)
case "":
http.Redirect(rw, req, "/", http.StatusFound)
case "checkVersion":
if req.FormValue("version") == app.ClientVersion {
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusBadRequest)
}
default:
rw.WriteHeader(http.StatusNotFound)
}
}
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)
return
}
rw.Write([]byte(result))
}
func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
var grup services.GridUpdate
if err := json.NewDecoder(req.Body).Decode(&grup); err != nil {
slog.Error("error decoding grid request", "error", err)
JSONError(rw, http.StatusBadRequest, "error decoding request", "BAD_REQUEST")
return
}
result, err := h.Client.ProcessGridUpdate(req.Context(), grup)
if err != nil {
slog.Error("grid update failed", "error", err)
return
}
json.NewEncoder(rw).Encode(result.Response)
}
func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
ct := req.Header.Get("Content-Type")
if fixed := services.FixMultipartContentType(ct); fixed != ct {
req.Header.Set("Content-Type", fixed)
}
if err := req.ParseMultipartForm(app.MultipartMaxMemory); err != nil {
slog.Error("multipart parse error", "error", err)
return
}
id := req.FormValue("id")
extraData := req.FormValue("extraData")
file, _, err := req.FormFile("file")
if err != nil {
slog.Error("form file error", "error", err)
return
}
defer file.Close()
if err := h.Client.ProcessGridUpload(req.Context(), id, extraData, file); err != nil {
slog.Error("grid upload failed", "error", err)
}
}
func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
buf, err := io.ReadAll(req.Body)
if err != nil {
slog.Error("error reading position update", "error", err)
return
}
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
slog.Error("position update failed", "error", err)
}
}
func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
buf, err := io.ReadAll(req.Body)
if err != nil {
slog.Error("error reading marker update", "error", err)
return
}
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
slog.Error("marker update failed", "error", err)
}
}

View File

@@ -1,28 +1,43 @@
package handlers
import (
"errors"
"net/http"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/apperr"
"github.com/andyleap/hnh-map/internal/app/services"
)
// Handlers holds HTTP handlers and their dependencies.
type Handlers struct {
App *app.App
Auth *services.AuthService
Map *services.MapService
Admin *services.AdminService
Auth *services.AuthService
Map *services.MapService
Admin *services.AdminService
Client *services.ClientService
Export *services.ExportService
}
// New creates Handlers with the given dependencies.
func New(a *app.App, auth *services.AuthService, mapSvc *services.MapService, admin *services.AdminService) *Handlers {
return &Handlers{App: a, Auth: auth, Map: mapSvc, Admin: admin}
func New(
auth *services.AuthService,
mapSvc *services.MapService,
admin *services.AdminService,
client *services.ClientService,
export *services.ExportService,
) *Handlers {
return &Handlers{
Auth: auth,
Map: mapSvc,
Admin: admin,
Client: client,
Export: export,
}
}
// 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)
s := h.Auth.GetSession(req.Context(), req)
if s == nil || !s.Auths.Has(app.AUTH_ADMIN) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return nil
@@ -34,3 +49,27 @@ func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.
func (h *Handlers) canAccessMap(s *app.Session) bool {
return s != nil && (s.Auths.Has(app.AUTH_MAP) || s.Auths.Has(app.AUTH_ADMIN))
}
// HandleServiceError maps service-level errors to HTTP responses.
func HandleServiceError(rw http.ResponseWriter, err error) {
switch {
case errors.Is(err, apperr.ErrNotFound):
JSONError(rw, http.StatusNotFound, err.Error(), "NOT_FOUND")
case errors.Is(err, apperr.ErrUnauthorized):
JSONError(rw, http.StatusUnauthorized, err.Error(), "UNAUTHORIZED")
case errors.Is(err, apperr.ErrForbidden):
JSONError(rw, http.StatusForbidden, err.Error(), "FORBIDDEN")
case errors.Is(err, apperr.ErrBadRequest):
JSONError(rw, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
case errors.Is(err, apperr.ErrOAuthOnly):
JSONError(rw, http.StatusUnauthorized, err.Error(), "OAUTH_ONLY")
case errors.Is(err, apperr.ErrProviderUnconfigured):
JSONError(rw, http.StatusServiceUnavailable, err.Error(), "PROVIDER_UNCONFIGURED")
case errors.Is(err, apperr.ErrStateExpired), errors.Is(err, apperr.ErrStateMismatch):
JSONError(rw, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
case errors.Is(err, apperr.ErrExchangeFailed), errors.Is(err, apperr.ErrUserInfoFailed):
JSONError(rw, http.StatusBadGateway, err.Error(), "OAUTH_ERROR")
default:
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
}
}

View File

@@ -0,0 +1,558 @@
package handlers_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/apperr"
"github.com/andyleap/hnh-map/internal/app/handlers"
"github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
type testEnv struct {
h *handlers.Handlers
st *store.Store
auth *services.AuthService
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
db, err := bbolt.Open(filepath.Join(dir, "test.db"), 0600, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
st := store.New(db)
auth := services.NewAuthService(st)
gridUpdates := &app.Topic[app.TileData]{}
mergeUpdates := &app.Topic[app.Merge]{}
mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: dir,
GridUpdates: gridUpdates,
MergeUpdates: mergeUpdates,
GetChars: func() []app.Character { return nil },
})
admin := services.NewAdminService(st, mapSvc)
client := services.NewClientService(services.ClientServiceDeps{
Store: st,
MapSvc: mapSvc,
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
})
export := services.NewExportService(st, mapSvc)
h := handlers.New(auth, mapSvc, admin, client, export)
return &testEnv{h: h, st: st, auth: auth}
}
func (env *testEnv) createUser(t *testing.T, username, password string, auths app.Auths) {
t.Helper()
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
u := app.User{Pass: hash, Auths: auths}
raw, _ := json.Marshal(u)
env.st.Update(context.Background(), func(tx *bbolt.Tx) error {
return env.st.PutUser(tx, username, raw)
})
}
func (env *testEnv) loginSession(t *testing.T, username string) string {
t.Helper()
return env.auth.CreateSession(context.Background(), username, false)
}
func withSession(req *http.Request, sid string) *http.Request {
req.AddCookie(&http.Cookie{Name: "session", Value: sid})
return req
}
func TestAPISetup_NoUsers(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil)
rr := httptest.NewRecorder()
env.h.APISetup(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct{ SetupRequired bool }
json.NewDecoder(rr.Body).Decode(&resp)
if !resp.SetupRequired {
t.Fatal("expected setupRequired=true")
}
}
func TestAPISetup_WithUsers(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil)
rr := httptest.NewRecorder()
env.h.APISetup(rr, req)
var resp struct{ SetupRequired bool }
json.NewDecoder(rr.Body).Decode(&resp)
if resp.SetupRequired {
t.Fatal("expected setupRequired=false with users")
}
}
func TestAPISetup_WrongMethod(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/map/api/setup", nil)
rr := httptest.NewRecorder()
env.h.APISetup(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", rr.Code)
}
}
func TestAPILogin_Success(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP})
body := `{"user":"alice","pass":"secret"}`
req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body))
rr := httptest.NewRecorder()
env.h.APILogin(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
cookies := rr.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == "session" && c.Value != "" {
found = true
}
}
if !found {
t.Fatal("expected session cookie")
}
}
func TestAPILogin_WrongPassword(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP})
body := `{"user":"alice","pass":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body))
rr := httptest.NewRecorder()
env.h.APILogin(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAPILogin_BadJSON(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader("{invalid"))
rr := httptest.NewRecorder()
env.h.APILogin(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
}
func TestAPILogin_MethodNotAllowed(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/login", nil)
rr := httptest.NewRecorder()
env.h.APILogin(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", rr.Code)
}
}
func TestAPIMe_Authenticated(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid)
rr := httptest.NewRecorder()
env.h.APIMe(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Username string `json:"username"`
Auths []string `json:"auths"`
}
json.NewDecoder(rr.Body).Decode(&resp)
if resp.Username != "alice" {
t.Fatalf("expected alice, got %s", resp.Username)
}
}
func TestAPIMe_Unauthenticated(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/me", nil)
rr := httptest.NewRecorder()
env.h.APIMe(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAPILogout(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", nil)
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/logout", nil), sid)
rr := httptest.NewRecorder()
env.h.APILogout(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid)
rr2 := httptest.NewRecorder()
env.h.APIMe(rr2, req2)
if rr2.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 after logout, got %d", rr2.Code)
}
}
func TestAPIMeTokens_Authenticated(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid)
rr := httptest.NewRecorder()
env.h.APIMeTokens(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct{ Tokens []string }
json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Tokens) != 1 {
t.Fatalf("expected 1 token, got %d", len(resp.Tokens))
}
}
func TestAPIMeTokens_Unauthenticated(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil)
rr := httptest.NewRecorder()
env.h.APIMeTokens(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAPIMeTokens_NoUploadAuth(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid)
rr := httptest.NewRecorder()
env.h.APIMeTokens(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rr.Code)
}
}
func TestAPIMePassword(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "old", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
body := `{"pass":"newpass"}`
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/password", strings.NewReader(body)), sid)
rr := httptest.NewRecorder()
env.h.APIMePassword(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestAdminUsers_RequiresAdmin(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminUsers(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for non-admin, got %d", rr.Code)
}
}
func TestAdminUsers_ListAndCreate(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
sid := env.loginSession(t, "admin")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminUsers(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
body := `{"user":"bob","pass":"secret","auths":["map","upload"]}`
req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/users", strings.NewReader(body)), sid)
rr2 := httptest.NewRecorder()
env.h.APIAdminUsers(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr2.Code, rr2.Body.String())
}
}
func TestAdminSettings(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
sid := env.loginSession(t, "admin")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminSettingsGet(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
body := `{"prefix":"pfx","title":"New Title","defaultHide":true}`
req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/settings", strings.NewReader(body)), sid)
rr2 := httptest.NewRecorder()
env.h.APIAdminSettingsPost(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr2.Code)
}
req3 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid)
rr3 := httptest.NewRecorder()
env.h.APIAdminSettingsGet(rr3, req3)
var resp struct {
Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"`
Title string `json:"title"`
}
json.NewDecoder(rr3.Body).Decode(&resp)
if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" {
t.Fatalf("unexpected settings: %+v", resp)
}
}
func TestAdminWipe(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
sid := env.loginSession(t, "admin")
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/wipe", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminWipe(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestAdminMaps(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
sid := env.loginSession(t, "admin")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/maps", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminMaps(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestAPIRouter_NotFound(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/nonexistent", nil)
rr := httptest.NewRecorder()
env.h.APIRouter(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rr.Code)
}
}
func TestAPIRouter_Config(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/config", nil), sid)
rr := httptest.NewRecorder()
env.h.APIRouter(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestAPIGetChars_Unauthenticated(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil)
rr := httptest.NewRecorder()
env.h.APIGetChars(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAPIGetMarkers_Unauthenticated(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil)
rr := httptest.NewRecorder()
env.h.APIGetMarkers(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAPIGetMaps_Unauthenticated(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/map/api/maps", nil)
rr := httptest.NewRecorder()
env.h.APIGetMaps(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAPIGetChars_NoMarkersAuth(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil), sid)
rr := httptest.NewRecorder()
env.h.APIGetChars(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var chars []interface{}
json.NewDecoder(rr.Body).Decode(&chars)
if len(chars) != 0 {
t.Fatal("expected empty array without markers auth")
}
}
func TestAPIGetMarkers_NoMarkersAuth(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil), sid)
rr := httptest.NewRecorder()
env.h.APIGetMarkers(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var markers []interface{}
json.NewDecoder(rr.Body).Decode(&markers)
if len(markers) != 0 {
t.Fatal("expected empty array without markers auth")
}
}
func TestRedirectLogout(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
rr := httptest.NewRecorder()
env.h.RedirectLogout(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected 302, got %d", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/login" {
t.Fatalf("expected redirect to /login, got %s", loc)
}
}
func TestRedirectLogout_WrongPath(t *testing.T) {
env := newTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/other", nil)
rr := httptest.NewRecorder()
env.h.RedirectLogout(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rr.Code)
}
}
func TestHandleServiceError(t *testing.T) {
tests := []struct {
name string
err error
status int
}{
{"not found", apperr.ErrNotFound, http.StatusNotFound},
{"unauthorized", apperr.ErrUnauthorized, http.StatusUnauthorized},
{"forbidden", apperr.ErrForbidden, http.StatusForbidden},
{"bad request", apperr.ErrBadRequest, http.StatusBadRequest},
{"oauth only", apperr.ErrOAuthOnly, http.StatusUnauthorized},
{"provider unconfigured", apperr.ErrProviderUnconfigured, http.StatusServiceUnavailable},
{"state expired", apperr.ErrStateExpired, http.StatusBadRequest},
{"exchange failed", apperr.ErrExchangeFailed, http.StatusBadGateway},
{"unknown", errors.New("something else"), http.StatusInternalServerError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := httptest.NewRecorder()
handlers.HandleServiceError(rr, tt.err)
if rr.Code != tt.status {
t.Fatalf("expected %d, got %d", tt.status, rr.Code)
}
})
}
}
func TestAdminUserByName(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
sid := env.loginSession(t, "admin")
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users/bob", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminUserByName(rr, req, "bob")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Username string `json:"username"`
Auths []string `json:"auths"`
}
json.NewDecoder(rr.Body).Decode(&resp)
if resp.Username != "bob" {
t.Fatalf("expected bob, got %s", resp.Username)
}
}
func TestAdminUserDelete(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "admin")
req := withSession(httptest.NewRequest(http.MethodDelete, "/map/api/admin/users/bob", nil), sid)
rr := httptest.NewRecorder()
env.h.APIAdminUserDelete(rr, req, "bob")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}

View File

@@ -0,0 +1,76 @@
package handlers
import (
"net/http"
"github.com/andyleap/hnh-map/internal/app"
)
// 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)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
config, err := h.Map.GetConfig(ctx, s.Auths)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, config)
}
// 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)
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) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, 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(ctx)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, markers)
}
// APIGetMaps handles GET /map/api/maps.
func (h *Handlers) APIGetMaps(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
showHidden := s.Auths.Has(app.AUTH_ADMIN)
maps, err := h.Map.GetMaps(ctx, showHidden)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, maps)
}

View File

@@ -1,9 +1,8 @@
package handlers
import (
"net/http"
"github.com/andyleap/hnh-map/internal/app/response"
"net/http"
)
// JSON writes v as JSON with the given status code.

View File

@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
)
var transparentPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
var tileRegex = regexp.MustCompile(`([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+)\.png`)
// WatchGridUpdates is the SSE endpoint for tile updates.
func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Access-Control-Allow-Origin", "*")
rw.Header().Set("X-Accel-Buffering", "no")
flusher, ok := rw.(http.Flusher)
if !ok {
JSONError(rw, http.StatusInternalServerError, "streaming unsupported", "INTERNAL_ERROR")
return
}
c := h.Map.WatchTiles()
mc := h.Map.WatchMerges()
tileCache := h.Map.GetAllTileCache(ctx)
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0]
flusher.Flush()
ticker := time.NewTicker(app.SSETickInterval)
defer ticker.Stop()
for {
select {
case e, ok := <-c:
if !ok {
return
}
found := false
for i := range tileCache {
if tileCache[i].M == e.MapID && tileCache[i].X == e.Coord.X && tileCache[i].Y == e.Coord.Y && tileCache[i].Z == e.Zoom {
tileCache[i].T = int(e.Cache)
found = true
}
}
if !found {
tileCache = append(tileCache, services.TileCache{
M: e.MapID,
X: e.Coord.X,
Y: e.Coord.Y,
Z: e.Zoom,
T: int(e.Cache),
})
}
case e, ok := <-mc:
if !ok {
return
}
raw, err := json.Marshal(e)
if err != nil {
slog.Error("failed to marshal merge event", "error", err)
}
fmt.Fprint(rw, "event: merge\n")
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
flusher.Flush()
case <-ticker.C:
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0]
flusher.Flush()
}
}
}
// GridTile serves tile images.
func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
tile := tileRegex.FindStringSubmatch(req.URL.Path)
if tile == nil || len(tile) < 5 {
JSONError(rw, http.StatusBadRequest, "invalid path", "BAD_REQUEST")
return
}
mapid, err := strconv.Atoi(tile[1])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
z, err := strconv.Atoi(tile[2])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
x, err := strconv.Atoi(tile[3])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
y, err := strconv.Atoi(tile[4])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
storageZ := z
if storageZ == 6 {
storageZ = 0
}
if storageZ < 0 || storageZ > app.MaxZoomLevel {
storageZ = 0
}
td := h.Map.GetTile(ctx, mapid, app.Coord{X: x, Y: y}, storageZ)
if td == nil {
rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private, max-age=3600")
rw.WriteHeader(http.StatusOK)
rw.Write(transparentPNG)
return
}
rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private immutable")
http.ServeFile(rw, req, filepath.Join(h.Map.GridStorage(), td.File))
}