Files
hnh-map/internal/app/api.go
Nikolay Tatarinov 82cb8a13f5 Update project documentation and improve frontend functionality
- Updated the backend documentation in CONTRIBUTING.md and README.md to reflect changes in application structure and API endpoints.
- Enhanced the frontend components in MapView.vue for better handling of context menu actions.
- Added new types and interfaces in TypeScript for improved type safety in the frontend.
- Introduced new utility classes for managing characters and markers in the map.
- Updated .gitignore to include .vscode directory for better development environment management.
2026-02-24 23:32:50 +03:00

762 lines
19 KiB
Go

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)
}