Add configuration files and update project documentation

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

View File

@@ -1,134 +0,0 @@
package app
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
type mapData struct {
Grids map[string]string
Markers map[string][]Marker
}
func (a *App) export(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
http.Error(rw, "Unauthorized", http.StatusUnauthorized)
return
}
rw.Header().Set("Content-Type", "application/zip")
rw.Header().Set("Content-Disposition", "attachment; filename=\"griddata.zip\"")
zw := zip.NewWriter(rw)
defer zw.Close()
err := a.db.Update(func(tx *bbolt.Tx) error {
maps := map[int]mapData{}
gridMap := map[string]int{}
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
err := grids.ForEach(func(k, v []byte) error {
gd := GridData{}
err := json.Unmarshal(v, &gd)
if err != nil {
return err
}
md, ok := maps[gd.Map]
if !ok {
md = mapData{
Grids: map[string]string{},
Markers: map[string][]Marker{},
}
maps[gd.Map] = md
}
md.Grids[gd.Coord.Name()] = gd.ID
gridMap[gd.ID] = gd.Map
mapb := tiles.Bucket([]byte(strconv.Itoa(gd.Map)))
if mapb == nil {
return nil
}
zoom := mapb.Bucket([]byte("0"))
if zoom == nil {
return nil
}
tdraw := zoom.Get([]byte(gd.Coord.Name()))
if tdraw == nil {
return nil
}
td := TileData{}
err = json.Unmarshal(tdraw, &td)
if err != nil {
return err
}
w, err := zw.Create(fmt.Sprintf("%d/%s.png", gd.Map, gd.ID))
if err != nil {
return err
}
f, err := os.Open(filepath.Join(a.gridStorage, td.File))
if err != nil {
return err
}
_, err = io.Copy(w, f)
f.Close()
return err
})
if err != nil {
return err
}
err = func() error {
markersb := tx.Bucket(store.BucketMarkers)
if markersb == nil {
return nil
}
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
if markersgrid == nil {
return nil
}
return markersgrid.ForEach(func(k, v []byte) error {
marker := Marker{}
err := json.Unmarshal(v, &marker)
if err != nil {
return nil
}
if _, ok := maps[gridMap[marker.GridID]]; ok {
maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker)
}
return nil
})
}()
if err != nil {
return err
}
for mapid, mapdata := range maps {
w, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid))
if err != nil {
return err
}
json.NewEncoder(w).Encode(mapdata)
}
return nil
})
if err != nil {
log.Println(err)
}
}

View File

@@ -1,49 +0,0 @@
package app
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) HideMarker(rw http.ResponseWriter, req *http.Request) {
if a.requireAdmin(rw, req) == nil {
return
}
err := a.db.Update(func(tx *bbolt.Tx) error {
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
grid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}
key := idB.Get([]byte(req.FormValue("id")))
if key == nil {
return fmt.Errorf("Could not find key %s", req.FormValue("id"))
}
raw := grid.Get(key)
if raw == nil {
return fmt.Errorf("Could not find key %s", string(key))
}
m := Marker{}
json.Unmarshal(raw, &m)
m.Hidden = true
raw, _ = json.Marshal(m)
grid.Put(key, raw)
return nil
})
if err != nil {
log.Println(err)
}
}

View File

@@ -1,306 +0,0 @@
package app
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) merge(rw http.ResponseWriter, req *http.Request) {
if s := a.getSession(req); s == nil || !s.Auths.Has(AUTH_ADMIN) {
http.Error(rw, "Unauthorized", http.StatusUnauthorized)
return
}
err := req.ParseMultipartForm(1024 * 1024 * 500)
if err != nil {
log.Println(err)
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
mergef, hdr, err := req.FormFile("merge")
if err != nil {
log.Println(err)
http.Error(rw, "request error", http.StatusBadRequest)
return
}
zr, err := zip.NewReader(mergef, hdr.Size)
if err != nil {
log.Println(err)
http.Error(rw, "request error", http.StatusBadRequest)
return
}
ops := []struct {
mapid int
x, y int
f string
}{}
newTiles := map[string]struct{}{}
err = a.db.Update(func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
mgrid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
for _, fhdr := range zr.File {
if strings.HasSuffix(fhdr.Name, ".json") {
f, err := fhdr.Open()
if err != nil {
return err
}
md := mapData{}
err = json.NewDecoder(f).Decode(&md)
if err != nil {
return err
}
for _, ms := range md.Markers {
for _, mraw := range ms {
key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.Position.X, mraw.Position.Y))
if mgrid.Get(key) != nil {
continue
}
if mraw.Image == "" {
mraw.Image = "gfx/terobjs/mm/custom"
}
id, err := idB.NextSequence()
if err != nil {
return err
}
idKey := []byte(strconv.Itoa(int(id)))
m := Marker{
Name: mraw.Name,
ID: int(id),
GridID: mraw.GridID,
Position: Position{
X: mraw.Position.X,
Y: mraw.Position.Y,
},
Image: mraw.Image,
}
raw, _ := json.Marshal(m)
mgrid.Put(key, raw)
idB.Put(idKey, key)
}
}
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
newGrids := map[Coord]string{}
maps := map[int]struct{ X, Y int }{}
for k, v := range md.Grids {
c := Coord{}
_, err := fmt.Sscanf(k, "%d_%d", &c.X, &c.Y)
if err != nil {
return err
}
newGrids[c] = v
gridRaw := grids.Get([]byte(v))
if gridRaw != nil {
gd := GridData{}
json.Unmarshal(gridRaw, &gd)
maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - c.X, gd.Coord.Y - c.Y}
}
}
if len(maps) == 0 {
seq, err := mapB.NextSequence()
if err != nil {
return err
}
mi := MapInfo{
ID: int(seq),
Name: strconv.Itoa(int(seq)),
Hidden: configb.Get([]byte("defaultHide")) != nil,
}
raw, _ := json.Marshal(mi)
err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw)
if err != nil {
return err
}
for c, grid := range newGrids {
cur := GridData{}
cur.ID = grid
cur.Map = int(seq)
cur.Coord = c
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
}
continue
}
mapid := -1
offset := struct{ X, Y int }{}
for id, off := range maps {
mi := MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil {
json.Unmarshal(mraw, &mi)
}
if mi.Priority {
mapid = id
offset = off
break
}
if id < mapid || mapid == -1 {
mapid = id
offset = off
}
}
for c, grid := range newGrids {
cur := GridData{}
if curRaw := grids.Get([]byte(grid)); curRaw != nil {
continue
}
cur.ID = grid
cur.Map = mapid
cur.Coord.X = c.X + offset.X
cur.Coord.Y = c.Y + offset.Y
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
}
if len(maps) > 1 {
grids.ForEach(func(k, v []byte) error {
gd := GridData{}
json.Unmarshal(v, &gd)
if gd.Map == mapid {
return nil
}
if merge, ok := maps[gd.Map]; ok {
var td *TileData
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map)))
if err != nil {
return err
}
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0)))
if err != nil {
return err
}
tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil {
json.Unmarshal(tileraw, &td)
}
gd.Map = mapid
gd.Coord.X += offset.X - merge.X
gd.Coord.Y += offset.Y - merge.Y
raw, _ := json.Marshal(gd)
if td != nil {
ops = append(ops, struct {
mapid int
x int
y int
f string
}{
mapid: mapid,
x: gd.Coord.X,
y: gd.Coord.Y,
f: td.File,
})
}
grids.Put(k, raw)
}
return nil
})
}
for mergeid, merge := range maps {
if mapid == mergeid {
continue
}
mapB.Delete([]byte(strconv.Itoa(mergeid)))
log.Println("Reporting merge", mergeid, mapid)
a.reportMerge(mergeid, mapid, Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
}
} else if strings.HasSuffix(fhdr.Name, ".png") {
os.MkdirAll(filepath.Join(a.gridStorage, "grids"), 0777)
f, err := os.Create(filepath.Join(a.gridStorage, "grids", filepath.Base(fhdr.Name)))
if err != nil {
return err
}
r, err := fhdr.Open()
if err != nil {
f.Close()
return err
}
io.Copy(f, r)
r.Close()
f.Close()
newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{}
}
}
for gid := range newTiles {
gridRaw := grids.Get([]byte(gid))
if gridRaw != nil {
gd := GridData{}
json.Unmarshal(gridRaw, &gd)
ops = append(ops, struct {
mapid int
x int
y int
f string
}{
mapid: gd.Map,
x: gd.Coord.X,
y: gd.Coord.Y,
f: filepath.Join("grids", gid+".png"),
})
}
}
return nil
})
if err != nil {
log.Println(err)
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
for _, op := range ops {
a.SaveTile(op.mapid, Coord{X: op.x, Y: op.y}, 0, op.f, time.Now().UnixNano())
}
a.doRebuildZooms()
}

View File

