package app import ( "crypto/rand" "encoding/hex" "encoding/json" "net/http" "os" "strconv" "strings" "go.etcd.io/bbolt" "golang.org/x/crypto/bcrypt" ) // --- Auth API --- 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"` } func (a *App) 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 { http.Error(rw, "bad request", http.StatusBadRequest) return } u := a.getUser(body.User, body.Pass) if u == nil { // Bootstrap: first admin via env HNHMAP_BOOTSTRAP_PASSWORD when no users exist if body.User == "admin" && body.Pass != "" { bootstrap := os.Getenv("HNHMAP_BOOTSTRAP_PASSWORD") if bootstrap != "" && body.Pass == bootstrap { var created bool a.db.Update(func(tx *bbolt.Tx) error { users, err := tx.CreateBucketIfNotExists([]byte("users")) if err != nil { return err } if users.Get([]byte("admin")) != nil { return nil } hash, err := bcrypt.GenerateFromPassword([]byte(body.Pass), bcrypt.DefaultCost) if err != nil { return err } u := User{Pass: hash, Auths: Auths{AUTH_ADMIN, AUTH_MAP, AUTH_MARKERS, AUTH_UPLOAD}} raw, _ := json.Marshal(u) users.Put([]byte("admin"), raw) created = true return nil }) if created { u = &User{Auths: Auths{AUTH_ADMIN, AUTH_MAP, AUTH_MARKERS, AUTH_UPLOAD}} } } } if u == nil { rw.WriteHeader(http.StatusUnauthorized) return } } sessionID := a.createSession(body.User, u.Auths.Has("tempadmin")) if sessionID == "" { http.Error(rw, "internal error", http.StatusInternalServerError) return } http.SetCookie(rw, &http.Cookie{ Name: "session", Value: sessionID, Path: "/", MaxAge: 24 * 7 * 3600, HttpOnly: true, Secure: req.TLS != nil, SameSite: http.SameSiteLaxMode, }) rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(meResponse{ Username: body.User, Auths: u.Auths, }) } func (a *App) apiSetup(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(struct { SetupRequired bool `json:"setupRequired"` }{SetupRequired: a.setupRequired()}) } func (a *App) apiLogout(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := a.getSession(req) if s != nil { a.deleteSession(s) } rw.WriteHeader(http.StatusOK) } func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := a.getSession(req) if s == nil { rw.WriteHeader(http.StatusUnauthorized) return } out := meResponse{Username: s.Username, Auths: s.Auths} a.db.View(func(tx *bbolt.Tx) error { ub := tx.Bucket([]byte("users")) if ub != nil { uRaw := ub.Get([]byte(s.Username)) if uRaw != nil { u := User{} json.Unmarshal(uRaw, &u) out.Tokens = u.Tokens } } config := tx.Bucket([]byte("config")) if config != nil { out.Prefix = string(config.Get([]byte("prefix"))) } return nil }) rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(out) } // --- Cabinet API --- func (a *App) apiMeTokens(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := a.getSession(req) if s == nil { rw.WriteHeader(http.StatusUnauthorized) return } if !s.Auths.Has(AUTH_UPLOAD) { rw.WriteHeader(http.StatusForbidden) return } tokens := a.generateTokenForUser(s.Username) if tokens == nil { http.Error(rw, "internal error", http.StatusInternalServerError) return } rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(map[string][]string{"tokens": tokens}) } type passwordRequest struct { Pass string `json:"pass"` } func (a *App) apiMePassword(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := a.getSession(req) if s == nil { rw.WriteHeader(http.StatusUnauthorized) return } var body passwordRequest if err := json.NewDecoder(req.Body).Decode(&body); err != nil { http.Error(rw, "bad request", http.StatusBadRequest) return } if err := a.setUserPassword(s.Username, body.Pass); err != nil { http.Error(rw, "bad request", http.StatusBadRequest) return } rw.WriteHeader(http.StatusOK) } // generateTokenForUser adds a new token for user and returns the full list. func (a *App) generateTokenForUser(username string) []string { tokenRaw := make([]byte, 16) if _, err := rand.Read(tokenRaw); err != nil { return nil } token := hex.EncodeToString(tokenRaw) var tokens []string err := a.db.Update(func(tx *bbolt.Tx) error { ub, _ := tx.CreateBucketIfNotExists([]byte("users")) uRaw := ub.Get([]byte(username)) u := User{} if uRaw != nil { json.Unmarshal(uRaw, &u) } u.Tokens = append(u.Tokens, token) tokens = u.Tokens buf, _ := json.Marshal(u) ub.Put([]byte(username), buf) tb, _ := tx.CreateBucketIfNotExists([]byte("tokens")) return tb.Put([]byte(token), []byte(username)) }) if err != nil { return nil } return tokens } // setUserPassword sets password for user (empty pass = no change). func (a *App) setUserPassword(username, pass string) error { if pass == "" { return nil } return a.db.Update(func(tx *bbolt.Tx) error { users, err := tx.CreateBucketIfNotExists([]byte("users")) if err != nil { return err } u := User{} raw := users.Get([]byte(username)) if raw != nil { json.Unmarshal(raw, &u) } u.Pass, _ = bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) raw, _ = json.Marshal(u) return users.Put([]byte(username), raw) }) } // --- Admin API (require admin auth) --- func (a *App) apiAdminUsers(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } var list []string a.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("users")) if b == nil { return nil } return b.ForEach(func(k, _ []byte) error { list = append(list, string(k)) return nil }) }) rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(list) } func (a *App) apiAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } var out struct { Username string `json:"username"` Auths []string `json:"auths"` } out.Username = name a.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("users")) if b == nil { return nil } raw := b.Get([]byte(name)) if raw != nil { u := User{} json.Unmarshal(raw, &u) out.Auths = u.Auths } return nil }) rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(out) } type adminUserBody struct { User string `json:"user"` Pass string `json:"pass"` Auths []string `json:"auths"` } func (a *App) apiAdminUserPost(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := a.requireAdmin(rw, req) if s == nil { return } var body adminUserBody if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" { http.Error(rw, "bad request", http.StatusBadRequest) return } tempAdmin := false err := a.db.Update(func(tx *bbolt.Tx) error { users, err := tx.CreateBucketIfNotExists([]byte("users")) if err != nil { return err } if s.Username == "admin" && users.Get([]byte("admin")) == nil { tempAdmin = true } u := User{} raw := users.Get([]byte(body.User)) if raw != nil { json.Unmarshal(raw, &u) } if body.Pass != "" { u.Pass, _ = bcrypt.GenerateFromPassword([]byte(body.Pass), bcrypt.DefaultCost) } u.Auths = body.Auths raw, _ = json.Marshal(u) return users.Put([]byte(body.User), raw) }) if err != nil { http.Error(rw, "internal error", http.StatusInternalServerError) return } if body.User == s.Username { s.Auths = body.Auths } if tempAdmin { a.deleteSession(s) } rw.WriteHeader(http.StatusOK) } func (a *App) apiAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) { if req.Method != http.MethodDelete { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } s := a.requireAdmin(rw, req) if s == nil { return } a.db.Update(func(tx *bbolt.Tx) error { users, _ := tx.CreateBucketIfNotExists([]byte("users")) u := User{} raw := users.Get([]byte(name)) if raw != nil { json.Unmarshal(raw, &u) } tokens, _ := tx.CreateBucketIfNotExists([]byte("tokens")) for _, tok := range u.Tokens { tokens.Delete([]byte(tok)) } return users.Delete([]byte(name)) }) if name == s.Username { a.deleteSession(s) } rw.WriteHeader(http.StatusOK) } type settingsResponse struct { Prefix string `json:"prefix"` DefaultHide bool `json:"defaultHide"` Title string `json:"title"` } func (a *App) apiAdminSettingsGet(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } out := settingsResponse{} a.db.View(func(tx *bbolt.Tx) error { c := tx.Bucket([]byte("config")) if c == nil { return nil } out.Prefix = string(c.Get([]byte("prefix"))) out.DefaultHide = c.Get([]byte("defaultHide")) != nil out.Title = string(c.Get([]byte("title"))) return nil }) rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(out) } type settingsBody struct { Prefix *string `json:"prefix"` DefaultHide *bool `json:"defaultHide"` Title *string `json:"title"` } func (a *App) apiAdminSettingsPost(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } var body settingsBody if err := json.NewDecoder(req.Body).Decode(&body); err != nil { http.Error(rw, "bad request", http.StatusBadRequest) return } err := a.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("config")) if err != nil { return err } if body.Prefix != nil { b.Put([]byte("prefix"), []byte(*body.Prefix)) } if body.DefaultHide != nil { if *body.DefaultHide { b.Put([]byte("defaultHide"), []byte("1")) } else { b.Delete([]byte("defaultHide")) } } if body.Title != nil { b.Put([]byte("title"), []byte(*body.Title)) } return nil }) if err != nil { http.Error(rw, "internal error", http.StatusInternalServerError) return } rw.WriteHeader(http.StatusOK) } type mapInfoJSON struct { ID int `json:"ID"` Name string `json:"Name"` Hidden bool `json:"Hidden"` Priority bool `json:"Priority"` } func (a *App) apiAdminMaps(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } var maps []mapInfoJSON a.db.View(func(tx *bbolt.Tx) error { mapB := tx.Bucket([]byte("maps")) if mapB == nil { return nil } return mapB.ForEach(func(k, v []byte) error { mi := MapInfo{} json.Unmarshal(v, &mi) if id, err := strconv.Atoi(string(k)); err == nil { mi.ID = id } maps = append(maps, mapInfoJSON{ ID: mi.ID, Name: mi.Name, Hidden: mi.Hidden, Priority: mi.Priority, }) return nil }) }) rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(maps) } type adminMapBody struct { Name string `json:"name"` Hidden bool `json:"hidden"` Priority bool `json:"priority"` } func (a *App) apiAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) { id, err := strconv.Atoi(idStr) if err != nil { http.Error(rw, "bad request", http.StatusBadRequest) return } if req.Method == http.MethodPost { // update map if a.requireAdmin(rw, req) == nil { return } var body adminMapBody if err := json.NewDecoder(req.Body).Decode(&body); err != nil { http.Error(rw, "bad request", http.StatusBadRequest) return } err := a.db.Update(func(tx *bbolt.Tx) error { maps, err := tx.CreateBucketIfNotExists([]byte("maps")) if err != nil { return err } raw := maps.Get([]byte(strconv.Itoa(id))) mi := MapInfo{} if raw != nil { json.Unmarshal(raw, &mi) } mi.ID = id mi.Name = body.Name mi.Hidden = body.Hidden mi.Priority = body.Priority raw, _ = json.Marshal(mi) return maps.Put([]byte(strconv.Itoa(id)), raw) }) if err != nil { http.Error(rw, "internal error", http.StatusInternalServerError) return } rw.WriteHeader(http.StatusOK) return } http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) } func (a *App) apiAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } id, err := strconv.Atoi(idStr) if err != nil { http.Error(rw, "bad request", http.StatusBadRequest) return } if a.requireAdmin(rw, req) == nil { return } var mi MapInfo err = a.db.Update(func(tx *bbolt.Tx) error { maps, err := tx.CreateBucketIfNotExists([]byte("maps")) if err != nil { return err } raw := maps.Get([]byte(strconv.Itoa(id))) if raw != nil { json.Unmarshal(raw, &mi) } mi.ID = id mi.Hidden = !mi.Hidden raw, _ = json.Marshal(mi) return maps.Put([]byte(strconv.Itoa(id)), raw) }) if err != nil { http.Error(rw, "internal error", http.StatusInternalServerError) return } rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(mapInfoJSON{ ID: mi.ID, Name: mi.Name, Hidden: mi.Hidden, Priority: mi.Priority, }) } func (a *App) apiAdminWipe(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } err := a.db.Update(func(tx *bbolt.Tx) error { for _, bname := range []string{"grids", "markers", "tiles", "maps"} { if tx.Bucket([]byte(bname)) != nil { if err := tx.DeleteBucket([]byte(bname)); err != nil { return err } } } return nil }) if err != nil { http.Error(rw, "internal error", http.StatusInternalServerError) return } rw.WriteHeader(http.StatusOK) } func (a *App) apiAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } a.doRebuildZooms() rw.WriteHeader(http.StatusOK) } func (a *App) apiAdminExport(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } a.export(rw, req) } func (a *App) apiAdminMerge(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) return } if a.requireAdmin(rw, req) == nil { return } a.merge(rw, req) } // --- API router: /map/api/... --- func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) { path := strings.TrimPrefix(req.URL.Path, "/map/api") path = strings.TrimPrefix(path, "/") // Delegate to existing handlers switch path { case "config": a.config(rw, req) return case "v1/characters": a.getChars(rw, req) return case "v1/markers": a.getMarkers(rw, req) return case "maps": a.getMaps(rw, req) return } if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" { switch path { case "admin/wipeTile": a.wipeTile(rw, req) case "admin/setCoords": a.setCoords(rw, req) case "admin/hideMarker": a.hideMarker(rw, req) } return } switch { case path == "setup": a.apiSetup(rw, req) return case path == "login": a.apiLogin(rw, req) return case path == "logout": a.apiLogout(rw, req) return case path == "me": a.apiMe(rw, req) return case path == "me/tokens": a.apiMeTokens(rw, req) return case path == "me/password": a.apiMePassword(rw, req) return case path == "admin/users": if req.Method == http.MethodPost { a.apiAdminUserPost(rw, req) } else { a.apiAdminUsers(rw, req) } return case strings.HasPrefix(path, "admin/users/"): name := strings.TrimPrefix(path, "admin/users/") if name == "" { http.Error(rw, "not found", http.StatusNotFound) return } if req.Method == http.MethodDelete { a.apiAdminUserDelete(rw, req, name) } else { a.apiAdminUserByName(rw, req, name) } return case path == "admin/settings": if req.Method == http.MethodGet { a.apiAdminSettingsGet(rw, req) } else { a.apiAdminSettingsPost(rw, req) } return case path == "admin/maps": a.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" { a.apiAdminMapToggleHidden(rw, req, idStr) return } if len(parts) == 1 { a.apiAdminMapByID(rw, req, idStr) return } http.Error(rw, "not found", http.StatusNotFound) return case path == "admin/wipe": a.apiAdminWipe(rw, req) return case path == "admin/rebuildZooms": a.apiAdminRebuildZooms(rw, req) return case path == "admin/export": a.apiAdminExport(rw, req) return case path == "admin/merge": a.apiAdminMerge(rw, req) return } http.Error(rw, "not found", http.StatusNotFound) }