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:
441
internal/app/handlers/admin.go
Normal file
441
internal/app/handlers/admin.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
224
internal/app/handlers/auth.go
Normal file
224
internal/app/handlers/auth.go
Normal 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)
|
||||
}
|
||||
125
internal/app/handlers/client.go
Normal file
125
internal/app/handlers/client.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
558
internal/app/handlers/handlers_test.go
Normal file
558
internal/app/handlers/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
76
internal/app/handlers/map.go
Normal file
76
internal/app/handlers/map.go
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
162
internal/app/handlers/tile.go
Normal file
162
internal/app/handlers/tile.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user