@@ -1,53 +0,0 @@
package app
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
type zoomproc struct {
c Coord
m int
}
func (a *App) doRebuildZooms() {
needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{}
a.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids)
if b == nil {
return nil
}
b.ForEach(func(k, v []byte) error {
grid := GridData{}
json.Unmarshal(v, &grid)
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil
})
tx.DeleteBucket(store.BucketTiles)
return nil
})
for g, id := range saveGrid {
f := fmt.Sprintf("%s/grids/%s.png", a.gridStorage, id)
if _, err := os.Stat(f); err != nil {
continue
}
a.SaveTile(g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
}
for z := 1; z <= 5; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
a.updateZoomLevel(p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
}

View File

@@ -1,189 +0,0 @@
package app
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) WipeTile(rw http.ResponseWriter, req *http.Request) {
if a.requireAdmin(rw, req) == nil {
return
}
mraw := req.FormValue("map")
mapid, err := strconv.Atoi(mraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
xraw := req.FormValue("x")
x, err := strconv.Atoi(xraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
yraw := req.FormValue("y")
y, err := strconv.Atoi(yraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
c := Coord{
X: x,
Y: y,
}
a.db.Update(func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
ids := [][]byte{}
err := grids.ForEach(func(k, v []byte) error {
g := GridData{}
err := json.Unmarshal(v, &g)
if err != nil {
return err
}
if g.Coord == c && g.Map == mapid {
ids = append(ids, k)
}
return nil
})
if err != nil {
return err
}
for _, id := range ids {
grids.Delete(id)
}
return nil
})
a.SaveTile(mapid, c, 0, "", -1)
for z := 1; z <= 5; z++ {
c = c.Parent()
a.updateZoomLevel(mapid, c, z)
}
rw.WriteHeader(200)
}
func (a *App) SetCoords(rw http.ResponseWriter, req *http.Request) {
if a.requireAdmin(rw, req) == nil {
return
}
mraw := req.FormValue("map")
mapid, err := strconv.Atoi(mraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
fxraw := req.FormValue("fx")
fx, err := strconv.Atoi(fxraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
fyraw := req.FormValue("fy")
fy, err := strconv.Atoi(fyraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
fc := Coord{
X: fx,
Y: fy,
}
txraw := req.FormValue("tx")
tx, err := strconv.Atoi(txraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
tyraw := req.FormValue("ty")
ty, err := strconv.Atoi(tyraw)
if err != nil {
http.Error(rw, "coord parse failed", http.StatusBadRequest)
return
}
tc := Coord{
X: tx,
Y: ty,
}
diff := Coord{
X: tc.X - fc.X,
Y: tc.Y - fc.Y,
}
tds := []*TileData{}
a.db.Update(func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapZooms == nil {
return nil
}
mapTiles := mapZooms.Bucket([]byte("0"))
err := grids.ForEach(func(k, v []byte) error {
g := GridData{}
err := json.Unmarshal(v, &g)
if err != nil {
return err
}
if g.Map == mapid {
g.Coord.X += diff.X
g.Coord.Y += diff.Y
raw, _ := json.Marshal(g)
grids.Put(k, raw)
}
return nil
})
if err != nil {
return err
}
err = mapTiles.ForEach(func(k, v []byte) error {
td := &TileData{}
err := json.Unmarshal(v, &td)
if err != nil {
return err
}
td.Coord.X += diff.X
td.Coord.Y += diff.Y
tds = append(tds, td)
return nil
})
if err != nil {
return err
}
err = tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
if err != nil {
return err
}
return nil
})
needProcess := map[zoomproc]struct{}{}
for _, td := range tds {
a.SaveTile(td.MapID, td.Coord, td.Zoom, td.File, time.Now().UnixNano())
needProcess[zoomproc{c: Coord{X: td.Coord.X, Y: td.Coord.Y}.Parent(), m: td.MapID}] = struct{}{}
}
for z := 1; z <= 5; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
a.updateZoomLevel(p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
rw.WriteHeader(200)
}

View File

@@ -1,790 +0,0 @@
package app
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"github.com/andyleap/hnh-map/internal/app/store"
"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
}
// OAuth-only users cannot login with password
if uByName := a.getUserByUsername(body.User); uByName != nil && uByName.Pass == nil {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(rw).Encode(map[string]string{"error": "Use OAuth to sign in"})
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(store.BucketUsers)
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(store.BucketUsers)
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(store.BucketConfig)
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(store.BucketUsers)
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(store.BucketTokens)
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(store.BucketUsers)
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(store.BucketUsers)
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(store.BucketUsers)
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(store.BucketUsers)
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(store.BucketUsers)
u := User{}
raw := users.Get([]byte(name))
if raw != nil {
json.Unmarshal(raw, &u)
}
tokens, _ := tx.CreateBucketIfNotExists(store.BucketTokens)
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(store.BucketConfig)
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(store.BucketConfig)
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(store.BucketMaps)
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(store.BucketMaps)
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(store.BucketMaps)
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 _, b := range [][]byte{store.BucketGrids, store.BucketMarkers, store.BucketTiles, store.BucketMaps} {
if tx.Bucket(b) != nil {
if err := tx.DeleteBucket(b); 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 == "oauth/providers":
a.APIOAuthProviders(rw, req)
return
case strings.HasPrefix(path, "oauth/"):
rest := strings.TrimPrefix(path, "oauth/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 {
http.Error(rw, "not found", http.StatusNotFound)
return
}
provider := parts[0]
action := parts[1]
switch action {
case "login":
a.OAuthLogin(rw, req, provider)
case "callback":
a.OAuthCallback(rw, req, provider)
default:
http.Error(rw, "not found", http.StatusNotFound)
}
return
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)
}

View File

@@ -11,6 +11,26 @@ import (
"go.etcd.io/bbolt"
)
const (
AUTH_ADMIN = "admin"
AUTH_MAP = "map"
AUTH_MARKERS = "markers"
AUTH_UPLOAD = "upload"
CharCleanupInterval = 10 * time.Second
CharStaleThreshold = 10 * time.Second
TileUpdateInterval = 30 * time.Minute
MaxZoomLevel = 5
GridSize = 100
SessionMaxAge = 7 * 24 * 3600 // 1 week in seconds
MultipartMaxMemory = 100 << 20 // 100 MB
MergeMaxMemory = 500 << 20 // 500 MB
ClientVersion = "4"
SSETickInterval = 5 * time.Second
SSETileChannelSize = 1000
SSEMergeChannelSize = 5
)
// App is the main application (map server) state.
type App struct {
gridStorage string
@@ -24,22 +44,29 @@ type App struct {
mergeUpdates Topic[Merge]
}
// GridStorage returns the grid storage path.
func (a *App) GridStorage() string {
return a.gridStorage
// NewApp creates an App with the given storage paths and database.
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
return &App{
gridStorage: gridStorage,
frontendRoot: frontendRoot,
db: db,
characters: make(map[string]Character),
}, nil
}
// GridUpdates returns the tile updates topic for MapService.
func (a *App) GridUpdates() *Topic[TileData] {
return &a.gridUpdates
}
// GridStorage returns the path to the grid storage directory.
func (a *App) GridStorage() string { return a.gridStorage }
// MergeUpdates returns the merge updates topic for MapService.
func (a *App) MergeUpdates() *Topic[Merge] {
return &a.mergeUpdates
}
// GridUpdates returns the tile update pub/sub topic.
func (a *App) GridUpdates() *Topic[TileData] { return &a.gridUpdates }
// GetCharacters returns a copy of all characters (for MapService).
// MergeUpdates returns the merge event pub/sub topic.
func (a *App) MergeUpdates() *Topic[Merge] { return &a.mergeUpdates }
// DB returns the underlying bbolt database.
func (a *App) DB() *bbolt.DB { return a.db }
// GetCharacters returns a copy of all characters.
func (a *App) GetCharacters() []Character {
a.chmu.RLock()
defer a.chmu.RUnlock()
@@ -50,128 +77,30 @@ func (a *App) GetCharacters() []Character {
return chars
}
// NewApp creates an App with the given storage paths and database.
// frontendRoot is the directory for the map SPA (e.g. "frontend").
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
return &App{
gridStorage: gridStorage,
frontendRoot: frontendRoot,
db: db,
characters: make(map[string]Character),
}, nil
// WithCharacters provides locked mutable access to the character map.
func (a *App) WithCharacters(fn func(chars map[string]Character)) {
a.chmu.Lock()
defer a.chmu.Unlock()
fn(a.characters)
}
type Session struct {
ID string
Username string
Auths Auths `json:"-"`
TempAdmin bool
}
type Character struct {
Name string `json:"name"`
ID int `json:"id"`
Map int `json:"map"`
Position Position `json:"position"`
Type string `json:"type"`
updated time.Time
}
type Marker struct {
Name string `json:"name"`
ID int `json:"id"`
GridID string `json:"gridID"`
Position Position `json:"position"`
Image string `json:"image"`
Hidden bool `json:"hidden"`
}
type FrontendMarker struct {
Name string `json:"name"`
ID int `json:"id"`
Map int `json:"map"`
Position Position `json:"position"`
Image string `json:"image"`
Hidden bool `json:"hidden"`
}
type MapInfo struct {
ID int
Name string
Hidden bool
Priority bool
}
type GridData struct {
ID string
Coord Coord
NextUpdate time.Time
Map int
}
type Coord struct {
X int `json:"x"`
Y int `json:"y"`
}
type Position struct {
X int `json:"x"`
Y int `json:"y"`
}
func (c Coord) Name() string {
return fmt.Sprintf("%d_%d", c.X, c.Y)
}
func (c Coord) Parent() Coord {
if c.X < 0 {
c.X--
}
if c.Y < 0 {
c.Y--
}
return Coord{
X: c.X / 2,
Y: c.Y / 2,
}
}
type Auths []string
func (a Auths) Has(auth string) bool {
for _, v := range a {
if v == auth {
return true
// CleanChars runs a background loop that removes stale character entries.
func (a *App) CleanChars() {
for range time.Tick(CharCleanupInterval) {
a.chmu.Lock()
for n, c := range a.characters {
if c.Updated.Before(time.Now().Add(-CharStaleThreshold)) {
delete(a.characters, n)
}
}
a.chmu.Unlock()
}
return false
}
const (
AUTH_ADMIN = "admin"
AUTH_MAP = "map"
AUTH_MARKERS = "markers"
AUTH_UPLOAD = "upload"
)
type User struct {
Pass []byte
Auths Auths
Tokens []string
// OAuth: provider -> subject (unique ID from provider)
OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789"
}
type Page struct {
Title string `json:"title"`
}
// serveSPARoot serves the map SPA from root: static files from frontend, fallback to index.html for client-side routes.
// Handles redirects from old /map/* URLs for backward compatibility.
func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
// serveSPARoot serves the map SPA: static files from frontend, fallback to index.html.
func (a *App) ServeSPARoot(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path
// Redirect old /map/* URLs to flat routes
if path == "/map" || path == "/map/" {
http.Redirect(rw, req, "/", http.StatusFound)
return
@@ -200,7 +129,6 @@ func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
}
}
// File serving: path relative to frontend root (with baseURL /, files are at root)
filePath := strings.TrimPrefix(path, "/")
if filePath == "" {
filePath = "index.html"
@@ -210,14 +138,12 @@ func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
http.NotFound(rw, req)
return
}
// Try both root and map/ for backward compatibility with old builds
tryPaths := []string{filePath, filepath.Join("map", filePath)}
var f http.File
for _, p := range tryPaths {
var err error
f, err = http.Dir(a.frontendRoot).Open(p)
if err == nil {
filePath = p
break
}
}
@@ -234,28 +160,130 @@ func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f)
}
// CleanChars runs a background loop that removes stale character entries. Call once as a goroutine.
func (a *App) CleanChars() {
for range time.Tick(time.Second * 10) {
a.chmu.Lock()
for n, c := range a.characters {
if c.updated.Before(time.Now().Add(-10 * time.Second)) {
delete(a.characters, n)
}
}
a.chmu.Unlock()
// --- Domain types ---
// Session represents an authenticated user session.
type Session struct {
ID string
Username string
Auths Auths `json:"-"`
TempAdmin bool
}
// Character represents a game character on the map.
type Character struct {
Name string `json:"name"`
ID int `json:"id"`
Map int `json:"map"`
Position Position `json:"position"`
Type string `json:"type"`
Updated time.Time
}
// Marker represents a map marker stored per grid.
type Marker struct {
Name string `json:"name"`
ID int `json:"id"`
GridID string `json:"gridID"`
Position Position `json:"position"`
Image string `json:"image"`
Hidden bool `json:"hidden"`
}
// FrontendMarker is a marker with map-level coordinates for the frontend.
type FrontendMarker struct {
Name string `json:"name"`
ID int `json:"id"`
Map int `json:"map"`
Position Position `json:"position"`
Image string `json:"image"`
Hidden bool `json:"hidden"`
}
// MapInfo describes a map instance.
type MapInfo struct {
ID int
Name string
Hidden bool
Priority bool
}
// GridData represents a grid tile with its coordinates and map assignment.
type GridData struct {
ID string
Coord Coord
NextUpdate time.Time
Map int
}
// Coord represents a grid coordinate pair.
type Coord struct {
X int `json:"x"`
Y int `json:"y"`
}
// Position represents a pixel position within a grid.
type Position struct {
X int `json:"x"`
Y int `json:"y"`
}
// Name returns the string representation "X_Y" used as bucket keys.
func (c Coord) Name() string {
return fmt.Sprintf("%d_%d", c.X, c.Y)
}
// Parent returns the parent coordinate at the next zoom level.
func (c Coord) Parent() Coord {
if c.X < 0 {
c.X--
}
if c.Y < 0 {
c.Y--
}
return Coord{
X: c.X / 2,
Y: c.Y / 2,
}
}
// RegisterRoutes registers all HTTP handlers for the app.
func (a *App) RegisterRoutes() {
http.HandleFunc("/client/", a.client)
http.HandleFunc("/logout", a.redirectLogout)
// Auths is a list of permission strings (e.g. "admin", "map", "upload").
type Auths []string
http.HandleFunc("/map/api/", a.apiRouter)
http.HandleFunc("/map/updates", a.watchGridUpdates)
http.HandleFunc("/map/grids/", a.gridTile)
// SPA catch-all: must be last
http.HandleFunc("/", a.serveSPARoot)
// Has returns true if the list contains the given permission.
func (a Auths) Has(auth string) bool {
for _, v := range a {
if v == auth {
return true
}
}
return false
}
// User represents a stored user account.
type User struct {
Pass []byte
Auths Auths
Tokens []string
OAuthLinks map[string]string `json:"oauth_links,omitempty"`
}
// Page holds page metadata for rendering.
type Page struct {
Title string `json:"title"`
}
// Config holds the application config sent to the frontend.
type Config struct {
Title string `json:"title"`
Auths []string `json:"auths"`
}
// TileData represents a tile image entry in the database.
type TileData struct {
MapID int
Coord Coord
Zoom int
File string
Cache int64
}

108
internal/app/app_test.go Normal file
View File

@@ -0,0 +1,108 @@
package app_test
import (
"testing"
"github.com/andyleap/hnh-map/internal/app"
)
func TestCoordName(t *testing.T) {
tests := []struct {
coord app.Coord
want string
}{
{app.Coord{X: 0, Y: 0}, "0_0"},
{app.Coord{X: 5, Y: -3}, "5_-3"},
{app.Coord{X: -1, Y: -1}, "-1_-1"},
}
for _, tt := range tests {
got := tt.coord.Name()
if got != tt.want {
t.Errorf("Coord{%d,%d}.Name() = %q, want %q", tt.coord.X, tt.coord.Y, got, tt.want)
}
}
}
func TestCoordParent(t *testing.T) {
tests := []struct {
coord app.Coord
parent app.Coord
}{
{app.Coord{X: 0, Y: 0}, app.Coord{X: 0, Y: 0}},
{app.Coord{X: 2, Y: 4}, app.Coord{X: 1, Y: 2}},
{app.Coord{X: 3, Y: 5}, app.Coord{X: 1, Y: 2}},
{app.Coord{X: -1, Y: -1}, app.Coord{X: -1, Y: -1}},
{app.Coord{X: -2, Y: -3}, app.Coord{X: -1, Y: -2}},
}
for _, tt := range tests {
got := tt.coord.Parent()
if got != tt.parent {
t.Errorf("Coord{%d,%d}.Parent() = %v, want %v", tt.coord.X, tt.coord.Y, got, tt.parent)
}
}
}
func TestAuthsHas(t *testing.T) {
auths := app.Auths{"admin", "map", "upload"}
if !auths.Has("admin") {
t.Error("expected Has(admin)=true")
}
if !auths.Has("map") {
t.Error("expected Has(map)=true")
}
if auths.Has("markers") {
t.Error("expected Has(markers)=false")
}
}
func TestAuthsHasEmpty(t *testing.T) {
var auths app.Auths
if auths.Has("anything") {
t.Error("expected nil auths to return false")
}
}
func TestTopicSendAndWatch(t *testing.T) {
topic := &app.Topic[string]{}
ch := make(chan *string, 10)
topic.Watch(ch)
msg := "hello"
topic.Send(&msg)
select {
case got := <-ch:
if *got != "hello" {
t.Fatalf("expected hello, got %s", *got)
}
default:
t.Fatal("expected message on channel")
}
}
func TestTopicClose(t *testing.T) {
topic := &app.Topic[int]{}
ch := make(chan *int, 10)
topic.Watch(ch)
topic.Close()
_, ok := <-ch
if ok {
t.Fatal("expected channel to be closed")
}
}
func TestTopicDropsSlowSubscriber(t *testing.T) {
topic := &app.Topic[int]{}
slow := make(chan *int) // unbuffered, will block
topic.Watch(slow)
val := 42
topic.Send(&val) // should drop the slow subscriber
_, ok := <-slow
if ok {
t.Fatal("expected slow subscriber channel to be closed")
}
}

View File

@@ -0,0 +1,18 @@
package apperr
import "errors"
// Domain errors returned by services. Handlers map these to HTTP status codes.
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrBadRequest = errors.New("bad request")
ErrInternal = errors.New("internal error")
ErrOAuthOnly = errors.New("use OAuth to sign in")
ErrProviderUnconfigured = errors.New("OAuth provider not configured")
ErrStateExpired = errors.New("OAuth state expired")
ErrStateMismatch = errors.New("OAuth state mismatch")
ErrExchangeFailed = errors.New("OAuth exchange failed")
ErrUserInfoFailed = errors.New("failed to get user info")
)

View File

@@ -1,160 +0,0 @@
package app
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"github.com/andyleap/hnh-map/internal/app/response"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
func (a *App) getSession(req *http.Request) *Session {
c, err := req.Cookie("session")
if err != nil {
return nil
}
var s *Session
a.db.View(func(tx *bbolt.Tx) error {
sessions := tx.Bucket(store.BucketSessions)
if sessions == nil {
return nil
}
session := sessions.Get([]byte(c.Value))
if session == nil {
return nil
}
err := json.Unmarshal(session, &s)
if err != nil {
return err
}
if s.TempAdmin {
s.Auths = Auths{AUTH_ADMIN}
return nil
}
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
raw := users.Get([]byte(s.Username))
if raw == nil {
s = nil
return nil
}
u := User{}
err = json.Unmarshal(raw, &u)
if err != nil {
s = nil
return err
}
s.Auths = u.Auths
return nil
})
return s
}
func (a *App) deleteSession(s *Session) {
a.db.Update(func(tx *bbolt.Tx) error {
sessions, err := tx.CreateBucketIfNotExists(store.BucketSessions)
if err != nil {
return err
}
return sessions.Delete([]byte(s.ID))
})
}
func (a *App) saveSession(s *Session) {
a.db.Update(func(tx *bbolt.Tx) error {
sessions, err := tx.CreateBucketIfNotExists(store.BucketSessions)
if err != nil {
return err
}
buf, err := json.Marshal(s)
if err != nil {
return err
}
return sessions.Put([]byte(s.ID), buf)
})
}
func (a *App) getPage(req *http.Request) Page {
p := Page{}
a.db.View(func(tx *bbolt.Tx) error {
c := tx.Bucket(store.BucketConfig)
if c == nil {
return nil
}
p.Title = string(c.Get([]byte("title")))
return nil
})
return p
}
func (a *App) getUser(user, pass string) (u *User) {
a.db.View(func(tx *bbolt.Tx) error {
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
raw := users.Get([]byte(user))
if raw != nil {
json.Unmarshal(raw, &u)
if u.Pass == nil {
u = nil
return nil
}
if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil {
u = nil
return nil
}
}
return nil
})
return u
}
// createSession creates a session for username, returns session ID or empty string.
func (a *App) createSession(username string, tempAdmin bool) string {
session := make([]byte, 32)
if _, err := rand.Read(session); err != nil {
return ""
}
sid := hex.EncodeToString(session)
s := &Session{
ID: sid,
Username: username,
TempAdmin: tempAdmin,
}
a.saveSession(s)
return sid
}
// setupRequired returns true if no users exist (first run).
func (a *App) setupRequired() bool {
var required bool
a.db.View(func(tx *bbolt.Tx) error {
ub := tx.Bucket(store.BucketUsers)
if ub == nil {
required = true
return nil
}
if ub.Stats().KeyN == 0 {
required = true
return nil
}
return nil
})
return required
}
func (a *App) requireAdmin(rw http.ResponseWriter, req *http.Request) *Session {
s := a.getSession(req)
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
response.JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return nil
}
return s
}

View File

@@ -1,107 +0,0 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
var clientPath = regexp.MustCompile("client/([^/]+)/(.*)")
var UserInfo struct{}
const VERSION = "4"
func (a *App) client(rw http.ResponseWriter, req *http.Request) {
matches := clientPath.FindStringSubmatch(req.URL.Path)
if matches == nil {
http.Error(rw, "Client token not found", http.StatusBadRequest)
return
}
auth := false
user := ""
a.db.View(func(tx *bbolt.Tx) error {
tb := tx.Bucket(store.BucketTokens)
if tb == nil {
return nil
}
userName := tb.Get([]byte(matches[1]))
if userName == nil {
return nil
}
ub := tx.Bucket(store.BucketUsers)
if ub == nil {
return nil
}
userRaw := ub.Get(userName)
if userRaw == nil {
return nil
}
u := User{}
json.Unmarshal(userRaw, &u)
if u.Auths.Has(AUTH_UPLOAD) {
user = string(userName)
auth = true
}
return nil
})
if !auth {
rw.WriteHeader(http.StatusUnauthorized)
return
}
ctx := context.WithValue(req.Context(), UserInfo, user)
req = req.WithContext(ctx)
switch matches[2] {
case "locate":
a.locate(rw, req)
case "gridUpdate":
a.gridUpdate(rw, req)
case "gridUpload":
a.gridUpload(rw, req)
case "positionUpdate":
a.updatePositions(rw, req)
case "markerUpdate":
a.uploadMarkers(rw, req)
case "":
http.Redirect(rw, req, "/", 302)
case "checkVersion":
if req.FormValue("version") == VERSION {
rw.WriteHeader(200)
} else {
rw.WriteHeader(http.StatusBadRequest)
}
default:
rw.WriteHeader(http.StatusNotFound)
}
}
func (a *App) locate(rw http.ResponseWriter, req *http.Request) {
grid := req.FormValue("gridID")
err := a.db.View(func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
curRaw := grids.Get([]byte(grid))
cur := GridData{}
if curRaw == nil {
return fmt.Errorf("grid not found")
}
err := json.Unmarshal(curRaw, &cur)
if err != nil {
return err
}
fmt.Fprintf(rw, "%d;%d;%d", cur.Map, cur.Coord.X, cur.Coord.Y)
return nil
})
if err != nil {
rw.WriteHeader(404)
}
}

View File

@@ -1,438 +0,0 @@
package app
import (
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/image/draw"
)
type GridUpdate struct {
Grids [][]string `json:"grids"`
}
type GridRequest struct {
GridRequests []string `json:"gridRequests"`
Map int `json:"map"`
Coords Coord `json:"coords"`
}
type ExtraData struct {
Season int
}
func (a *App) gridUpdate(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
dec := json.NewDecoder(req.Body)
grup := GridUpdate{}
err := dec.Decode(&grup)
if err != nil {
log.Println("Error decoding grid request json: ", err)
http.Error(rw, "Error decoding request", http.StatusBadRequest)
return
}
log.Println(grup)
ops := []struct {
mapid int
x, y int
f string
}{}
greq := GridRequest{}
err = a.db.Update(func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
maps := map[int]struct{ X, Y int }{}
for x, row := range grup.Grids {
for y, grid := range row {
gridRaw := grids.Get([]byte(grid))
if gridRaw != nil {
gd := GridData{}
json.Unmarshal(gridRaw, &gd)
maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y}
}
}
}
if len(maps) == 0 {
seq, err := mapB.NextSequence()
if err != nil {
return err
}
mi := MapInfo{
ID: int(seq),
Name: strconv.Itoa(int(seq)),
Hidden: configb.Get([]byte("defaultHide")) != nil,
}
raw, _ := json.Marshal(mi)
err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw)
if err != nil {
return err
}
log.Println("Client made mapid ", seq)
for x, row := range grup.Grids {
for y, grid := range row {
cur := GridData{}
cur.ID = grid
cur.Map = int(seq)
cur.Coord.X = x - 1
cur.Coord.Y = y - 1
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
greq.GridRequests = append(greq.GridRequests, grid)
}
}
greq.Coords = Coord{0, 0}
return nil
}
mapid := -1
offset := struct{ X, Y int }{}
for id, off := range maps {
mi := MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil {
json.Unmarshal(mraw, &mi)
}
if mi.Priority {
mapid = id
offset = off
break
}
if id < mapid || mapid == -1 {
mapid = id
offset = off
}
}
log.Println("Client in mapid ", mapid)
for x, row := range grup.Grids {
for y, grid := range row {
cur := GridData{}
if curRaw := grids.Get([]byte(grid)); curRaw != nil {
json.Unmarshal(curRaw, &cur)
if time.Now().After(cur.NextUpdate) {
greq.GridRequests = append(greq.GridRequests, grid)
}
continue
}
cur.ID = grid
cur.Map = mapid
cur.Coord.X = x + offset.X
cur.Coord.Y = y + offset.Y
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
greq.GridRequests = append(greq.GridRequests, grid)
}
}
if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 {
if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil {
cur := GridData{}
json.Unmarshal(curRaw, &cur)
greq.Map = cur.Map
greq.Coords = cur.Coord
}
}
if len(maps) > 1 {
grids.ForEach(func(k, v []byte) error {
gd := GridData{}
json.Unmarshal(v, &gd)
if gd.Map == mapid {
return nil
}
if merge, ok := maps[gd.Map]; ok {
var td *TileData
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map)))
if err != nil {
return err
}
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0)))
if err != nil {
return err
}
tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil {
json.Unmarshal(tileraw, &td)
}
gd.Map = mapid
gd.Coord.X += offset.X - merge.X
gd.Coord.Y += offset.Y - merge.Y
raw, _ := json.Marshal(gd)
if td != nil {
ops = append(ops, struct {
mapid int
x int
y int
f string
}{
mapid: mapid,
x: gd.Coord.X,
y: gd.Coord.Y,
f: td.File,
})
}
grids.Put(k, raw)
}
return nil
})
}
for mergeid, merge := range maps {
if mapid == mergeid {
continue
}
mapB.Delete([]byte(strconv.Itoa(mergeid)))
log.Println("Reporting merge", mergeid, mapid)
a.reportMerge(mergeid, mapid, Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
}
return nil
})
if err != nil {
log.Println(err)
return
}
needProcess := map[zoomproc]struct{}{}
for _, op := range ops {
a.SaveTile(op.mapid, Coord{X: op.x, Y: op.y}, 0, op.f, time.Now().UnixNano())
needProcess[zoomproc{c: Coord{X: op.x, Y: op.y}.Parent(), m: op.mapid}] = struct{}{}
}
for z := 1; z <= 5; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
a.updateZoomLevel(p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
log.Println(greq)
json.NewEncoder(rw).Encode(greq)
}
func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
if strings.Count(req.Header.Get("Content-Type"), "=") >= 2 && strings.Count(req.Header.Get("Content-Type"), "\"") == 0 {
parts := strings.SplitN(req.Header.Get("Content-Type"), "=", 2)
req.Header.Set("Content-Type", parts[0]+"=\""+parts[1]+"\"")
}
err := req.ParseMultipartForm(100000000)
if err != nil {
log.Println(err)
return
}
id := req.FormValue("id")
extraData := req.FormValue("extraData")
if extraData != "" {
ed := ExtraData{}
json.Unmarshal([]byte(extraData), &ed)
if ed.Season == 3 {
needTile := false
a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
curRaw := b.Get([]byte(id))
if curRaw == nil {
return fmt.Errorf("Unknown grid id: %s", id)
}
cur := GridData{}
err = json.Unmarshal(curRaw, &cur)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
maps, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(cur.Map)))
if err != nil {
return err
}
zooms, err := maps.CreateBucketIfNotExists([]byte("0"))
if err != nil {
return err
}
tdRaw := zooms.Get([]byte(cur.Coord.Name()))
if tdRaw == nil {
needTile = true
return nil
}
td := TileData{}
err = json.Unmarshal(tdRaw, &td)
if err != nil {
return err
}
if td.File == "" {
needTile = true
return nil
}
if time.Now().After(cur.NextUpdate) {
cur.NextUpdate = time.Now().Add(time.Minute * 30)
}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
b.Put([]byte(id), raw)
return nil
})
if !needTile {
log.Println("ignoring tile upload: winter")
return
} else {
log.Println("Missing tile, using winter version")
}
}
}
file, _, err := req.FormFile("file")
if err != nil {
log.Println(err)
return
}
log.Println("map tile for ", id)
updateTile := false
cur := GridData{}
mapid := 0
a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
curRaw := b.Get([]byte(id))
if curRaw == nil {
return fmt.Errorf("Unknown grid id: %s", id)
}
err = json.Unmarshal(curRaw, &cur)
if err != nil {
return err
}
updateTile = time.Now().After(cur.NextUpdate)
mapid = cur.Map
if updateTile {
cur.NextUpdate = time.Now().Add(time.Minute * 30)
}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
b.Put([]byte(id), raw)
return nil
})
if updateTile {
os.MkdirAll(fmt.Sprintf("%s/grids", a.gridStorage), 0600)
f, err := os.Create(fmt.Sprintf("%s/grids/%s.png", a.gridStorage, cur.ID))
if err != nil {
return
}
_, err = io.Copy(f, file)
if err != nil {
f.Close()
return
}
f.Close()
a.SaveTile(mapid, cur.Coord, 0, fmt.Sprintf("grids/%s.png", cur.ID), time.Now().UnixNano())
c := cur.Coord
for z := 1; z <= 5; z++ {
c = c.Parent()
a.updateZoomLevel(mapid, c, z)
}
}
}
func (a *App) updateZoomLevel(mapid int, c Coord, z int) {
img := image.NewNRGBA(image.Rect(0, 0, 100, 100))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
for x := 0; x <= 1; x++ {
for y := 0; y <= 1; y++ {
subC := c
subC.X *= 2
subC.Y *= 2
subC.X += x
subC.Y += y
td := a.GetTile(mapid, subC, z-1)
if td == nil || td.File == "" {
continue
}
subf, err := os.Open(filepath.Join(a.gridStorage, td.File))
if err != nil {
continue
}
subimg, _, err := image.Decode(subf)
subf.Close()
if err != nil {
continue
}
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
}
}
os.MkdirAll(fmt.Sprintf("%s/%d/%d", a.gridStorage, mapid, z), 0600)
f, err := os.Create(fmt.Sprintf("%s/%d/%d/%s.png", a.gridStorage, mapid, z, c.Name()))
a.SaveTile(mapid, c, z, fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name()), time.Now().UnixNano())
if err != nil {
return
}
defer func() {
f.Close()
}()
png.Encode(f, img)
}

