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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
108
internal/app/app_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
18
internal/app/apperr/errors.go
Normal file
18
internal/app/apperr/errors.go
Normal 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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
441
internal/app/handlers/admin.go
Normal file
441
internal/app/handlers/admin.go
Normal file
@@ -0,0 +1,441 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
)
|
||||
|
||||
type mapInfoJSON struct {
|
||||
ID int `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Hidden bool `json:"Hidden"`
|
||||
Priority bool `json:"Priority"`
|
||||
}
|
||||
|
||||
// APIAdminUsers handles GET/POST /map/api/admin/users.
|
||||
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
if req.Method == http.MethodGet {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
list, err := h.Admin.ListUsers(ctx)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
s := h.requireAdmin(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
Auths []string `json:"auths"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
if body.User == s.Username {
|
||||
s.Auths = body.Auths
|
||||
}
|
||||
if adminCreated && s.Username == "admin" {
|
||||
h.Auth.DeleteSession(ctx, s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminUserByName handles GET /map/api/admin/users/:name.
|
||||
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
auths, found := h.Admin.GetUser(req.Context(), name)
|
||||
out := struct {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
}{Username: name}
|
||||
if found {
|
||||
out.Auths = auths
|
||||
}
|
||||
JSON(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
|
||||
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
|
||||
if req.Method != http.MethodDelete {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
s := h.requireAdmin(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
if err := h.Admin.DeleteUser(ctx, name); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
if name == s.Username {
|
||||
h.Auth.DeleteSession(ctx, s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSettingsGet handles GET /map/api/admin/settings.
|
||||
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, struct {
|
||||
Prefix string `json:"prefix"`
|
||||
DefaultHide bool `json:"defaultHide"`
|
||||
Title string `json:"title"`
|
||||
}{Prefix: prefix, DefaultHide: defaultHide, Title: title})
|
||||
}
|
||||
|
||||
// APIAdminSettingsPost handles POST /map/api/admin/settings.
|
||||
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Prefix *string `json:"prefix"`
|
||||
DefaultHide *bool `json:"defaultHide"`
|
||||
Title *string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminMaps handles GET /map/api/admin/maps.
|
||||
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
maps, err := h.Admin.ListMaps(req.Context())
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
out := make([]mapInfoJSON, len(maps))
|
||||
for i, m := range maps {
|
||||
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
|
||||
}
|
||||
JSON(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIAdminMapByID handles POST /map/api/admin/maps/:id.
|
||||
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Priority bool `json:"priority"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
|
||||
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, mapInfoJSON{
|
||||
ID: mi.ID,
|
||||
Name: mi.Name,
|
||||
Hidden: mi.Hidden,
|
||||
Priority: mi.Priority,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminWipe handles POST /map/api/admin/wipe.
|
||||
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.Wipe(req.Context()); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminWipeTile handles POST /map/api/admin/wipeTile.
|
||||
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(req.FormValue("map"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
x, err := strconv.Atoi(req.FormValue("x"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
y, err := strconv.Atoi(req.FormValue("y"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSetCoords handles POST /map/api/admin/setCoords.
|
||||
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(req.FormValue("map"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
fx, err := strconv.Atoi(req.FormValue("fx"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
fy, err := strconv.Atoi(req.FormValue("fy"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
tx, err := strconv.Atoi(req.FormValue("tx"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
ty, err := strconv.Atoi(req.FormValue("ty"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminHideMarker handles POST /map/api/admin/hideMarker.
|
||||
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
markerID := req.FormValue("id")
|
||||
if markerID == "" {
|
||||
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
|
||||
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
h.Admin.RebuildZooms(req.Context())
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminExport handles GET /map/api/admin/export.
|
||||
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/zip")
|
||||
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
|
||||
if err := h.Export.Export(req.Context(), rw); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// APIAdminMerge handles POST /map/api/admin/merge.
|
||||
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
mergef, hdr, err := req.FormFile("merge")
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
zr, err := zip.NewReader(mergef, hdr.Size)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Export.Merge(req.Context(), zr); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminRoute routes /map/api/admin/* sub-paths.
|
||||
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
|
||||
switch {
|
||||
case path == "wipeTile":
|
||||
h.APIAdminWipeTile(rw, req)
|
||||
case path == "setCoords":
|
||||
h.APIAdminSetCoords(rw, req)
|
||||
case path == "hideMarker":
|
||||
h.APIAdminHideMarker(rw, req)
|
||||
case path == "users":
|
||||
h.APIAdminUsers(rw, req)
|
||||
case strings.HasPrefix(path, "users/"):
|
||||
name := strings.TrimPrefix(path, "users/")
|
||||
if name == "" {
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodDelete {
|
||||
h.APIAdminUserDelete(rw, req, name)
|
||||
} else {
|
||||
h.APIAdminUserByName(rw, req, name)
|
||||
}
|
||||
case path == "settings":
|
||||
if req.Method == http.MethodGet {
|
||||
h.APIAdminSettingsGet(rw, req)
|
||||
} else {
|
||||
h.APIAdminSettingsPost(rw, req)
|
||||
}
|
||||
case path == "maps":
|
||||
h.APIAdminMaps(rw, req)
|
||||
case strings.HasPrefix(path, "maps/"):
|
||||
rest := strings.TrimPrefix(path, "maps/")
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
idStr := parts[0]
|
||||
if len(parts) == 2 && parts[1] == "toggle-hidden" {
|
||||
h.APIAdminMapToggleHidden(rw, req, idStr)
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
h.APIAdminMapByID(rw, req, idStr)
|
||||
return
|
||||
}
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
case path == "wipe":
|
||||
h.APIAdminWipe(rw, req)
|
||||
case path == "rebuildZooms":
|
||||
h.APIAdminRebuildZooms(rw, req)
|
||||
case path == "export":
|
||||
h.APIAdminExport(rw, req)
|
||||
case path == "merge":
|
||||
h.APIAdminMerge(rw, req)
|
||||
default:
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
}
|
||||
}
|
||||
@@ -1,458 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
Tokens []string `json:"tokens,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
// APILogin handles POST /map/api/login.
|
||||
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body loginRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if u := h.Auth.GetUserByUsername(body.User); u != nil && u.Pass == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Use OAuth to sign in", "OAUTH_ONLY")
|
||||
return
|
||||
}
|
||||
u := h.Auth.GetUser(body.User, body.Pass)
|
||||
if u == nil {
|
||||
if boot := h.Auth.BootstrapAdmin(body.User, body.Pass, services.GetBootstrapPassword()); boot != nil {
|
||||
u = boot
|
||||
} else {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
}
|
||||
sessionID := h.Auth.CreateSession(body.User, u.Auths.Has("tempadmin"))
|
||||
if sessionID == "" {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: 24 * 7 * 3600,
|
||||
HttpOnly: true,
|
||||
Secure: req.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
JSON(rw, http.StatusOK, meResponse{Username: body.User, Auths: u.Auths})
|
||||
}
|
||||
|
||||
// APISetup handles GET /map/api/setup.
|
||||
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, struct {
|
||||
SetupRequired bool `json:"setupRequired"`
|
||||
}{SetupRequired: h.Auth.SetupRequired()})
|
||||
}
|
||||
|
||||
// APILogout handles POST /map/api/logout.
|
||||
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.Auth.GetSession(req)
|
||||
if s != nil {
|
||||
h.Auth.DeleteSession(s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIMe handles GET /map/api/me.
|
||||
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.Auth.GetSession(req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
out := meResponse{Username: s.Username, Auths: s.Auths}
|
||||
out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(s.Username)
|
||||
JSON(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIMeTokens handles POST /map/api/me/tokens.
|
||||
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.Auth.GetSession(req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
if !s.Auths.Has(app.AUTH_UPLOAD) {
|
||||
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
|
||||
return
|
||||
}
|
||||
tokens := h.Auth.GenerateTokenForUser(s.Username)
|
||||
if tokens == nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, map[string][]string{"tokens": tokens})
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
|
||||
// APIMePassword handles POST /map/api/me/password.
|
||||
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.Auth.GetSession(req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
var body passwordRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Auth.SetUserPassword(s.Username, body.Pass); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIConfig handles GET /map/api/config.
|
||||
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
|
||||
s := h.Auth.GetSession(req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
config, err := h.Map.GetConfig(s.Auths)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, config)
|
||||
}
|
||||
|
||||
// APIGetChars handles GET /map/api/v1/characters.
|
||||
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
||||
s := h.Auth.GetSession(req)
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
|
||||
JSON(rw, http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
chars := h.Map.GetCharacters()
|
||||
JSON(rw, http.StatusOK, chars)
|
||||
}
|
||||
|
||||
// APIGetMarkers handles GET /map/api/v1/markers.
|
||||
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
|
||||
s := h.Auth.GetSession(req)
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
|
||||
JSON(rw, http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
markers, err := h.Map.GetMarkers()
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, markers)
|
||||
}
|
||||
|
||||
// APIGetMaps handles GET /map/api/maps.
|
||||
func (h *Handlers) APIGetMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
s := h.Auth.GetSession(req)
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
showHidden := s.Auths.Has(app.AUTH_ADMIN)
|
||||
maps, err := h.Map.GetMaps(showHidden)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, maps)
|
||||
}
|
||||
|
||||
// --- Admin API ---
|
||||
|
||||
// APIAdminUsers handles GET/POST /map/api/admin/users.
|
||||
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
list, err := h.Admin.ListUsers()
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.requireAdmin(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
Auths []string `json:"auths"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
adminCreated, err := h.Admin.CreateOrUpdateUser(body.User, body.Pass, body.Auths)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
if body.User == s.Username {
|
||||
s.Auths = body.Auths
|
||||
}
|
||||
if adminCreated && s.Username == "admin" {
|
||||
h.Auth.DeleteSession(s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminUserByName handles GET /map/api/admin/users/:name.
|
||||
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
auths, found := h.Admin.GetUser(name)
|
||||
out := struct {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
}{Username: name}
|
||||
if found {
|
||||
out.Auths = auths
|
||||
}
|
||||
JSON(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
|
||||
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
|
||||
if req.Method != http.MethodDelete {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.requireAdmin(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.DeleteUser(name); err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
if name == s.Username {
|
||||
h.Auth.DeleteSession(s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSettingsGet handles GET /map/api/admin/settings.
|
||||
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
prefix, defaultHide, title, err := h.Admin.GetSettings()
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, struct {
|
||||
Prefix string `json:"prefix"`
|
||||
DefaultHide bool `json:"defaultHide"`
|
||||
Title string `json:"title"`
|
||||
}{Prefix: prefix, DefaultHide: defaultHide, Title: title})
|
||||
}
|
||||
|
||||
// APIAdminSettingsPost handles POST /map/api/admin/settings.
|
||||
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Prefix *string `json:"prefix"`
|
||||
DefaultHide *bool `json:"defaultHide"`
|
||||
Title *string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.UpdateSettings(body.Prefix, body.DefaultHide, body.Title); err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
type mapInfoJSON struct {
|
||||
ID int `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Hidden bool `json:"Hidden"`
|
||||
Priority bool `json:"Priority"`
|
||||
}
|
||||
|
||||
// APIAdminMaps handles GET /map/api/admin/maps.
|
||||
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
maps, err := h.Admin.ListMaps()
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
out := make([]mapInfoJSON, len(maps))
|
||||
for i, m := range maps {
|
||||
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
|
||||
}
|
||||
JSON(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIAdminMapByID handles POST /map/api/admin/maps/:id.
|
||||
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Priority bool `json:"priority"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.UpdateMap(id, body.Name, body.Hidden, body.Priority); err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
|
||||
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mi, err := h.Admin.ToggleMapHidden(id)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, mapInfoJSON{
|
||||
ID: mi.ID,
|
||||
Name: mi.Name,
|
||||
Hidden: mi.Hidden,
|
||||
Priority: mi.Priority,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminWipe handles POST /map/api/admin/wipe.
|
||||
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.Wipe(); err != nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIRouter routes /map/api/* requests.
|
||||
// APIRouter routes /map/api/* requests to the appropriate handler.
|
||||
func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
|
||||
path := strings.TrimPrefix(req.URL.Path, "/map/api")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
@@ -470,22 +23,29 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
|
||||
case "maps":
|
||||
h.APIGetMaps(rw, req)
|
||||
return
|
||||
}
|
||||
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" {
|
||||
switch path {
|
||||
case "admin/wipeTile":
|
||||
h.App.WipeTile(rw, req)
|
||||
case "admin/setCoords":
|
||||
h.App.SetCoords(rw, req)
|
||||
case "admin/hideMarker":
|
||||
h.App.HideMarker(rw, req)
|
||||
}
|
||||
case "setup":
|
||||
h.APISetup(rw, req)
|
||||
return
|
||||
case "login":
|
||||
h.APILogin(rw, req)
|
||||
return
|
||||
case "logout":
|
||||
h.APILogout(rw, req)
|
||||
return
|
||||
case "me":
|
||||
h.APIMe(rw, req)
|
||||
return
|
||||
case "me/tokens":
|
||||
h.APIMeTokens(rw, req)
|
||||
return
|
||||
case "me/password":
|
||||
h.APIMePassword(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "oauth/providers":
|
||||
h.App.APIOAuthProviders(rw, req)
|
||||
h.APIOAuthProviders(rw, req)
|
||||
return
|
||||
case strings.HasPrefix(path, "oauth/"):
|
||||
rest := strings.TrimPrefix(path, "oauth/")
|
||||
@@ -498,85 +58,16 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
|
||||
action := parts[1]
|
||||
switch action {
|
||||
case "login":
|
||||
h.App.OAuthLogin(rw, req, provider)
|
||||
h.APIOAuthLogin(rw, req, provider)
|
||||
case "callback":
|
||||
h.App.OAuthCallback(rw, req, provider)
|
||||
h.APIOAuthCallback(rw, req, provider)
|
||||
default:
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
}
|
||||
return
|
||||
case path == "setup":
|
||||
h.APISetup(rw, req)
|
||||
return
|
||||
case path == "login":
|
||||
h.APILogin(rw, req)
|
||||
return
|
||||
case path == "logout":
|
||||
h.APILogout(rw, req)
|
||||
return
|
||||
case path == "me":
|
||||
h.APIMe(rw, req)
|
||||
return
|
||||
case path == "me/tokens":
|
||||
h.APIMeTokens(rw, req)
|
||||
return
|
||||
case path == "me/password":
|
||||
h.APIMePassword(rw, req)
|
||||
return
|
||||
case path == "admin/users":
|
||||
if req.Method == http.MethodPost {
|
||||
h.APIAdminUsers(rw, req)
|
||||
} else {
|
||||
h.APIAdminUsers(rw, req)
|
||||
}
|
||||
return
|
||||
case strings.HasPrefix(path, "admin/users/"):
|
||||
name := strings.TrimPrefix(path, "admin/users/")
|
||||
if name == "" {
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodDelete {
|
||||
h.APIAdminUserDelete(rw, req, name)
|
||||
} else {
|
||||
h.APIAdminUserByName(rw, req, name)
|
||||
}
|
||||
return
|
||||
case path == "admin/settings":
|
||||
if req.Method == http.MethodGet {
|
||||
h.APIAdminSettingsGet(rw, req)
|
||||
} else {
|
||||
h.APIAdminSettingsPost(rw, req)
|
||||
}
|
||||
return
|
||||
case path == "admin/maps":
|
||||
h.APIAdminMaps(rw, req)
|
||||
return
|
||||
case strings.HasPrefix(path, "admin/maps/"):
|
||||
rest := strings.TrimPrefix(path, "admin/maps/")
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
idStr := parts[0]
|
||||
if len(parts) == 2 && parts[1] == "toggle-hidden" {
|
||||
h.APIAdminMapToggleHidden(rw, req, idStr)
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
h.APIAdminMapByID(rw, req, idStr)
|
||||
return
|
||||
}
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
return
|
||||
case path == "admin/wipe":
|
||||
h.APIAdminWipe(rw, req)
|
||||
return
|
||||
case path == "admin/rebuildZooms":
|
||||
h.App.APIAdminRebuildZooms(rw, req)
|
||||
return
|
||||
case path == "admin/export":
|
||||
h.App.APIAdminExport(rw, req)
|
||||
return
|
||||
case path == "admin/merge":
|
||||
h.App.APIAdminMerge(rw, req)
|
||||
case strings.HasPrefix(path, "admin/"):
|
||||
adminPath := strings.TrimPrefix(path, "admin/")
|
||||
h.APIAdminRoute(rw, req, adminPath)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
224
internal/app/handlers/auth.go
Normal file
224
internal/app/handlers/auth.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
Tokens []string `json:"tokens,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
|
||||
// APILogin handles POST /map/api/login.
|
||||
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
var body loginRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if u := h.Auth.GetUserByUsername(ctx, body.User); u != nil && u.Pass == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Use OAuth to sign in", "OAUTH_ONLY")
|
||||
return
|
||||
}
|
||||
u := h.Auth.GetUser(ctx, body.User, body.Pass)
|
||||
if u == nil {
|
||||
if boot := h.Auth.BootstrapAdmin(ctx, body.User, body.Pass, services.GetBootstrapPassword()); boot != nil {
|
||||
u = boot
|
||||
} else {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
}
|
||||
sessionID := h.Auth.CreateSession(ctx, body.User, u.Auths.Has("tempadmin"))
|
||||
if sessionID == "" {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: app.SessionMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: req.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
JSON(rw, http.StatusOK, meResponse{Username: body.User, Auths: u.Auths})
|
||||
}
|
||||
|
||||
// APISetup handles GET /map/api/setup.
|
||||
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, struct {
|
||||
SetupRequired bool `json:"setupRequired"`
|
||||
}{SetupRequired: h.Auth.SetupRequired(req.Context())})
|
||||
}
|
||||
|
||||
// APILogout handles POST /map/api/logout.
|
||||
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s != nil {
|
||||
h.Auth.DeleteSession(ctx, s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIMe handles GET /map/api/me.
|
||||
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
out := meResponse{Username: s.Username, Auths: s.Auths}
|
||||
out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(ctx, s.Username)
|
||||
JSON(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIMeTokens handles POST /map/api/me/tokens.
|
||||
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
if !s.Auths.Has(app.AUTH_UPLOAD) {
|
||||
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
|
||||
return
|
||||
}
|
||||
tokens := h.Auth.GenerateTokenForUser(ctx, s.Username)
|
||||
if tokens == nil {
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, map[string][]string{"tokens": tokens})
|
||||
}
|
||||
|
||||
// APIMePassword handles POST /map/api/me/password.
|
||||
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
var body passwordRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Auth.SetUserPassword(ctx, s.Username, body.Pass); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIOAuthProviders handles GET /map/api/oauth/providers.
|
||||
func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, services.OAuthProviders())
|
||||
}
|
||||
|
||||
// APIOAuthLogin handles GET /map/api/oauth/:provider/login.
|
||||
func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
redirect := req.URL.Query().Get("redirect")
|
||||
authURL, err := h.Auth.OAuthInitLogin(req.Context(), provider, redirect, req)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(rw, req, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// APIOAuthCallback handles GET /map/api/oauth/:provider/callback.
|
||||
func (h *Handlers) APIOAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
code := req.URL.Query().Get("code")
|
||||
state := req.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
JSONError(rw, http.StatusBadRequest, "missing code or state", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
sessionID, redirectTo, err := h.Auth.OAuthHandleCallback(req.Context(), provider, code, state, req)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: app.SessionMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: req.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(rw, req, redirectTo, http.StatusFound)
|
||||
}
|
||||
|
||||
// RedirectLogout handles GET /logout.
|
||||
func (h *Handlers) RedirectLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path != "/logout" {
|
||||
http.NotFound(rw, req)
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s != nil {
|
||||
h.Auth.DeleteSession(ctx, s)
|
||||
}
|
||||
http.Redirect(rw, req, "/login", http.StatusFound)
|
||||
}
|
||||
125
internal/app/handlers/client.go
Normal file
125
internal/app/handlers/client.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
)
|
||||
|
||||
var clientPath = regexp.MustCompile(`client/([^/]+)/(.*)`)
|
||||
|
||||
// ClientRouter handles /client/* requests with token-based auth.
|
||||
func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
|
||||
matches := clientPath.FindStringSubmatch(req.URL.Path)
|
||||
if matches == nil {
|
||||
JSONError(rw, http.StatusBadRequest, "Client token not found", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
username, err := h.Auth.ValidateClientToken(ctx, matches[1])
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = username
|
||||
|
||||
switch matches[2] {
|
||||
case "locate":
|
||||
h.clientLocate(rw, req)
|
||||
case "gridUpdate":
|
||||
h.clientGridUpdate(rw, req)
|
||||
case "gridUpload":
|
||||
h.clientGridUpload(rw, req)
|
||||
case "positionUpdate":
|
||||
h.clientPositionUpdate(rw, req)
|
||||
case "markerUpdate":
|
||||
h.clientMarkerUpdate(rw, req)
|
||||
case "":
|
||||
http.Redirect(rw, req, "/", http.StatusFound)
|
||||
case "checkVersion":
|
||||
if req.FormValue("version") == app.ClientVersion {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) clientLocate(rw http.ResponseWriter, req *http.Request) {
|
||||
gridID := req.FormValue("gridID")
|
||||
result, err := h.Client.Locate(req.Context(), gridID)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rw.Write([]byte(result))
|
||||
}
|
||||
|
||||
func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||
defer req.Body.Close()
|
||||
var grup services.GridUpdate
|
||||
if err := json.NewDecoder(req.Body).Decode(&grup); err != nil {
|
||||
slog.Error("error decoding grid request", "error", err)
|
||||
JSONError(rw, http.StatusBadRequest, "error decoding request", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
result, err := h.Client.ProcessGridUpdate(req.Context(), grup)
|
||||
if err != nil {
|
||||
slog.Error("grid update failed", "error", err)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(rw).Encode(result.Response)
|
||||
}
|
||||
|
||||
func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
|
||||
ct := req.Header.Get("Content-Type")
|
||||
if fixed := services.FixMultipartContentType(ct); fixed != ct {
|
||||
req.Header.Set("Content-Type", fixed)
|
||||
}
|
||||
if err := req.ParseMultipartForm(app.MultipartMaxMemory); err != nil {
|
||||
slog.Error("multipart parse error", "error", err)
|
||||
return
|
||||
}
|
||||
id := req.FormValue("id")
|
||||
extraData := req.FormValue("extraData")
|
||||
file, _, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
slog.Error("form file error", "error", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := h.Client.ProcessGridUpload(req.Context(), id, extraData, file); err != nil {
|
||||
slog.Error("grid upload failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||
defer req.Body.Close()
|
||||
buf, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
slog.Error("error reading position update", "error", err)
|
||||
return
|
||||
}
|
||||
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
|
||||
slog.Error("position update failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||
defer req.Body.Close()
|
||||
buf, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
slog.Error("error reading marker update", "error", err)
|
||||
return
|
||||
}
|
||||
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
|
||||
slog.Error("marker update failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/apperr"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
)
|
||||
|
||||
// Handlers holds HTTP handlers and their dependencies.
|
||||
type Handlers struct {
|
||||
App *app.App
|
||||
Auth *services.AuthService
|
||||
Map *services.MapService
|
||||
Admin *services.AdminService
|
||||
Auth *services.AuthService
|
||||
Map *services.MapService
|
||||
Admin *services.AdminService
|
||||
Client *services.ClientService
|
||||
Export *services.ExportService
|
||||
}
|
||||
|
||||
// New creates Handlers with the given dependencies.
|
||||
func New(a *app.App, auth *services.AuthService, mapSvc *services.MapService, admin *services.AdminService) *Handlers {
|
||||
return &Handlers{App: a, Auth: auth, Map: mapSvc, Admin: admin}
|
||||
func New(
|
||||
auth *services.AuthService,
|
||||
mapSvc *services.MapService,
|
||||
admin *services.AdminService,
|
||||
client *services.ClientService,
|
||||
export *services.ExportService,
|
||||
) *Handlers {
|
||||
return &Handlers{
|
||||
Auth: auth,
|
||||
Map: mapSvc,
|
||||
Admin: admin,
|
||||
Client: client,
|
||||
Export: export,
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin returns session if admin, or writes 401 and returns nil.
|
||||
func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.Session {
|
||||
s := h.Auth.GetSession(req)
|
||||
s := h.Auth.GetSession(req.Context(), req)
|
||||
if s == nil || !s.Auths.Has(app.AUTH_ADMIN) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return nil
|
||||
@@ -34,3 +49,27 @@ func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.
|
||||
func (h *Handlers) canAccessMap(s *app.Session) bool {
|
||||
return s != nil && (s.Auths.Has(app.AUTH_MAP) || s.Auths.Has(app.AUTH_ADMIN))
|
||||
}
|
||||
|
||||
// HandleServiceError maps service-level errors to HTTP responses.
|
||||
func HandleServiceError(rw http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, apperr.ErrNotFound):
|
||||
JSONError(rw, http.StatusNotFound, err.Error(), "NOT_FOUND")
|
||||
case errors.Is(err, apperr.ErrUnauthorized):
|
||||
JSONError(rw, http.StatusUnauthorized, err.Error(), "UNAUTHORIZED")
|
||||
case errors.Is(err, apperr.ErrForbidden):
|
||||
JSONError(rw, http.StatusForbidden, err.Error(), "FORBIDDEN")
|
||||
case errors.Is(err, apperr.ErrBadRequest):
|
||||
JSONError(rw, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
|
||||
case errors.Is(err, apperr.ErrOAuthOnly):
|
||||
JSONError(rw, http.StatusUnauthorized, err.Error(), "OAUTH_ONLY")
|
||||
case errors.Is(err, apperr.ErrProviderUnconfigured):
|
||||
JSONError(rw, http.StatusServiceUnavailable, err.Error(), "PROVIDER_UNCONFIGURED")
|
||||
case errors.Is(err, apperr.ErrStateExpired), errors.Is(err, apperr.ErrStateMismatch):
|
||||
JSONError(rw, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
|
||||
case errors.Is(err, apperr.ErrExchangeFailed), errors.Is(err, apperr.ErrUserInfoFailed):
|
||||
JSONError(rw, http.StatusBadGateway, err.Error(), "OAUTH_ERROR")
|
||||
default:
|
||||
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
|
||||
}
|
||||
}
|
||||
|
||||
558
internal/app/handlers/handlers_test.go
Normal file
558
internal/app/handlers/handlers_test.go
Normal file
@@ -0,0 +1,558 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/apperr"
|
||||
"github.com/andyleap/hnh-map/internal/app/handlers"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
h *handlers.Handlers
|
||||
st *store.Store
|
||||
auth *services.AuthService
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := bbolt.Open(filepath.Join(dir, "test.db"), 0600, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
st := store.New(db)
|
||||
auth := services.NewAuthService(st)
|
||||
gridUpdates := &app.Topic[app.TileData]{}
|
||||
mergeUpdates := &app.Topic[app.Merge]{}
|
||||
|
||||
mapSvc := services.NewMapService(services.MapServiceDeps{
|
||||
Store: st,
|
||||
GridStorage: dir,
|
||||
GridUpdates: gridUpdates,
|
||||
MergeUpdates: mergeUpdates,
|
||||
GetChars: func() []app.Character { return nil },
|
||||
})
|
||||
admin := services.NewAdminService(st, mapSvc)
|
||||
client := services.NewClientService(services.ClientServiceDeps{
|
||||
Store: st,
|
||||
MapSvc: mapSvc,
|
||||
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
|
||||
})
|
||||
export := services.NewExportService(st, mapSvc)
|
||||
|
||||
h := handlers.New(auth, mapSvc, admin, client, export)
|
||||
return &testEnv{h: h, st: st, auth: auth}
|
||||
}
|
||||
|
||||
func (env *testEnv) createUser(t *testing.T, username, password string, auths app.Auths) {
|
||||
t.Helper()
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
u := app.User{Pass: hash, Auths: auths}
|
||||
raw, _ := json.Marshal(u)
|
||||
env.st.Update(context.Background(), func(tx *bbolt.Tx) error {
|
||||
return env.st.PutUser(tx, username, raw)
|
||||
})
|
||||
}
|
||||
|
||||
func (env *testEnv) loginSession(t *testing.T, username string) string {
|
||||
t.Helper()
|
||||
return env.auth.CreateSession(context.Background(), username, false)
|
||||
}
|
||||
|
||||
func withSession(req *http.Request, sid string) *http.Request {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sid})
|
||||
return req
|
||||
}
|
||||
|
||||
func TestAPISetup_NoUsers(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APISetup(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp struct{ SetupRequired bool }
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if !resp.SetupRequired {
|
||||
t.Fatal("expected setupRequired=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISetup_WithUsers(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APISetup(rr, req)
|
||||
|
||||
var resp struct{ SetupRequired bool }
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.SetupRequired {
|
||||
t.Fatal("expected setupRequired=false with users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISetup_WrongMethod(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/map/api/setup", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APISetup(rr, req)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILogin_Success(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP})
|
||||
|
||||
body := `{"user":"alice","pass":"secret"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APILogin(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
cookies := rr.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "session" && c.Value != "" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILogin_WrongPassword(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP})
|
||||
|
||||
body := `{"user":"alice","pass":"wrong"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APILogin(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILogin_BadJSON(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader("{invalid"))
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APILogin(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILogin_MethodNotAllowed(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/login", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APILogin(rr, req)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMe_Authenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIMe(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
}
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.Username != "alice" {
|
||||
t.Fatalf("expected alice, got %s", resp.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMe_Unauthenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/me", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIMe(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILogout(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", nil)
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/logout", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APILogout(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid)
|
||||
rr2 := httptest.NewRecorder()
|
||||
env.h.APIMe(rr2, req2)
|
||||
if rr2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 after logout, got %d", rr2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMeTokens_Authenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIMeTokens(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct{ Tokens []string }
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if len(resp.Tokens) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d", len(resp.Tokens))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMeTokens_Unauthenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIMeTokens(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMeTokens_NoUploadAuth(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIMeTokens(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMePassword(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "old", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
body := `{"pass":"newpass"}`
|
||||
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/password", strings.NewReader(body)), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIMePassword(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsers_RequiresAdmin(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminUsers(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for non-admin, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsers_ListAndCreate(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
sid := env.loginSession(t, "admin")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminUsers(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
body := `{"user":"bob","pass":"secret","auths":["map","upload"]}`
|
||||
req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/users", strings.NewReader(body)), sid)
|
||||
rr2 := httptest.NewRecorder()
|
||||
env.h.APIAdminUsers(rr2, req2)
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr2.Code, rr2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
sid := env.loginSession(t, "admin")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminSettingsGet(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
body := `{"prefix":"pfx","title":"New Title","defaultHide":true}`
|
||||
req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/settings", strings.NewReader(body)), sid)
|
||||
rr2 := httptest.NewRecorder()
|
||||
env.h.APIAdminSettingsPost(rr2, req2)
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr2.Code)
|
||||
}
|
||||
|
||||
req3 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid)
|
||||
rr3 := httptest.NewRecorder()
|
||||
env.h.APIAdminSettingsGet(rr3, req3)
|
||||
|
||||
var resp struct {
|
||||
Prefix string `json:"prefix"`
|
||||
DefaultHide bool `json:"defaultHide"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
json.NewDecoder(rr3.Body).Decode(&resp)
|
||||
if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" {
|
||||
t.Fatalf("unexpected settings: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWipe(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
sid := env.loginSession(t, "admin")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/wipe", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminWipe(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMaps(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
sid := env.loginSession(t, "admin")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/maps", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminMaps(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRouter_NotFound(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIRouter(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRouter_Config(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/config", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIRouter(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetChars_Unauthenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIGetChars(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetMarkers_Unauthenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIGetMarkers(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetMaps_Unauthenticated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/map/api/maps", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIGetMaps(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetChars_NoMarkersAuth(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIGetChars(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var chars []interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&chars)
|
||||
if len(chars) != 0 {
|
||||
t.Fatal("expected empty array without markers auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetMarkers_NoMarkersAuth(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "alice")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIGetMarkers(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var markers []interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&markers)
|
||||
if len(markers) != 0 {
|
||||
t.Fatal("expected empty array without markers auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectLogout(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.RedirectLogout(rr, req)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", rr.Code)
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); loc != "/login" {
|
||||
t.Fatalf("expected redirect to /login, got %s", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectLogout_WrongPath(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/other", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.RedirectLogout(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
}{
|
||||
{"not found", apperr.ErrNotFound, http.StatusNotFound},
|
||||
{"unauthorized", apperr.ErrUnauthorized, http.StatusUnauthorized},
|
||||
{"forbidden", apperr.ErrForbidden, http.StatusForbidden},
|
||||
{"bad request", apperr.ErrBadRequest, http.StatusBadRequest},
|
||||
{"oauth only", apperr.ErrOAuthOnly, http.StatusUnauthorized},
|
||||
{"provider unconfigured", apperr.ErrProviderUnconfigured, http.StatusServiceUnavailable},
|
||||
{"state expired", apperr.ErrStateExpired, http.StatusBadRequest},
|
||||
{"exchange failed", apperr.ErrExchangeFailed, http.StatusBadGateway},
|
||||
{"unknown", errors.New("something else"), http.StatusInternalServerError},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlers.HandleServiceError(rr, tt.err)
|
||||
if rr.Code != tt.status {
|
||||
t.Fatalf("expected %d, got %d", tt.status, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserByName(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
|
||||
sid := env.loginSession(t, "admin")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users/bob", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminUserByName(rr, req, "bob")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
}
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.Username != "bob" {
|
||||
t.Fatalf("expected bob, got %s", resp.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserDelete(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP})
|
||||
sid := env.loginSession(t, "admin")
|
||||
|
||||
req := withSession(httptest.NewRequest(http.MethodDelete, "/map/api/admin/users/bob", nil), sid)
|
||||
rr := httptest.NewRecorder()
|
||||
env.h.APIAdminUserDelete(rr, req, "bob")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
76
internal/app/handlers/map.go
Normal file
76
internal/app/handlers/map.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
)
|
||||
|
||||
// APIConfig handles GET /map/api/config.
|
||||
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
config, err := h.Map.GetConfig(ctx, s.Auths)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, config)
|
||||
}
|
||||
|
||||
// APIGetChars handles GET /map/api/v1/characters.
|
||||
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
|
||||
JSON(rw, http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
chars := h.Map.GetCharacters()
|
||||
JSON(rw, http.StatusOK, chars)
|
||||
}
|
||||
|
||||
// APIGetMarkers handles GET /map/api/v1/markers.
|
||||
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
|
||||
JSON(rw, http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
markers, err := h.Map.GetMarkers(ctx)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, markers)
|
||||
}
|
||||
|
||||
// APIGetMaps handles GET /map/api/maps.
|
||||
func (h *Handlers) APIGetMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
showHidden := s.Auths.Has(app.AUTH_ADMIN)
|
||||
maps, err := h.Map.GetMaps(ctx, showHidden)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, maps)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app/response"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// JSON writes v as JSON with the given status code.
|
||||
|
||||
162
internal/app/handlers/tile.go
Normal file
162
internal/app/handlers/tile.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
)
|
||||
|
||||
var transparentPNG = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
var tileRegex = regexp.MustCompile(`([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+)\.png`)
|
||||
|
||||
// WatchGridUpdates is the SSE endpoint for tile updates.
|
||||
func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if !h.canAccessMap(s) {
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "text/event-stream")
|
||||
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
rw.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := rw.(http.Flusher)
|
||||
if !ok {
|
||||
JSONError(rw, http.StatusInternalServerError, "streaming unsupported", "INTERNAL_ERROR")
|
||||
return
|
||||
}
|
||||
|
||||
c := h.Map.WatchTiles()
|
||||
mc := h.Map.WatchMerges()
|
||||
|
||||
tileCache := h.Map.GetAllTileCache(ctx)
|
||||
|
||||
raw, _ := json.Marshal(tileCache)
|
||||
fmt.Fprint(rw, "data: ")
|
||||
rw.Write(raw)
|
||||
fmt.Fprint(rw, "\n\n")
|
||||
tileCache = tileCache[:0]
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(app.SSETickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-c:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for i := range tileCache {
|
||||
if tileCache[i].M == e.MapID && tileCache[i].X == e.Coord.X && tileCache[i].Y == e.Coord.Y && tileCache[i].Z == e.Zoom {
|
||||
tileCache[i].T = int(e.Cache)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
tileCache = append(tileCache, services.TileCache{
|
||||
M: e.MapID,
|
||||
X: e.Coord.X,
|
||||
Y: e.Coord.Y,
|
||||
Z: e.Zoom,
|
||||
T: int(e.Cache),
|
||||
})
|
||||
}
|
||||
case e, ok := <-mc:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
slog.Error("failed to marshal merge event", "error", err)
|
||||
}
|
||||
fmt.Fprint(rw, "event: merge\n")
|
||||
fmt.Fprint(rw, "data: ")
|
||||
rw.Write(raw)
|
||||
fmt.Fprint(rw, "\n\n")
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
raw, _ := json.Marshal(tileCache)
|
||||
fmt.Fprint(rw, "data: ")
|
||||
rw.Write(raw)
|
||||
fmt.Fprint(rw, "\n\n")
|
||||
tileCache = tileCache[:0]
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GridTile serves tile images.
|
||||
func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if !h.canAccessMap(s) {
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tile := tileRegex.FindStringSubmatch(req.URL.Path)
|
||||
if tile == nil || len(tile) < 5 {
|
||||
JSONError(rw, http.StatusBadRequest, "invalid path", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(tile[1])
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
z, err := strconv.Atoi(tile[2])
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
x, err := strconv.Atoi(tile[3])
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
y, err := strconv.Atoi(tile[4])
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
storageZ := z
|
||||
if storageZ == 6 {
|
||||
storageZ = 0
|
||||
}
|
||||
if storageZ < 0 || storageZ > app.MaxZoomLevel {
|
||||
storageZ = 0
|
||||
}
|
||||
td := h.Map.GetTile(ctx, mapid, app.Coord{X: x, Y: y}, storageZ)
|
||||
if td == nil {
|
||||
rw.Header().Set("Content-Type", "image/png")
|
||||
rw.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write(transparentPNG)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "image/png")
|
||||
rw.Header().Set("Cache-Control", "private immutable")
|
||||
http.ServeFile(rw, req, filepath.Join(h.Map.GridStorage(), td.File))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
93
internal/app/migrations_test.go
Normal file
93
internal/app/migrations_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
292
internal/app/services/admin_test.go
Normal file
292
internal/app/services/admin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
339
internal/app/services/auth_test.go
Normal file
339
internal/app/services/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
477
internal/app/services/client.go
Normal file
477
internal/app/services/client.go
Normal 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
|
||||
}
|
||||
32
internal/app/services/client_test.go
Normal file
32
internal/app/services/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
382
internal/app/services/export.go
Normal file
382
internal/app/services/export.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
301
internal/app/services/map_test.go
Normal file
301
internal/app/services/map_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
532
internal/app/store/db_test.go
Normal file
532
internal/app/store/db_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user