View File

@@ -1,83 +0,0 @@
package app
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) uploadMarkers(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
markers := []struct {
Name string
GridID string
X, Y int
Image string
Type string
Color string
}{}
buf, err := io.ReadAll(req.Body)
if err != nil {
log.Println("Error reading marker json: ", err)
return
}
err = json.Unmarshal(buf, &markers)
if err != nil {
log.Println("Error decoding marker json: ", err)
log.Println("Original json: ", string(buf))
return
}
err = a.db.Update(func(tx *bbolt.Tx) error {
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
grid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}
for _, mraw := range markers {
key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.X, mraw.Y))
if grid.Get(key) != nil {
continue
}
if mraw.Image == "" {
mraw.Image = "gfx/terobjs/mm/custom"
}
id, err := idB.NextSequence()
if err != nil {
return err
}
idKey := []byte(strconv.Itoa(int(id)))
m := Marker{
Name: mraw.Name,
ID: int(id),
GridID: mraw.GridID,
Position: Position{
X: mraw.X,
Y: mraw.Y,
},
Image: mraw.Image,
}
raw, _ := json.Marshal(m)
grid.Put(key, raw)
idB.Put(idKey, key)
}
return nil
})
if err != nil {
log.Println("Error update db: ", err)
return
}
}

View File

@@ -1,98 +0,0 @@
package app
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) updatePositions(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
craws := map[string]struct {
Name string
GridID string
Coords struct {
X, Y int
}
Type string
}{}
buf, err := io.ReadAll(req.Body)
if err != nil {
log.Println("Error reading position update json: ", err)
return
}
err = json.Unmarshal(buf, &craws)
if err != nil {
log.Println("Error decoding position update json: ", err)
log.Println("Original json: ", string(buf))
return
}
// Read grid data first (inside db.View), then update characters (with chmu only).
// Avoid holding db.View and chmu simultaneously to prevent deadlock.
gridDataByID := make(map[string]GridData)
a.db.View(func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
for _, craw := range craws {
grid := grids.Get([]byte(craw.GridID))
if grid != nil {
var gd GridData
if json.Unmarshal(grid, &gd) == nil {
gridDataByID[craw.GridID] = gd
}
}
}
return nil
})
a.chmu.Lock()
defer a.chmu.Unlock()
for id, craw := range craws {
gd, ok := gridDataByID[craw.GridID]
if !ok {
continue
}
idnum, _ := strconv.Atoi(id)
c := Character{
Name: craw.Name,
ID: idnum,
Map: gd.Map,
Position: Position{
X: craw.Coords.X + (gd.Coord.X * 100),
Y: craw.Coords.Y + (gd.Coord.Y * 100),
},
Type: craw.Type,
updated: time.Now(),
}
old, ok := a.characters[id]
if !ok {
a.characters[id] = c
} else {
if old.Type == "player" {
if c.Type == "player" {
a.characters[id] = c
} else {
old.Position = c.Position
a.characters[id] = old
}
} else if old.Type != "unknown" {
if c.Type != "unknown" {
a.characters[id] = c
} else {
old.Position = c.Position
a.characters[id] = old
}
} else {
a.characters[id] = c
}
}
}
}

View File

@@ -0,0 +1,441 @@
package handlers
import (
"archive/zip"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/andyleap/hnh-map/internal/app"
)
type mapInfoJSON struct {
ID int `json:"ID"`
Name string `json:"Name"`
Hidden bool `json:"Hidden"`
Priority bool `json:"Priority"`
}
// APIAdminUsers handles GET/POST /map/api/admin/users.
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
if req.Method == http.MethodGet {
if h.requireAdmin(rw, req) == nil {
return
}
list, err := h.Admin.ListUsers(ctx)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, list)
return
}
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
var body struct {
User string `json:"user"`
Pass string `json:"pass"`
Auths []string `json:"auths"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths)
if err != nil {
HandleServiceError(rw, err)
return
}
if body.User == s.Username {
s.Auths = body.Auths
}
if adminCreated && s.Username == "admin" {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
auths, found := h.Admin.GetUser(req.Context(), name)
out := struct {
Username string `json:"username"`
Auths []string `json:"auths"`
}{Username: name}
if found {
out.Auths = auths
}
JSON(rw, http.StatusOK, out)
}
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodDelete {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
ctx := req.Context()
if err := h.Admin.DeleteUser(ctx, name); err != nil {
HandleServiceError(rw, err)
return
}
if name == s.Username {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, struct {
Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"`
Title string `json:"title"`
}{Prefix: prefix, DefaultHide: defaultHide, Title: title})
}
// APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Prefix *string `json:"prefix"`
DefaultHide *bool `json:"defaultHide"`
Title *string `json:"title"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
maps, err := h.Admin.ListMaps(req.Context())
if err != nil {
HandleServiceError(rw, err)
return
}
out := make([]mapInfoJSON, len(maps))
for i, m := range maps {
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
}
JSON(rw, http.StatusOK, out)
}
// APIAdminMapByID handles POST /map/api/admin/maps/:id.
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Name string `json:"name"`
Hidden bool `json:"hidden"`
Priority bool `json:"priority"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, mapInfoJSON{
ID: mi.ID,
Name: mi.Name,
Hidden: mi.Hidden,
Priority: mi.Priority,
})
}
// APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := h.Admin.Wipe(req.Context()); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminWipeTile handles POST /map/api/admin/wipeTile.
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
x, err := strconv.Atoi(req.FormValue("x"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
y, err := strconv.Atoi(req.FormValue("y"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSetCoords handles POST /map/api/admin/setCoords.
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
fx, err := strconv.Atoi(req.FormValue("fx"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
fy, err := strconv.Atoi(req.FormValue("fy"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
tx, err := strconv.Atoi(req.FormValue("tx"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
ty, err := strconv.Atoi(req.FormValue("ty"))
if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return
}
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminHideMarker handles POST /map/api/admin/hideMarker.
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil {
return
}
markerID := req.FormValue("id")
if markerID == "" {
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
return
}
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
h.Admin.RebuildZooms(req.Context())
rw.WriteHeader(http.StatusOK)
}
// APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
rw.Header().Set("Content-Type", "application/zip")
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
if err := h.Export.Export(req.Context(), rw); err != nil {
HandleServiceError(rw, err)
}
}
// APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
mergef, hdr, err := req.FormFile("merge")
if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
zr, err := zip.NewReader(mergef, hdr.Size)
if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return
}
if err := h.Export.Merge(req.Context(), zr); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminRoute routes /map/api/admin/* sub-paths.
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
switch {
case path == "wipeTile":
h.APIAdminWipeTile(rw, req)
case path == "setCoords":
h.APIAdminSetCoords(rw, req)
case path == "hideMarker":
h.APIAdminHideMarker(rw, req)
case path == "users":
h.APIAdminUsers(rw, req)
case strings.HasPrefix(path, "users/"):
name := strings.TrimPrefix(path, "users/")
if name == "" {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return
}
if req.Method == http.MethodDelete {
h.APIAdminUserDelete(rw, req, name)
} else {
h.APIAdminUserByName(rw, req, name)
}
case path == "settings":
if req.Method == http.MethodGet {
h.APIAdminSettingsGet(rw, req)
} else {
h.APIAdminSettingsPost(rw, req)
}
case path == "maps":
h.APIAdminMaps(rw, req)
case strings.HasPrefix(path, "maps/"):
rest := strings.TrimPrefix(path, "maps/")
parts := strings.SplitN(rest, "/", 2)
idStr := parts[0]
if len(parts) == 2 && parts[1] == "toggle-hidden" {
h.APIAdminMapToggleHidden(rw, req, idStr)
return
}
if len(parts) == 1 {
h.APIAdminMapByID(rw, req, idStr)
return
}
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
case path == "wipe":
h.APIAdminWipe(rw, req)
case path == "rebuildZooms":
h.APIAdminRebuildZooms(rw, req)
case path == "export":
h.APIAdminExport(rw, req)
case path == "merge":
h.APIAdminMerge(rw, req)
default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}
}

View File

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

View File

@@ -0,0 +1,224 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
)
type loginRequest struct {
User string `json:"user"`
Pass string `json:"pass"`
}
type meResponse struct {
Username string `json:"username"`
Auths []string `json:"auths"`
Tokens []string `json:"tokens,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
type passwordRequest struct {
Pass string `json:"pass"`
}
// APILogin handles POST /map/api/login.
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
var body loginRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if u := h.Auth.GetUserByUsername(ctx, body.User); u != nil && u.Pass == nil {
JSONError(rw, http.StatusUnauthorized, "Use OAuth to sign in", "OAUTH_ONLY")
return
}
u := h.Auth.GetUser(ctx, body.User, body.Pass)
if u == nil {
if boot := h.Auth.BootstrapAdmin(ctx, body.User, body.Pass, services.GetBootstrapPassword()); boot != nil {
u = boot
} else {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
}
sessionID := h.Auth.CreateSession(ctx, body.User, u.Auths.Has("tempadmin"))
if sessionID == "" {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
http.SetCookie(rw, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
MaxAge: app.SessionMaxAge,
HttpOnly: true,
Secure: req.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
JSON(rw, http.StatusOK, meResponse{Username: body.User, Auths: u.Auths})
}
// APISetup handles GET /map/api/setup.
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
JSON(rw, http.StatusOK, struct {
SetupRequired bool `json:"setupRequired"`
}{SetupRequired: h.Auth.SetupRequired(req.Context())})
}
// APILogout handles POST /map/api/logout.
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s != nil {
h.Auth.DeleteSession(ctx, s)
}
rw.WriteHeader(http.StatusOK)
}
// APIMe handles GET /map/api/me.
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
out := meResponse{Username: s.Username, Auths: s.Auths}
out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(ctx, s.Username)
JSON(rw, http.StatusOK, out)
}
// APIMeTokens handles POST /map/api/me/tokens.
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_UPLOAD) {
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
return
}
tokens := h.Auth.GenerateTokenForUser(ctx, s.Username)
if tokens == nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, map[string][]string{"tokens": tokens})
}
// APIMePassword handles POST /map/api/me/password.
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body passwordRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Auth.SetUserPassword(ctx, s.Username, body.Pass); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
rw.WriteHeader(http.StatusOK)
}
// APIOAuthProviders handles GET /map/api/oauth/providers.
func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
JSON(rw, http.StatusOK, services.OAuthProviders())
}
// APIOAuthLogin handles GET /map/api/oauth/:provider/login.
func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
redirect := req.URL.Query().Get("redirect")
authURL, err := h.Auth.OAuthInitLogin(req.Context(), provider, redirect, req)
if err != nil {
HandleServiceError(rw, err)
return
}
http.Redirect(rw, req, authURL, http.StatusFound)
}
// APIOAuthCallback handles GET /map/api/oauth/:provider/callback.
func (h *Handlers) APIOAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
code := req.URL.Query().Get("code")
state := req.URL.Query().Get("state")
if code == "" || state == "" {
JSONError(rw, http.StatusBadRequest, "missing code or state", "BAD_REQUEST")
return
}
sessionID, redirectTo, err := h.Auth.OAuthHandleCallback(req.Context(), provider, code, state, req)
if err != nil {
HandleServiceError(rw, err)
return
}
http.SetCookie(rw, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
MaxAge: app.SessionMaxAge,
HttpOnly: true,
Secure: req.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(rw, req, redirectTo, http.StatusFound)
}
// RedirectLogout handles GET /logout.
func (h *Handlers) RedirectLogout(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/logout" {
http.NotFound(rw, req)
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s != nil {
h.Auth.DeleteSession(ctx, s)
}
http.Redirect(rw, req, "/login", http.StatusFound)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
package handlers
import (
"net/http"
"github.com/andyleap/hnh-map/internal/app"
)
// APIConfig handles GET /map/api/config.
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
config, err := h.Map.GetConfig(ctx, s.Auths)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, config)
}
// APIGetChars handles GET /map/api/v1/characters.
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
JSON(rw, http.StatusOK, []interface{}{})
return
}
chars := h.Map.GetCharacters()
JSON(rw, http.StatusOK, chars)
}
// APIGetMarkers handles GET /map/api/v1/markers.
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
JSON(rw, http.StatusOK, []interface{}{})
return
}
markers, err := h.Map.GetMarkers(ctx)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, markers)
}
// APIGetMaps handles GET /map/api/maps.
func (h *Handlers) APIGetMaps(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
showHidden := s.Auths.Has(app.AUTH_ADMIN)
maps, err := h.Map.GetMaps(ctx, showHidden)
if err != nil {
HandleServiceError(rw, err)
return
}
JSON(rw, http.StatusOK, maps)
}

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
package app
import (
"net/http"
)
func (a *App) redirectLogout(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/logout" {
http.NotFound(rw, req)
return
}
s := a.getSession(req)
if s != nil {
a.deleteSession(s)
}
http.Redirect(rw, req, "/login", http.StatusFound)
}

View File

@@ -1,145 +0,0 @@
package app
import (
"encoding/json"
"net/http"
"strconv"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
type Config struct {
Title string `json:"title"`
Auths []string `json:"auths"`
}
func (a *App) canAccessMap(s *Session) bool {
return s != nil && (s.Auths.Has(AUTH_MAP) || s.Auths.Has(AUTH_ADMIN))
}
func (a *App) getChars(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
if !s.Auths.Has(AUTH_MARKERS) && !s.Auths.Has(AUTH_ADMIN) {
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode([]interface{}{})
return
}
chars := []Character{}
a.chmu.RLock()
defer a.chmu.RUnlock()
for _, v := range a.characters {
chars = append(chars, v)
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(chars)
}
func (a *App) getMarkers(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
if !s.Auths.Has(AUTH_MARKERS) && !s.Auths.Has(AUTH_ADMIN) {
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode([]interface{}{})
return
}
markers := []FrontendMarker{}
a.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketMarkers)
if b == nil {
return nil
}
grid := b.Bucket(store.BucketMarkersGrid)
if grid == nil {
return nil
}
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
return grid.ForEach(func(k, v []byte) error {
marker := Marker{}
json.Unmarshal(v, &marker)
graw := grids.Get([]byte(marker.GridID))
if graw == nil {
return nil
}
g := GridData{}
json.Unmarshal(graw, &g)
markers = append(markers, FrontendMarker{
Image: marker.Image,
Hidden: marker.Hidden,
ID: marker.ID,
Name: marker.Name,
Map: g.Map,
Position: Position{
X: marker.Position.X + g.Coord.X*100,
Y: marker.Position.Y + g.Coord.Y*100,
},
})
return nil
})
})
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(markers)
}
func (a *App) getMaps(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
showHidden := s.Auths.Has(AUTH_ADMIN)
maps := map[int]*MapInfo{}
a.db.View(func(tx *bbolt.Tx) error {
mapB := tx.Bucket(store.BucketMaps)
if mapB == nil {
return nil
}
return mapB.ForEach(func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k))
if err != nil {
return nil
}
mi := &MapInfo{}
json.Unmarshal(v, &mi)
if mi.Hidden && !showHidden {
return nil
}
maps[mapid] = mi
return nil
})
})
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(maps)
}
func (a *App) config(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if s == nil {
rw.WriteHeader(http.StatusUnauthorized)
return
}
config := Config{
Auths: s.Auths,
}
a.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig)
if b == nil {
return nil
}
title := b.Get([]byte("title"))
config.Title = string(title)
return nil
})
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(config)
}

View File

@@ -0,0 +1,93 @@
package app_test
import (
"path/filepath"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func newTestDB(t *testing.T) *bbolt.DB {
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() })
return db
}
func TestRunMigrations_FreshDB(t *testing.T) {
db := newTestDB(t)
if err := app.RunMigrations(db); err != nil {
t.Fatalf("migrations failed on fresh DB: %v", err)
}
db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig)
if b == nil {
t.Fatal("expected config bucket after migrations")
}
v := b.Get([]byte("version"))
if v == nil {
t.Fatal("expected version key in config")
}
title := b.Get([]byte("title"))
if title == nil || string(title) != "HnH Automapper Server" {
t.Fatalf("expected default title, got %s", title)
}
return nil
})
if tx, _ := db.Begin(false); tx != nil {
if tx.Bucket(store.BucketOAuthStates) == nil {
t.Fatal("expected oauth_states bucket after migrations")
}
tx.Rollback()
}
}
func TestRunMigrations_Idempotent(t *testing.T) {
db := newTestDB(t)
if err := app.RunMigrations(db); err != nil {
t.Fatalf("first run failed: %v", err)
}
if err := app.RunMigrations(db); err != nil {
t.Fatalf("second run failed: %v", err)
}
db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig)
if b == nil {
t.Fatal("expected config bucket")
}
v := b.Get([]byte("version"))
if v == nil {
t.Fatal("expected version key")
}
return nil
})
}
func TestRunMigrations_SetsVersion(t *testing.T) {
db := newTestDB(t)
if err := app.RunMigrations(db); err != nil {
t.Fatal(err)
}
var version string
db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig)
version = string(b.Get([]byte("version")))
return nil
})
if version == "" || version == "0" {
t.Fatalf("expected non-zero version, got %q", version)
}
}

View File

@@ -1,312 +0,0 @@
package app
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const oauthStateTTL = 10 * time.Minute
type oauthState struct {
Provider string `json:"provider"`
RedirectURI string `json:"redirect_uri,omitempty"`
CreatedAt int64 `json:"created_at"`
}
// oauthConfig returns OAuth2 config for the given provider, or nil if not configured.
func (a *App) oauthConfig(provider string, baseURL string) *oauth2.Config {
switch provider {
case "google":
clientID := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return nil
}
redirectURL := strings.TrimSuffix(baseURL, "/") + "/map/api/oauth/google/callback"
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
default:
return nil
}
}
func (a *App) baseURL(req *http.Request) string {
if base := os.Getenv("HNHMAP_BASE_URL"); base != "" {
return strings.TrimSuffix(base, "/")
}
scheme := "https"
if req.TLS == nil {
scheme = "http"
}
host := req.Host
if h := req.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
return scheme + "://" + host
}
func (a *App) OAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
baseURL := a.baseURL(req)
cfg := a.oauthConfig(provider, baseURL)
if cfg == nil {
http.Error(rw, "OAuth provider not configured", http.StatusServiceUnavailable)
return
}
state := make([]byte, 32)
if _, err := rand.Read(state); err != nil {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
stateStr := hex.EncodeToString(state)
redirect := req.URL.Query().Get("redirect")
st := oauthState{
Provider: provider,
RedirectURI: redirect,
CreatedAt: time.Now().Unix(),
}
stRaw, _ := json.Marshal(st)
err := a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(store.BucketOAuthStates)
if err != nil {
return err
}
return b.Put([]byte(stateStr), stRaw)
})
if err != nil {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
authURL := cfg.AuthCodeURL(stateStr, oauth2.AccessTypeOffline)
http.Redirect(rw, req, authURL, http.StatusFound)
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
func (a *App) OAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
code := req.URL.Query().Get("code")
state := req.URL.Query().Get("state")
if code == "" || state == "" {
http.Error(rw, "missing code or state", http.StatusBadRequest)
return
}
baseURL := a.baseURL(req)
cfg := a.oauthConfig(provider, baseURL)
if cfg == nil {
http.Error(rw, "OAuth provider not configured", http.StatusServiceUnavailable)
return
}
var st oauthState
err := a.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketOAuthStates)
if b == nil {
return nil
}
raw := b.Get([]byte(state))
if raw == nil {
return nil
}
json.Unmarshal(raw, &st)
return b.Delete([]byte(state))
})
if err != nil || st.Provider == "" {
http.Error(rw, "invalid or expired state", http.StatusBadRequest)
return
}
if time.Since(time.Unix(st.CreatedAt, 0)) > oauthStateTTL {
http.Error(rw, "state expired", http.StatusBadRequest)
return
}
if st.Provider != provider {
http.Error(rw, "state mismatch", http.StatusBadRequest)
return
}
tok, err := cfg.Exchange(req.Context(), code)
if err != nil {
http.Error(rw, "OAuth exchange failed", http.StatusBadRequest)
return
}
var sub, email string
switch provider {
case "google":
sub, email, err = a.googleUserInfo(tok.AccessToken)
if err != nil {
http.Error(rw, "failed to get user info", http.StatusInternalServerError)
return
}
default:
http.Error(rw, "unsupported provider", http.StatusBadRequest)
return
}
username, _ := a.findOrCreateOAuthUser(provider, sub, email)
if username == "" {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
sessionID := a.createSession(username, false)
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,
})
redirectTo := "/profile"
if st.RedirectURI != "" {
if u, err := url.Parse(st.RedirectURI); err == nil && u.Path != "" && !strings.HasPrefix(u.Path, "//") {
redirectTo = u.Path
if u.RawQuery != "" {
redirectTo += "?" + u.RawQuery
}
}
}
http.Redirect(rw, req, redirectTo, http.StatusFound)
}
func (a *App) googleUserInfo(accessToken string) (sub, email string, err error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v3/userinfo", nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", err
}
var info googleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", "", err
}
return info.Sub, info.Email, nil
}
// findOrCreateOAuthUser finds user by oauth_links[provider]==sub, or creates new user.
// Returns (username, nil) or ("", err) on error.
func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User) {
var username string
err := a.db.Update(func(tx *bbolt.Tx) error {
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
if err != nil {
return err
}
// Search by OAuth link
_ = users.ForEach(func(k, v []byte) error {
user := User{}
if json.Unmarshal(v, &user) != nil {
return nil
}
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
username = string(k)
return nil
}
return nil
})
if username != "" {
// Update OAuthLinks if needed
raw := users.Get([]byte(username))
if raw != nil {
user := User{}
json.Unmarshal(raw, &user)
if user.OAuthLinks == nil {
user.OAuthLinks = map[string]string{provider: sub}
} else {
user.OAuthLinks[provider] = sub
}
raw, _ = json.Marshal(user)
users.Put([]byte(username), raw)
}
return nil
}
// Create new user
username = email
if username == "" {
username = provider + "_" + sub
}
// Check if username already exists (e.g. local user with same email)
if users.Get([]byte(username)) != nil {
username = provider + "_" + sub
}
newUser := &User{
Pass: nil,
Auths: Auths{AUTH_MAP, AUTH_MARKERS, AUTH_UPLOAD},
OAuthLinks: map[string]string{provider: sub},
}
raw, _ := json.Marshal(newUser)
return users.Put([]byte(username), raw)
})
if err != nil {
return "", nil
}
return username, nil
}
// getUserByUsername returns user without password check (for OAuth-only check).
func (a *App) getUserByUsername(username string) *User {
var u *User
a.db.View(func(tx *bbolt.Tx) error {
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
raw := users.Get([]byte(username))
if raw != nil {
json.Unmarshal(raw, &u)
}
return nil
})
return u
}
// apiOAuthProviders returns list of configured OAuth providers.
func (a *App) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var providers []string
if os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID") != "" && os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET") != "" {
providers = append(providers, "google")
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(providers)
}

View File

@@ -9,29 +9,27 @@ import (
// APIHandler is the interface for API routing (implemented by handlers.Handlers).
type APIHandler interface {
APIRouter(rw http.ResponseWriter, req *http.Request)
ClientRouter(rw http.ResponseWriter, req *http.Request)
RedirectLogout(rw http.ResponseWriter, req *http.Request)
WatchGridUpdates(rw http.ResponseWriter, req *http.Request)
GridTile(rw http.ResponseWriter, req *http.Request)
}
// Router returns the HTTP router for the app.
// publicDir is used for /js/ static file serving (e.g. "public").
// apiHandler handles /map/api/* requests; if nil, uses built-in apiRouter.
func (a *App) Router(publicDir string, apiHandler APIHandler) http.Handler {
func (a *App) Router(publicDir string, h APIHandler) http.Handler {
r := chi.NewRouter()
r.Handle("/js/*", http.FileServer(http.Dir(publicDir)))
r.HandleFunc("/client/*", a.client)
r.HandleFunc("/logout", a.redirectLogout)
r.HandleFunc("/client/*", h.ClientRouter)
r.HandleFunc("/logout", h.RedirectLogout)
r.Route("/map", func(r chi.Router) {
if apiHandler != nil {
r.HandleFunc("/api/*", apiHandler.APIRouter)
} else {
r.HandleFunc("/api/*", a.apiRouter)
}
r.HandleFunc("/updates", a.watchGridUpdates)
r.Handle("/grids/*", http.StripPrefix("/map/grids", http.HandlerFunc(a.gridTile)))
r.HandleFunc("/api/*", h.APIRouter)
r.HandleFunc("/updates", h.WatchGridUpdates)
r.Handle("/grids/*", http.StripPrefix("/map/grids", http.HandlerFunc(h.GridTile)))
})
r.HandleFunc("/*", a.serveSPARoot)
r.HandleFunc("/*", a.ServeSPARoot)
return r
}

View File

@@ -1,7 +1,10 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"github.com/andyleap/hnh-map/internal/app"
@@ -10,20 +13,21 @@ import (
"golang.org/x/crypto/bcrypt"
)
// AdminService handles admin business logic (users, settings, maps, wipe).
// AdminService handles admin business logic (users, settings, maps, wipe, tile ops).
type AdminService struct {
st *store.Store
st *store.Store
mapSvc *MapService
}
// NewAdminService creates an AdminService.
func NewAdminService(st *store.Store) *AdminService {
return &AdminService{st: st}
// NewAdminService creates an AdminService with the given store and map service.
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
return &AdminService{st: st, mapSvc: mapSvc}
}
// ListUsers returns all usernames.
func (s *AdminService) ListUsers() ([]string, error) {
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
var list []string
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachUser(tx, func(k, _ []byte) error {
list = append(list, string(k))
return nil
@@ -32,9 +36,9 @@ func (s *AdminService) ListUsers() ([]string, error) {
return list, err
}
// GetUser returns user auths by username.
func (s *AdminService) GetUser(username string) (auths app.Auths, found bool) {
s.st.View(func(tx *bbolt.Tx) error {
// GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool) {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
@@ -49,9 +53,9 @@ func (s *AdminService) GetUser(username string) (auths app.Auths, found bool) {
}
// CreateOrUpdateUser creates or updates a user.
// Returns (true, nil) when admin user was created and didn't exist before (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(func(tx *bbolt.Tx) error {
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
existed := s.st.GetUser(tx, username) != nil
u := app.User{}
raw := s.st.GetUser(tx, username)
@@ -79,8 +83,8 @@ func (s *AdminService) CreateOrUpdateUser(username string, pass string, auths ap
}
// DeleteUser removes a user and their tokens.
func (s *AdminService) DeleteUser(username string) error {
return s.st.Update(func(tx *bbolt.Tx) error {
func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
@@ -93,9 +97,9 @@ func (s *AdminService) DeleteUser(username string) error {
})
}
// GetSettings returns prefix, defaultHide, title.
func (s *AdminService) GetSettings() (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(func(tx *bbolt.Tx) error {
// GetSettings returns the current server settings.
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
if v := s.st.GetConfig(tx, "prefix"); v != nil {
prefix = string(v)
}
@@ -110,9 +114,9 @@ func (s *AdminService) GetSettings() (prefix string, defaultHide bool, title str
return prefix, defaultHide, title, err
}
// UpdateSettings updates config keys.
func (s *AdminService) UpdateSettings(prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(func(tx *bbolt.Tx) error {
// UpdateSettings updates the specified server settings (nil fields are skipped).
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
if prefix != nil {
s.st.PutConfig(tx, "prefix", []byte(*prefix))
}
@@ -130,10 +134,10 @@ func (s *AdminService) UpdateSettings(prefix *string, defaultHide *bool, title *
})
}
// ListMaps returns all maps.
func (s *AdminService) ListMaps() ([]app.MapInfo, error) {
// ListMaps returns all maps for the admin panel.
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
var maps []app.MapInfo
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{}
json.Unmarshal(v, &mi)
@@ -148,9 +152,9 @@ func (s *AdminService) ListMaps() ([]app.MapInfo, error) {
}
// GetMap returns a map by ID.
func (s *AdminService) GetMap(id int) (*app.MapInfo, bool) {
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool) {
var mi *app.MapInfo
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
if raw != nil {
mi = &app.MapInfo{}
@@ -162,9 +166,9 @@ func (s *AdminService) GetMap(id int) (*app.MapInfo, bool) {
return mi, mi != nil
}
// UpdateMap updates map name, hidden, priority.
func (s *AdminService) UpdateMap(id int, name string, hidden, priority bool) error {
return s.st.Update(func(tx *bbolt.Tx) error {
// UpdateMap updates a map's name, hidden, and priority fields.
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
mi := app.MapInfo{}
raw := s.st.GetMap(tx, id)
if raw != nil {
@@ -179,10 +183,10 @@ func (s *AdminService) UpdateMap(id int, name string, hidden, priority bool) err
})
}
// ToggleMapHidden flips the hidden flag.
func (s *AdminService) ToggleMapHidden(id int) (*app.MapInfo, error) {
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
var mi *app.MapInfo
err := s.st.Update(func(tx *bbolt.Tx) error {
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{}
if raw != nil {
@@ -196,9 +200,9 @@ func (s *AdminService) ToggleMapHidden(id int) (*app.MapInfo, error) {
return mi, err
}
// Wipe deletes grids, markers, tiles, maps buckets.
func (s *AdminService) Wipe() error {
return s.st.Update(func(tx *bbolt.Tx) error {
// Wipe deletes all grids, markers, tiles, and maps from the database.
func (s *AdminService) Wipe(ctx context.Context) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
for _, b := range [][]byte{
store.BucketGrids,
store.BucketMarkers,
@@ -214,3 +218,137 @@ func (s *AdminService) Wipe() error {
return nil
})
}
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
c := app.Coord{X: x, Y: y}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
var ids [][]byte
err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil {
return err
}
if g.Coord == c && g.Map == mapid {
ids = append(ids, k)
}
return nil
})
if err != nil {
return err
}
for _, id := range ids {
grids.Delete(id)
}
return nil
}); err != nil {
return err
}
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
zc := c
for z := 1; z <= app.MaxZoomLevel; z++ {
zc = zc.Parent()
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
}
return nil
}
// SetCoords shifts all grid and tile coordinates by a delta.
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
fc := app.Coord{X: fx, Y: fy}
tc := app.Coord{X: tx2, Y: ty}
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
var tds []*app.TileData
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapZooms == nil {
return nil
}
mapTiles := mapZooms.Bucket([]byte("0"))
if err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil {
return err
}
if g.Map == mapid {
g.Coord.X += diff.X
g.Coord.Y += diff.Y
raw, _ := json.Marshal(g)
grids.Put(k, raw)
}
return nil
}); err != nil {
return err
}
if err := mapTiles.ForEach(func(k, v []byte) error {
td := &app.TileData{}
if err := json.Unmarshal(v, td); err != nil {
return err
}
td.Coord.X += diff.X
td.Coord.Y += diff.Y
tds = append(tds, td)
return nil
}); err != nil {
return err
}
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
}); err != nil {
return err
}
ops := make([]TileOp, len(tds))
for i, td := range tds {
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
}
s.mapSvc.ProcessZoomLevels(ctx, ops)
return nil
}
// HideMarker marks a marker as hidden.
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
_, idB, err := s.st.CreateMarkersBuckets(tx)
if err != nil {
return err
}
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return fmt.Errorf("markers grid bucket not found")
}
key := idB.Get([]byte(markerID))
if key == nil {
slog.Warn("marker not found", "id", markerID)
return nil
}
raw := grid.Get(key)
if raw == nil {
return nil
}
m := app.Marker{}
json.Unmarshal(raw, &m)
m.Hidden = true
raw, _ = json.Marshal(m)
grid.Put(key, raw)
return nil
})
}
// RebuildZooms delegates to MapService.
func (s *AdminService) RebuildZooms(ctx context.Context) {
s.mapSvc.RebuildZooms(ctx)
}

View File

@@ -0,0 +1,292 @@
package services_test
import (
"context"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
t.Helper()
db := newTestDB(t)
st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{},
})
return services.NewAdminService(st, mapSvc), st
}
func TestListUsers_Empty(t *testing.T) {
admin, _ := newTestAdmin(t)
users, err := admin.ListUsers(context.Background())
if err != nil {
t.Fatal(err)
}
if len(users) != 0 {
t.Fatalf("expected 0 users, got %d", len(users))
}
}
func TestListUsers_WithUsers(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", nil)
createUser(t, st, "bob", "pass", nil)
users, err := admin.ListUsers(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
}
func TestAdminGetUser_Found(t *testing.T) {
admin, st := newTestAdmin(t)
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
auths, found := admin.GetUser(context.Background(), "alice")
if !found {
t.Fatal("expected found")
}
if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found := admin.GetUser(context.Background(), "ghost")
if found {
t.Fatal("expected not found")
}
}
func TestCreateOrUpdateUser_New(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
auths, found := admin.GetUser(ctx, "bob")
if !found {
t.Fatal("expected user to exist")
}
if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestCreateOrUpdateUser_Update(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
auths, found := admin.GetUser(ctx, "alice")
if !found {
t.Fatal("expected user")
}
if !auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth after update")
}
}
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if !adminCreated {
t.Fatal("expected adminCreated=true for new admin user")
}
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if adminCreated {
t.Fatal("expected adminCreated=false for existing admin user")
}
}
func TestDeleteUser(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
auth := services.NewAuthService(st)
auth.GenerateTokenForUser(ctx, "alice")
if err := admin.DeleteUser(ctx, "alice"); err != nil {
t.Fatal(err)
}
_, found := admin.GetUser(ctx, "alice")
if found {
t.Fatal("expected user to be deleted")
}
}
func TestGetSettings_Defaults(t *testing.T) {
admin, _ := newTestAdmin(t)
prefix, defaultHide, title, err := admin.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if prefix != "" || defaultHide || title != "" {
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
}
}
func TestUpdateSettings(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
p := "pfx"
dh := true
ti := "My Map"
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
t.Fatal(err)
}
prefix, defaultHide, title, err := admin.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if prefix != "pfx" {
t.Fatalf("expected pfx, got %s", prefix)
}
if !defaultHide {
t.Fatal("expected defaultHide=true")
}
if title != "My Map" {
t.Fatalf("expected My Map, got %s", title)
}
dh2 := false
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
t.Fatal(err)
}
_, defaultHide2, _, _ := admin.GetSettings(ctx)
if defaultHide2 {
t.Fatal("expected defaultHide=false after update")
}
}
func TestListMaps_Empty(t *testing.T) {
admin, _ := newTestAdmin(t)
maps, err := admin.ListMaps(context.Background())
if err != nil {
t.Fatal(err)
}
if len(maps) != 0 {
t.Fatalf("expected 0 maps, got %d", len(maps))
}
}
func TestMapCRUD(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
t.Fatal(err)
}
mi, found := admin.GetMap(ctx, 1)
if !found || mi == nil {
t.Fatal("expected map")
}
if mi.Name != "world" {
t.Fatalf("expected world, got %s", mi.Name)
}
maps, err := admin.ListMaps(ctx)
if err != nil {
t.Fatal(err)
}
if len(maps) != 1 {
t.Fatalf("expected 1 map, got %d", len(maps))
}
}
func TestToggleMapHidden(t *testing.T) {
admin, _ := newTestAdmin(t)
ctx := context.Background()
admin.UpdateMap(ctx, 1, "world", false, false)
mi, err := admin.ToggleMapHidden(ctx, 1)
if err != nil {
t.Fatal(err)
}
if !mi.Hidden {
t.Fatal("expected hidden=true after toggle")
}
mi, err = admin.ToggleMapHidden(ctx, 1)
if err != nil {
t.Fatal(err)
}
if mi.Hidden {
t.Fatal("expected hidden=false after second toggle")
}
}
func TestWipe(t *testing.T) {
admin, st := newTestAdmin(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutGrid(tx, "g1", []byte("data"))
st.PutMap(tx, 1, []byte("data"))
st.PutTile(tx, 1, 0, "0_0", []byte("data"))
st.CreateMarkersBuckets(tx)
return nil
})
if err := admin.Wipe(ctx); err != nil {
t.Fatal(err)
}
st.View(ctx, func(tx *bbolt.Tx) error {
if st.GetGrid(tx, "g1") != nil {
t.Fatal("expected grids wiped")
}
if st.GetMap(tx, 1) != nil {
t.Fatal("expected maps wiped")
}
if st.GetTile(tx, 1, 0, "0_0") != nil {
t.Fatal("expected tiles wiped")
}
if st.GetMarkersGridBucket(tx) != nil {
t.Fatal("expected markers wiped")
}
return nil
})
}
func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found := admin.GetMap(context.Background(), 999)
if found {
t.Fatal("expected not found")
}
}

View File

@@ -1,36 +1,58 @@
package services
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/apperr"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// AuthService handles authentication and session business logic.
const oauthStateTTL = 10 * time.Minute
type oauthState struct {
Provider string `json:"provider"`
RedirectURI string `json:"redirect_uri,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
// AuthService handles authentication, sessions, and OAuth business logic.
type AuthService struct {
st *store.Store
}
// NewAuthService creates an AuthService.
// NewAuthService creates an AuthService with the given store.
func NewAuthService(st *store.Store) *AuthService {
return &AuthService{st: st}
}
// GetSession returns the session from the request cookie, or nil.
func (s *AuthService) GetSession(req *http.Request) *app.Session {
func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Session {
c, err := req.Cookie("session")
if err != nil {
return nil
}
var sess *app.Session
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetSession(tx, c.Value)
if raw == nil {
return nil
@@ -59,15 +81,17 @@ func (s *AuthService) GetSession(req *http.Request) *app.Session {
}
// DeleteSession removes a session.
func (s *AuthService) DeleteSession(sess *app.Session) {
s.st.Update(func(tx *bbolt.Tx) error {
func (s *AuthService) DeleteSession(ctx context.Context, sess *app.Session) {
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
return s.st.DeleteSession(tx, sess.ID)
})
}); err != nil {
slog.Error("failed to delete session", "session_id", sess.ID, "error", err)
}
}
// SaveSession stores a session.
func (s *AuthService) SaveSession(sess *app.Session) error {
return s.st.Update(func(tx *bbolt.Tx) error {
func (s *AuthService) SaveSession(ctx context.Context, sess *app.Session) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
buf, err := json.Marshal(sess)
if err != nil {
return err
@@ -77,7 +101,7 @@ func (s *AuthService) SaveSession(sess *app.Session) error {
}
// CreateSession creates a session for username, returns session ID or empty string.
func (s *AuthService) CreateSession(username string, tempAdmin bool) string {
func (s *AuthService) CreateSession(ctx context.Context, username string, tempAdmin bool) string {
session := make([]byte, 32)
if _, err := rand.Read(session); err != nil {
return ""
@@ -88,16 +112,16 @@ func (s *AuthService) CreateSession(username string, tempAdmin bool) string {
Username: username,
TempAdmin: tempAdmin,
}
if s.SaveSession(sess) != nil {
if s.SaveSession(ctx, sess) != nil {
return ""
}
return sid
}
// GetUser returns user if username/password match.
func (s *AuthService) GetUser(username, pass string) *app.User {
func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User {
var u *app.User
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
@@ -117,9 +141,9 @@ func (s *AuthService) GetUser(username, pass string) *app.User {
}
// GetUserByUsername returns user without password check (for OAuth-only check).
func (s *AuthService) GetUserByUsername(username string) *app.User {
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User {
var u *app.User
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw != nil {
json.Unmarshal(raw, &u)
@@ -130,9 +154,9 @@ func (s *AuthService) GetUserByUsername(username string) *app.User {
}
// SetupRequired returns true if no users exist (first run).
func (s *AuthService) SetupRequired() bool {
func (s *AuthService) SetupRequired(ctx context.Context) bool {
var required bool
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
if s.st.UserCount(tx) == 0 {
required = true
}
@@ -142,14 +166,13 @@ func (s *AuthService) SetupRequired() bool {
}
// BootstrapAdmin creates the first admin user if bootstrap env is set and no users exist.
// Returns the user if created, nil otherwise.
func (s *AuthService) BootstrapAdmin(username, pass, bootstrapEnv string) *app.User {
func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootstrapEnv string) *app.User {
if username != "admin" || pass == "" || bootstrapEnv == "" || pass != bootstrapEnv {
return nil
}
var created bool
var u *app.User
s.st.Update(func(tx *bbolt.Tx) error {
s.st.Update(ctx, func(tx *bbolt.Tx) error {
if s.st.GetUser(tx, "admin") != nil {
return nil
}
@@ -181,8 +204,8 @@ func GetBootstrapPassword() string {
}
// GetUserTokensAndPrefix returns tokens and config prefix for a user.
func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string, prefix string) {
s.st.View(func(tx *bbolt.Tx) error {
func (s *AuthService) GetUserTokensAndPrefix(ctx context.Context, username string) (tokens []string, prefix string) {
s.st.View(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
@@ -198,14 +221,14 @@ func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string,
}
// GenerateTokenForUser adds a new token for user and returns the full list.
func (s *AuthService) GenerateTokenForUser(username string) []string {
func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string) []string {
tokenRaw := make([]byte, 16)
if _, err := rand.Read(tokenRaw); err != nil {
return nil
}
token := hex.EncodeToString(tokenRaw)
var tokens []string
s.st.Update(func(tx *bbolt.Tx) error {
s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
@@ -221,11 +244,11 @@ func (s *AuthService) GenerateTokenForUser(username string) []string {
}
// SetUserPassword sets password for user (empty pass = no change).
func (s *AuthService) SetUserPassword(username, pass string) error {
func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string) error {
if pass == "" {
return nil
}
return s.st.Update(func(tx *bbolt.Tx) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
@@ -240,3 +263,243 @@ func (s *AuthService) SetUserPassword(username, pass string) error {
return s.st.PutUser(tx, username, raw)
})
}
// ValidateClientToken validates a client token and returns the username if valid with upload permission.
func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (string, error) {
var username string
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
userName := s.st.GetTokenUser(tx, token)
if userName == nil {
return apperr.ErrUnauthorized
}
uRaw := s.st.GetUser(tx, string(userName))
if uRaw == nil {
return apperr.ErrUnauthorized
}
var u app.User
json.Unmarshal(uRaw, &u)
if !u.Auths.Has(app.AUTH_UPLOAD) {
return apperr.ErrForbidden
}
username = string(userName)
return nil
})
return username, err
}
// --- OAuth ---
// OAuthConfig returns OAuth2 config for the given provider, or nil if not configured.
func OAuthConfig(provider string, baseURL string) *oauth2.Config {
switch provider {
case "google":
clientID := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return nil
}
redirectURL := strings.TrimSuffix(baseURL, "/") + "/map/api/oauth/google/callback"
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
default:
return nil
}
}
// BaseURL returns the configured base URL for the app.
func BaseURL(req *http.Request) string {
if base := os.Getenv("HNHMAP_BASE_URL"); base != "" {
return strings.TrimSuffix(base, "/")
}
scheme := "https"
if req.TLS == nil {
scheme = "http"
}
host := req.Host
if h := req.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
return scheme + "://" + host
}
// OAuthProviders returns list of configured OAuth providers.
func OAuthProviders() []string {
var providers []string
if os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID") != "" && os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET") != "" {
providers = append(providers, "google")
}
return providers
}
// OAuthInitLogin creates an OAuth state and returns the redirect URL.
func (s *AuthService) OAuthInitLogin(ctx context.Context, provider, redirectURI string, req *http.Request) (string, error) {
baseURL := BaseURL(req)
cfg := OAuthConfig(provider, baseURL)
if cfg == nil {
return "", apperr.ErrProviderUnconfigured
}
state := make([]byte, 32)
if _, err := rand.Read(state); err != nil {
return "", err
}
stateStr := hex.EncodeToString(state)
st := oauthState{
Provider: provider,
RedirectURI: redirectURI,
CreatedAt: time.Now().Unix(),
}
stRaw, _ := json.Marshal(st)
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
return s.st.PutOAuthState(tx, stateStr, stRaw)
}); err != nil {
return "", err
}
authURL := cfg.AuthCodeURL(stateStr, oauth2.AccessTypeOffline)
return authURL, nil
}
// OAuthHandleCallback processes the OAuth callback, validates state, exchanges code, and creates a session.
// Returns (sessionID, redirectPath, error).
func (s *AuthService) OAuthHandleCallback(ctx context.Context, provider, code, state string, req *http.Request) (string, string, error) {
baseURL := BaseURL(req)
cfg := OAuthConfig(provider, baseURL)
if cfg == nil {
return "", "", apperr.ErrProviderUnconfigured
}
var st oauthState
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetOAuthState(tx, state)
if raw == nil {
return apperr.ErrBadRequest
}
json.Unmarshal(raw, &st)
return s.st.DeleteOAuthState(tx, state)
})
if err != nil || st.Provider == "" {
return "", "", apperr.ErrBadRequest
}
if time.Since(time.Unix(st.CreatedAt, 0)) > oauthStateTTL {
return "", "", apperr.ErrStateExpired
}
if st.Provider != provider {
return "", "", apperr.ErrStateMismatch
}
tok, err := cfg.Exchange(ctx, code)
if err != nil {
slog.Error("OAuth exchange failed", "provider", provider, "error", err)
return "", "", apperr.ErrExchangeFailed
}
var sub, email string
switch provider {
case "google":
sub, email, err = fetchGoogleUserInfo(ctx, tok.AccessToken)
if err != nil {
slog.Error("failed to get Google user info", "error", err)
return "", "", apperr.ErrUserInfoFailed
}
default:
return "", "", apperr.ErrBadRequest
}
username := s.findOrCreateOAuthUser(ctx, provider, sub, email)
if username == "" {
return "", "", apperr.ErrInternal
}
sessionID := s.CreateSession(ctx, username, false)
if sessionID == "" {
return "", "", apperr.ErrInternal
}
redirectTo := "/profile"
if st.RedirectURI != "" {
if u, err := url.Parse(st.RedirectURI); err == nil && u.Path != "" && !strings.HasPrefix(u.Path, "//") {
redirectTo = u.Path
if u.RawQuery != "" {
redirectTo += "?" + u.RawQuery
}
}
}
return sessionID, redirectTo, nil
}
func fetchGoogleUserInfo(ctx context.Context, accessToken string) (sub, email string, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v3/userinfo", nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", apperr.ErrUserInfoFailed
}
var info googleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", "", err
}
return info.Sub, info.Email, nil
}
func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub, email string) string {
var username string
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
_ = s.st.ForEachUser(tx, func(k, v []byte) error {
user := app.User{}
if json.Unmarshal(v, &user) != nil {
return nil
}
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
username = string(k)
}
return nil
})
if username != "" {
raw := s.st.GetUser(tx, username)
if raw != nil {
user := app.User{}
json.Unmarshal(raw, &user)
if user.OAuthLinks == nil {
user.OAuthLinks = map[string]string{provider: sub}
} else {
user.OAuthLinks[provider] = sub
}
raw, _ = json.Marshal(user)
s.st.PutUser(tx, username, raw)
}
return nil
}
username = email
if username == "" {
username = provider + "_" + sub
}
if s.st.GetUser(tx, username) != nil {
username = provider + "_" + sub
}
newUser := &app.User{
Pass: nil,
Auths: app.Auths{app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD},
OAuthLinks: map[string]string{provider: sub},
}
raw, _ := json.Marshal(newUser)
return s.st.PutUser(tx, username, raw)
})
if err != nil {
slog.Error("failed to find or create OAuth user", "provider", provider, "error", err)
return ""
}
return username
}

View File

@@ -0,0 +1,339 @@
package services_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"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"
)
func newTestDB(t *testing.T) *bbolt.DB {
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() })
return db
}
func newTestAuth(t *testing.T) (*services.AuthService, *store.Store) {
t.Helper()
db := newTestDB(t)
st := store.New(db)
return services.NewAuthService(st), st
}
func createUser(t *testing.T, st *store.Store, username, password string, auths app.Auths) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
u := app.User{Pass: hash, Auths: auths}
raw, _ := json.Marshal(u)
st.Update(context.Background(), func(tx *bbolt.Tx) error {
return st.PutUser(tx, username, raw)
})
}
func TestSetupRequired_EmptyDB(t *testing.T) {
auth, _ := newTestAuth(t)
if !auth.SetupRequired(context.Background()) {
t.Fatal("expected setup required on empty DB")
}
}
func TestSetupRequired_WithUsers(t *testing.T) {
auth, st := newTestAuth(t)
createUser(t, st, "admin", "pass", app.Auths{app.AUTH_ADMIN})
if auth.SetupRequired(context.Background()) {
t.Fatal("expected setup not required when users exist")
}
}
func TestGetUser_ValidPassword(t *testing.T) {
auth, st := newTestAuth(t)
createUser(t, st, "alice", "secret", app.Auths{app.AUTH_MAP})
u := auth.GetUser(context.Background(), "alice", "secret")
if u == nil {
t.Fatal("expected user with correct password")
}
if !u.Auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth")
}
}
func TestGetUser_InvalidPassword(t *testing.T) {
auth, st := newTestAuth(t)
createUser(t, st, "alice", "secret", nil)
u := auth.GetUser(context.Background(), "alice", "wrong")
if u != nil {
t.Fatal("expected nil with wrong password")
}
}
func TestGetUser_NonExistent(t *testing.T) {
auth, _ := newTestAuth(t)
u := auth.GetUser(context.Background(), "ghost", "pass")
if u != nil {
t.Fatal("expected nil for non-existent user")
}
}
func TestGetUserByUsername(t *testing.T) {
auth, st := newTestAuth(t)
createUser(t, st, "alice", "secret", app.Auths{app.AUTH_MAP})
u := auth.GetUserByUsername(context.Background(), "alice")
if u == nil {
t.Fatal("expected user")
}
}
func TestGetUserByUsername_NonExistent(t *testing.T) {
auth, _ := newTestAuth(t)
u := auth.GetUserByUsername(context.Background(), "ghost")
if u != nil {
t.Fatal("expected nil")
}
}
func TestCreateSession_GetSession(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
sid := auth.CreateSession(ctx, "alice", false)
if sid == "" {
t.Fatal("expected non-empty session ID")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sid})
sess := auth.GetSession(ctx, req)
if sess == nil {
t.Fatal("expected session")
}
if sess.Username != "alice" {
t.Fatalf("expected alice, got %s", sess.Username)
}
if !sess.Auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth from user")
}
}
func TestGetSession_NoCookie(t *testing.T) {
auth, _ := newTestAuth(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
sess := auth.GetSession(context.Background(), req)
if sess != nil {
t.Fatal("expected nil session without cookie")
}
}
func TestGetSession_InvalidSession(t *testing.T) {
auth, _ := newTestAuth(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
sess := auth.GetSession(context.Background(), req)
if sess != nil {
t.Fatal("expected nil for invalid session")
}
}
func TestGetSession_TempAdmin(t *testing.T) {
auth, _ := newTestAuth(t)
ctx := context.Background()
sid := auth.CreateSession(ctx, "temp", true)
if sid == "" {
t.Fatal("expected session ID")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sid})
sess := auth.GetSession(ctx, req)
if sess == nil {
t.Fatal("expected session")
}
if !sess.TempAdmin {
t.Fatal("expected temp admin")
}
if !sess.Auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth for temp admin")
}
}
func TestDeleteSession(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", nil)
sid := auth.CreateSession(ctx, "alice", false)
sess := &app.Session{ID: sid, Username: "alice"}
auth.DeleteSession(ctx, sess)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sid})
if auth.GetSession(ctx, req) != nil {
t.Fatal("expected nil after deletion")
}
}
func TestSetUserPassword(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
if err := auth.SetUserPassword(ctx, "alice", "new"); err != nil {
t.Fatal(err)
}
u := auth.GetUser(ctx, "alice", "new")
if u == nil {
t.Fatal("expected user with new password")
}
if auth.GetUser(ctx, "alice", "old") != nil {
t.Fatal("old password should not work")
}
}
func TestSetUserPassword_EmptyIsNoop(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", nil)
if err := auth.SetUserPassword(ctx, "alice", ""); err != nil {
t.Fatal(err)
}
if auth.GetUser(ctx, "alice", "pass") == nil {
t.Fatal("password should be unchanged")
}
}
func TestGenerateTokenForUser(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
tokens := auth.GenerateTokenForUser(ctx, "alice")
if len(tokens) != 1 {
t.Fatalf("expected 1 token, got %d", len(tokens))
}
tokens2 := auth.GenerateTokenForUser(ctx, "alice")
if len(tokens2) != 2 {
t.Fatalf("expected 2 tokens, got %d", len(tokens2))
}
}
func TestGetUserTokensAndPrefix(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "prefix", []byte("myprefix"))
})
auth.GenerateTokenForUser(ctx, "alice")
tokens, prefix := auth.GetUserTokensAndPrefix(ctx, "alice")
if len(tokens) != 1 {
t.Fatalf("expected 1 token, got %d", len(tokens))
}
if prefix != "myprefix" {
t.Fatalf("expected myprefix, got %s", prefix)
}
}
func TestValidateClientToken_Valid(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
tokens := auth.GenerateTokenForUser(ctx, "alice")
username, err := auth.ValidateClientToken(ctx, tokens[0])
if err != nil {
t.Fatal(err)
}
if username != "alice" {
t.Fatalf("expected alice, got %s", username)
}
}
func TestValidateClientToken_Invalid(t *testing.T) {
auth, _ := newTestAuth(t)
_, err := auth.ValidateClientToken(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error for invalid token")
}
}
func TestValidateClientToken_NoUploadPerm(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP})
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutToken(tx, "tok123", "alice")
})
_, err := auth.ValidateClientToken(ctx, "tok123")
if err == nil {
t.Fatal("expected error without upload permission")
}
}
func TestBootstrapAdmin_Success(t *testing.T) {
auth, _ := newTestAuth(t)
ctx := context.Background()
u := auth.BootstrapAdmin(ctx, "admin", "bootstrap123", "bootstrap123")
if u == nil {
t.Fatal("expected user creation")
}
if !u.Auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth")
}
}
func TestBootstrapAdmin_WrongUsername(t *testing.T) {
auth, _ := newTestAuth(t)
u := auth.BootstrapAdmin(context.Background(), "notadmin", "pass", "pass")
if u != nil {
t.Fatal("expected nil for non-admin username")
}
}
func TestBootstrapAdmin_MismatchPassword(t *testing.T) {
auth, _ := newTestAuth(t)
u := auth.BootstrapAdmin(context.Background(), "admin", "pass", "different")
if u != nil {
t.Fatal("expected nil for mismatched password")
}
}
func TestBootstrapAdmin_AlreadyExists(t *testing.T) {
auth, st := newTestAuth(t)
ctx := context.Background()
createUser(t, st, "admin", "existing", app.Auths{app.AUTH_ADMIN})
u := auth.BootstrapAdmin(ctx, "admin", "pass", "pass")
if u != nil {
t.Fatal("expected nil when admin already exists")
}
}

View File

@@ -0,0 +1,477 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
// GridUpdate is the client grid update request body.
type GridUpdate struct {
Grids [][]string `json:"grids"`
}
// GridRequest is the grid update response.
type GridRequest struct {
GridRequests []string `json:"gridRequests"`
Map int `json:"map"`
Coords app.Coord `json:"coords"`
}
// ExtraData carries season info from the client.
type ExtraData struct {
Season int
}
// ClientService handles game client operations.
type ClientService struct {
st *store.Store
mapSvc *MapService
// withChars provides locked mutable access to the character map.
withChars func(fn func(chars map[string]app.Character))
}
// ClientServiceDeps holds dependencies for ClientService.
type ClientServiceDeps struct {
Store *store.Store
MapSvc *MapService
WithChars func(fn func(chars map[string]app.Character))
}
// NewClientService creates a ClientService with the given dependencies.
func NewClientService(d ClientServiceDeps) *ClientService {
return &ClientService{
st: d.Store,
mapSvc: d.MapSvc,
withChars: d.WithChars,
}
}
// Locate returns "mapid;x;y" for a grid, or error if not found.
func (s *ClientService) Locate(ctx context.Context, gridID string) (string, error) {
var result string
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, gridID)
if raw == nil {
return fmt.Errorf("grid not found")
}
cur := app.GridData{}
if err := json.Unmarshal(raw, &cur); err != nil {
return err
}
result = fmt.Sprintf("%d;%d;%d", cur.Map, cur.Coord.X, cur.Coord.Y)
return nil
})
return result, err
}
// GridUpdateResult contains the response and any tile operations to process.
type GridUpdateResult struct {
Response GridRequest
Ops []TileOp
}
// ProcessGridUpdate handles a client grid update and returns the response.
func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate) (*GridUpdateResult, error) {
result := &GridUpdateResult{}
greq := &result.Response
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
maps := map[int]struct{ X, Y int }{}
for x, row := range grup.Grids {
for y, grid := range row {
gridRaw := grids.Get([]byte(grid))
if gridRaw != nil {
gd := app.GridData{}
json.Unmarshal(gridRaw, &gd)
maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y}
}
}
}
if len(maps) == 0 {
seq, err := mapB.NextSequence()
if err != nil {
return err
}
mi := app.MapInfo{
ID: int(seq),
Name: strconv.Itoa(int(seq)),
Hidden: configb.Get([]byte("defaultHide")) != nil,
}
raw, _ := json.Marshal(mi)
if err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw); err != nil {
return err
}
slog.Info("client created new map", "map_id", seq)
for x, row := range grup.Grids {
for y, grid := range row {
cur := app.GridData{ID: grid, Map: int(seq), Coord: app.Coord{X: x - 1, Y: y - 1}}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
greq.GridRequests = append(greq.GridRequests, grid)
}
}
greq.Coords = app.Coord{X: 0, Y: 0}
return nil
}
mapid := -1
offset := struct{ X, Y int }{}
for id, off := range maps {
mi := app.MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil {
json.Unmarshal(mraw, &mi)
}
if mi.Priority {
mapid = id
offset = off
break
}
if id < mapid || mapid == -1 {
mapid = id
offset = off
}
}
slog.Debug("client in map", "map_id", mapid)
for x, row := range grup.Grids {
for y, grid := range row {
cur := app.GridData{}
if curRaw := grids.Get([]byte(grid)); curRaw != nil {
json.Unmarshal(curRaw, &cur)
if time.Now().After(cur.NextUpdate) {
greq.GridRequests = append(greq.GridRequests, grid)
}
continue
}
cur.ID = grid
cur.Map = mapid
cur.Coord.X = x + offset.X
cur.Coord.Y = y + offset.Y
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
greq.GridRequests = append(greq.GridRequests, grid)
}
}
if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 {
if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil {
cur := app.GridData{}
json.Unmarshal(curRaw, &cur)
greq.Map = cur.Map
greq.Coords = cur.Coord
}
}
if len(maps) > 1 {
grids.ForEach(func(k, v []byte) error {
gd := app.GridData{}
json.Unmarshal(v, &gd)
if gd.Map == mapid {
return nil
}
if merge, ok := maps[gd.Map]; ok {
var td *app.TileData
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map)))
if err != nil {
return err
}
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0)))
if err != nil {
return err
}
tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil {
json.Unmarshal(tileraw, &td)
}
gd.Map = mapid
gd.Coord.X += offset.X - merge.X
gd.Coord.Y += offset.Y - merge.Y
raw, _ := json.Marshal(gd)
if td != nil {
result.Ops = append(result.Ops, TileOp{
MapID: mapid,
X: gd.Coord.X,
Y: gd.Coord.Y,
File: td.File,
})
}
grids.Put(k, raw)
}
return nil
})
}
for mergeid, merge := range maps {
if mapid == mergeid {
continue
}
mapB.Delete([]byte(strconv.Itoa(mergeid)))
slog.Info("reporting merge", "from", mergeid, "to", mapid)
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
}
return nil
})
if err != nil {
return nil, err
}
s.mapSvc.ProcessZoomLevels(ctx, result.Ops)
return result, nil
}
// ProcessGridUpload handles a tile image upload from the client.
func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error {
if extraData != "" {
ed := ExtraData{}
json.Unmarshal([]byte(extraData), &ed)
if ed.Season == 3 {
needTile := false
s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id)
if raw == nil {
return fmt.Errorf("unknown grid id: %s", id)
}
cur := app.GridData{}
if err := json.Unmarshal(raw, &cur); err != nil {
return err
}
tdRaw := s.st.GetTile(tx, cur.Map, 0, cur.Coord.Name())
if tdRaw == nil {
needTile = true
return nil
}
td := app.TileData{}
if err := json.Unmarshal(tdRaw, &td); err != nil {
return err
}
if td.File == "" {
needTile = true
return nil
}
if time.Now().After(cur.NextUpdate) {
cur.NextUpdate = time.Now().Add(app.TileUpdateInterval)
}
raw, _ = json.Marshal(cur)
return s.st.PutGrid(tx, id, raw)
})
if !needTile {
slog.Debug("ignoring tile upload: winter")
return nil
}
slog.Debug("missing tile, using winter version")
}
}
slog.Debug("processing tile upload", "grid_id", id)
updateTile := false
cur := app.GridData{}
mapid := 0
s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id)
if raw == nil {
return fmt.Errorf("unknown grid id: %s", id)
}
if err := json.Unmarshal(raw, &cur); err != nil {
return err
}
updateTile = time.Now().After(cur.NextUpdate)
mapid = cur.Map
if updateTile {
cur.NextUpdate = time.Now().Add(app.TileUpdateInterval)
}
raw, _ = json.Marshal(cur)
return s.st.PutGrid(tx, id, raw)
})
if updateTile {
gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage())
if err := os.MkdirAll(gridDir, 0755); err != nil {
slog.Error("failed to create grids dir", "error", err)
return err
}
f, err := os.Create(fmt.Sprintf("%s/grids/%s.png", s.mapSvc.GridStorage(), cur.ID))
if err != nil {
return err
}
if _, err = io.Copy(f, fileReader); err != nil {
f.Close()
return err
}
f.Close()
s.mapSvc.SaveTile(ctx, mapid, cur.Coord, 0, fmt.Sprintf("grids/%s.png", cur.ID), time.Now().UnixNano())
c := cur.Coord
for z := 1; z <= app.MaxZoomLevel; z++ {
c = c.Parent()
s.mapSvc.UpdateZoomLevel(ctx, mapid, c, z)
}
}
return nil
}
// UpdatePositions updates character positions from client data.
func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error {
craws := map[string]struct {
Name string
GridID string
Coords struct{ X, Y int }
Type string
}{}
if err := json.Unmarshal(data, &craws); err != nil {
slog.Error("failed to decode position update", "error", err)
return err
}
gridDataByID := make(map[string]app.GridData)
s.st.View(ctx, func(tx *bbolt.Tx) error {
for _, craw := range craws {
raw := s.st.GetGrid(tx, craw.GridID)
if raw != nil {
var gd app.GridData
if json.Unmarshal(raw, &gd) == nil {
gridDataByID[craw.GridID] = gd
}
}
}
return nil
})
s.withChars(func(chars map[string]app.Character) {
for id, craw := range craws {
gd, ok := gridDataByID[craw.GridID]
if !ok {
continue
}
idnum, _ := strconv.Atoi(id)
c := app.Character{
Name: craw.Name,
ID: idnum,
Map: gd.Map,
Position: app.Position{
X: craw.Coords.X + (gd.Coord.X * app.GridSize),
Y: craw.Coords.Y + (gd.Coord.Y * app.GridSize),
},
Type: craw.Type,
Updated: time.Now(),
}
old, ok := chars[id]
if !ok {
chars[id] = c
} else {
if old.Type == "player" {
if c.Type == "player" {
chars[id] = c
} else {
old.Position = c.Position
chars[id] = old
}
} else if old.Type != "unknown" {
if c.Type != "unknown" {
chars[id] = c
} else {
old.Position = c.Position
chars[id] = old
}
} else {
chars[id] = c
}
}
}
})
return nil
}
// UploadMarkers stores markers uploaded by the client.
func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error {
markers := []struct {
Name string
GridID string
X, Y int
Image string
Type string
Color string
}{}
if err := json.Unmarshal(data, &markers); err != nil {
slog.Error("failed to decode marker upload", "error", err)
return err
}
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
grid, idB, err := s.st.CreateMarkersBuckets(tx)
if err != nil {
return err
}
for _, mraw := range markers {
key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.X, mraw.Y))
if grid.Get(key) != nil {
continue
}
img := mraw.Image
if img == "" {
img = "gfx/terobjs/mm/custom"
}
id, err := idB.NextSequence()
if err != nil {
return err
}
idKey := []byte(strconv.Itoa(int(id)))
m := app.Marker{
Name: mraw.Name,
ID: int(id),
GridID: mraw.GridID,
Position: app.Position{X: mraw.X, Y: mraw.Y},
Image: img,
}
raw, _ := json.Marshal(m)
grid.Put(key, raw)
idB.Put(idKey, key)
}
return nil
})
}
// FixMultipartContentType fixes broken multipart Content-Type headers from some game clients.
func FixMultipartContentType(ct string) string {
if strings.Count(ct, "=") >= 2 && strings.Count(ct, "\"") == 0 {
parts := strings.SplitN(ct, "=", 2)
return parts[0] + "=\"" + parts[1] + "\""
}
return ct
}

View File

@@ -0,0 +1,32 @@
package services_test
import (
"testing"
"github.com/andyleap/hnh-map/internal/app/services"
)
func TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123"
got := services.FixMultipartContentType(ct)
want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"`
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) {
ct := `multipart/form-data; boundary="----WebKitFormBoundary"`
got := services.FixMultipartContentType(ct)
if got != ct {
t.Fatalf("expected unchanged, got %q", got)
}
}
func TestFixMultipartContentType_Normal(t *testing.T) {
ct := "multipart/form-data; boundary=----WebKitFormBoundary"
got := services.FixMultipartContentType(ct)
if got != ct {
t.Fatalf("expected unchanged, got %q", got)
}
}

View File

@@ -0,0 +1,382 @@
package services
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
type mapData struct {
Grids map[string]string
Markers map[string][]app.Marker
}
// ExportService handles map data export and import (merge).
type ExportService struct {
st *store.Store
mapSvc *MapService
}
// NewExportService creates an ExportService with the given store and map service.
func NewExportService(st *store.Store, mapSvc *MapService) *ExportService {
return &ExportService{st: st, mapSvc: mapSvc}
}
// Export writes all map data as a ZIP archive to the given writer.
func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
zw := zip.NewWriter(w)
defer zw.Close()
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
maps := map[int]mapData{}
gridMap := map[string]int{}
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
if err := grids.ForEach(func(k, v []byte) error {
gd := app.GridData{}
if err := json.Unmarshal(v, &gd); err != nil {
return err
}
md, ok := maps[gd.Map]
if !ok {
md = mapData{
Grids: map[string]string{},
Markers: map[string][]app.Marker{},
}
maps[gd.Map] = md
}
md.Grids[gd.Coord.Name()] = gd.ID
gridMap[gd.ID] = gd.Map
mapb := tiles.Bucket([]byte(strconv.Itoa(gd.Map)))
if mapb == nil {
return nil
}
zoom := mapb.Bucket([]byte("0"))
if zoom == nil {
return nil
}
tdraw := zoom.Get([]byte(gd.Coord.Name()))
if tdraw == nil {
return nil
}
td := app.TileData{}
if err := json.Unmarshal(tdraw, &td); err != nil {
return err
}
fw, err := zw.Create(fmt.Sprintf("%d/%s.png", gd.Map, gd.ID))
if err != nil {
return err
}
f, err := os.Open(filepath.Join(s.mapSvc.GridStorage(), td.File))
if err != nil {
return err
}
_, err = io.Copy(fw, f)
f.Close()
return err
}); err != nil {
return err
}
markersb := tx.Bucket(store.BucketMarkers)
if markersb != nil {
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
if markersgrid != nil {
markersgrid.ForEach(func(k, v []byte) error {
marker := app.Marker{}
if json.Unmarshal(v, &marker) != nil {
return nil
}
if _, ok := maps[gridMap[marker.GridID]]; ok {
maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker)
}
return nil
})
}
}
for mapid, md := range maps {
fw, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid))
if err != nil {
return err
}
json.NewEncoder(fw).Encode(md)
}
return nil
})
}
// Merge imports map data from a ZIP file.
func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
var ops []TileOp
newTiles := map[string]struct{}{}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
mgrid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
for _, fhdr := range zr.File {
if strings.HasSuffix(fhdr.Name, ".json") {
if err := s.processMergeJSON(fhdr, grids, tiles, mapB, configb, mgrid, idB, &ops); err != nil {
return err
}
} else if strings.HasSuffix(fhdr.Name, ".png") {
if err := os.MkdirAll(filepath.Join(s.mapSvc.GridStorage(), "grids"), 0755); err != nil {
return err
}
f, err := os.Create(filepath.Join(s.mapSvc.GridStorage(), "grids", filepath.Base(fhdr.Name)))
if err != nil {
return err
}
r, err := fhdr.Open()
if err != nil {
f.Close()
return err
}
io.Copy(f, r)
r.Close()
f.Close()
newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{}
}
}
for gid := range newTiles {
gridRaw := grids.Get([]byte(gid))
if gridRaw != nil {
gd := app.GridData{}
json.Unmarshal(gridRaw, &gd)
ops = append(ops, TileOp{
MapID: gd.Map,
X: gd.Coord.X,
Y: gd.Coord.Y,
File: filepath.Join("grids", gid+".png"),
})
}
}
return nil
}); err != nil {
return err
}
for _, op := range ops {
s.mapSvc.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
}
s.mapSvc.RebuildZooms(ctx)
return nil
}
func (s *ExportService) processMergeJSON(
fhdr *zip.File,
grids, tiles, mapB, configb, mgrid, idB *bbolt.Bucket,
ops *[]TileOp,
) error {
f, err := fhdr.Open()
if err != nil {
return err
}
defer f.Close()
md := mapData{}
if err := json.NewDecoder(f).Decode(&md); err != nil {
return err
}
for _, ms := range md.Markers {
for _, mraw := range ms {
key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.Position.X, mraw.Position.Y))
if mgrid.Get(key) != nil {
continue
}
img := mraw.Image
if img == "" {
img = "gfx/terobjs/mm/custom"
}
id, err := idB.NextSequence()
if err != nil {
return err
}
idKey := []byte(strconv.Itoa(int(id)))
m := app.Marker{
Name: mraw.Name,
ID: int(id),
GridID: mraw.GridID,
Position: app.Position{X: mraw.Position.X, Y: mraw.Position.Y},
Image: img,
}
raw, _ := json.Marshal(m)
mgrid.Put(key, raw)
idB.Put(idKey, key)
}
}
newGrids := map[app.Coord]string{}
existingMaps := map[int]struct{ X, Y int }{}
for k, v := range md.Grids {
c := app.Coord{}
if _, err := fmt.Sscanf(k, "%d_%d", &c.X, &c.Y); err != nil {
return err
}
newGrids[c] = v
gridRaw := grids.Get([]byte(v))
if gridRaw != nil {
gd := app.GridData{}
json.Unmarshal(gridRaw, &gd)
existingMaps[gd.Map] = struct{ X, Y int }{gd.Coord.X - c.X, gd.Coord.Y - c.Y}
}
}
if len(existingMaps) == 0 {
seq, err := mapB.NextSequence()
if err != nil {
return err
}
mi := app.MapInfo{
ID: int(seq),
Name: strconv.Itoa(int(seq)),
Hidden: configb.Get([]byte("defaultHide")) != nil,
}
raw, _ := json.Marshal(mi)
if err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw); err != nil {
return err
}
for c, grid := range newGrids {
cur := app.GridData{ID: grid, Map: int(seq), Coord: c}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
}
return nil
}
mapid := -1
offset := struct{ X, Y int }{}
for id, off := range existingMaps {
mi := app.MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil {
json.Unmarshal(mraw, &mi)
}
if mi.Priority {
mapid = id
offset = off
break
}
if id < mapid || mapid == -1 {
mapid = id
offset = off
}
}
for c, grid := range newGrids {
if grids.Get([]byte(grid)) != nil {
continue
}
cur := app.GridData{
ID: grid,
Map: mapid,
Coord: app.Coord{X: c.X + offset.X, Y: c.Y + offset.Y},
}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
grids.Put([]byte(grid), raw)
}
if len(existingMaps) > 1 {
grids.ForEach(func(k, v []byte) error {
gd := app.GridData{}
json.Unmarshal(v, &gd)
if gd.Map == mapid {
return nil
}
if merge, ok := existingMaps[gd.Map]; ok {
var td *app.TileData
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map)))
if err != nil {
return err
}
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0)))
if err != nil {
return err
}
tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil {
json.Unmarshal(tileraw, &td)
}
gd.Map = mapid
gd.Coord.X += offset.X - merge.X
gd.Coord.Y += offset.Y - merge.Y
raw, _ := json.Marshal(gd)
if td != nil {
*ops = append(*ops, TileOp{
MapID: mapid,
X: gd.Coord.X,
Y: gd.Coord.Y,
File: td.File,
})
}
grids.Put(k, raw)
}
return nil
})
}
for mergeid, merge := range existingMaps {
if mapid == mergeid {
continue
}
mapB.Delete([]byte(strconv.Itoa(mergeid)))
slog.Info("reporting merge", "from", mergeid, "to", mapid)
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
}
return nil
}

View File

@@ -1,14 +1,28 @@
package services
import (
"context"
"encoding/json"
"fmt"
"image"
"image/png"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/image/draw"
)
type zoomproc struct {
c app.Coord
m int
}
// MapService handles map, markers, grids, tiles business logic.
type MapService struct {
st *store.Store
@@ -18,7 +32,7 @@ type MapService struct {
getChars func() []app.Character
}
// MapServiceDeps holds dependencies for MapService.
// MapServiceDeps holds dependencies for MapService construction.
type MapServiceDeps struct {
Store *store.Store
GridStorage string
@@ -27,7 +41,7 @@ type MapServiceDeps struct {
GetChars func() []app.Character
}
// NewMapService creates a MapService.
// NewMapService creates a MapService with the given dependencies.
func NewMapService(d MapServiceDeps) *MapService {
return &MapService{
st: d.Store,
@@ -38,12 +52,10 @@ func NewMapService(d MapServiceDeps) *MapService {
}
}
// GridStorage returns the grid storage path.
func (s *MapService) GridStorage() string {
return s.gridStorage
}
// GridStorage returns the grid storage directory path.
func (s *MapService) GridStorage() string { return s.gridStorage }
// GetCharacters returns all characters (from in-memory map).
// GetCharacters returns all current characters.
func (s *MapService) GetCharacters() []app.Character {
if s.getChars == nil {
return nil
@@ -51,10 +63,10 @@ func (s *MapService) GetCharacters() []app.Character {
return s.getChars()
}
// GetMarkers returns all markers as FrontendMarker list.
func (s *MapService) GetMarkers() ([]app.FrontendMarker, error) {
// GetMarkers returns all markers with computed map positions.
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
var markers []app.FrontendMarker
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return nil
@@ -79,8 +91,8 @@ func (s *MapService) GetMarkers() ([]app.FrontendMarker, error) {
Name: marker.Name,
Map: g.Map,
Position: app.Position{
X: marker.Position.X + g.Coord.X*100,
Y: marker.Position.Y + g.Coord.Y*100,
X: marker.Position.X + g.Coord.X*app.GridSize,
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
},
})
return nil
@@ -89,10 +101,10 @@ func (s *MapService) GetMarkers() ([]app.FrontendMarker, error) {
return markers, err
}
// GetMaps returns maps, optionally filtering hidden for non-admin.
func (s *MapService) GetMaps(showHidden bool) (map[int]*app.MapInfo, error) {
// GetMaps returns all maps, optionally including hidden ones.
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
maps := make(map[int]*app.MapInfo)
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k))
if err != nil {
@@ -110,10 +122,10 @@ func (s *MapService) GetMaps(showHidden bool) (map[int]*app.MapInfo, error) {
return maps, err
}
// GetConfig returns config (title) and auths for session.
func (s *MapService) GetConfig(auths app.Auths) (app.Config, error) {
// GetConfig returns the application config for the frontend.
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
config := app.Config{Auths: auths}
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
config.Title = string(title)
@@ -123,10 +135,10 @@ func (s *MapService) GetConfig(auths app.Auths) (app.Config, error) {
return config, err
}
// GetPage returns page title.
func (s *MapService) GetPage() (app.Page, error) {
// GetPage returns page metadata (title).
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
p := app.Page{}
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
p.Title = string(title)
@@ -136,10 +148,10 @@ func (s *MapService) GetPage() (app.Page, error) {
return p, err
}
// GetGrid returns GridData by ID.
func (s *MapService) GetGrid(id string) (*app.GridData, error) {
// GetGrid returns a grid by its ID.
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
var gd *app.GridData
err := s.st.View(func(tx *bbolt.Tx) error {
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id)
if raw == nil {
return nil
@@ -150,10 +162,10 @@ func (s *MapService) GetGrid(id string) (*app.GridData, error) {
return gd, err
}
// GetTile returns TileData for map/zoom/coord.
func (s *MapService) GetTile(mapID int, c app.Coord, zoom int) *app.TileData {
// GetTile returns a tile by map ID, coordinate, and zoom level.
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil {
td = &app.TileData{}
@@ -164,6 +176,103 @@ func (s *MapService) GetTile(mapID int, c app.Coord, zoom int) *app.TileData {
return td
}
// SaveTile persists a tile and broadcasts the update.
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
s.st.Update(ctx, func(tx *bbolt.Tx) error {
td := &app.TileData{
MapID: mapid,
Coord: c,
Zoom: z,
File: f,
Cache: t,
}
raw, err := json.Marshal(td)
if err != nil {
return err
}
s.gridUpdates.Send(td)
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
})
}
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles.
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
for x := 0; x <= 1; x++ {
for y := 0; y <= 1; y++ {
subC := c
subC.X *= 2
subC.Y *= 2
subC.X += x
subC.Y += y
td := s.GetTile(ctx, mapid, subC, z-1)
if td == nil || td.File == "" {
continue
}
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
if err != nil {
continue
}
subimg, _, err := image.Decode(subf)
subf.Close()
if err != nil {
continue
}
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
}
}
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
slog.Error("failed to create zoom dir", "error", err)
return
}
f, err := os.Create(fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name()))
s.SaveTile(ctx, mapid, c, z, fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name()), time.Now().UnixNano())
if err != nil {
return
}
defer f.Close()
png.Encode(f, img)
}
// RebuildZooms rebuilds all zoom levels from base tiles.
func (s *MapService) RebuildZooms(ctx context.Context) {
needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{}
s.st.Update(ctx, func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids)
if b == nil {
return nil
}
b.ForEach(func(k, v []byte) error {
grid := app.GridData{}
json.Unmarshal(v, &grid)
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil
})
tx.DeleteBucket(store.BucketTiles)
return nil
})
for g, id := range saveGrid {
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
if _, err := os.Stat(f); err != nil {
continue
}
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
}
for z := 1; z <= app.MaxZoomLevel; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
}
// ReportMerge sends a merge event.
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
s.mergeUpdates.Send(&app.Merge{
@@ -172,3 +281,66 @@ func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
Shift: shift,
})
}
// WatchTiles creates a channel that receives tile updates.
func (s *MapService) WatchTiles() chan *app.TileData {
c := make(chan *app.TileData, app.SSETileChannelSize)
s.gridUpdates.Watch(c)
return c
}
// WatchMerges creates a channel that receives merge updates.
func (s *MapService) WatchMerges() chan *app.Merge {
c := make(chan *app.Merge, app.SSEMergeChannelSize)
s.mergeUpdates.Watch(c)
return c
}
// GetAllTileCache returns all tiles for the initial SSE cache dump.
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
var cache []TileCache
s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
td := app.TileData{}
json.Unmarshal(v, &td)
cache = append(cache, TileCache{
M: td.MapID,
X: td.Coord.X,
Y: td.Coord.Y,
Z: td.Zoom,
T: int(td.Cache),
})
return nil
})
})
return cache
}
// TileCache represents a minimal tile entry for SSE streaming.
type TileCache struct {
M, X, Y, Z, T int
}
// ProcessZoomLevels processes zoom levels for a set of tile operations.
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
needProcess := map[zoomproc]struct{}{}
for _, op := range ops {
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
}
for z := 1; z <= app.MaxZoomLevel; z++ {
process := needProcess
needProcess = map[zoomproc]struct{}{}
for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
}
}
// TileOp represents a tile save operation.
type TileOp struct {
MapID int
X, Y int
File string
}

View File

@@ -0,0 +1,301 @@
package services_test
import (
"context"
"encoding/json"
"testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func newTestMapService(t *testing.T) (*services.MapService, *store.Store) {
t.Helper()
db := newTestDB(t)
st := store.New(db)
chars := []app.Character{
{Name: "Hero", ID: 1, Map: 1, Position: app.Position{X: 100, Y: 200}},
}
svc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{},
MergeUpdates: &app.Topic[app.Merge]{},
GetChars: func() []app.Character { return chars },
})
return svc, st
}
func TestGetCharacters(t *testing.T) {
svc, _ := newTestMapService(t)
chars := svc.GetCharacters()
if len(chars) != 1 {
t.Fatalf("expected 1 character, got %d", len(chars))
}
if chars[0].Name != "Hero" {
t.Fatalf("expected Hero, got %s", chars[0].Name)
}
}
func TestGetCharacters_Nil(t *testing.T) {
db := newTestDB(t)
st := store.New(db)
svc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{},
})
chars := svc.GetCharacters()
if chars != nil {
t.Fatalf("expected nil characters, got %v", chars)
}
}
func TestGetConfig(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "title", []byte("Test Map"))
})
config, err := svc.GetConfig(ctx, app.Auths{app.AUTH_MAP})
if err != nil {
t.Fatal(err)
}
if config.Title != "Test Map" {
t.Fatalf("expected Test Map, got %s", config.Title)
}
hasMap := false
for _, a := range config.Auths {
if a == app.AUTH_MAP {
hasMap = true
}
}
if !hasMap {
t.Fatal("expected map auth in config")
}
}
func TestGetConfig_Empty(t *testing.T) {
svc, _ := newTestMapService(t)
config, err := svc.GetConfig(context.Background(), app.Auths{app.AUTH_ADMIN})
if err != nil {
t.Fatal(err)
}
if config.Title != "" {
t.Fatalf("expected empty title, got %s", config.Title)
}
}
func TestGetPage(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "title", []byte("Map Page"))
})
page, err := svc.GetPage(ctx)
if err != nil {
t.Fatal(err)
}
if page.Title != "Map Page" {
t.Fatalf("expected Map Page, got %s", page.Title)
}
}
func TestGetGrid(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 5, Y: 10}}
raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw)
})
got, err := svc.GetGrid(ctx, "g1")
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("expected grid data")
}
if got.Map != 1 || got.Coord.X != 5 || got.Coord.Y != 10 {
t.Fatalf("unexpected grid data: %+v", got)
}
}
func TestGetGrid_NotFound(t *testing.T) {
svc, _ := newTestMapService(t)
got, err := svc.GetGrid(context.Background(), "nonexistent")
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatal("expected nil for missing grid")
}
}
func TestGetMaps_Empty(t *testing.T) {
svc, _ := newTestMapService(t)
maps, err := svc.GetMaps(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if len(maps) != 0 {
t.Fatalf("expected 0 maps, got %d", len(maps))
}
}
func TestGetMaps_HiddenFilter(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
mi1 := app.MapInfo{ID: 1, Name: "visible", Hidden: false}
mi2 := app.MapInfo{ID: 2, Name: "hidden", Hidden: true}
raw1, _ := json.Marshal(mi1)
raw2, _ := json.Marshal(mi2)
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutMap(tx, 1, raw1)
st.PutMap(tx, 2, raw2)
return nil
})
maps, err := svc.GetMaps(ctx, false)
if err != nil {
t.Fatal(err)
}
if len(maps) != 1 {
t.Fatalf("expected 1 visible map, got %d", len(maps))
}
maps, err = svc.GetMaps(ctx, true)
if err != nil {
t.Fatal(err)
}
if len(maps) != 2 {
t.Fatalf("expected 2 maps with showHidden, got %d", len(maps))
}
}
func TestGetMarkers_Empty(t *testing.T) {
svc, _ := newTestMapService(t)
markers, err := svc.GetMarkers(context.Background())
if err != nil {
t.Fatal(err)
}
if len(markers) != 0 {
t.Fatalf("expected 0 markers, got %d", len(markers))
}
}
func TestGetMarkers_WithData(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
gdRaw, _ := json.Marshal(gd)
m := app.Marker{Name: "Tower", ID: 1, GridID: "g1", Position: app.Position{X: 10, Y: 20}, Image: "gfx/terobjs/mm/tower"}
mRaw, _ := json.Marshal(m)
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutGrid(tx, "g1", gdRaw)
grid, _, err := st.CreateMarkersBuckets(tx)
if err != nil {
return err
}
return grid.Put([]byte("g1_10_20"), mRaw)
})
markers, err := svc.GetMarkers(ctx)
if err != nil {
t.Fatal(err)
}
if len(markers) != 1 {
t.Fatalf("expected 1 marker, got %d", len(markers))
}
if markers[0].Name != "Tower" {
t.Fatalf("expected Tower, got %s", markers[0].Name)
}
expectedX := 10 + 2*app.GridSize
expectedY := 20 + 3*app.GridSize
if markers[0].Position.X != expectedX || markers[0].Position.Y != expectedY {
t.Fatalf("expected position (%d,%d), got (%d,%d)", expectedX, expectedY, markers[0].Position.X, markers[0].Position.Y)
}
}
func TestGetTile(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
td := app.TileData{MapID: 1, Coord: app.Coord{X: 0, Y: 0}, Zoom: 0, File: "grids/g1.png", Cache: 12345}
raw, _ := json.Marshal(td)
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutTile(tx, 1, 0, "0_0", raw)
})
got := svc.GetTile(ctx, 1, app.Coord{X: 0, Y: 0}, 0)
if got == nil {
t.Fatal("expected tile data")
}
if got.File != "grids/g1.png" {
t.Fatalf("expected grids/g1.png, got %s", got.File)
}
}
func TestGetTile_NotFound(t *testing.T) {
svc, _ := newTestMapService(t)
got := svc.GetTile(context.Background(), 1, app.Coord{X: 0, Y: 0}, 0)
if got != nil {
t.Fatal("expected nil for missing tile")
}
}
func TestGridStorage(t *testing.T) {
svc, _ := newTestMapService(t)
if svc.GridStorage() == "" {
t.Fatal("expected non-empty grid storage path")
}
}
func TestWatchTilesAndMerges(t *testing.T) {
svc, _ := newTestMapService(t)
tc := svc.WatchTiles()
if tc == nil {
t.Fatal("expected non-nil tile channel")
}
mc := svc.WatchMerges()
if mc == nil {
t.Fatal("expected non-nil merge channel")
}
}
func TestGetAllTileCache_Empty(t *testing.T) {
svc, _ := newTestMapService(t)
cache := svc.GetAllTileCache(context.Background())
if len(cache) != 0 {
t.Fatalf("expected 0 cache entries, got %d", len(cache))
}
}
func TestGetAllTileCache_WithData(t *testing.T) {
svc, st := newTestMapService(t)
ctx := context.Background()
td := app.TileData{MapID: 1, Coord: app.Coord{X: 1, Y: 2}, Zoom: 0, Cache: 999}
raw, _ := json.Marshal(td)
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutTile(tx, 1, 0, "1_2", raw)
})
cache := svc.GetAllTileCache(ctx)
if len(cache) != 1 {
t.Fatalf("expected 1 cache entry, got %d", len(cache))
}
if cache[0].M != 1 || cache[0].X != 1 || cache[0].Y != 2 {
t.Fatalf("unexpected cache entry: %+v", cache[0])
}
}

View File

@@ -1,6 +1,7 @@
package store
import (
"context"
"strconv"
"go.etcd.io/bbolt"
@@ -16,19 +17,29 @@ func New(db *bbolt.DB) *Store {
return &Store{db: db}
}
// View runs fn in a read-only transaction.
func (s *Store) View(fn func(tx *bbolt.Tx) error) error {
return s.db.View(fn)
// View runs fn in a read-only transaction. Checks context before starting.
func (s *Store) View(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return s.db.View(fn)
}
}
// Update runs fn in a read-write transaction.
func (s *Store) Update(fn func(tx *bbolt.Tx) error) error {
return s.db.Update(fn)
// Update runs fn in a read-write transaction. Checks context before starting.
func (s *Store) Update(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return s.db.Update(fn)
}
}
// --- Users ---
// GetUser returns raw user bytes by username, or nil if not found.
// GetUser returns the raw JSON for a user, or nil if not found.
func (s *Store) GetUser(tx *bbolt.Tx, username string) []byte {
b := tx.Bucket(BucketUsers)
if b == nil {
@@ -37,7 +48,7 @@ func (s *Store) GetUser(tx *bbolt.Tx, username string) []byte {
return b.Get([]byte(username))
}
// PutUser stores user bytes by username.
// PutUser stores a user (creates the bucket if needed).
func (s *Store) PutUser(tx *bbolt.Tx, username string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketUsers)
if err != nil {
@@ -46,7 +57,7 @@ func (s *Store) PutUser(tx *bbolt.Tx, username string, raw []byte) error {
return b.Put([]byte(username), raw)
}
// DeleteUser removes a user.
// DeleteUser removes a user by username.
func (s *Store) DeleteUser(tx *bbolt.Tx, username string) error {
b := tx.Bucket(BucketUsers)
if b == nil {
@@ -55,7 +66,7 @@ func (s *Store) DeleteUser(tx *bbolt.Tx, username string) error {
return b.Delete([]byte(username))
}
// ForEachUser calls fn for each user key.
// ForEachUser iterates over all users.
func (s *Store) ForEachUser(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketUsers)
if b == nil {
@@ -64,7 +75,7 @@ func (s *Store) ForEachUser(tx *bbolt.Tx, fn func(k, v []byte) error) error {
return b.ForEach(fn)
}
// UserCount returns the number of users.
// UserCount returns the number of users in the database.
func (s *Store) UserCount(tx *bbolt.Tx) int {
b := tx.Bucket(BucketUsers)
if b == nil {
@@ -75,7 +86,7 @@ func (s *Store) UserCount(tx *bbolt.Tx) int {
// --- Sessions ---
// GetSession returns raw session bytes by ID, or nil if not found.
// GetSession returns the raw JSON for a session, or nil if not found.
func (s *Store) GetSession(tx *bbolt.Tx, id string) []byte {
b := tx.Bucket(BucketSessions)
if b == nil {
@@ -84,7 +95,7 @@ func (s *Store) GetSession(tx *bbolt.Tx, id string) []byte {
return b.Get([]byte(id))
}
// PutSession stores session bytes.
// PutSession stores a session.
func (s *Store) PutSession(tx *bbolt.Tx, id string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketSessions)
if err != nil {
@@ -93,7 +104,7 @@ func (s *Store) PutSession(tx *bbolt.Tx, id string, raw []byte) error {
return b.Put([]byte(id), raw)
}
// DeleteSession removes a session.
// DeleteSession removes a session by ID.
func (s *Store) DeleteSession(tx *bbolt.Tx, id string) error {
b := tx.Bucket(BucketSessions)
if b == nil {
@@ -104,7 +115,7 @@ func (s *Store) DeleteSession(tx *bbolt.Tx, id string) error {
// --- Tokens ---
// GetTokenUser returns username for token, or nil if not found.
// GetTokenUser returns the username associated with a token, or nil.
func (s *Store) GetTokenUser(tx *bbolt.Tx, token string) []byte {
b := tx.Bucket(BucketTokens)
if b == nil {
@@ -113,7 +124,7 @@ func (s *Store) GetTokenUser(tx *bbolt.Tx, token string) []byte {
return b.Get([]byte(token))
}
// PutToken stores token -> username mapping.
// PutToken associates a token with a username.
func (s *Store) PutToken(tx *bbolt.Tx, token, username string) error {
b, err := tx.CreateBucketIfNotExists(BucketTokens)
if err != nil {
@@ -133,7 +144,7 @@ func (s *Store) DeleteToken(tx *bbolt.Tx, token string) error {
// --- Config ---
// GetConfig returns config value by key.
// GetConfig returns a config value by key, or nil.
func (s *Store) GetConfig(tx *bbolt.Tx, key string) []byte {
b := tx.Bucket(BucketConfig)
if b == nil {
@@ -142,7 +153,7 @@ func (s *Store) GetConfig(tx *bbolt.Tx, key string) []byte {
return b.Get([]byte(key))
}
// PutConfig stores config value.
// PutConfig stores a config key-value pair.
func (s *Store) PutConfig(tx *bbolt.Tx, key string, value []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketConfig)
if err != nil {
@@ -162,7 +173,7 @@ func (s *Store) DeleteConfig(tx *bbolt.Tx, key string) error {
// --- Maps ---
// GetMap returns raw MapInfo bytes by ID.
// GetMap returns the raw JSON for a map, or nil if not found.
func (s *Store) GetMap(tx *bbolt.Tx, id int) []byte {
b := tx.Bucket(BucketMaps)
if b == nil {
@@ -171,7 +182,7 @@ func (s *Store) GetMap(tx *bbolt.Tx, id int) []byte {
return b.Get([]byte(strconv.Itoa(id)))
}
// PutMap stores MapInfo.
// PutMap stores a map entry.
func (s *Store) PutMap(tx *bbolt.Tx, id int, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketMaps)
if err != nil {
@@ -180,7 +191,7 @@ func (s *Store) PutMap(tx *bbolt.Tx, id int, raw []byte) error {
return b.Put([]byte(strconv.Itoa(id)), raw)
}
// DeleteMap removes a map.
// DeleteMap removes a map by ID.
func (s *Store) DeleteMap(tx *bbolt.Tx, id int) error {
b := tx.Bucket(BucketMaps)
if b == nil {
@@ -189,7 +200,7 @@ func (s *Store) DeleteMap(tx *bbolt.Tx, id int) error {
return b.Delete([]byte(strconv.Itoa(id)))
}
// MapsNextSequence returns next map ID sequence.
// MapsNextSequence returns the next auto-increment ID for maps.
func (s *Store) MapsNextSequence(tx *bbolt.Tx) (uint64, error) {
b, err := tx.CreateBucketIfNotExists(BucketMaps)
if err != nil {
@@ -198,7 +209,7 @@ func (s *Store) MapsNextSequence(tx *bbolt.Tx) (uint64, error) {
return b.NextSequence()
}
// MapsSetSequence sets map bucket sequence.
// MapsSetSequence sets the maps bucket sequence counter.
func (s *Store) MapsSetSequence(tx *bbolt.Tx, v uint64) error {
b := tx.Bucket(BucketMaps)
if b == nil {
@@ -207,7 +218,7 @@ func (s *Store) MapsSetSequence(tx *bbolt.Tx, v uint64) error {
return b.SetSequence(v)
}
// ForEachMap calls fn for each map.
// ForEachMap iterates over all maps.
func (s *Store) ForEachMap(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketMaps)
if b == nil {
@@ -218,7 +229,7 @@ func (s *Store) ForEachMap(tx *bbolt.Tx, fn func(k, v []byte) error) error {
// --- Grids ---
// GetGrid returns raw GridData bytes by ID.
// GetGrid returns the raw JSON for a grid, or nil if not found.
func (s *Store) GetGrid(tx *bbolt.Tx, id string) []byte {
b := tx.Bucket(BucketGrids)
if b == nil {
@@ -227,7 +238,7 @@ func (s *Store) GetGrid(tx *bbolt.Tx, id string) []byte {
return b.Get([]byte(id))
}
// PutGrid stores GridData.
// PutGrid stores a grid entry.
func (s *Store) PutGrid(tx *bbolt.Tx, id string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketGrids)
if err != nil {
@@ -236,7 +247,7 @@ func (s *Store) PutGrid(tx *bbolt.Tx, id string, raw []byte) error {
return b.Put([]byte(id), raw)
}
// DeleteGrid removes a grid.
// DeleteGrid removes a grid by ID.
func (s *Store) DeleteGrid(tx *bbolt.Tx, id string) error {
b := tx.Bucket(BucketGrids)
if b == nil {
@@ -245,7 +256,7 @@ func (s *Store) DeleteGrid(tx *bbolt.Tx, id string) error {
return b.Delete([]byte(id))
}
// ForEachGrid calls fn for each grid.
// ForEachGrid iterates over all grids.
func (s *Store) ForEachGrid(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketGrids)
if b == nil {
@@ -256,7 +267,7 @@ func (s *Store) ForEachGrid(tx *bbolt.Tx, fn func(k, v []byte) error) error {
// --- Tiles (nested: mapid -> zoom -> coord) ---
// GetTile returns raw TileData bytes.
// GetTile returns the raw JSON for a tile at the given map/zoom/coord, or nil.
func (s *Store) GetTile(tx *bbolt.Tx, mapID, zoom int, coordKey string) []byte {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
@@ -273,7 +284,7 @@ func (s *Store) GetTile(tx *bbolt.Tx, mapID, zoom int, coordKey string) []byte {
return zoomB.Get([]byte(coordKey))
}
// PutTile stores TileData.
// PutTile stores a tile entry (creates nested buckets as needed).
func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []byte) error {
tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
if err != nil {
@@ -290,7 +301,7 @@ func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []by
return zoomB.Put([]byte(coordKey), raw)
}
// DeleteTilesBucket removes the tiles bucket (for wipe).
// DeleteTilesBucket removes the entire tiles bucket.
func (s *Store) DeleteTilesBucket(tx *bbolt.Tx) error {
if tx.Bucket(BucketTiles) == nil {
return nil
@@ -298,7 +309,7 @@ func (s *Store) DeleteTilesBucket(tx *bbolt.Tx) error {
return tx.DeleteBucket(BucketTiles)
}
// ForEachTile calls fn for each tile in the nested structure.
// ForEachTile iterates over all tiles across all maps and zoom levels.
func (s *Store) ForEachTile(tx *bbolt.Tx, fn func(mapK, zoomK, coordK, v []byte) error) error {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
@@ -321,7 +332,7 @@ func (s *Store) ForEachTile(tx *bbolt.Tx, fn func(mapK, zoomK, coordK, v []byte)
})
}
// GetTilesMapBucket returns the bucket for a map's tiles, or nil.
// GetTilesMapBucket returns the tiles sub-bucket for a specific map, or nil.
func (s *Store) GetTilesMapBucket(tx *bbolt.Tx, mapID int) *bbolt.Bucket {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
@@ -330,7 +341,7 @@ func (s *Store) GetTilesMapBucket(tx *bbolt.Tx, mapID int) *bbolt.Bucket {
return tiles.Bucket([]byte(strconv.Itoa(mapID)))
}
// CreateTilesMapBucket creates and returns the bucket for a map's tiles.
// CreateTilesMapBucket returns or creates the tiles sub-bucket for a specific map.
func (s *Store) CreateTilesMapBucket(tx *bbolt.Tx, mapID int) (*bbolt.Bucket, error) {
tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
if err != nil {
@@ -339,18 +350,22 @@ func (s *Store) CreateTilesMapBucket(tx *bbolt.Tx, mapID int) (*bbolt.Bucket, er
return tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapID)))
}
// DeleteTilesMapBucket removes a map's tile bucket.
// DeleteTilesMapBucket removes the tiles sub-bucket for a specific map.
func (s *Store) DeleteTilesMapBucket(tx *bbolt.Tx, mapID int) error {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
return nil
}
return tiles.DeleteBucket([]byte(strconv.Itoa(mapID)))
key := []byte(strconv.Itoa(mapID))
if tiles.Bucket(key) == nil {
return nil
}
return tiles.DeleteBucket(key)
}
// --- Markers (nested: grid bucket, id bucket) ---
// GetMarkersGridBucket returns the markers-by-grid bucket.
// GetMarkersGridBucket returns the markers grid sub-bucket, or nil.
func (s *Store) GetMarkersGridBucket(tx *bbolt.Tx) *bbolt.Bucket {
mb := tx.Bucket(BucketMarkers)
if mb == nil {
@@ -359,7 +374,7 @@ func (s *Store) GetMarkersGridBucket(tx *bbolt.Tx) *bbolt.Bucket {
return mb.Bucket(BucketMarkersGrid)
}
// GetMarkersIDBucket returns the markers-by-id bucket.
// GetMarkersIDBucket returns the markers ID sub-bucket, or nil.
func (s *Store) GetMarkersIDBucket(tx *bbolt.Tx) *bbolt.Bucket {
mb := tx.Bucket(BucketMarkers)
if mb == nil {
@@ -368,7 +383,7 @@ func (s *Store) GetMarkersIDBucket(tx *bbolt.Tx) *bbolt.Bucket {
return mb.Bucket(BucketMarkersID)
}
// CreateMarkersBuckets creates markers, grid, and id buckets.
// CreateMarkersBuckets returns or creates both markers sub-buckets (grid and id).
func (s *Store) CreateMarkersBuckets(tx *bbolt.Tx) (*bbolt.Bucket, *bbolt.Bucket, error) {
mb, err := tx.CreateBucketIfNotExists(BucketMarkers)
if err != nil {
@@ -385,7 +400,7 @@ func (s *Store) CreateMarkersBuckets(tx *bbolt.Tx) (*bbolt.Bucket, *bbolt.Bucket
return grid, idB, nil
}
// MarkersNextSequence returns next marker ID.
// MarkersNextSequence returns the next auto-increment ID for markers.
func (s *Store) MarkersNextSequence(tx *bbolt.Tx) (uint64, error) {
mb := tx.Bucket(BucketMarkers)
if mb == nil {
@@ -400,7 +415,7 @@ func (s *Store) MarkersNextSequence(tx *bbolt.Tx) (uint64, error) {
// --- OAuth states ---
// GetOAuthState returns raw state bytes.
// GetOAuthState returns the raw JSON for an OAuth state, or nil.
func (s *Store) GetOAuthState(tx *bbolt.Tx, state string) []byte {
b := tx.Bucket(BucketOAuthStates)
if b == nil {
@@ -409,7 +424,7 @@ func (s *Store) GetOAuthState(tx *bbolt.Tx, state string) []byte {
return b.Get([]byte(state))
}
// PutOAuthState stores state.
// PutOAuthState stores an OAuth state entry.
func (s *Store) PutOAuthState(tx *bbolt.Tx, state string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketOAuthStates)
if err != nil {
@@ -418,7 +433,7 @@ func (s *Store) PutOAuthState(tx *bbolt.Tx, state string, raw []byte) error {
return b.Put([]byte(state), raw)
}
// DeleteOAuthState removes state.
// DeleteOAuthState removes an OAuth state entry.
func (s *Store) DeleteOAuthState(tx *bbolt.Tx, state string) error {
b := tx.Bucket(BucketOAuthStates)
if b == nil {
@@ -429,16 +444,15 @@ func (s *Store) DeleteOAuthState(tx *bbolt.Tx, state string) error {
// --- Bucket existence (for wipe) ---
// BucketExists returns true if the bucket exists.
// BucketExists returns true if a top-level bucket with the given name exists.
func (s *Store) BucketExists(tx *bbolt.Tx, name []byte) bool {
return tx.Bucket(name) != nil
}
// DeleteBucket removes a bucket.
// DeleteBucket removes a top-level bucket (no-op if it doesn't exist).
func (s *Store) DeleteBucket(tx *bbolt.Tx, name []byte) error {
if tx.Bucket(name) == nil {
return nil
}
return tx.DeleteBucket(name)
}

View File

@@ -0,0 +1,532 @@
package store_test
import (
"context"
"path/filepath"
"testing"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func newTestStore(t *testing.T) *store.Store {
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() })
return store.New(db)
}
func TestUserCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
// Verify user doesn't exist on empty DB.
st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetUser(tx, "alice"); got != nil {
t.Fatal("expected nil user before creation")
}
return nil
})
// Create user.
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutUser(tx, "alice", []byte(`{"pass":"hash"}`))
}); err != nil {
t.Fatal(err)
}
// Verify user exists and count is correct (separate transaction for accurate Stats).
st.View(ctx, func(tx *bbolt.Tx) error {
got := st.GetUser(tx, "alice")
if got == nil || string(got) != `{"pass":"hash"}` {
t.Fatalf("expected user data, got %s", got)
}
if c := st.UserCount(tx); c != 1 {
t.Fatalf("expected 1 user, got %d", c)
}
return nil
})
// Delete user.
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteUser(tx, "alice")
}); err != nil {
t.Fatal(err)
}
st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetUser(tx, "alice"); got != nil {
t.Fatal("expected nil user after deletion")
}
return nil
})
}
func TestForEachUser(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutUser(tx, "alice", []byte("1"))
st.PutUser(tx, "bob", []byte("2"))
return nil
})
var names []string
st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachUser(tx, func(k, _ []byte) error {
names = append(names, string(k))
return nil
})
})
if len(names) != 2 {
t.Fatalf("expected 2 users, got %d", len(names))
}
}
func TestUserCountEmptyBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.View(ctx, func(tx *bbolt.Tx) error {
if c := st.UserCount(tx); c != 0 {
t.Fatalf("expected 0 users on empty db, got %d", c)
}
return nil
})
}
func TestSessionCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetSession(tx, "sess1"); got != nil {
t.Fatal("expected nil session")
}
if err := st.PutSession(tx, "sess1", []byte(`{"user":"alice"}`)); err != nil {
return err
}
got := st.GetSession(tx, "sess1")
if string(got) != `{"user":"alice"}` {
t.Fatalf("unexpected session data: %s", got)
}
return st.DeleteSession(tx, "sess1")
})
if err != nil {
t.Fatal(err)
}
}
func TestTokenCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetTokenUser(tx, "tok"); got != nil {
t.Fatal("expected nil token")
}
if err := st.PutToken(tx, "tok", "alice"); err != nil {
return err
}
got := st.GetTokenUser(tx, "tok")
if string(got) != "alice" {
t.Fatalf("expected alice, got %s", got)
}
return st.DeleteToken(tx, "tok")
})
if err != nil {
t.Fatal(err)
}
}
func TestConfigCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetConfig(tx, "title"); got != nil {
t.Fatal("expected nil config")
}
if err := st.PutConfig(tx, "title", []byte("My Map")); err != nil {
return err
}
got := st.GetConfig(tx, "title")
if string(got) != "My Map" {
t.Fatalf("expected My Map, got %s", got)
}
return st.DeleteConfig(tx, "title")
})
if err != nil {
t.Fatal(err)
}
}
func TestMapCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetMap(tx, 1); got != nil {
t.Fatal("expected nil map")
}
if err := st.PutMap(tx, 1, []byte(`{"name":"world"}`)); err != nil {
return err
}
got := st.GetMap(tx, 1)
if string(got) != `{"name":"world"}` {
t.Fatalf("unexpected map data: %s", got)
}
seq, err := st.MapsNextSequence(tx)
if err != nil {
return err
}
if seq == 0 {
t.Fatal("expected non-zero sequence")
}
return st.DeleteMap(tx, 1)
})
if err != nil {
t.Fatal(err)
}
}
func TestForEachMap(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutMap(tx, 1, []byte("a"))
st.PutMap(tx, 2, []byte("b"))
return nil
})
var count int
st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachMap(tx, func(_, _ []byte) error {
count++
return nil
})
})
if count != 2 {
t.Fatalf("expected 2 maps, got %d", count)
}
}
func TestGridCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetGrid(tx, "g1"); got != nil {
t.Fatal("expected nil grid")
}
if err := st.PutGrid(tx, "g1", []byte(`{"map":1}`)); err != nil {
return err
}
got := st.GetGrid(tx, "g1")
if string(got) != `{"map":1}` {
t.Fatalf("unexpected grid data: %s", got)
}
return st.DeleteGrid(tx, "g1")
})
if err != nil {
t.Fatal(err)
}
}
func TestTileCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetTile(tx, 1, 0, "0_0"); got != nil {
t.Fatal("expected nil tile")
}
if err := st.PutTile(tx, 1, 0, "0_0", []byte("png")); err != nil {
return err
}
got := st.GetTile(tx, 1, 0, "0_0")
if string(got) != "png" {
t.Fatalf("unexpected tile data: %s", got)
}
if got := st.GetTile(tx, 1, 0, "1_1"); got != nil {
t.Fatal("expected nil for different coord")
}
if got := st.GetTile(tx, 2, 0, "0_0"); got != nil {
t.Fatal("expected nil for different map")
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func TestForEachTile(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutTile(tx, 1, 0, "0_0", []byte("a"))
st.PutTile(tx, 1, 1, "0_0", []byte("b"))
st.PutTile(tx, 2, 0, "1_1", []byte("c"))
return nil
})
var count int
st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachTile(tx, func(_, _, _, _ []byte) error {
count++
return nil
})
})
if count != 3 {
t.Fatalf("expected 3 tiles, got %d", count)
}
}
func TestTilesMapBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
if b := st.GetTilesMapBucket(tx, 1); b != nil {
t.Fatal("expected nil bucket before creation")
}
b, err := st.CreateTilesMapBucket(tx, 1)
if err != nil {
return err
}
if b == nil {
t.Fatal("expected non-nil bucket")
}
if b2 := st.GetTilesMapBucket(tx, 1); b2 == nil {
t.Fatal("expected non-nil after create")
}
return st.DeleteTilesMapBucket(tx, 1)
})
}
func TestDeleteTilesBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutTile(tx, 1, 0, "0_0", []byte("a"))
return st.DeleteTilesBucket(tx)
})
st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetTile(tx, 1, 0, "0_0"); got != nil {
t.Fatal("expected nil after bucket deletion")
}
return nil
})
}
func TestMarkerBuckets(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
if b := st.GetMarkersGridBucket(tx); b != nil {
t.Fatal("expected nil grid bucket before creation")
}
if b := st.GetMarkersIDBucket(tx); b != nil {
t.Fatal("expected nil id bucket before creation")
}
grid, idB, err := st.CreateMarkersBuckets(tx)
if err != nil {
return err
}
if grid == nil || idB == nil {
t.Fatal("expected non-nil marker buckets")
}
seq, err := st.MarkersNextSequence(tx)
if err != nil {
return err
}
if seq == 0 {
t.Fatal("expected non-zero sequence")
}
return nil
})
}
func TestOAuthStateCRUD(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
if got := st.GetOAuthState(tx, "state1"); got != nil {
t.Fatal("expected nil")
}
if err := st.PutOAuthState(tx, "state1", []byte(`{"provider":"google"}`)); err != nil {
return err
}
got := st.GetOAuthState(tx, "state1")
if string(got) != `{"provider":"google"}` {
t.Fatalf("unexpected state data: %s", got)
}
return st.DeleteOAuthState(tx, "state1")
})
if err != nil {
t.Fatal(err)
}
}
func TestBucketExistsAndDelete(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error {
if st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to not exist")
}
st.PutUser(tx, "alice", []byte("x"))
if !st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to exist")
}
return st.DeleteBucket(tx, store.BucketUsers)
})
st.View(ctx, func(tx *bbolt.Tx) error {
if st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to be deleted")
}
return nil
})
}
func TestDeleteBucketNonExistent(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteBucket(tx, store.BucketUsers)
})
if err != nil {
t.Fatalf("deleting non-existent bucket should not error: %v", err)
}
}
func TestViewCancelledContext(t *testing.T) {
st := newTestStore(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := st.View(ctx, func(_ *bbolt.Tx) error {
t.Fatal("should not execute")
return nil
})
if err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
func TestUpdateCancelledContext(t *testing.T) {
st := newTestStore(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := st.Update(ctx, func(_ *bbolt.Tx) error {
t.Fatal("should not execute")
return nil
})
if err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
func TestDeleteSessionNoBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteSession(tx, "nonexistent")
})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteTokenNoBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteToken(tx, "nonexistent")
})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteUserNoBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteUser(tx, "nonexistent")
})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteConfigNoBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteConfig(tx, "nonexistent")
})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteMapNoBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteMap(tx, 1)
})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteGridNoBucket(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.DeleteGrid(tx, "g1")
})
if err != nil {
t.Fatal(err)
}
}

View File

@@ -1,270 +0,0 @@
package app
import (
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
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,
}
type TileData struct {
MapID int
Coord Coord
Zoom int
File string
Cache int64
}
func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
a.db.View(func(tx *bbolt.Tx) error {
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
mapb := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapb == nil {
return nil
}
zoom := mapb.Bucket([]byte(strconv.Itoa(z)))
if zoom == nil {
return nil
}
tileraw := zoom.Get([]byte(c.Name()))
if tileraw == nil {
return nil
}
json.Unmarshal(tileraw, &td)
return nil
})
return
}
func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
a.db.Update(func(tx *bbolt.Tx) error {
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapid)))
if err != nil {
return err
}
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(z)))
if err != nil {
return err
}
td := &TileData{
MapID: mapid,
Coord: c,
Zoom: z,
File: f,
Cache: t,
}
raw, err := json.Marshal(td)
if err != nil {
return err
}
a.gridUpdates.Send(td)
return zoom.Put([]byte(c.Name()), raw)
})
return
}
func (a *App) reportMerge(from, to int, shift Coord) {
a.mergeUpdates.Send(&Merge{
From: from,
To: to,
Shift: shift,
})
}
type TileCache struct {
M, X, Y, Z, T int
}
func (a *App) watchGridUpdates(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.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 {
http.Error(rw, "Streaming unsupported!", http.StatusInternalServerError)
return
}
c := make(chan *TileData, 1000)
mc := make(chan *Merge, 5)
a.gridUpdates.Watch(c)
a.mergeUpdates.Watch(mc)
tileCache := make([]TileCache, 0, 100)
a.db.View(func(tx *bbolt.Tx) error {
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
return tiles.ForEach(func(mk, mv []byte) error {
mapb := tiles.Bucket(mk)
if mapb == nil {
return nil
}
return mapb.ForEach(func(k, v []byte) error {
zoom := mapb.Bucket(k)
if zoom == nil {
return nil
}
return zoom.ForEach(func(tk, tv []byte) error {
td := TileData{}
json.Unmarshal(tv, &td)
tileCache = append(tileCache, TileCache{
M: td.MapID,
X: td.Coord.X,
Y: td.Coord.Y,
Z: td.Zoom,
T: int(td.Cache),
})
return nil
})
})
})
})
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0]
flusher.Flush()
ticker := time.NewTicker(5 * time.Second)
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, TileCache{
M: e.MapID,
X: e.Coord.X,
Y: e.Coord.Y,
Z: e.Zoom,
T: int(e.Cache),
})
}
case e, ok := <-mc:
log.Println(e, ok)
if !ok {
return
}
raw, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
log.Println(string(raw))
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()
}
}
}
var tileRegex = regexp.MustCompile("([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+).png")
func (a *App) gridTile(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
tile := tileRegex.FindStringSubmatch(req.URL.Path)
if tile == nil || len(tile) < 5 {
http.Error(rw, "invalid path", http.StatusBadRequest)
return
}
mapid, err := strconv.Atoi(tile[1])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
z, err := strconv.Atoi(tile[2])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
x, err := strconv.Atoi(tile[3])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
y, err := strconv.Atoi(tile[4])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
// Map frontend Leaflet zoom (1…6) to storage level (0…5): z=6 → 0 (max detail), z=1..5 → same
storageZ := z
if storageZ == 6 {
storageZ = 0
}
if storageZ < 0 || storageZ > 5 {
storageZ = 0
}
td := a.GetTile(mapid, 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(a.gridStorage, td.File))
}

View File

@@ -38,6 +38,7 @@ func (t *Topic[T]) Close() {
t.c = t.c[:0]
}
// Merge represents a map merge event (two maps becoming one).
type Merge struct {
From, To int
Shift Coord