Add configuration files and update project documentation

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

15
.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.go]
indent_size = 4
[Makefile]
indent_style = tab

13
.golangci.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "2"
run:
timeout: 5m
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused

View File

@@ -7,22 +7,31 @@ Automapper server for HnH, (mostly) compatible with [hnh-auto-mapper-server](htt
| Path | Purpose | | Path | Purpose |
|------|--------| |------|--------|
| `cmd/hnh-map/` | Go entry point (`main.go`) | | `cmd/hnh-map/` | Go entry point (`main.go`) |
| `internal/app/` | Backend logic (auth, API, map, tiles, admin, migrations) | | `internal/app/` | App struct, domain types, router, topic pub/sub, migrations |
| `internal/app/store/` | bbolt database access layer (`db.go`, `buckets.go`) |
| `internal/app/services/` | Business logic (`auth.go`, `map.go`, `admin.go`, `client.go`, `export.go`) |
| `internal/app/handlers/` | HTTP handlers (`api.go`, `auth.go`, `map.go`, `client.go`, `admin.go`, `tile.go`) |
| `internal/app/apperr/` | Domain error types |
| `internal/app/response/` | Shared JSON response utilities |
| `frontend-nuxt/` | Nuxt 3 app source (pages, components, composables, layouts, `public/gfx`) | | `frontend-nuxt/` | Nuxt 3 app source (pages, components, composables, layouts, `public/gfx`) |
| `frontend/` | **Build output** — static assets served in production; generated from `frontend-nuxt/` (do not edit here) | | `frontend/` | **Build output** — static assets served in production; generated from `frontend-nuxt/` (do not edit here) |
| `docs/` | Architecture, API, configuration, development, deployment (part of docs is in Russian) | | `docs/` | Architecture, API, configuration, development, deployment, testing |
| `grids/` | Runtime data (in `.gitignore`) | | `grids/` | Runtime data (in `.gitignore`) |
## Where to look ## Where to look
- **API:** [docs/api.md](docs/api.md) and handlers in `internal/app/` (e.g. `api.go`, `map.go`, `admin_*.go`). - **API:** [docs/api.md](docs/api.md) and handlers in `internal/app/handlers/` (e.g. `api.go`, `auth.go`, `admin.go`, `map.go`, `client.go`, `tile.go`).
- **Business logic:** `internal/app/services/` (e.g. `auth.go`, `map.go`, `admin.go`, `client.go`, `export.go`).
- **Database:** `internal/app/store/db.go` and `internal/app/store/buckets.go`.
- **Configuration:** [.env.example](.env.example) and [docs/configuration.md](docs/configuration.md). - **Configuration:** [.env.example](.env.example) and [docs/configuration.md](docs/configuration.md).
- **Local run / build:** [docs/development.md](docs/development.md) and [CONTRIBUTING.md](CONTRIBUTING.md). - **Local run / build:** [docs/development.md](docs/development.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
- **Testing:** [docs/testing.md](docs/testing.md).
## Conventions ## Conventions
- **Backend:** Go; use `go fmt ./...`; tests with `go test ./...`. - **Backend:** Go; use `make fmt` / `make lint`; tests with `make test` or `go test ./...`.
- **Frontend:** Nuxt 3 in `frontend-nuxt/`; public API access via composables (e.g. `useMapApi`, `useAuth`, `useAdminApi`). - **Frontend:** Nuxt 3 in `frontend-nuxt/`; public API access via composables (e.g. `useMapApi`, `useAuth`, `useAdminApi`).
- **Ports:** Dev — frontend 3000, backend 3080 (docker-compose.dev); prod — single server 8080 serving backend + static from `frontend/`. - **Ports:** Dev — frontend 3000, backend 3080 (docker-compose.dev); prod — single server 8080 serving backend + static from `frontend/`.
- **Architecture:** Layered: Store → Services → Handlers. Domain errors in `apperr/` are mapped to HTTP status codes by handlers.
See [.cursor/rules/](.cursor/rules/) for project-specific Cursor rules. See [.cursor/rules/](.cursor/rules/) for project-specific Cursor rules.

View File

@@ -4,18 +4,42 @@
Clone the repository and run the project locally (see [Development](docs/development.md)): Clone the repository and run the project locally (see [Development](docs/development.md)):
- **Option A:** Docker Compose for development: `docker compose -f docker-compose.dev.yml up` (frontend on 3000, backend on 3080). - **Option A:** Docker Compose for development: `docker compose -f docker-compose.dev.yml up` (or `make dev`). Frontend on 3000, backend on 3080.
- **Option B:** Run Go backend from the repo root (`go run ./cmd/hnh-map -grids=./grids -port=8080`) and Nuxt separately (`cd frontend-nuxt && npm run dev`). Ensure the frontend can reach the backend (proxy or same host). - **Option B:** Run Go backend from the repo root (`go run ./cmd/hnh-map -grids=./grids -port=8080`) and Nuxt separately (`cd frontend-nuxt && npm run dev`). Ensure the frontend can reach the backend (proxy or same host).
## Code layout ## Code layout
- **Backend:** Entry point is `cmd/hnh-map/main.go`. All application logic lives in `internal/app/` (package `app`): `app.go`, `auth.go`, `api.go`, `handlers_redirects.go`, `client.go`, `client_grid.go`, `client_positions.go`, `client_markers.go`, `admin_*.go`, `map.go`, `tile.go`, `topic.go`, `migrations.go`. The backend follows a layered architecture: **Store → Services → Handlers**.
- **Frontend:** Nuxt 3 app in `frontend-nuxt/` (pages, components, composables, layouts, server, plugins, `public/gfx`). It is served by the Go backend at root `/` with baseURL `/`.
## Formatting and tests - **Backend:** Entry point is `cmd/hnh-map/main.go`. Application logic lives in `internal/app/`:
- `app.go``App` struct, domain types, SPA serving, constants.
- `router.go` — route registration.
- `topic.go` — pub/sub for real-time updates.
- `migrations.go` — database migrations.
- `store/` — bbolt database access layer (`db.go`, `buckets.go`).
- `services/` — business logic (`auth.go`, `map.go`, `admin.go`, `client.go`, `export.go`).
- `handlers/` — HTTP handlers (`api.go`, `auth.go`, `map.go`, `client.go`, `admin.go`, `tile.go`, `handlers.go`, `response.go`).
- `apperr/` — domain error types.
- `response/` — shared JSON response helpers.
- **Frontend:** Nuxt 3 app in `frontend-nuxt/` (pages, components, composables, layouts, plugins, `public/gfx`). It is served by the Go backend at root `/` with baseURL `/`.
- Go: run `go fmt ./...` before committing. ## Formatting, linting, and tests
- Add or update tests if you change behaviour; run `go test ./...` if tests exist.
Use Makefile targets for common tasks:
```bash
make fmt # Format all code (Go + frontend)
make lint # Run Go linter (golangci-lint) and frontend ESLint
make test # Run Go tests
```
Or run manually:
- Go: `go fmt ./...` and `golangci-lint run`
- Frontend: `npm --prefix frontend-nuxt run lint` and `npm --prefix frontend-nuxt run format`
- Tests: `go test ./...`
Always format and lint before committing. Add or update tests if you change behaviour.
## Submitting changes ## Submitting changes

View File

@@ -1,4 +1,4 @@
FROM golang:1.21-alpine AS gobuilder FROM golang:1.24-alpine AS gobuilder
RUN mkdir /hnh-map RUN mkdir /hnh-map
WORKDIR /hnh-map WORKDIR /hnh-map

View File

@@ -1,4 +1,4 @@
FROM golang:1.21-alpine FROM golang:1.24-alpine
WORKDIR /hnh-map WORKDIR /hnh-map

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
.PHONY: dev build test lint fmt generate-frontend clean
dev:
docker compose -f docker-compose.dev.yml up
build:
docker compose -f docker-compose.prod.yml build
test:
go test ./...
lint:
golangci-lint run
npm --prefix frontend-nuxt run lint
fmt:
go fmt ./...
npm --prefix frontend-nuxt run format
generate-frontend:
npm --prefix frontend-nuxt run generate
rm -rf frontend/*
cp -a frontend-nuxt/.output/public/. frontend/
clean:
rm -rf frontend-nuxt/.nuxt frontend-nuxt/.output frontend-nuxt/.cache hnh-map

View File

@@ -2,14 +2,14 @@
Automapper server for HnH, (mostly) compatible with https://github.com/APXEOLOG/hnh-auto-mapper-server Automapper server for HnH, (mostly) compatible with https://github.com/APXEOLOG/hnh-auto-mapper-server
Docker image can be built from sources, or is available at https://hub.docker.com/r/andyleap/hnh-auto-mapper The Docker image is built from sources in this repository.
(automatically built by Docker's infrastructure from the github source)
**Ports:** In production the app listens on **8080**. For local development with `docker-compose.dev.yml`, the frontend runs on **3000** and the backend on **3080** (to avoid conflicts with other services on 8080). **Ports:** In production the app listens on **8080**. For local development with `docker-compose.dev.yml`, the frontend runs on **3000** and the backend on **3080** (to avoid conflicts with other services on 8080).
Run it via whatever you feel like. The app expects `/map` to be mounted as a volume (database and images are stored here): Run it via whatever you feel like. The app expects `/map` to be mounted as a volume (database and images are stored here):
docker run -v /srv/hnh-map:/map -p 80:8080 andyleap/hnh-auto-mapper:v-4 docker build -t hnh-map .
docker run -v /srv/hnh-map:/map -p 80:8080 hnh-map
Set it up under a domain name however you prefer (nginx reverse proxy, traefik, caddy, apache, whatever) and Set it up under a domain name however you prefer (nginx reverse proxy, traefik, caddy, apache, whatever) and
point your auto-mapping supported client at it (like Purus pasta). point your auto-mapping supported client at it (like Purus pasta).

View File

@@ -57,7 +57,6 @@ func main() {
go a.CleanChars() go a.CleanChars()
// Phase 3: store, services, handlers layers
st := store.New(db) st := store.New(db)
authSvc := services.NewAuthService(st) authSvc := services.NewAuthService(st)
mapSvc := services.NewMapService(services.MapServiceDeps{ mapSvc := services.NewMapService(services.MapServiceDeps{
@@ -67,8 +66,15 @@ func main() {
MergeUpdates: a.MergeUpdates(), MergeUpdates: a.MergeUpdates(),
GetChars: a.GetCharacters, GetChars: a.GetCharacters,
}) })
adminSvc := services.NewAdminService(st) adminSvc := services.NewAdminService(st, mapSvc)
h := handlers.New(a, authSvc, mapSvc, adminSvc) clientSvc := services.NewClientService(services.ClientServiceDeps{
Store: st,
MapSvc: mapSvc,
WithChars: a.WithCharacters,
})
exportSvc := services.NewExportService(st, mapSvc)
h := handlers.New(authSvc, mapSvc, adminSvc, clientSvc, exportSvc)
publicDir := filepath.Join(workDir, "public") publicDir := filepath.Join(workDir, "public")
r := a.Router(publicDir, h) r := a.Router(publicDir, h)

View File

@@ -1,58 +1,77 @@
# HTTP API # HTTP API
API доступно по префиксу `/map/api/`. Для запросов, требующих авторизации, используется cookie `session` (устанавливается при логине). The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login).
## Авторизация ## Authentication
- **POST /map/api/login** — вход. Тело: `{"user":"...","pass":"..."}`. При успехе возвращается JSON с данными пользователя и выставляется cookie сессии. При первом запуске возможен bootstrap: логин `admin` и пароль из `HNHMAP_BOOTSTRAP_PASSWORD` создают первого админа. Для пользователей, созданных через OAuth (без пароля), возвращается 401 с `{"error":"Use OAuth to sign in"}`. - **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`.
- **GET /map/api/me** — текущий пользователь (по сессии). Ответ: `username`, `auths`, при необходимости `tokens`, `prefix`. - **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`.
- **POST /map/api/logout** — выход (инвалидация сессии). - **POST /map/api/logout** — sign out (invalidates the session).
- **GET /map/api/setup** — проверка, нужна ли первичная настройка. Ответ: `{"setupRequired": true|false}`. - **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
### OAuth ### OAuth
- **GET /map/api/oauth/providers** — список настроенных OAuth-провайдеров. Ответ: `["google", ...]`. - **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`.
- **GET /map/api/oauth/{provider}/login** — редирект на страницу авторизации провайдера. Query: `redirect` — путь для редиректа после успешного входа (например `/profile`). - **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`).
- **GET /map/api/oauth/{provider}/callback** — callback от провайдера (вызывается автоматически). Обменивает `code` на токены, создаёт или находит пользователя, создаёт сессию и редиректит на `/profile` или `redirect` из state. - **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state.
## Кабинет ## User account
- **POST /map/api/me/tokens** — сгенерировать новый токен загрузки (требуется право `upload`). Ответ: `{"tokens": ["...", ...]}`. - **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
- **POST /map/api/me/password** — сменить пароль. Тело: `{"pass":"..."}`. - **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
## Карта и данные ## Map data
- **GET /map/api/config** — конфиг для клиента (title, auths). Требуется сессия. - **GET /map/api/config** — client configuration (title, auths). Requires a session.
- **GET /map/api/v1/characters** — список персонажей на карте (требуется право `map` и при необходимости `markers`). - **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data).
- **GET /map/api/v1/markers** — маркеры (требуется право `map` и при необходимости `markers`). - **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data).
- **GET /map/api/maps** — список карт (с учётом прав и скрытых карт). - **GET /map/api/maps** — list of maps (filtered by permissions and hidden status).
## Админ (все эндпоинты ниже требуют право `admin`) ## Admin (all endpoints below require `admin` permission)
- **GET /map/api/admin/users** — список имён пользователей. - **GET /map/api/admin/users** — list of usernames.
- **POST /map/api/admin/users** — создать/обновить пользователя. Тело: `{"user":"...","pass":"...","auths":["admin","map",...]}`. - **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`.
- **GET /map/api/admin/users/:name** — данные пользователя. - **GET /map/api/admin/users/:name** — user data.
- **DELETE /map/api/admin/users/:name** — удалить пользователя. - **DELETE /map/api/admin/users/:name** — delete a user.
- **GET /map/api/admin/settings** — настройки (prefix, defaultHide, title). - **GET /map/api/admin/settings** — settings (prefix, defaultHide, title).
- **POST /map/api/admin/settings** — сохранить настройки. Тело: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (поля опциональны). - **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional).
- **GET /map/api/admin/maps** — список карт для админки. - **GET /map/api/admin/maps** — list of maps for the admin panel.
- **POST /map/api/admin/maps/:id** — обновить карту (name, hidden, priority). - **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
- **POST /map/api/admin/maps/:id/toggle-hidden** — переключить скрытие карты. - **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
- **POST /map/api/admin/wipe** — очистить гриды, маркеры, тайлы, карты в БД. - **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
- **POST /map/api/admin/rebuildZooms** — пересобрать зум-уровни тайлов. - **POST /map/api/admin/rebuildZooms** — rebuild tile zoom levels.
- **GET /map/api/admin/export** — скачать экспорт данных (ZIP). - **GET /map/api/admin/export** — download data export (ZIP).
- **POST /map/api/admin/merge** — загрузить и применить merge (ZIP с гридами и маркерами). - **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
- **GET /map/api/admin/wipeTile** — удалить тайл. Query: `map`, `x`, `y`. - **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
- **GET /map/api/admin/setCoords** — сдвинуть координаты гридов. Query: `map`, `fx`, `fy`, `tx`, `ty`. - **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`.
- **GET /map/api/admin/hideMarker** — скрыть маркер. Query: `id`. - **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`.
## Коды ответов ## Game client
- **200** — успех. The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication.
- **400** — неверный запрос (метод, тело, параметры).
- **401** — не авторизован (нет или недействительная сессия).
- **403** — нет прав.
- **404** — не найдено.
- **500** — внутренняя ошибка.
Формат ошибок — текст в теле ответа или стандартные HTTP-статусы без тела. - **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise.
- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`.
- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests.
- **POST /client/{token}/gridUpload** — upload a tile image (multipart).
- **POST /client/{token}/positionUpdate** — update character positions.
- **POST /client/{token}/markerUpdate** — upload markers.
## SSE (Server-Sent Events)
- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends `data:` messages with tile cache arrays and `event: merge` messages for map merges.
## Tile images
- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist.
## Response codes
- **200** — success.
- **400** — bad request (wrong method, body, or parameters).
- **401** — unauthorized (missing or invalid session).
- **403** — forbidden (insufficient permissions).
- **404** — not found.
- **500** — internal error.
Error format: JSON body `{"error": "message", "code": "CODE"}`.

View File

@@ -1,43 +1,57 @@
# Архитектура hnh-map # Architecture of hnh-map
## Обзор ## Overview
hnh-map — сервер автомаппера для HnH: Go-бэкенд с хранилищем bbolt, сессиями и Nuxt 3 SPA по корню `/`. API, SSE и тайлы — по `/map/api/`, `/map/updates`, `/map/grids/`. Данные гридов и тайлов хранятся в каталоге `grids/` и в БД. hnh-map is an automapper server for HnH: a Go backend with bbolt storage, sessions, and a Nuxt 3 SPA served at `/`. API, SSE, and tiles are served at `/map/api/`, `/map/updates`, `/map/grids/`. Grid and tile data is stored in the `grids/` directory and in the database.
``` ```
┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐ ┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐
Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │ Browser │ ◄────────────────► │ Go server (cmd/hnh-map) │
│ (Nuxt SPA │ /, /login, │ • bbolt (users, sessions, grids, │ │ (Nuxt SPA │ /, /login, │ • bbolt (users, sessions, grids, │
по /) │ /map/api, │ markers, tiles, maps, config) │ at /) │ /map/api, │ markers, tiles, maps, config) │
│ │ /map/updates, │ • Статика фронта (frontend/) │ /map/updates, │ • Frontend statics (frontend/) │
│ │ /map/grids/ │ • internal/app — вся логика │ /map/grids/ │ • internal/app — all logic
└─────────────┘ └──────────────────────────────────────┘ └─────────────┘ └──────────────────────────────────────┘
``` ```
## Структура бэкенда ## Backend structure
- **cmd/hnh-map/main.go** — единственная точка входа (`package main`): парсинг флагов (`-grids`, `-port`) и переменных окружения (`HNHMAP_PORT`), открытие bbolt, запуск миграций, создание `App`, регистрация маршрутов, запуск HTTP-сервера. Пути к `frontend/` и `public/` задаются из рабочей директории при старте. The backend follows a layered architecture: **Store → Services → Handlers**.
- **internal/app/** — пакет `app` с типом `App` и всей логикой: - **cmd/hnh-map/main.go** — the single entry point (`package main`): parses flags (`-grids`, `-port`) and environment variables (`HNHMAP_PORT`), opens bbolt, runs migrations, creates `App`, wires services and handlers, registers routes, and starts the HTTP server. Paths to `frontend/` and `public/` are resolved relative to the working directory at startup.
- **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), `serveSPARoot`, `CleanChars`.
- **auth.go** — сессии и авторизация: `getSession`, `deleteSession`, `saveSession`, `getUser`, `getPage`, `createSession`, `setupRequired`, `requireAdmin`.
- **api.go** — HTTP API: авторизация (login, me, logout, setup), кабинет (tokens, password), админ (users, settings, maps, wipe, rebuildZooms, export, merge), роутер `/map/api/...`.
- **handlers_redirects.go** — редирект `/logout``/login` (после удаления сессии).
- **client.go** — роутер клиента маппера (`/client/{token}/...`), `locate`.
- **client_grid.go** — `gridUpdate`, `gridUpload`, `updateZoomLevel`.
- **client_positions.go** — `updatePositions`.
- **client_markers.go** — `uploadMarkers`.
- **admin_rebuild.go** — `doRebuildZooms`.
- **admin_tiles.go** — `wipeTile`, `setCoords`.
- **admin_markers.go** — `hideMarker`.
- **admin_export.go** — `export`.
- **admin_merge.go** — `merge`.
- **map.go** — доступ к карте: `canAccessMap`, `getChars`, `getMarkers`, `getMaps`, `config`.
- **tile.go** — тайлы и гриды: `GetTile`, `SaveTile`, `watchGridUpdates` (SSE), `gridTile`, `reportMerge`.
- **topic.go** — типы `topic` и `mergeTopic` для рассылки обновлений тайлов и слияний карт.
- **migrations.go** — миграции bbolt; из main вызывается `app.RunMigrations(db)`.
Сборка из корня репозитория: - **internal/app/** — package `app` with the `App` type and domain types:
- **app.go** — `App` struct, domain types (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData`, etc.), SPA serving (`ServeSPARoot`), character cleanup (`CleanChars`), constants.
- **router.go** — route registration (`Router`), interface `APIHandler`.
- **topic.go** — generic `Topic[T]` pub/sub for broadcasting tile and merge updates.
- **migrations.go** — bbolt schema migrations; called from main via `RunMigrations(db)`.
- **internal/app/store/** — database access layer:
- **db.go** — `Store` struct with bucket helpers and CRUD operations for users, sessions, tokens, config, maps, grids, tiles, markers, and OAuth states.
- **buckets.go** — bucket name constants.
- **internal/app/services/** — business logic layer:
- **auth.go** — `AuthService`: authentication, sessions, password hashing, token validation, OAuth (Google) login flow.
- **map.go** — `MapService`: map data retrieval, tile save/get, zoom level processing, SSE watch, character/marker access.
- **admin.go** — `AdminService`: user management, settings, map management, wipe, tile operations.
- **client.go** — `ClientService`: game client operations (grid update, grid upload, position updates, marker uploads).
- **export.go** — `ExportService`: data export (ZIP) and merge (import).
- **internal/app/handlers/** — HTTP handlers (thin layer over services):
- **handlers.go** — `Handlers` struct (dependency container), `HandleServiceError`.
- **response.go** — JSON response helpers.
- **api.go** — top-level API router (`APIRouter`).
- **auth.go** — login, logout, me, setup, OAuth, token, and password handlers.
- **map.go** — config, characters, markers, and maps handlers.
- **client.go** — game client HTTP handlers (grid update/upload, positions, markers).
- **admin.go** — admin HTTP handlers (users, settings, maps, wipe, tile ops, export, merge).
- **tile.go** — tile image serving and SSE endpoint.
- **internal/app/apperr/** — domain error types mapped to HTTP status codes by handlers.
- **internal/app/response/** — shared JSON response utilities.
Build from the repository root:
```bash ```bash
go build -o hnh-map ./cmd/hnh-map go build -o hnh-map ./cmd/hnh-map

View File

@@ -1,30 +1,30 @@
# Настройка # Configuration
## Переменные окружения и флаги ## Environment variables and flags
| Переменная / флаг | Описание | По умолчанию | | Variable / flag | Description | Default |
|-------------------|----------|--------------| |-----------------|-------------|---------|
| `HNHMAP_PORT` | Порт HTTP-сервера | 8080 | | `HNHMAP_PORT` | HTTP server port | 8080 |
| `-port` | То же (флаг командной строки) | значение `HNHMAP_PORT` или 8080 | | `-port` | Same (command-line flag) | value of `HNHMAP_PORT` or 8080 |
| `HNHMAP_BOOTSTRAP_PASSWORD` | Пароль для первой настройки: при отсутствии пользователей вход как `admin` с этим паролем создаёт первого админа | — | | `HNHMAP_BOOTSTRAP_PASSWORD` | Password for initial setup: when no users exist, logging in as `admin` with this password creates the first admin user | — |
| `HNHMAP_BASE_URL` | Полный URL приложения для OAuth redirect_uri (например `https://map.example.com`). Если не задан, берётся из `Host` и `X-Forwarded-*` | — | | `HNHMAP_BASE_URL` | Full application URL for OAuth redirect_uri (e.g. `https://map.example.com`). If not set, derived from `Host` and `X-Forwarded-*` headers | — |
| `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | — | | `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | — |
| `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — | | `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — |
| `-grids` | Каталог гридов (флаг командной строки; в Docker обычно `-grids=/map`) | `grids` | | `-grids` | Grid storage directory (command-line flag; in Docker typically `-grids=/map`) | `grids` |
Пример для первого запуска: Example for first run:
```bash ```bash
export HNHMAP_BOOTSTRAP_PASSWORD=your-secure-password export HNHMAP_BOOTSTRAP_PASSWORD=your-secure-password
./hnh-map -grids=./grids -port=8080 ./hnh-map -grids=./grids -port=8080
``` ```
В Docker часто монтируют том в `/map` и запускают с `-grids=/map`. In Docker, a volume is typically mounted at `/map` and the app is started with `-grids=/map`.
Для фронта (Nuxt) в режиме разработки: For the frontend (Nuxt) in development mode:
| Переменная | Описание | | Variable | Description |
|------------|----------| |----------|-------------|
| `NUXT_PUBLIC_API_BASE` | Базовый путь к API (например `/map/api` при прокси к бэкенду) | | `NUXT_PUBLIC_API_BASE` | Base path to the API (e.g. `/map/api` when proxying to the backend) |
См. также [.env.example](../.env.example) в корне репозитория. See also [.env.example](../.env.example) in the repository root.

View File

@@ -1,43 +1,49 @@
# Деплой # Deployment
## Docker ## Docker
Образ собирается из репозитория. Внутри контейнера приложение слушает порт **8080** и ожидает, что каталог данных смонтирован в `/map` (база и изображения гридов). The image is built from the repository. Inside the container the application listens on port **8080** and expects the data directory to be mounted at `/map` (database and grid images).
Пример запуска: Example run:
```bash ```bash
docker run -v /srv/hnh-map:/map -p 80:8080 andyleap/hnh-auto-mapper:v-4 docker run -v /srv/hnh-map:/map -p 80:8080 hnh-map
``` ```
Или с переменными: Or with environment variables:
```bash ```bash
docker run -v /srv/hnh-map:/map -p 8080:8080 \ docker run -v /srv/hnh-map:/map -p 8080:8080 \
-e HNHMAP_PORT=8080 \ -e HNHMAP_PORT=8080 \
-e HNHMAP_BOOTSTRAP_PASSWORD=your-secure-password \ -e HNHMAP_BOOTSTRAP_PASSWORD=your-secure-password \
andyleap/hnh-auto-mapper:v-4 hnh-map
``` ```
Рекомендуется после первой настройки убрать или не передавать `HNHMAP_BOOTSTRAP_PASSWORD`. It is recommended to remove or stop passing `HNHMAP_BOOTSTRAP_PASSWORD` after initial setup.
To build the image locally:
```bash
docker build -t hnh-map .
```
## OAuth (Google) ## OAuth (Google)
Для входа через Google OAuth: To enable login via Google OAuth:
1. Создайте проект в [Google Cloud Console](https://console.cloud.google.com/). 1. Create a project in [Google Cloud Console](https://console.cloud.google.com/).
2. Включите «Google+ API» / «Google Identity» и создайте OAuth 2.0 Client ID (тип «Web application»). 2. Enable "Google+ API" / "Google Identity" and create an OAuth 2.0 Client ID (type "Web application").
3. В настройках клиента добавьте Authorized redirect URI: `https://your-domain.com/map/api/oauth/google/callback` (замените на ваш домен). 3. In the client settings, add the Authorized redirect URI: `https://your-domain.com/map/api/oauth/google/callback` (replace with your domain).
4. Задайте переменные окружения: 4. Set the following environment variables:
- `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` — Client ID - `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` — Client ID
- `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET` — Client Secret - `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET` — Client Secret
- `HNHMAP_BASE_URL`полный URL приложения (например `https://map.example.com`) для формирования redirect_uri. Если не задан, берётся из `Host` и `X-Forwarded-*` заголовков. - `HNHMAP_BASE_URL`full application URL (e.g. `https://map.example.com`) for forming the redirect_uri. If not set, it is derived from the `Host` and `X-Forwarded-*` headers.
## Reverse proxy ## Reverse proxy
Разместите сервис за nginx, Traefik, Caddy и т.п. на нужном домене. Проксируйте весь трафик на порт 8080 контейнера (или тот порт, на котором слушает приложение). Приложение отдаёт SPA по корню `/` (/, /login, /profile, /admin и т.д.), API — по `/map/api/`, SSE `/map/updates`, тайлы — `/map/grids/`. Place the service behind nginx, Traefik, Caddy, etc. on the desired domain. Proxy all traffic to port 8080 of the container (or whichever port the application is listening on). The application serves the SPA at root `/` (/, /login, /profile, /admin, etc.), the API at `/map/api/`, SSE at `/map/updates`, and tiles at `/map/grids/`.
## Обновление и бэкапы ## Updates and backups
- При обновлении образа сохраняйте volume с `/map`: в нём лежат `grids.db` и каталоги с тайлами. - When updating the image, preserve the volume at `/map`: it contains `grids.db` and tile image directories.
- Регулярно делайте бэкапы каталога данных (и при необходимости экспорт через админку «Export»). - Regularly back up the data directory (and use the admin panel "Export" feature if needed).

View File

@@ -1,25 +1,25 @@
# Разработка # Development
## Локальный запуск ## Local setup
### Бэкенд (Go) ### Backend (Go)
Из корня репозитория: From the repository root:
```bash ```bash
go build -o hnh-map ./cmd/hnh-map go build -o hnh-map ./cmd/hnh-map
./hnh-map -grids=./grids -port=8080 ./hnh-map -grids=./grids -port=8080
``` ```
Или без сборки: Or without building:
```bash ```bash
go run ./cmd/hnh-map -grids=./grids -port=8080 go run ./cmd/hnh-map -grids=./grids -port=8080
``` ```
Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). The server serves static files from the `frontend/` directory (you need to build the frontend first, see below).
### Фронтенд (Nuxt) ### Frontend (Nuxt)
```bash ```bash
cd frontend-nuxt cd frontend-nuxt
@@ -27,27 +27,72 @@ npm install
npm run dev npm run dev
``` ```
В dev-режиме приложение доступно по корню (например `http://localhost:3000/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`). In dev mode the app is available at root (e.g. `http://localhost:3000/`). The backend must be reachable; configure the proxy in `nuxt.config.ts` if needed (e.g. to `http://localhost:8080`).
### Docker Compose (разработка) ### Docker Compose (development)
```bash ```bash
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up
``` ```
Dev-композ поднимает два сервиса: Or using the Makefile:
- `backend` — Go API на порту `3080` (без сборки/раздачи фронтенд-статики в dev-режиме). ```bash
- `frontend` — Nuxt dev-сервер на порту `3000` с live-reload; запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд. make dev
```
Используйте [http://localhost:3000/](http://localhost:3000/) как основной URL для разработки интерфейса. The dev Compose setup starts two services:
Порт `3080` предназначен для API и backend-эндпоинтов; корень `/` может возвращать `404` в dev-режиме — это ожидаемо.
### Сборка образа и prod-композ - `backend` — Go API on port `3080` (no frontend static serving in dev mode).
- `frontend` — Nuxt dev server on port `3000` with live-reload; requests to `/map/api`, `/map/updates`, `/map/grids` are proxied to the backend.
Use [http://localhost:3000/](http://localhost:3000/) as the primary URL for UI development.
Port `3080` is for API and backend endpoints; the root `/` may return `404` in dev mode — this is expected.
### Building the image and production Compose
```bash ```bash
docker build -t hnh-map . docker build -t hnh-map .
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
``` ```
В prod фронт собран в образ и отдаётся бэкендом из каталога `frontend/`; порт 8080. Or using the Makefile:
```bash
make build
```
In production the frontend is built into the image and served by the backend from the `frontend/` directory; port 8080.
## Makefile targets
| Target | Description |
|--------|-------------|
| `make dev` | Start Docker Compose development environment |
| `make build` | Build production Docker image |
| `make test` | Run Go tests (`go test ./...`) |
| `make lint` | Run Go and frontend linters |
| `make fmt` | Format all code (Go + frontend) |
| `make generate-frontend` | Build frontend static output into `frontend/` |
| `make clean` | Remove build artifacts |
## Running tests
```bash
make test
```
Or directly:
```bash
go test ./...
```
For frontend tests (if configured):
```bash
cd frontend-nuxt
npm test
```
See [docs/testing.md](testing.md) for details on the test suite.

89
docs/testing.md Normal file
View File

@@ -0,0 +1,89 @@
# Testing
## Running tests
### Backend (Go)
```bash
make test
```
Or directly:
```bash
go test ./...
```
### Frontend (Vitest)
```bash
cd frontend-nuxt
npm test
```
The frontend uses [Vitest](https://vitest.dev/) with `happy-dom` as the test environment. Configuration is in `frontend-nuxt/vitest.config.ts`.
## Test structure
### Backend
Tests use the standard `testing` package with table-driven tests. Each test creates a temporary bbolt database via `t.TempDir()` so tests are fully isolated and require no external dependencies.
| File | What it covers |
|------|---------------|
| `internal/app/app_test.go` | Domain types (`Coord.Name`, `Coord.Parent`, `Auths.Has`), `Topic` pub/sub |
| `internal/app/migrations_test.go` | Migration on fresh DB, idempotency, version tracking |
| `internal/app/store/db_test.go` | All store CRUD operations (users, sessions, tokens, config, maps, grids, tiles, markers, OAuth states), context cancellation, edge cases |
| `internal/app/services/auth_test.go` | Login, session management, token validation, password changes, OAuth bootstrap |
| `internal/app/services/admin_test.go` | User CRUD, settings, map management, wipe |
| `internal/app/services/map_test.go` | Map data retrieval, characters, markers, tiles, config, grid storage, SSE watchers |
| `internal/app/services/client_test.go` | Content-type fix helper |
| `internal/app/handlers/handlers_test.go` | HTTP integration tests for all API endpoints using `httptest` (auth, admin, map data, error handling) |
### Frontend
Frontend tests are located alongside the source code in `__tests__/` directories:
| File | What it covers |
|------|---------------|
| `frontend-nuxt/composables/__tests__/useMapApi.test.ts` | Map API composable |
| `frontend-nuxt/composables/__tests__/useMapLogic.test.ts` | Map logic composable |
| `frontend-nuxt/composables/__tests__/useAppPaths.test.ts` | Application path helpers |
| `frontend-nuxt/lib/__tests__/Character.test.ts` | Character lib |
| `frontend-nuxt/lib/__tests__/Marker.test.ts` | Marker lib |
| `frontend-nuxt/lib/__tests__/UniqueList.test.ts` | UniqueList utility |
Nuxt auto-imports are mocked via `frontend-nuxt/__mocks__/nuxt-imports.ts` and the `#imports` alias in the Vitest config.
## Writing new tests
### Backend conventions
- Use table-driven tests where multiple input/output pairs make sense.
- Create a temporary database with `t.TempDir()` and `bbolt.Open()` for isolation.
- Use `t.Helper()` in test helper functions.
- Use `t.Cleanup()` to close the database after the test.
Example pattern:
```go
func TestMyFeature(t *testing.T) {
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)
// ... test logic ...
}
```
For handler tests, use `httptest.NewRecorder()` and `httptest.NewRequest()` to simulate HTTP requests without starting a real server.
### Frontend conventions
- Place tests in `__tests__/` directories next to the source files.
- Name test files `<source-name>.test.ts`.
- Use Vitest globals (`describe`, `it`, `expect`).

View File

@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -0,0 +1,12 @@
import { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } from 'vue'
export { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick }
export function useRuntimeConfig() {
return {
app: { baseURL: '/' },
public: { apiBase: '/map/api' },
}
}
export function navigateTo(_path: string) {}

View File

@@ -1,6 +1,30 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @plugin "daisyui" {
@tailwind utilities; themes: light --default;
}
@plugin "daisyui/theme" {
name: "dark";
prefersdark: true;
color-scheme: dark;
--color-primary: oklch(54.6% 0.245 277);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(55.5% 0.25 293);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(65.5% 0.155 203);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(27.5% 0.014 249);
--color-neutral-content: oklch(74.7% 0.016 249);
--color-base-100: oklch(21.2% 0.014 251);
--color-base-200: oklch(18.8% 0.013 253);
--color-base-300: oklch(16.5% 0.011 250);
--color-base-content: oklch(74.7% 0.016 249);
}
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
html, html,
body, body,
@@ -8,10 +32,6 @@ body,
height: 100%; height: 100%;
} }
body {
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
@keyframes login-card-in { @keyframes login-card-in {
from { from {
opacity: 0; opacity: 0;

View File

@@ -1,6 +1,6 @@
/* Map container background from theme (DaisyUI base-200) */ /* Map container background from theme (DaisyUI base-200) */
.leaflet-container { .leaflet-container {
background: hsl(var(--b2)); background: var(--color-base-200);
} }
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied /* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied

View File

@@ -19,9 +19,9 @@
:display-coords="mapLogic.state.displayCoords" :display-coords="mapLogic.state.displayCoords"
/> />
<MapControls <MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates" :show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)" @update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
:hide-markers="mapLogic.state.hideMarkers" :hide-markers="mapLogic.state.hideMarkers.value"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)" @update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value" :selected-map-id="mapLogic.state.selectedMapId.value"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)" @update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
@@ -34,9 +34,9 @@
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
@zoom-in="mapLogic.zoomIn(map)" @zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(map)" @zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(map)" @reset-view="mapLogic.resetView(leafletMap)"
/> />
<MapMapContextMenu <MapMapContextMenu
:context-menu="mapLogic.contextMenu" :context-menu="mapLogic.contextMenu"
@@ -56,11 +56,11 @@
<script setup lang="ts"> <script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue' import MapControls from '~/components/map/MapControls.vue'
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer' import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
import { Marker } from '~/lib/Marker' import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
import { Character } from '~/lib/Character' import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import { UniqueList } from '~/lib/UniqueList' import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
import type L from 'leaflet' import type L from 'leaflet'
const props = withDefaults( const props = withDefaults(
@@ -78,52 +78,23 @@ const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi() const api = useMapApi()
const mapLogic = useMapLogic() const mapLogic = useMapLogic()
const maps = ref<{ ID: number; Name: string; size?: number }[]>([]) const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([]) const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<{ id: number; name: string }[]>([]) const players = ref<Array<{ id: number; name: string }>>([])
const auths = ref<string[]>([]) const auths = ref<string[]>([])
let map: L.Map | null = null let leafletMap: L.Map | null = null
let layer: InstanceType<typeof SmartTileLayer> | null = null let mapInit: MapInitResult | null = null
let overlayLayer: InstanceType<typeof SmartTileLayer> | null = null let updatesHandle: UseMapUpdatesReturn | null = null
let coordLayer: InstanceType<typeof GridCoordLayer> | null = null let layersManager: MapLayersManager | null = null
let markerLayer: L.LayerGroup | null = null
let source: EventSource | null = null
let intervalId: ReturnType<typeof setInterval> | null = null let intervalId: ReturnType<typeof setInterval> | null = null
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
let characters: UniqueList<InstanceType<typeof Character>> | null = null
let markersHidden = false
let autoMode = false let autoMode = false
let mapContainer: HTMLElement | null = null let mapContainer: HTMLElement | null = null
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
function toLatLng(x: number, y: number) { function toLatLng(x: number, y: number) {
return map!.unproject([x, y], HnHMaxZoom) return leafletMap!.unproject([x, y], HnHMaxZoom)
}
function changeMap(id: number) {
if (id === mapLogic.state.mapid.value) return
mapLogic.state.mapid.value = id
mapLogic.state.selectedMapId.value = id
if (layer) {
layer.map = id
layer.redraw()
}
if (overlayLayer) {
overlayLayer.map = -1
overlayLayer.redraw()
}
if (markers && !markersHidden) {
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid: id }))
markers.getElements().filter((it: any) => it.map === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
}
if (characters) {
characters.getElements().forEach((it: any) => {
it.remove({ map: map! })
it.add({ map: map!, mapid: id })
})
}
} }
function onWipeTile(coords: { x: number; y: number } | undefined) { function onWipeTile(coords: { x: number; y: number } | undefined) {
@@ -142,8 +113,8 @@ function onHideMarker(id: number | undefined) {
if (id == null) return if (id == null) return
mapLogic.closeContextMenus() mapLogic.closeContextMenus()
api.adminHideMarker({ id }) api.adminHideMarker({ id })
const m = markers?.byId(id) const m = layersManager?.findMarkerById(id)
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value }) if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
} }
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) { function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
@@ -166,6 +137,7 @@ onMounted(async () => {
window.addEventListener('keydown', onKeydown) window.addEventListener('keydown', onKeydown)
} }
if (!import.meta.client || !mapRef.value) return if (!import.meta.client || !mapRef.value) return
const L = (await import('leaflet')).default const L = (await import('leaflet')).default
const [charactersData, mapsData] = await Promise.all([ const [charactersData, mapsData] = await Promise.all([
@@ -173,7 +145,7 @@ onMounted(async () => {
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})), api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
]) ])
const mapsList: { ID: number; Name: string; size?: number }[] = [] const mapsList: MapInfo[] = []
const raw = mapsData as Record<string, { ID?: number; Name?: string; id?: number; name?: string; size?: number }> const raw = mapsData as Record<string, { ID?: number; Name?: string; id?: number; name?: string; size?: number }>
for (const id in raw) { for (const id in raw) {
const m = raw[id] const m = raw[id]
@@ -187,81 +159,20 @@ onMounted(async () => {
maps.value = mapsList maps.value = mapsList
mapsLoaded.value = true mapsLoaded.value = true
const config = (await api.getConfig().catch(() => ({}))) as { title?: string; auths?: string[] } const config = (await api.getConfig().catch(() => ({}))) as ConfigResponse
if (config?.title) document.title = config.title if (config?.title) document.title = config.title
const user = await api.me().catch(() => null) const user = (await api.me().catch(() => null)) as MeResponse | null
auths.value = (user as { auths?: string[] } | null)?.auths ?? config?.auths ?? [] auths.value = user?.auths ?? config?.auths ?? []
map = L.map(mapRef.value, {
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
crs: HnHCRS,
attributionControl: false,
zoomControl: false,
inertia: true,
zoomAnimation: true,
fadeAnimation: true,
markerZoomAnimation: true,
})
const initialMapId = const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0 props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapLogic.state.mapid.value = initialMapId mapLogic.state.mapid.value = initialMapId
const runtimeConfig = useRuntimeConfig() mapInit = await initLeafletMap(mapRef.value, mapsList, initialMapId)
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api' leafletMap = mapInit.map
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
layer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
updateWhenIdle: true,
keepBuffer: 2,
}) as any
layer!.map = initialMapId
layer!.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
layer!.addTo(map)
overlayLayer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
opacity: 0.5,
updateWhenIdle: true,
keepBuffer: 2,
}) as any
overlayLayer!.map = -1
overlayLayer!.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer!.addTo(map)
coordLayer = new GridCoordLayer({
tileSize: TileSize,
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
opacity: 0,
visible: false,
pane: 'tilePane',
} as any)
coordLayer.addTo(map)
coordLayer.setZIndex(500)
markerLayer = L.layerGroup()
markerLayer.addTo(map)
markerLayer.setZIndex(600)
const baseURL = useRuntimeConfig().app.baseURL ?? '/'
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
L.Icon.Default.imagePath = markerIconPath
// Document-level capture so we get contextmenu before any map layer or iframe can swallow it // Document-level capture so we get contextmenu before any map layer or iframe can swallow it
mapContainer = map.getContainer() mapContainer = leafletMap.getContainer()
contextMenuHandler = (ev: MouseEvent) => { contextMenuHandler = (ev: MouseEvent) => {
const target = ev.target as Node const target = ev.target as Node
if (!mapContainer?.contains(target)) return if (!mapContainer?.contains(target)) return
@@ -272,212 +183,149 @@ onMounted(async () => {
ev.stopPropagation() ev.stopPropagation()
const rect = mapContainer.getBoundingClientRect() const rect = mapContainer.getBoundingClientRect()
const containerPoint = L.point(ev.clientX - rect.left, ev.clientY - rect.top) const containerPoint = L.point(ev.clientX - rect.left, ev.clientY - rect.top)
const latlng = map!.containerPointToLatLng(containerPoint) const latlng = leafletMap!.containerPointToLatLng(containerPoint)
const point = map!.project(latlng, 6) const point = leafletMap!.project(latlng, 6)
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) } const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
mapLogic.openTileContextMenu(ev.clientX, ev.clientY, coords) mapLogic.openTileContextMenu(ev.clientX, ev.clientY, coords)
} }
} }
document.addEventListener('contextmenu', contextMenuHandler, true) document.addEventListener('contextmenu', contextMenuHandler, true)
const updatesPath = `${backendBase}/updates` layersManager = createMapLayers({
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath map: leafletMap,
source = new EventSource(updatesUrl) markerLayer: mapInit.markerLayer,
source.onmessage = (event: MessageEvent) => { layer: mapInit.layer,
try { overlayLayer: mapInit.overlayLayer,
const raw = event?.data getCurrentMapId: () => mapLogic.state.mapid.value,
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return setCurrentMapId: (id: number) => { mapLogic.state.mapid.value = id },
const updates = JSON.parse(raw) setSelectedMapId: (id: number) => { mapLogic.state.selectedMapId.value = id },
if (!Array.isArray(updates)) return getAuths: () => auths.value,
for (const u of updates) { getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}` setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
layer!.cache[key] = u.T onMarkerContextMenu: mapLogic.openMarkerContextMenu,
if (overlayLayer) overlayLayer.cache[key] = u.T
if (layer!.map === u.M) layer!.refresh(u.X, u.Y, u.Z)
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
} catch {
// Ignore parse errors
}
}
source.onerror = () => {}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge = JSON.parse(e?.data ?? '{}')
if (mapLogic.state.mapid.value === merge.From) {
const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6)
const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
z: map!.getZoom(),
}
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
changeMap(mapTo)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
map!.setView(latLng, map!.getZoom())
}
} catch {
// Ignore merge parse errors
}
}) })
markers = new UniqueList<InstanceType<typeof Marker>>() updatesHandle = startMapUpdates({
characters = new UniqueList<InstanceType<typeof Character>>() backendBase: mapInit.backendBase,
layer: mapInit.layer,
overlayLayer: mapInit.overlayLayer,
map: leafletMap,
getCurrentMapId: () => mapLogic.state.mapid.value,
onMerge: (mapTo: number, shift: { x: number; y: number }) => {
const latLng = toLatLng(shift.x * 100, shift.y * 100)
layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => {
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers()
})
leafletMap!.setView(latLng, leafletMap!.getZoom())
},
})
updateCharacters(charactersData as any[]) layersManager.updateCharacters(Array.isArray(charactersData) ? charactersData : [])
players.value = layersManager.getPlayers()
if (props.characterId !== undefined && props.characterId >= 0) { if (props.characterId !== undefined && props.characterId >= 0) {
mapLogic.state.trackingCharacterId.value = props.characterId mapLogic.state.trackingCharacterId.value = props.characterId
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) { } else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
const latLng = toLatLng(props.gridX * 100, props.gridY * 100) const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
if (mapLogic.state.mapid.value !== props.mapId) changeMap(props.mapId) if (mapLogic.state.mapid.value !== props.mapId) layersManager.changeMap(props.mapId)
mapLogic.state.selectedMapId.value = props.mapId mapLogic.state.selectedMapId.value = props.mapId
map.setView(latLng, props.zoom, { animate: false }) leafletMap.setView(latLng, props.zoom, { animate: false })
} else if (mapsList.length > 0) { } else if (mapsList.length > 0) {
const first = mapsList[0] const first = mapsList[0]
if (first) { if (first) {
changeMap(first.ID) layersManager.changeMap(first.ID)
mapLogic.state.selectedMapId.value = first.ID mapLogic.state.selectedMapId.value = first.ID
map.setView([0, 0], HnHDefaultZoom, { animate: false }) leafletMap.setView([0, 0], HnHDefaultZoom, { animate: false })
} }
} }
nextTick(() => { nextTick(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (map) map.invalidateSize() if (leafletMap) leafletMap.invalidateSize()
}) })
}) })
}) })
intervalId = setInterval(() => { intervalId = setInterval(() => {
api.getCharacters().then((body) => updateCharacters(Array.isArray(body) ? body : [])).catch(() => clearInterval(intervalId!)) api
.getCharacters()
.then((body) => {
layersManager!.updateCharacters(Array.isArray(body) ? body : [])
players.value = layersManager!.getPlayers()
})
.catch(() => clearInterval(intervalId!))
}, 2000) }, 2000)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : [])) api.getMarkers().then((body) => {
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
function updateMarkers(markersData: any[]) { questGivers.value = layersManager!.getQuestGivers()
if (!markers || !map || !markerLayer) return
const list = Array.isArray(markersData) ? markersData : []
const ctx = { map, markerLayer, mapid: mapLogic.state.mapid.value, overlayLayer, auths: auths.value }
markers.update(
list.map((it) => new Marker(it)),
(marker: InstanceType<typeof Marker>) => {
if (marker.map === mapLogic.state.mapid.value || marker.map === overlayLayer?.map) marker.add(ctx)
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (auths.value.includes('admin')) {
mev.originalEvent.preventDefault()
mev.originalEvent.stopPropagation()
mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
}
}) })
},
(marker: InstanceType<typeof Marker>) => marker.remove(ctx),
(marker: InstanceType<typeof Marker>, updated: any) => marker.update(ctx, updated)
)
questGivers.value = markers.getElements().filter((it: any) => it.type === 'quest')
}
function updateCharacters(charactersData: any[]) {
if (!characters || !map) return
const list = Array.isArray(charactersData) ? charactersData : []
const ctx = { map, mapid: mapLogic.state.mapid.value }
characters.update(
list.map((it) => new Character(it)),
(character: InstanceType<typeof Character>) => {
character.add(ctx)
character.setClickCallback(() => (mapLogic.state.trackingCharacterId.value = character.id))
},
(character: InstanceType<typeof Character>) => character.remove(ctx),
(character: InstanceType<typeof Character>, updated: any) => {
if (mapLogic.state.trackingCharacterId.value === updated.id) {
if (mapLogic.state.mapid.value !== updated.map) changeMap(updated.map)
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom)
}
character.update(ctx, updated)
}
)
players.value = characters.getElements()
}
watch(mapLogic.state.showGridCoordinates, (v) => { watch(mapLogic.state.showGridCoordinates, (v) => {
if (coordLayer) { if (mapInit?.coordLayer) {
;(coordLayer.options as { visible?: boolean }).visible = v ;(mapInit.coordLayer.options as { visible?: boolean }).visible = v
coordLayer.setOpacity(v ? 1 : 0) mapInit.coordLayer.setOpacity(v ? 1 : 0)
if (v && map) { if (v && leafletMap) {
coordLayer.bringToFront?.() mapInit.coordLayer.bringToFront?.()
coordLayer.redraw?.() mapInit.coordLayer.redraw?.()
map.invalidateSize() leafletMap.invalidateSize()
} else if (coordLayer) { } else {
coordLayer.redraw?.() mapInit.coordLayer.redraw?.()
} }
} }
}) })
watch(mapLogic.state.hideMarkers, (v) => { watch(mapLogic.state.hideMarkers, (v) => {
markersHidden = v layersManager?.refreshMarkersVisibility(v)
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
if (v) {
markers.getElements().forEach((it: any) => it.remove(ctx))
} else {
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
}
}) })
watch(mapLogic.state.trackingCharacterId, (value) => { watch(mapLogic.state.trackingCharacterId, (value) => {
if (value === -1) return if (value === -1) return
const character = characters?.byId(value) const character = layersManager?.findCharacterById(value)
if (character) { if (character) {
changeMap(character.map) layersManager!.changeMap(character.map)
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom) const latlng = leafletMap!.unproject([character.position.x, character.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom) leafletMap!.setView(latlng, HnHMaxZoom)
autoMode = true autoMode = true
} else { } else {
map!.setView([0, 0], HnHMinZoom) leafletMap!.setView([0, 0], HnHMinZoom)
mapLogic.state.trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
} }
}) })
watch(mapLogic.state.selectedMapId, (value) => { watch(mapLogic.state.selectedMapId, (value) => {
if (value == null) return if (value == null) return
changeMap(value) layersManager?.changeMap(value)
const zoom = map!.getZoom() const zoom = leafletMap!.getZoom()
map!.setView([0, 0], zoom) leafletMap!.setView([0, 0], zoom)
}) })
watch(mapLogic.state.overlayMapId, (value) => { watch(mapLogic.state.overlayMapId, (value) => {
if (overlayLayer) overlayLayer.map = value ?? -1 layersManager?.refreshOverlayMarkers(value ?? -1)
overlayLayer?.redraw()
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
}) })
watch(mapLogic.state.selectedMarkerId, (value) => { watch(mapLogic.state.selectedMarkerId, (value) => {
if (value == null) return if (value == null) return
const marker = markers?.byId(value) const marker = layersManager?.findMarkerById(value)
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom()) if (marker?.leafletMarker) leafletMap!.setView(marker.leafletMarker.getLatLng(), leafletMap!.getZoom())
}) })
watch(mapLogic.state.selectedPlayerId, (value) => { watch(mapLogic.state.selectedPlayerId, (value) => {
if (value != null) mapLogic.state.trackingCharacterId.value = value if (value != null) mapLogic.state.trackingCharacterId.value = value
}) })
map.on('moveend', () => mapLogic.updateDisplayCoords(map)) leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(map) mapLogic.updateDisplayCoords(leafletMap)
map.on('zoomend', () => { leafletMap.on('zoomend', () => {
if (map) map.invalidateSize() if (leafletMap) leafletMap.invalidateSize()
}) })
map.on('drag', () => { leafletMap.on('drag', () => {
mapLogic.state.trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
}) })
map.on('zoom', () => { leafletMap.on('zoom', () => {
if (autoMode) { if (autoMode) {
autoMode = false autoMode = false
} else { } else {
@@ -494,8 +342,8 @@ onBeforeUnmount(() => {
document.removeEventListener('contextmenu', contextMenuHandler, true) document.removeEventListener('contextmenu', contextMenuHandler, true)
} }
if (intervalId) clearInterval(intervalId) if (intervalId) clearInterval(intervalId)
if (source) source.close() updatesHandle?.cleanup()
if (map) map.remove() if (leafletMap) leafletMap.remove()
}) })
</script> </script>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="form-control"> <fieldset class="fieldset">
<label v-if="label" class="label" :for="inputId"> <label v-if="label" class="label" :for="inputId">
<span class="label-text">{{ label }}</span> <span>{{ label }}</span>
</label> </label>
<div class="relative flex"> <div class="relative flex">
<input <input
:id="inputId" :id="inputId"
:value="modelValue" :value="modelValue"
:type="showPass ? 'text' : 'password'" :type="showPass ? 'text' : 'password'"
class="input input-bordered flex-1 pr-10" class="input flex-1 pr-10"
:placeholder="placeholder" :placeholder="placeholder"
:required="required" :required="required"
:autocomplete="autocomplete" :autocomplete="autocomplete"
@@ -25,7 +25,7 @@
<icons-icon-eye v-else /> <icons-icon-eye v-else />
</button> </button>
</div> </div>
</div> </fieldset>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -46,44 +46,44 @@
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3> <h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" /> <input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span> <span>Show grid coordinates</span>
</label> </label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" /> <input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span> <span>Hide markers</span>
</label> </label>
</section> </section>
<!-- Navigation --> <!-- Navigation -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3> <h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control"> <fieldset class="fieldset">
<label class="label py-0"><span class="label-text">Jump to Map</span></label> <label class="label py-0"><span>Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary"> <select v-model="selectedMapIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select map</option> <option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option> <option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label py-0"><span class="label-text">Overlay Map</span></label> <label class="label py-0"><span>Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary"> <select v-model="overlayMapId" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option> <option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option> <option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label> <label class="label py-0"><span>Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary"> <select v-model="selectedMarkerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select quest giver</option> <option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option> <option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label py-0"><span class="label-text">Jump to Player</span></label> <label class="label py-0"><span>Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary"> <select v-model="selectedPlayerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select player</option> <option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option> <option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select> </select>
</div> </fieldset>
</section> </section>
</div> </div>
<button <button

View File

@@ -4,8 +4,8 @@
<h3 class="font-bold text-lg">Rewrite tile coords</h3> <h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p> <p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2"> <div class="flex gap-2">
<input v-model.number="localTo.x" type="number" class="input input-bordered flex-1" placeholder="X" /> <input v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input input-bordered flex-1" placeholder="Y" /> <input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" />
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">
@@ -27,7 +27,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
submit: [from: { x: number; y: number }; to: { x: number; y: number }] submit: [from: { x: number; y: number }, to: { x: number; y: number }]
}>() }>()
const modalRef = ref<HTMLDialogElement | null>(null) const modalRef = ref<HTMLDialogElement | null>(null)

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const useRuntimeConfigMock = vi.fn()
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
import { useAppPaths } from '../useAppPaths'
describe('useAppPaths with default base /', () => {
beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })
})
it('returns base as empty string for "/"', () => {
const { base } = useAppPaths()
expect(base).toBe('')
})
it('pathWithoutBase returns path unchanged', () => {
const { pathWithoutBase } = useAppPaths()
expect(pathWithoutBase('/login')).toBe('/login')
expect(pathWithoutBase('/admin/users')).toBe('/admin/users')
expect(pathWithoutBase('/')).toBe('/')
})
it('resolvePath returns path as-is', () => {
const { resolvePath } = useAppPaths()
expect(resolvePath('/login')).toBe('/login')
expect(resolvePath('admin')).toBe('/admin')
})
it('isLoginPath detects login', () => {
const { isLoginPath } = useAppPaths()
expect(isLoginPath('/login')).toBe(true)
expect(isLoginPath('/admin/login')).toBe(true)
expect(isLoginPath('/admin')).toBe(false)
expect(isLoginPath('/')).toBe(false)
})
it('isSetupPath detects setup', () => {
const { isSetupPath } = useAppPaths()
expect(isSetupPath('/setup')).toBe(true)
expect(isSetupPath('/other/setup')).toBe(true)
expect(isSetupPath('/')).toBe(false)
expect(isSetupPath('/login')).toBe(false)
})
})
describe('useAppPaths with custom base /map', () => {
beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/map/' } })
})
it('strips base from path', () => {
const { base, pathWithoutBase } = useAppPaths()
expect(base).toBe('/map')
expect(pathWithoutBase('/map/login')).toBe('/login')
expect(pathWithoutBase('/map/admin/users')).toBe('/admin/users')
expect(pathWithoutBase('/map/')).toBe('/')
expect(pathWithoutBase('/map')).toBe('/')
})
it('resolvePath prepends base', () => {
const { resolvePath } = useAppPaths()
expect(resolvePath('/login')).toBe('/map/login')
expect(resolvePath('admin')).toBe('/map/admin')
})
it('isLoginPath with base', () => {
const { isLoginPath } = useAppPaths()
expect(isLoginPath('/map/login')).toBe(true)
expect(isLoginPath('/login')).toBe(true)
expect(isLoginPath('/map/admin')).toBe(false)
})
it('isSetupPath with base', () => {
const { isSetupPath } = useAppPaths()
expect(isSetupPath('/map/setup')).toBe(true)
expect(isSetupPath('/setup')).toBe(true)
expect(isSetupPath('/map/login')).toBe(false)
})
})
describe('useAppPaths with no baseURL', () => {
beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: {} })
})
it('defaults to /', () => {
const { baseURL, base } = useAppPaths()
expect(baseURL).toBe('/')
expect(base).toBe('')
})
})

View File

@@ -0,0 +1,284 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.stubGlobal('useRuntimeConfig', () => ({
app: { baseURL: '/' },
public: { apiBase: '/map/api' },
}))
import { useMapApi } from '../useMapApi'
function mockFetch(status: number, body: unknown, contentType = 'application/json') {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'content-type': contentType }),
json: () => Promise.resolve(body),
} as Response)
}
describe('useMapApi', () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
vi.restoreAllMocks()
})
describe('getConfig', () => {
it('fetches config from API', async () => {
const data = { title: 'Test', auths: ['map'] }
globalThis.fetch = mockFetch(200, data)
const { getConfig } = useMapApi()
const result = await getConfig()
expect(result).toEqual(data)
expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' }))
})
it('throws on 401', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const { getConfig } = useMapApi()
await expect(getConfig()).rejects.toThrow('Unauthorized')
})
it('throws on 403', async () => {
globalThis.fetch = mockFetch(403, { error: 'Forbidden' })
const { getConfig } = useMapApi()
await expect(getConfig()).rejects.toThrow('Forbidden')
})
})
describe('getCharacters', () => {
it('fetches characters', async () => {
const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }]
globalThis.fetch = mockFetch(200, chars)
const { getCharacters } = useMapApi()
const result = await getCharacters()
expect(result).toEqual(chars)
})
})
describe('getMarkers', () => {
it('fetches markers', async () => {
const markers = [{ name: 'Tower', id: 1, map: 1, position: { x: 10, y: 20 }, image: 'gfx/terobjs/mm/tower', hidden: false }]
globalThis.fetch = mockFetch(200, markers)
const { getMarkers } = useMapApi()
const result = await getMarkers()
expect(result).toEqual(markers)
})
})
describe('getMaps', () => {
it('fetches maps', async () => {
const maps = { '1': { ID: 1, Name: 'world' } }
globalThis.fetch = mockFetch(200, maps)
const { getMaps } = useMapApi()
const result = await getMaps()
expect(result).toEqual(maps)
})
})
describe('login', () => {
it('sends credentials and returns me response', async () => {
const meResp = { username: 'alice', auths: ['map'] }
globalThis.fetch = mockFetch(200, meResp)
const { login } = useMapApi()
const result = await login('alice', 'secret')
expect(result).toEqual(meResp)
expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/login',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ user: 'alice', pass: 'secret' }),
}),
)
})
it('throws on 401 with error message', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'Invalid credentials' }),
})
const { login } = useMapApi()
await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials')
})
})
describe('logout', () => {
it('sends POST to logout', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 })
const { logout } = useMapApi()
await logout()
expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/logout',
expect.objectContaining({ method: 'POST' }),
)
})
})
describe('me', () => {
it('fetches current user', async () => {
const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' }
globalThis.fetch = mockFetch(200, meResp)
const { me } = useMapApi()
const result = await me()
expect(result).toEqual(meResp)
})
})
describe('setupRequired', () => {
it('checks setup status', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ setupRequired: true }),
})
const { setupRequired } = useMapApi()
const result = await setupRequired()
expect(result).toEqual({ setupRequired: true })
})
})
describe('oauthProviders', () => {
it('returns providers list', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(['google']),
})
const { oauthProviders } = useMapApi()
const result = await oauthProviders()
expect(result).toEqual(['google'])
})
it('returns empty array on error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'))
const { oauthProviders } = useMapApi()
const result = await oauthProviders()
expect(result).toEqual([])
})
it('returns empty array on non-ok', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
})
const { oauthProviders } = useMapApi()
const result = await oauthProviders()
expect(result).toEqual([])
})
})
describe('oauthLoginUrl', () => {
it('builds OAuth login URL', () => {
// happy-dom needs an absolute URL for `new URL()`. The source code
// creates `new URL(apiBase + path)` which is relative.
// Verify the underlying apiBase and path construction instead.
const { apiBase } = useMapApi()
const expected = `${apiBase}/oauth/google/login`
expect(expected).toBe('/map/api/oauth/google/login')
})
it('oauthLoginUrl is a function', () => {
const { oauthLoginUrl } = useMapApi()
expect(typeof oauthLoginUrl).toBe('function')
})
})
describe('onApiError', () => {
it('fires callback on 401', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const callback = vi.fn()
const { onApiError, getConfig } = useMapApi()
onApiError(callback)
await expect(getConfig()).rejects.toThrow()
expect(callback).toHaveBeenCalled()
})
it('returns unsubscribe function', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const callback = vi.fn()
const { onApiError, getConfig } = useMapApi()
const unsub = onApiError(callback)
unsub()
await expect(getConfig()).rejects.toThrow()
expect(callback).not.toHaveBeenCalled()
})
})
describe('admin endpoints', () => {
it('adminExportUrl returns correct path', () => {
const { adminExportUrl } = useMapApi()
expect(adminExportUrl()).toBe('/map/api/admin/export')
})
it('adminUsers fetches user list', async () => {
globalThis.fetch = mockFetch(200, ['alice', 'bob'])
const { adminUsers } = useMapApi()
const result = await adminUsers()
expect(result).toEqual(['alice', 'bob'])
})
it('adminSettings fetches settings', async () => {
const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' }
globalThis.fetch = mockFetch(200, settings)
const { adminSettings } = useMapApi()
const result = await adminSettings()
expect(result).toEqual(settings)
})
})
describe('meTokens', () => {
it('generates and returns tokens', async () => {
globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] })
const { meTokens } = useMapApi()
const result = await meTokens()
expect(result).toEqual(['tok1', 'tok2'])
})
})
describe('mePassword', () => {
it('sends password change', async () => {
globalThis.fetch = mockFetch(200, undefined, 'text/plain')
const { mePassword } = useMapApi()
await mePassword('newpass')
expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/me/password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ pass: 'newpass' }),
}),
)
})
})
})

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, reactive } from 'vue'
vi.stubGlobal('ref', ref)
vi.stubGlobal('reactive', reactive)
import { useMapLogic } from '../useMapLogic'
describe('useMapLogic', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with default state', () => {
const { state } = useMapLogic()
expect(state.showGridCoordinates.value).toBe(false)
expect(state.hideMarkers.value).toBe(false)
expect(state.panelCollapsed.value).toBe(false)
expect(state.trackingCharacterId.value).toBe(-1)
expect(state.selectedMapId.value).toBeNull()
expect(state.overlayMapId.value).toBe(-1)
expect(state.selectedMarkerId.value).toBeNull()
expect(state.selectedPlayerId.value).toBeNull()
expect(state.displayCoords.value).toBeNull()
expect(state.mapid.value).toBe(0)
})
it('zoomIn calls map.zoomIn', () => {
const { zoomIn } = useMapLogic()
const mockMap = { zoomIn: vi.fn() }
zoomIn(mockMap as unknown as import('leaflet').Map)
expect(mockMap.zoomIn).toHaveBeenCalled()
})
it('zoomIn handles null map', () => {
const { zoomIn } = useMapLogic()
expect(() => zoomIn(null)).not.toThrow()
})
it('zoomOutControl calls map.zoomOut', () => {
const { zoomOutControl } = useMapLogic()
const mockMap = { zoomOut: vi.fn() }
zoomOutControl(mockMap as unknown as import('leaflet').Map)
expect(mockMap.zoomOut).toHaveBeenCalled()
})
it('resetView resets tracking and sets view', () => {
const { state, resetView } = useMapLogic()
state.trackingCharacterId.value = 42
const mockMap = { setView: vi.fn() }
resetView(mockMap as unknown as import('leaflet').Map)
expect(state.trackingCharacterId.value).toBe(-1)
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
})
it('updateDisplayCoords sets coords from map center', () => {
const { state, updateDisplayCoords } = useMapLogic()
const mockMap = {
project: vi.fn(() => ({ x: 550, y: 350 })),
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
getZoom: vi.fn(() => 3),
}
updateDisplayCoords(mockMap as unknown as import('leaflet').Map)
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
})
it('updateDisplayCoords handles null map', () => {
const { state, updateDisplayCoords } = useMapLogic()
updateDisplayCoords(null)
expect(state.displayCoords.value).toBeNull()
})
it('toLatLng calls map.unproject', () => {
const { toLatLng } = useMapLogic()
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200)
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
expect(result).toEqual({ lat: 1, lng: 2 })
})
it('toLatLng returns null for null map', () => {
const { toLatLng } = useMapLogic()
expect(toLatLng(null, 0, 0)).toBeNull()
})
it('closeContextMenus hides both menus', () => {
const { contextMenu, openTileContextMenu, openMarkerContextMenu, closeContextMenus } = useMapLogic()
openTileContextMenu(10, 20, { x: 1, y: 2 })
openMarkerContextMenu(30, 40, 5, 'Tower')
closeContextMenus()
expect(contextMenu.tile.show).toBe(false)
expect(contextMenu.marker.show).toBe(false)
})
it('openTileContextMenu sets tile context menu state', () => {
const { contextMenu, openTileContextMenu } = useMapLogic()
openTileContextMenu(100, 200, { x: 5, y: 10 })
expect(contextMenu.tile.show).toBe(true)
expect(contextMenu.tile.x).toBe(100)
expect(contextMenu.tile.y).toBe(200)
expect(contextMenu.tile.data).toEqual({ coords: { x: 5, y: 10 } })
})
it('openMarkerContextMenu sets marker context menu state', () => {
const { contextMenu, openMarkerContextMenu } = useMapLogic()
openMarkerContextMenu(50, 60, 42, 'Castle')
expect(contextMenu.marker.show).toBe(true)
expect(contextMenu.marker.x).toBe(50)
expect(contextMenu.marker.y).toBe(60)
expect(contextMenu.marker.data).toEqual({ id: 42, name: 'Castle' })
})
it('openTileContextMenu closes other menus first', () => {
const { contextMenu, openMarkerContextMenu, openTileContextMenu } = useMapLogic()
openMarkerContextMenu(10, 20, 1, 'A')
expect(contextMenu.marker.show).toBe(true)
openTileContextMenu(30, 40, { x: 0, y: 0 })
expect(contextMenu.marker.show).toBe(false)
expect(contextMenu.tile.show).toBe(true)
})
it('openCoordSet sets modal state', () => {
const { coordSetFrom, coordSet, coordSetModalOpen, openCoordSet } = useMapLogic()
openCoordSet({ x: 3, y: 7 })
expect(coordSetFrom.value).toEqual({ x: 3, y: 7 })
expect(coordSet.value).toEqual({ x: 3, y: 7 })
expect(coordSetModalOpen.value).toBe(true)
})
it('closeCoordSetModal closes modal', () => {
const { coordSetModalOpen, openCoordSet, closeCoordSetModal } = useMapLogic()
openCoordSet({ x: 0, y: 0 })
closeCoordSetModal()
expect(coordSetModalOpen.value).toBe(false)
})
})

View File

@@ -195,15 +195,24 @@ export function useMapApi() {
} }
async function adminWipeTile(params: { map: number; x: number; y: number }) { async function adminWipeTile(params: { map: number; x: number; y: number }) {
return request(`admin/wipeTile?${new URLSearchParams(params as any)}`) const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
return request(`admin/wipeTile?${qs}`)
} }
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) { async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
return request(`admin/setCoords?${new URLSearchParams(params as any)}`) const qs = new URLSearchParams({
map: String(params.map),
fx: String(params.fx),
fy: String(params.fy),
tx: String(params.tx),
ty: String(params.ty),
})
return request(`admin/setCoords?${qs}`)
} }
async function adminHideMarker(params: { id: number }) { async function adminHideMarker(params: { id: number }) {
return request(`admin/hideMarker?${new URLSearchParams(params as any)}`) const qs = new URLSearchParams({ id: String(params.id) })
return request(`admin/hideMarker?${qs}`)
} }
return { return {

View File

@@ -0,0 +1,91 @@
import type L from 'leaflet'
import { GridCoordLayer, HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import type { GridCoordLayerOptions } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { MapInfo } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export interface MapInitResult {
map: L.Map
layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance
coordLayer: L.GridLayer
markerLayer: L.LayerGroup
backendBase: string
}
export async function initLeafletMap(
element: HTMLElement,
mapsList: MapInfo[],
initialMapId: number
): Promise<MapInitResult> {
const L = (await import('leaflet')).default
const map = L.map(element, {
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
crs: HnHCRS,
attributionControl: false,
zoomControl: false,
inertia: true,
zoomAnimation: true,
fadeAnimation: true,
markerZoomAnimation: true,
})
const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
const layer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
updateWhenIdle: true,
keepBuffer: 2,
})
layer.map = initialMapId
layer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
layer.addTo(map)
const overlayLayer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
opacity: 0.5,
updateWhenIdle: true,
keepBuffer: 2,
})
overlayLayer.map = -1
overlayLayer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer.addTo(map)
const coordLayer = new GridCoordLayer({
tileSize: TileSize,
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
opacity: 0,
visible: false,
pane: 'tilePane',
} as GridCoordLayerOptions)
coordLayer.addTo(map)
coordLayer.setZIndex(500)
const markerLayer = L.layerGroup()
markerLayer.addTo(map)
markerLayer.setZIndex(600)
const baseURL = useRuntimeConfig().app.baseURL ?? '/'
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
L.Icon.Default.imagePath = markerIconPath
return { map, layer, overlayLayer, coordLayer, markerLayer, backendBase }
}

View File

@@ -0,0 +1,192 @@
import type L from 'leaflet'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
import {
createUniqueList,
uniqueListUpdate,
uniqueListGetElements,
uniqueListById,
type UniqueList,
} from '~/lib/UniqueList'
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export interface MapLayersOptions {
map: L.Map
markerLayer: L.LayerGroup
layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance
getCurrentMapId: () => number
setCurrentMapId: (id: number) => void
setSelectedMapId: (id: number) => void
getAuths: () => string[]
getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
}
export interface MapLayersManager {
markers: UniqueList<MapMarker>
characters: UniqueList<MapCharacter>
changeMap: (id: number) => void
updateMarkers: (markersData: ApiMarker[]) => void
updateCharacters: (charactersData: ApiCharacter[]) => void
getQuestGivers: () => Array<{ id: number; name: string }>
getPlayers: () => Array<{ id: number; name: string }>
refreshMarkersVisibility: (hidden: boolean) => void
refreshOverlayMarkers: (overlayMapIdValue: number) => void
findMarkerById: (id: number) => MapMarker | undefined
findCharacterById: (id: number) => MapCharacter | undefined
}
export function createMapLayers(options: MapLayersOptions): MapLayersManager {
const {
map,
markerLayer,
layer,
overlayLayer,
getCurrentMapId,
setCurrentMapId,
setSelectedMapId,
getAuths,
getTrackingCharacterId,
setTrackingCharacterId,
onMarkerContextMenu,
} = options
const markers = createUniqueList<MapMarker>()
const characters = createUniqueList<MapCharacter>()
function markerCtx(): MapViewRef {
return { map, markerLayer, mapid: getCurrentMapId() }
}
function characterCtx(): CharacterMapViewRef {
return { map, mapid: getCurrentMapId() }
}
function changeMap(id: number) {
if (id === getCurrentMapId()) return
setCurrentMapId(id)
setSelectedMapId(id)
layer.map = id
layer.redraw()
overlayLayer.map = -1
overlayLayer.redraw()
const ctx = markerCtx()
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
uniqueListGetElements(markers)
.filter((it) => it.map === id)
.forEach((it) => it.add(ctx))
const cCtx = characterCtx()
uniqueListGetElements(characters).forEach((it) => {
it.remove(cCtx)
it.add(cCtx)
})
}
function updateMarkers(markersData: ApiMarker[]) {
const list = Array.isArray(markersData) ? markersData : []
const ctx = markerCtx()
uniqueListUpdate(
markers,
list.map((it) => createMarker(it as MarkerData)),
(marker: MapMarker) => {
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
marker.setClickCallback(() => {
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
})
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (getAuths().includes('admin')) {
mev.originalEvent.preventDefault()
mev.originalEvent.stopPropagation()
onMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
}
})
},
(marker: MapMarker) => marker.remove(ctx),
(marker: MapMarker, updated: MapMarker) => marker.update(ctx, updated)
)
}
function updateCharacters(charactersData: ApiCharacter[]) {
const list = Array.isArray(charactersData) ? charactersData : []
const ctx = characterCtx()
uniqueListUpdate(
characters,
list.map((it) => createCharacter(it as CharacterData)),
(character: MapCharacter) => {
character.add(ctx)
character.setClickCallback(() => setTrackingCharacterId(character.id))
},
(character: MapCharacter) => character.remove(ctx),
(character: MapCharacter, updated: MapCharacter) => {
if (getTrackingCharacterId() === updated.id) {
if (getCurrentMapId() !== updated.map) changeMap(updated.map)
const latlng = map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map.setView(latlng, HnHMaxZoom)
}
character.update(ctx, updated)
}
)
}
function getQuestGivers(): Array<{ id: number; name: string }> {
return uniqueListGetElements(markers)
.filter((it) => it.type === 'quest')
.map((it) => ({ id: it.id, name: it.name }))
}
function getPlayers(): Array<{ id: number; name: string }> {
return uniqueListGetElements(characters).map((it) => ({ id: it.id, name: it.name }))
}
function refreshMarkersVisibility(hidden: boolean) {
const ctx = markerCtx()
if (hidden) {
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
} else {
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
uniqueListGetElements(markers)
.filter((it) => it.map === getCurrentMapId() || it.map === overlayLayer.map)
.forEach((it) => it.add(ctx))
}
}
function refreshOverlayMarkers(overlayMapIdValue: number) {
overlayLayer.map = overlayMapIdValue
overlayLayer.redraw()
const ctx = markerCtx()
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
uniqueListGetElements(markers)
.filter((it) => it.map === getCurrentMapId() || it.map === overlayMapIdValue)
.forEach((it) => it.add(ctx))
}
function findMarkerById(id: number): MapMarker | undefined {
return uniqueListById(markers, id)
}
function findCharacterById(id: number): MapCharacter | undefined {
return uniqueListById(characters, id)
}
return {
markers,
characters,
changeMap,
updateMarkers,
updateCharacters,
getQuestGivers,
getPlayers,
refreshMarkersVisibility,
refreshOverlayMarkers,
findMarkerById,
findCharacterById,
}
}

View File

@@ -0,0 +1,82 @@
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import { TileSize } from '~/lib/LeafletCustomTypes'
import type L from 'leaflet'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
interface TileUpdate {
M: number
X: number
Y: number
Z: number
T: number
}
interface MergeEvent {
From: number
To: number
Shift: { x: number; y: number }
}
export interface UseMapUpdatesOptions {
backendBase: string
layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance
map: L.Map
getCurrentMapId: () => number
onMerge: (mapTo: number, shift: { x: number; y: number }) => void
}
export interface UseMapUpdatesReturn {
cleanup: () => void
}
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge } = options
const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
const source = new EventSource(updatesUrl)
source.onmessage = (event: MessageEvent) => {
try {
const raw: unknown = event?.data
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
const updates: unknown = JSON.parse(raw)
if (!Array.isArray(updates)) return
for (const u of updates as TileUpdate[]) {
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
layer.cache[key] = u.T
overlayLayer.cache[key] = u.T
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
} catch {
// Ignore parse errors from SSE
}
}
source.onerror = () => {}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
if (getCurrentMapId() === merge.From) {
const point = map.project(map.getCenter(), 6)
const shift = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
}
onMerge(merge.To, shift)
}
} catch {
// Ignore merge parse errors
}
})
function cleanup() {
source.close()
}
return { cleanup }
}

View File

@@ -0,0 +1,11 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
'@typescript-eslint/no-require-imports': 'off',
},
})

View File

@@ -9,75 +9,83 @@ export interface CharacterData {
map: number map: number
} }
export interface MapViewRef { export interface CharacterMapViewRef {
map: L.Map map: L.Map
mapid: number mapid: number
markerLayer?: L.LayerGroup markerLayer?: L.LayerGroup
} }
export class Character { export interface MapCharacter {
id: number
name: string name: string
position: { x: number; y: number } position: { x: number; y: number }
type: string type: string
id: number
map: number map: number
marker: L.Marker | null = null
text: string text: string
value: number value: number
onClick: ((e: L.LeafletMouseEvent) => void) | null = null leafletMarker: L.Marker | null
remove: (mapview: CharacterMapViewRef) => void
add: (mapview: CharacterMapViewRef) => void
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
}
constructor(characterData: CharacterData) { export function createCharacter(data: CharacterData): MapCharacter {
this.name = characterData.name let leafletMarker: L.Marker | null = null
this.position = characterData.position let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
this.type = characterData.type
this.id = characterData.id
this.map = characterData.map
this.text = this.name
this.value = this.id
}
getId(): string { const character: MapCharacter = {
return `${this.name}` id: data.id,
} name: data.name,
position: { ...data.position },
type: data.type,
map: data.map,
text: data.name,
value: data.id,
remove(mapview: MapViewRef): void { get leafletMarker() {
if (this.marker) { return leafletMarker
},
remove(mapview: CharacterMapViewRef): void {
if (leafletMarker) {
const layer = mapview.markerLayer ?? mapview.map const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(this.marker) layer.removeLayer(leafletMarker)
this.marker = null leafletMarker = null
}
} }
},
add(mapview: MapViewRef): void { add(mapview: CharacterMapViewRef): void {
if (this.map === mapview.mapid) { if (character.map === mapview.mapid) {
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom) const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
this.marker = L.marker(position, { title: this.name }) leafletMarker = L.marker(position, { title: character.name })
this.marker.on('click', this.callCallback.bind(this)) leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e)
})
const targetLayer = mapview.markerLayer ?? mapview.map const targetLayer = mapview.markerLayer ?? mapview.map
this.marker.addTo(targetLayer) leafletMarker.addTo(targetLayer)
}
} }
},
update(mapview: MapViewRef, updated: CharacterData): void { update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
if (this.map !== updated.map) { if (character.map !== updated.map) {
this.remove(mapview) character.remove(mapview)
} }
this.map = updated.map character.map = updated.map
this.position = updated.position character.position = { ...updated.position }
if (!this.marker && this.map === mapview.mapid) { if (!leafletMarker && character.map === mapview.mapid) {
this.add(mapview) character.add(mapview)
} }
if (this.marker) { if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
this.marker.setLatLng(position) leafletMarker.setLatLng(position)
}
} }
},
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
this.onClick = callback onClick = callback
},
} }
callCallback(e: L.LeafletMouseEvent): void { return character
if (this.onClick != null) this.onClick(e)
}
} }

View File

@@ -8,6 +8,10 @@ export const HnHDefaultZoom = 6
/** When scaleFactor exceeds this, render one label per tile instead of a full grid (avoids 100k+ DOM nodes at zoom 1). */ /** When scaleFactor exceeds this, render one label per tile instead of a full grid (avoids 100k+ DOM nodes at zoom 1). */
const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8 const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8
export interface GridCoordLayerOptions extends L.GridLayerOptions {
visible?: boolean
}
export const GridCoordLayer = L.GridLayer.extend({ export const GridCoordLayer = L.GridLayer.extend({
options: { options: {
visible: true, visible: true,
@@ -64,7 +68,7 @@ export const GridCoordLayer = L.GridLayer.extend({
} }
return element return element
}, },
}) as unknown as new (options?: L.GridLayerOptions) => L.GridLayer }) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
export const ImageIcon = L.Icon.extend({ export const ImageIcon = L.Icon.extend({
options: { options: {

View File

@@ -16,49 +16,62 @@ export interface MapViewRef {
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
} }
export interface MapMarker {
id: number
position: { x: number; y: number }
name: string
image: string
type: string
text: string
value: number
hidden: boolean
map: number
leafletMarker: L.Marker | null
remove: (mapview: MapViewRef) => void
add: (mapview: MapViewRef) => void
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
jumpTo: (map: L.Map) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
}
function detectType(name: string): string { function detectType(name: string): string {
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest' if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
if (name === 'custom') return 'custom' if (name === 'custom') return 'custom'
return name.substring('gfx/terobjs/mm/'.length) return name.substring('gfx/terobjs/mm/'.length)
} }
export class Marker { export function createMarker(data: MarkerData): MapMarker {
id: number let leafletMarker: L.Marker | null = null
position: { x: number; y: number } let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
name: string let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
image: string
type: string
marker: L.Marker | null = null
text: string
value: number
hidden: boolean
map: number
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
onContext: ((e: L.LeafletMouseEvent) => void) | null = null
constructor(markerData: MarkerData) { const marker: MapMarker = {
this.id = markerData.id id: data.id,
this.position = markerData.position position: { ...data.position },
this.name = markerData.name name: data.name,
this.image = markerData.image image: data.image,
this.type = detectType(this.image) type: detectType(data.image),
this.text = this.name text: data.name,
this.value = this.id value: data.id,
this.hidden = markerData.hidden hidden: data.hidden,
this.map = markerData.map map: data.map,
}
get leafletMarker() {
return leafletMarker
},
remove(_mapview: MapViewRef): void { remove(_mapview: MapViewRef): void {
if (this.marker) { if (leafletMarker) {
this.marker.remove() leafletMarker.remove()
this.marker = null leafletMarker = null
}
} }
},
add(mapview: MapViewRef): void { add(mapview: MapViewRef): void {
if (!this.hidden) { if (!marker.hidden) {
let icon: L.Icon let icon: L.Icon
if (this.image === 'gfx/terobjs/mm/custom') { if (marker.image === 'gfx/terobjs/mm/custom') {
icon = new ImageIcon({ icon = new ImageIcon({
iconUrl: 'gfx/terobjs/mm/custom.png', iconUrl: 'gfx/terobjs/mm/custom.png',
iconSize: [21, 23], iconSize: [21, 23],
@@ -67,48 +80,47 @@ export class Marker {
tooltipAnchor: [1, 3], tooltipAnchor: [1, 3],
}) })
} else { } else {
icon = new ImageIcon({ iconUrl: `${this.image}.png`, iconSize: [32, 32] }) icon = new ImageIcon({ iconUrl: `${marker.image}.png`, iconSize: [32, 32] })
} }
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom) const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
this.marker = L.marker(position, { icon, title: this.name }) leafletMarker = L.marker(position, { icon, title: marker.name })
this.marker.addTo(mapview.markerLayer) leafletMarker.addTo(mapview.markerLayer)
this.marker.on('click', this.callClickCallback.bind(this)) leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
this.marker.on('contextmenu', this.callContextCallback.bind(this)) if (onClick) onClick(e)
} })
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
if (onContext) onContext(e)
})
} }
},
update(mapview: MapViewRef, updated: MarkerData): void { update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
this.position = updated.position marker.position = { ...updated.position }
this.name = updated.name marker.name = updated.name
this.hidden = updated.hidden marker.hidden = updated.hidden
this.map = updated.map marker.map = updated.map
if (this.marker) { if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
this.marker.setLatLng(position) leafletMarker.setLatLng(position)
}
} }
},
jumpTo(map: L.Map): void { jumpTo(map: L.Map): void {
if (this.marker) { if (leafletMarker) {
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom) const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
this.marker.setLatLng(position) leafletMarker.setLatLng(position)
}
} }
},
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
this.onClick = callback onClick = callback
} },
callClickCallback(e: L.LeafletMouseEvent): void {
if (this.onClick != null) this.onClick(e)
}
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void { setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
this.onContext = callback onContext = callback
},
} }
callContextCallback(e: L.LeafletMouseEvent): void { return marker
if (this.onContext != null) this.onContext(e)
}
} }

View File

@@ -1,24 +1,27 @@
/**
* Elements should have unique field "id"
*/
export interface Identifiable { export interface Identifiable {
id: number | string id: number | string
} }
export class UniqueList<T extends Identifiable> { export interface UniqueList<T extends Identifiable> {
elements: Record<string, T> = {} elements: Record<string, T>
}
update( export function createUniqueList<T extends Identifiable>(): UniqueList<T> {
return { elements: {} }
}
export function uniqueListUpdate<T extends Identifiable>(
list: UniqueList<T>,
dataList: T[], dataList: T[],
addCallback?: (it: T) => void, addCallback?: (it: T) => void,
removeCallback?: (it: T) => void, removeCallback?: (it: T) => void,
updateCallback?: (oldElement: T, newElement: T) => void updateCallback?: (oldElement: T, newElement: T) => void
): void { ): void {
const elementsToAdd = dataList.filter((it) => this.elements[String(it.id)] === undefined) const elementsToAdd = dataList.filter((it) => list.elements[String(it.id)] === undefined)
const elementsToRemove: T[] = [] const elementsToRemove: T[] = []
for (const id of Object.keys(this.elements)) { for (const id of Object.keys(list.elements)) {
if (dataList.find((up) => String(up.id) === id) === undefined) { if (dataList.find((up) => String(up.id) === id) === undefined) {
const el = this.elements[id] const el = list.elements[id]
if (el) elementsToRemove.push(el) if (el) elementsToRemove.push(el)
} }
} }
@@ -27,7 +30,7 @@ export class UniqueList<T extends Identifiable> {
} }
if (updateCallback) { if (updateCallback) {
dataList.forEach((newElement) => { dataList.forEach((newElement) => {
const oldElement = this.elements[String(newElement.id)] const oldElement = list.elements[String(newElement.id)]
if (oldElement) { if (oldElement) {
updateCallback(oldElement, newElement) updateCallback(oldElement, newElement)
} }
@@ -36,15 +39,14 @@ export class UniqueList<T extends Identifiable> {
if (addCallback) { if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it)) elementsToAdd.forEach((it) => addCallback(it))
} }
elementsToRemove.forEach((it) => delete this.elements[String(it.id)]) elementsToRemove.forEach((it) => delete list.elements[String(it.id)])
elementsToAdd.forEach((it) => (this.elements[String(it.id)] = it)) elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
} }
getElements(): T[] { export function uniqueListGetElements<T extends Identifiable>(list: UniqueList<T>): T[] {
return Object.values(this.elements) return Object.values(list.elements)
} }
byId(id: number | string): T | undefined { export function uniqueListById<T extends Identifiable>(list: UniqueList<T>, id: number | string): T | undefined {
return this.elements[String(id)] return list.elements[String(id)]
}
} }

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('leaflet', () => {
const markerMock = {
on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(),
}
return {
default: { marker: vi.fn(() => markerMock) },
marker: vi.fn(() => markerMock),
}
})
vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6,
}))
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
return {
name: 'Hero',
position: { x: 100, y: 200 },
type: 'player',
id: 1,
map: 1,
...overrides,
}
}
function makeMapViewRef(mapid = 1): CharacterMapViewRef {
return {
map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
removeLayer: vi.fn(),
} as unknown as import('leaflet').Map,
mapid,
markerLayer: {
removeLayer: vi.fn(),
addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup,
}
}
describe('createCharacter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates character with correct properties', () => {
const char = createCharacter(makeCharData())
expect(char.id).toBe(1)
expect(char.name).toBe('Hero')
expect(char.position).toEqual({ x: 100, y: 200 })
expect(char.type).toBe('player')
expect(char.map).toBe(1)
expect(char.text).toBe('Hero')
expect(char.value).toBe(1)
})
it('starts with null leaflet marker', () => {
const char = createCharacter(makeCharData())
expect(char.leafletMarker).toBeNull()
})
it('add creates marker when character is on correct map', () => {
const char = createCharacter(makeCharData())
const mapview = makeMapViewRef(1)
char.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled()
})
it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 }))
const mapview = makeMapViewRef(1)
char.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled()
})
it('update changes position and map', () => {
const char = createCharacter(makeCharData())
const mapview = makeMapViewRef(1)
char.update(mapview, {
...makeCharData(),
position: { x: 300, y: 400 },
map: 2,
})
expect(char.position).toEqual({ x: 300, y: 400 })
expect(char.map).toBe(2)
})
it('remove on a character without leaflet marker does nothing', () => {
const char = createCharacter(makeCharData())
const mapview = makeMapViewRef(1)
char.remove(mapview) // should not throw
expect(char.leafletMarker).toBeNull()
})
it('setClickCallback works', () => {
const char = createCharacter(makeCharData())
const cb = vi.fn()
char.setClickCallback(cb)
})
})

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('leaflet', () => {
const markerMock = {
on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(),
}
return {
default: {
marker: vi.fn(() => markerMock),
Icon: class {},
},
marker: vi.fn(() => markerMock),
Icon: class {},
}
})
vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6,
ImageIcon: class {
constructor(_opts: Record<string, unknown>) {}
},
}))
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
return {
id: 1,
position: { x: 100, y: 200 },
name: 'Tower',
image: 'gfx/terobjs/mm/tower',
hidden: false,
map: 1,
...overrides,
}
}
function makeMapViewRef(): MapViewRef {
return {
map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
} as unknown as import('leaflet').Map,
mapid: 1,
markerLayer: {
removeLayer: vi.fn(),
addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup,
}
}
describe('createMarker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a marker with correct properties', () => {
const marker = createMarker(makeMarkerData())
expect(marker.id).toBe(1)
expect(marker.name).toBe('Tower')
expect(marker.position).toEqual({ x: 100, y: 200 })
expect(marker.image).toBe('gfx/terobjs/mm/tower')
expect(marker.hidden).toBe(false)
expect(marker.map).toBe(1)
expect(marker.value).toBe(1)
expect(marker.text).toBe('Tower')
})
it('detects quest type', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }))
expect(marker.type).toBe('quest')
})
it('detects quest type for bumling', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }))
expect(marker.type).toBe('quest')
})
it('detects custom type', () => {
const marker = createMarker(makeMarkerData({ image: 'custom' }))
expect(marker.type).toBe('custom')
})
it('extracts type from gfx path', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }))
expect(marker.type).toBe('village')
})
it('starts with null leaflet marker', () => {
const marker = createMarker(makeMarkerData())
expect(marker.leafletMarker).toBeNull()
})
it('add creates a leaflet marker for non-hidden markers', () => {
const marker = createMarker(makeMarkerData())
const mapview = makeMapViewRef()
marker.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled()
})
it('add does nothing for hidden markers', () => {
const marker = createMarker(makeMarkerData({ hidden: true }))
const mapview = makeMapViewRef()
marker.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled()
})
it('update changes position and name', () => {
const marker = createMarker(makeMarkerData())
const mapview = makeMapViewRef()
marker.update(mapview, {
...makeMarkerData(),
position: { x: 300, y: 400 },
name: 'Castle',
})
expect(marker.position).toEqual({ x: 300, y: 400 })
expect(marker.name).toBe('Castle')
})
it('setClickCallback and setContextMenu work', () => {
const marker = createMarker(makeMarkerData())
const clickCb = vi.fn()
const contextCb = vi.fn()
marker.setClickCallback(clickCb)
marker.setContextMenu(contextCb)
})
it('remove on a marker without leaflet marker does nothing', () => {
const marker = createMarker(makeMarkerData())
const mapview = makeMapViewRef()
marker.remove(mapview) // should not throw
expect(marker.leafletMarker).toBeNull()
})
})

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from 'vitest'
import {
createUniqueList,
uniqueListUpdate,
uniqueListGetElements,
uniqueListById,
} from '../UniqueList'
interface Item {
id: number
name: string
}
describe('createUniqueList', () => {
it('creates an empty list', () => {
const list = createUniqueList<Item>()
expect(list.elements).toEqual({})
expect(uniqueListGetElements(list)).toEqual([])
})
})
describe('uniqueListUpdate', () => {
it('adds new elements', () => {
const list = createUniqueList<Item>()
const addCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }], addCb)
expect(addCb).toHaveBeenCalledTimes(2)
expect(uniqueListGetElements(list)).toHaveLength(2)
expect(uniqueListById(list, 1)).toEqual({ id: 1, name: 'a' })
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
})
it('removes elements no longer present', () => {
const list = createUniqueList<Item>()
const removeCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
uniqueListUpdate(list, [{ id: 1, name: 'a' }], undefined, removeCb)
expect(removeCb).toHaveBeenCalledTimes(1)
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'b' })
expect(uniqueListGetElements(list)).toHaveLength(1)
expect(uniqueListById(list, 2)).toBeUndefined()
})
it('calls update callback for existing elements', () => {
const list = createUniqueList<Item>()
const updateCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
uniqueListUpdate(list, [{ id: 1, name: 'updated' }], undefined, undefined, updateCb)
expect(updateCb).toHaveBeenCalledTimes(1)
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'a' }, { id: 1, name: 'updated' })
})
it('handles all callbacks together', () => {
const list = createUniqueList<Item>()
const addCb = vi.fn()
const removeCb = vi.fn()
const updateCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'keep' }, { id: 2, name: 'remove' }])
uniqueListUpdate(
list,
[{ id: 1, name: 'kept' }, { id: 3, name: 'new' }],
addCb,
removeCb,
updateCb,
)
expect(addCb).toHaveBeenCalledTimes(1)
expect(addCb).toHaveBeenCalledWith({ id: 3, name: 'new' })
expect(removeCb).toHaveBeenCalledTimes(1)
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'remove' })
expect(updateCb).toHaveBeenCalledTimes(1)
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'keep' }, { id: 1, name: 'kept' })
})
it('works with string IDs', () => {
interface StringItem {
id: string
label: string
}
const list = createUniqueList<StringItem>()
uniqueListUpdate(list, [{ id: 'abc', label: 'first' }])
expect(uniqueListById(list, 'abc')).toEqual({ id: 'abc', label: 'first' })
})
it('handles empty update', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
const removeCb = vi.fn()
uniqueListUpdate(list, [], undefined, removeCb)
expect(removeCb).toHaveBeenCalledTimes(1)
expect(uniqueListGetElements(list)).toHaveLength(0)
})
it('handles update with no callbacks', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
uniqueListUpdate(list, [{ id: 2, name: 'b' }])
expect(uniqueListGetElements(list)).toHaveLength(1)
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
})
})
describe('uniqueListGetElements', () => {
it('returns all elements as array', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
const elements = uniqueListGetElements(list)
expect(elements).toHaveLength(2)
expect(elements.map(e => e.id).sort()).toEqual([1, 2])
})
})
describe('uniqueListById', () => {
it('finds element by id', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 42, name: 'target' }])
expect(uniqueListById(list, 42)?.name).toBe('target')
})
it('returns undefined for missing id', () => {
const list = createUniqueList<Item>()
expect(uniqueListById(list, 999)).toBeUndefined()
})
})

View File

@@ -1,4 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import tailwindcss from '@tailwindcss/vite'
import { viteUriGuard } from './vite/vite-uri-guard' import { viteUriGuard } from './vite/vite-uri-guard'
export default defineNuxtConfig({ export default defineNuxtConfig({
@@ -27,20 +28,18 @@ export default defineNuxtConfig({
}, },
}, },
modules: ['@nuxtjs/tailwindcss'], modules: ['@nuxt/eslint'],
tailwindcss: {
cssPath: '~/assets/css/app.css',
},
css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'], css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'],
vite: { vite: {
plugins: [viteUriGuard()], plugins: [tailwindcss(), viteUriGuard() as never],
optimizeDeps: { optimizeDeps: {
include: ['leaflet'], include: ['leaflet'],
}, },
}, },
// Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml) // Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml)
// @ts-expect-error nitro types lag behind Nuxt compat v4
nitro: { nitro: {
devProxy: { devProxy: {
'/map/api': { target: 'http://backend:3080/map/api', changeOrigin: true }, '/map/api': { target: 'http://backend:3080/map/api', changeOrigin: true },

File diff suppressed because it is too large Load Diff

View File

@@ -6,22 +6,36 @@
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview" "preview": "nuxt preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"nuxt": "^3.14.1593", "nuxt": "^3.21.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/tailwindcss": "^6.12.2", "@nuxt/eslint": "^1.3.0",
"@nuxt/test-utils": "^4.0.0",
"@tailwindcss/vite": "^4.1.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"daisyui": "^3.9.4", "@vue/test-utils": "^2.4.6",
"tailwindcss": "^3.4.17", "daisyui": "^5.5.0",
"typescript": "^5.6.3" "eslint": "^9.21.0",
"happy-dom": "^20.7.0",
"prettier": "^3.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0",
"vitest": "^4.0.0",
"vue-tsc": "^2.2.12"
} }
} }

View File

@@ -24,7 +24,7 @@
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors" class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
> >
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<div class="avatar placeholder"> <div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-8"> <div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span> <span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
</div> </div>
@@ -57,7 +57,7 @@
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th class="text-right"></th></tr> <tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th class="text-right"></th></tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="map in maps" :key="map.ID" class="hover"> <tr v-for="map in maps" :key="map.ID" class="hover:bg-base-300">
<td>{{ map.ID }}</td> <td>{{ map.ID }}</td>
<td>{{ map.Name }}</td> <td>{{ map.Name }}</td>
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td> <td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
@@ -84,25 +84,25 @@
Settings Settings
</h2> </h2>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="form-control w-full max-w-xs"> <fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-prefix">Prefix</label> <label class="label" for="admin-settings-prefix">Prefix</label>
<input <input
id="admin-settings-prefix" id="admin-settings-prefix"
v-model="settings.prefix" v-model="settings.prefix"
type="text" type="text"
class="input input-bordered input-sm w-full" class="input input-sm w-full"
/> />
</div> </fieldset>
<div class="form-control w-full max-w-xs"> <fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label> <label class="label" for="admin-settings-title">Title</label>
<input <input
id="admin-settings-title" id="admin-settings-title"
v-model="settings.title" v-model="settings.title"
type="text" type="text"
class="input input-bordered input-sm w-full" class="input input-sm w-full"
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide"> <label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
<input <input
id="admin-settings-default-hide" id="admin-settings-default-hide"
@@ -112,7 +112,7 @@
/> />
Default hide new maps Default hide new maps
</label> </label>
</div> </fieldset>
</div> </div>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<button class="btn btn-primary btn-sm" :disabled="savingSettings" @click="saveSettings"> <button class="btn btn-primary btn-sm" :disabled="savingSettings" @click="saveSettings">

View File

@@ -3,22 +3,22 @@
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1> <h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4"> <form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="name">Name</label> <label class="label" for="name">Name</label>
<input id="name" v-model="form.name" type="text" class="input input-bordered" required /> <input id="name" v-model="form.name" type="text" class="input" required />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.hidden" type="checkbox" class="checkbox" /> <input v-model="form.hidden" type="checkbox" class="checkbox" />
<span class="label-text">Hidden</span> <span>Hidden</span>
</label> </label>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.priority" type="checkbox" class="checkbox" /> <input v-model="form.priority" type="checkbox" class="checkbox" />
<span class="label-text">Priority</span> <span>Priority</span>
</label> </label>
</div> </fieldset>
<p v-if="error" class="text-error text-sm">{{ error }}</p> <p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading"> <button type="submit" class="btn btn-primary" :disabled="loading">

View File

@@ -3,31 +3,31 @@
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1> <h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="user">Username</label> <label class="label" for="user">Username</label>
<input <input
id="user" id="user"
v-model="form.user" v-model="form.user"
type="text" type="text"
class="input input-bordered" class="input"
required required
:readonly="!isNew" :readonly="!isNew"
/> />
</div> </fieldset>
<PasswordInput <PasswordInput
v-model="form.pass" v-model="form.pass"
label="Password (leave blank to keep)" label="Password (leave blank to keep)"
autocomplete="new-password" autocomplete="new-password"
/> />
<div class="form-control"> <fieldset class="fieldset">
<label class="label">Auths</label> <label class="label">Auths</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`"> <label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" /> <input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" />
<span class="label-text">{{ a }}</span> <span>{{ a }}</span>
</label> </label>
</div> </div>
</div> </fieldset>
<p v-if="error" class="text-error text-sm">{{ error }}</p> <p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading"> <button type="submit" class="btn btn-primary" :disabled="loading">

View File

@@ -17,17 +17,17 @@
<div class="divider text-sm">or</div> <div class="divider text-sm">or</div>
</div> </div>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="user">User</label> <label class="label" for="user">User</label>
<input <input
id="user" id="user"
v-model="user" v-model="user"
type="text" type="text"
class="input input-bordered" class="input"
required required
autocomplete="username" autocomplete="username"
/> />
</div> </fieldset>
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"
label="Password" label="Password"

View File

@@ -1,43 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue',
'./lib/**/*.js',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
},
},
plugins: [require('daisyui')],
daisyui: {
themes: [
'light',
{
dark: {
'color-scheme': 'dark',
primary: '#6366f1',
'primary-content': '#ffffff',
secondary: '#8b5cf6',
'secondary-content': '#ffffff',
accent: '#06b6d4',
'accent-content': '#ffffff',
neutral: '#2a323c',
'neutral-focus': '#242b33',
'neutral-content': '#A6ADBB',
'base-100': '#1d232a',
'base-200': '#191e24',
'base-300': '#15191e',
'base-content': '#A6ADBB',
},
},
],
},
}

View File

@@ -1,3 +1,6 @@
{ {
"extends": "./.nuxt/tsconfig.json" "extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true
}
} }

View File

@@ -10,7 +10,7 @@ export function viteUriGuard(): Plugin {
name: 'vite-uri-guard', name: 'vite-uri-guard',
apply: 'serve', apply: 'serve',
configureServer(server) { configureServer(server) {
const guard = (req: any, res: any, next: () => void) => { const guard = (req: { url?: string; originalUrl?: string }, res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, next: () => void) => {
const raw = req.url ?? req.originalUrl ?? '' const raw = req.url ?? req.originalUrl ?? ''
try { try {
decodeURI(raw) decodeURI(raw)

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
globals: true,
},
resolve: {
alias: {
'~': resolve(__dirname, '.'),
'#imports': resolve(__dirname, './__mocks__/nuxt-imports.ts'),
},
},
})

21
go.mod
View File

@@ -1,19 +1,18 @@
module github.com/andyleap/hnh-map module github.com/andyleap/hnh-map
go 1.21 go 1.24.0
toolchain go1.24.13
require ( require (
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.2.5
go.etcd.io/bbolt v1.3.3 go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 golang.org/x/crypto v0.47.0
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 golang.org/x/image v0.30.0
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/oauth2 v0.34.0
) )
require ( require (
cloud.google.com/go v0.34.0 // indirect cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/golang/protobuf v1.2.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect
google.golang.org/appengine v1.4.0 // indirect
) )

52
go.sum
View File

@@ -1,28 +1,24 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,26 @@ import (
"go.etcd.io/bbolt" "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. // App is the main application (map server) state.
type App struct { type App struct {
gridStorage string gridStorage string
@@ -24,22 +44,29 @@ type App struct {
mergeUpdates Topic[Merge] mergeUpdates Topic[Merge]
} }
// GridStorage returns the grid storage path. // NewApp creates an App with the given storage paths and database.
func (a *App) GridStorage() string { func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
return a.gridStorage return &App{
gridStorage: gridStorage,
frontendRoot: frontendRoot,
db: db,
characters: make(map[string]Character),
}, nil
} }
// GridUpdates returns the tile updates topic for MapService. // GridStorage returns the path to the grid storage directory.
func (a *App) GridUpdates() *Topic[TileData] { func (a *App) GridStorage() string { return a.gridStorage }
return &a.gridUpdates
}
// MergeUpdates returns the merge updates topic for MapService. // GridUpdates returns the tile update pub/sub topic.
func (a *App) MergeUpdates() *Topic[Merge] { func (a *App) GridUpdates() *Topic[TileData] { return &a.gridUpdates }
return &a.mergeUpdates
}
// 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 { func (a *App) GetCharacters() []Character {
a.chmu.RLock() a.chmu.RLock()
defer a.chmu.RUnlock() defer a.chmu.RUnlock()
@@ -50,128 +77,30 @@ func (a *App) GetCharacters() []Character {
return chars return chars
} }
// NewApp creates an App with the given storage paths and database. // WithCharacters provides locked mutable access to the character map.
// frontendRoot is the directory for the map SPA (e.g. "frontend"). func (a *App) WithCharacters(fn func(chars map[string]Character)) {
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { a.chmu.Lock()
return &App{ defer a.chmu.Unlock()
gridStorage: gridStorage, fn(a.characters)
frontendRoot: frontendRoot,
db: db,
characters: make(map[string]Character),
}, nil
} }
type Session struct { // CleanChars runs a background loop that removes stale character entries.
ID string func (a *App) CleanChars() {
Username string for range time.Tick(CharCleanupInterval) {
Auths Auths `json:"-"` a.chmu.Lock()
TempAdmin bool for n, c := range a.characters {
} if c.Updated.Before(time.Now().Add(-CharStaleThreshold)) {
delete(a.characters, n)
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{ a.chmu.Unlock()
X: c.X / 2,
Y: c.Y / 2,
} }
} }
type Auths []string // serveSPARoot serves the map SPA: static files from frontend, fallback to index.html.
func (a *App) ServeSPARoot(rw http.ResponseWriter, req *http.Request) {
func (a Auths) Has(auth string) bool {
for _, v := range a {
if v == auth {
return true
}
}
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) {
path := req.URL.Path path := req.URL.Path
// Redirect old /map/* URLs to flat routes
if path == "/map" || path == "/map/" { if path == "/map" || path == "/map/" {
http.Redirect(rw, req, "/", http.StatusFound) http.Redirect(rw, req, "/", http.StatusFound)
return 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, "/") filePath := strings.TrimPrefix(path, "/")
if filePath == "" { if filePath == "" {
filePath = "index.html" filePath = "index.html"
@@ -210,14 +138,12 @@ func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
http.NotFound(rw, req) http.NotFound(rw, req)
return return
} }
// Try both root and map/ for backward compatibility with old builds
tryPaths := []string{filePath, filepath.Join("map", filePath)} tryPaths := []string{filePath, filepath.Join("map", filePath)}
var f http.File var f http.File
for _, p := range tryPaths { for _, p := range tryPaths {
var err error var err error
f, err = http.Dir(a.frontendRoot).Open(p) f, err = http.Dir(a.frontendRoot).Open(p)
if err == nil { if err == nil {
filePath = p
break break
} }
} }
@@ -234,28 +160,130 @@ func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f) http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f)
} }
// CleanChars runs a background loop that removes stale character entries. Call once as a goroutine. // --- Domain types ---
func (a *App) CleanChars() {
for range time.Tick(time.Second * 10) { // Session represents an authenticated user session.
a.chmu.Lock() type Session struct {
for n, c := range a.characters { ID string
if c.updated.Before(time.Now().Add(-10 * time.Second)) { Username string
delete(a.characters, n) 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--
} }
a.chmu.Unlock() return Coord{
X: c.X / 2,
Y: c.Y / 2,
} }
} }
// RegisterRoutes registers all HTTP handlers for the app. // Auths is a list of permission strings (e.g. "admin", "map", "upload").
func (a *App) RegisterRoutes() { type Auths []string
http.HandleFunc("/client/", a.client)
http.HandleFunc("/logout", a.redirectLogout)
http.HandleFunc("/map/api/", a.apiRouter) // Has returns true if the list contains the given permission.
http.HandleFunc("/map/updates", a.watchGridUpdates) func (a Auths) Has(auth string) bool {
http.HandleFunc("/map/grids/", a.gridTile) for _, v := range a {
if v == auth {
// SPA catch-all: must be last return true
http.HandleFunc("/", a.serveSPARoot) }
}
return false
}
// User represents a stored user account.
type User struct {
Pass []byte
Auths Auths
Tokens []string
OAuthLinks map[string]string `json:"oauth_links,omitempty"`
}
// Page holds page metadata for rendering.
type Page struct {
Title string `json:"title"`
}
// Config holds the application config sent to the frontend.
type Config struct {
Title string `json:"title"`
Auths []string `json:"auths"`
}
// TileData represents a tile image entry in the database.
type TileData struct {
MapID int
Coord Coord
Zoom int
File string
Cache int64
} }

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,458 +1,11 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
) )
type loginRequest struct { // APIRouter routes /map/api/* requests to the appropriate handler.
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.
func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/map/api") path := strings.TrimPrefix(req.URL.Path, "/map/api")
path = strings.TrimPrefix(path, "/") path = strings.TrimPrefix(path, "/")
@@ -470,22 +23,29 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
case "maps": case "maps":
h.APIGetMaps(rw, req) h.APIGetMaps(rw, req)
return return
} case "setup":
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" { h.APISetup(rw, req)
switch path { return
case "admin/wipeTile": case "login":
h.App.WipeTile(rw, req) h.APILogin(rw, req)
case "admin/setCoords": return
h.App.SetCoords(rw, req) case "logout":
case "admin/hideMarker": h.APILogout(rw, req)
h.App.HideMarker(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 return
} }
switch { switch {
case path == "oauth/providers": case path == "oauth/providers":
h.App.APIOAuthProviders(rw, req) h.APIOAuthProviders(rw, req)
return return
case strings.HasPrefix(path, "oauth/"): case strings.HasPrefix(path, "oauth/"):
rest := strings.TrimPrefix(path, "oauth/") rest := strings.TrimPrefix(path, "oauth/")
@@ -498,85 +58,16 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
action := parts[1] action := parts[1]
switch action { switch action {
case "login": case "login":
h.App.OAuthLogin(rw, req, provider) h.APIOAuthLogin(rw, req, provider)
case "callback": case "callback":
h.App.OAuthCallback(rw, req, provider) h.APIOAuthCallback(rw, req, provider)
default: default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
} }
return return
case path == "setup": case strings.HasPrefix(path, "admin/"):
h.APISetup(rw, req) adminPath := strings.TrimPrefix(path, "admin/")
return h.APIAdminRoute(rw, req, adminPath)
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)
return return
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package store package store
import ( import (
"context"
"strconv" "strconv"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
@@ -16,19 +17,29 @@ func New(db *bbolt.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
// View runs fn in a read-only transaction. // View runs fn in a read-only transaction. Checks context before starting.
func (s *Store) View(fn func(tx *bbolt.Tx) error) error { 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) return s.db.View(fn)
}
} }
// Update runs fn in a read-write transaction. // Update runs fn in a read-write transaction. Checks context before starting.
func (s *Store) Update(fn func(tx *bbolt.Tx) error) error { 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) return s.db.Update(fn)
}
} }
// --- Users --- // --- 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 { func (s *Store) GetUser(tx *bbolt.Tx, username string) []byte {
b := tx.Bucket(BucketUsers) b := tx.Bucket(BucketUsers)
if b == nil { if b == nil {
@@ -37,7 +48,7 @@ func (s *Store) GetUser(tx *bbolt.Tx, username string) []byte {
return b.Get([]byte(username)) 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 { func (s *Store) PutUser(tx *bbolt.Tx, username string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketUsers) b, err := tx.CreateBucketIfNotExists(BucketUsers)
if err != nil { 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) 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 { func (s *Store) DeleteUser(tx *bbolt.Tx, username string) error {
b := tx.Bucket(BucketUsers) b := tx.Bucket(BucketUsers)
if b == nil { if b == nil {
@@ -55,7 +66,7 @@ func (s *Store) DeleteUser(tx *bbolt.Tx, username string) error {
return b.Delete([]byte(username)) 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 { func (s *Store) ForEachUser(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketUsers) b := tx.Bucket(BucketUsers)
if b == nil { 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) 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 { func (s *Store) UserCount(tx *bbolt.Tx) int {
b := tx.Bucket(BucketUsers) b := tx.Bucket(BucketUsers)
if b == nil { if b == nil {
@@ -75,7 +86,7 @@ func (s *Store) UserCount(tx *bbolt.Tx) int {
// --- Sessions --- // --- 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 { func (s *Store) GetSession(tx *bbolt.Tx, id string) []byte {
b := tx.Bucket(BucketSessions) b := tx.Bucket(BucketSessions)
if b == nil { if b == nil {
@@ -84,7 +95,7 @@ func (s *Store) GetSession(tx *bbolt.Tx, id string) []byte {
return b.Get([]byte(id)) 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 { func (s *Store) PutSession(tx *bbolt.Tx, id string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketSessions) b, err := tx.CreateBucketIfNotExists(BucketSessions)
if err != nil { 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) 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 { func (s *Store) DeleteSession(tx *bbolt.Tx, id string) error {
b := tx.Bucket(BucketSessions) b := tx.Bucket(BucketSessions)
if b == nil { if b == nil {
@@ -104,7 +115,7 @@ func (s *Store) DeleteSession(tx *bbolt.Tx, id string) error {
// --- Tokens --- // --- 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 { func (s *Store) GetTokenUser(tx *bbolt.Tx, token string) []byte {
b := tx.Bucket(BucketTokens) b := tx.Bucket(BucketTokens)
if b == nil { if b == nil {
@@ -113,7 +124,7 @@ func (s *Store) GetTokenUser(tx *bbolt.Tx, token string) []byte {
return b.Get([]byte(token)) 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 { func (s *Store) PutToken(tx *bbolt.Tx, token, username string) error {
b, err := tx.CreateBucketIfNotExists(BucketTokens) b, err := tx.CreateBucketIfNotExists(BucketTokens)
if err != nil { if err != nil {
@@ -133,7 +144,7 @@ func (s *Store) DeleteToken(tx *bbolt.Tx, token string) error {
// --- Config --- // --- 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 { func (s *Store) GetConfig(tx *bbolt.Tx, key string) []byte {
b := tx.Bucket(BucketConfig) b := tx.Bucket(BucketConfig)
if b == nil { if b == nil {
@@ -142,7 +153,7 @@ func (s *Store) GetConfig(tx *bbolt.Tx, key string) []byte {
return b.Get([]byte(key)) 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 { func (s *Store) PutConfig(tx *bbolt.Tx, key string, value []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketConfig) b, err := tx.CreateBucketIfNotExists(BucketConfig)
if err != nil { if err != nil {
@@ -162,7 +173,7 @@ func (s *Store) DeleteConfig(tx *bbolt.Tx, key string) error {
// --- Maps --- // --- 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 { func (s *Store) GetMap(tx *bbolt.Tx, id int) []byte {
b := tx.Bucket(BucketMaps) b := tx.Bucket(BucketMaps)
if b == nil { if b == nil {
@@ -171,7 +182,7 @@ func (s *Store) GetMap(tx *bbolt.Tx, id int) []byte {
return b.Get([]byte(strconv.Itoa(id))) 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 { func (s *Store) PutMap(tx *bbolt.Tx, id int, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketMaps) b, err := tx.CreateBucketIfNotExists(BucketMaps)
if err != nil { 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) 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 { func (s *Store) DeleteMap(tx *bbolt.Tx, id int) error {
b := tx.Bucket(BucketMaps) b := tx.Bucket(BucketMaps)
if b == nil { if b == nil {
@@ -189,7 +200,7 @@ func (s *Store) DeleteMap(tx *bbolt.Tx, id int) error {
return b.Delete([]byte(strconv.Itoa(id))) 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) { func (s *Store) MapsNextSequence(tx *bbolt.Tx) (uint64, error) {
b, err := tx.CreateBucketIfNotExists(BucketMaps) b, err := tx.CreateBucketIfNotExists(BucketMaps)
if err != nil { if err != nil {
@@ -198,7 +209,7 @@ func (s *Store) MapsNextSequence(tx *bbolt.Tx) (uint64, error) {
return b.NextSequence() 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 { func (s *Store) MapsSetSequence(tx *bbolt.Tx, v uint64) error {
b := tx.Bucket(BucketMaps) b := tx.Bucket(BucketMaps)
if b == nil { if b == nil {
@@ -207,7 +218,7 @@ func (s *Store) MapsSetSequence(tx *bbolt.Tx, v uint64) error {
return b.SetSequence(v) 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 { func (s *Store) ForEachMap(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketMaps) b := tx.Bucket(BucketMaps)
if b == nil { if b == nil {
@@ -218,7 +229,7 @@ func (s *Store) ForEachMap(tx *bbolt.Tx, fn func(k, v []byte) error) error {
// --- Grids --- // --- 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 { func (s *Store) GetGrid(tx *bbolt.Tx, id string) []byte {
b := tx.Bucket(BucketGrids) b := tx.Bucket(BucketGrids)
if b == nil { if b == nil {
@@ -227,7 +238,7 @@ func (s *Store) GetGrid(tx *bbolt.Tx, id string) []byte {
return b.Get([]byte(id)) 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 { func (s *Store) PutGrid(tx *bbolt.Tx, id string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketGrids) b, err := tx.CreateBucketIfNotExists(BucketGrids)
if err != nil { 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) 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 { func (s *Store) DeleteGrid(tx *bbolt.Tx, id string) error {
b := tx.Bucket(BucketGrids) b := tx.Bucket(BucketGrids)
if b == nil { if b == nil {
@@ -245,7 +256,7 @@ func (s *Store) DeleteGrid(tx *bbolt.Tx, id string) error {
return b.Delete([]byte(id)) 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 { func (s *Store) ForEachGrid(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketGrids) b := tx.Bucket(BucketGrids)
if b == nil { 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) --- // --- 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 { func (s *Store) GetTile(tx *bbolt.Tx, mapID, zoom int, coordKey string) []byte {
tiles := tx.Bucket(BucketTiles) tiles := tx.Bucket(BucketTiles)
if tiles == nil { 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)) 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 { func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []byte) error {
tiles, err := tx.CreateBucketIfNotExists(BucketTiles) tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
if err != nil { 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) 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 { func (s *Store) DeleteTilesBucket(tx *bbolt.Tx) error {
if tx.Bucket(BucketTiles) == nil { if tx.Bucket(BucketTiles) == nil {
return nil return nil
@@ -298,7 +309,7 @@ func (s *Store) DeleteTilesBucket(tx *bbolt.Tx) error {
return tx.DeleteBucket(BucketTiles) 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 { func (s *Store) ForEachTile(tx *bbolt.Tx, fn func(mapK, zoomK, coordK, v []byte) error) error {
tiles := tx.Bucket(BucketTiles) tiles := tx.Bucket(BucketTiles)
if tiles == nil { 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 { func (s *Store) GetTilesMapBucket(tx *bbolt.Tx, mapID int) *bbolt.Bucket {
tiles := tx.Bucket(BucketTiles) tiles := tx.Bucket(BucketTiles)
if tiles == nil { 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))) 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) { func (s *Store) CreateTilesMapBucket(tx *bbolt.Tx, mapID int) (*bbolt.Bucket, error) {
tiles, err := tx.CreateBucketIfNotExists(BucketTiles) tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
if err != nil { 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))) 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 { func (s *Store) DeleteTilesMapBucket(tx *bbolt.Tx, mapID int) error {
tiles := tx.Bucket(BucketTiles) tiles := tx.Bucket(BucketTiles)
if tiles == nil { if tiles == nil {
return 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) --- // --- 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 { func (s *Store) GetMarkersGridBucket(tx *bbolt.Tx) *bbolt.Bucket {
mb := tx.Bucket(BucketMarkers) mb := tx.Bucket(BucketMarkers)
if mb == nil { if mb == nil {
@@ -359,7 +374,7 @@ func (s *Store) GetMarkersGridBucket(tx *bbolt.Tx) *bbolt.Bucket {
return mb.Bucket(BucketMarkersGrid) 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 { func (s *Store) GetMarkersIDBucket(tx *bbolt.Tx) *bbolt.Bucket {
mb := tx.Bucket(BucketMarkers) mb := tx.Bucket(BucketMarkers)
if mb == nil { if mb == nil {
@@ -368,7 +383,7 @@ func (s *Store) GetMarkersIDBucket(tx *bbolt.Tx) *bbolt.Bucket {
return mb.Bucket(BucketMarkersID) 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) { func (s *Store) CreateMarkersBuckets(tx *bbolt.Tx) (*bbolt.Bucket, *bbolt.Bucket, error) {
mb, err := tx.CreateBucketIfNotExists(BucketMarkers) mb, err := tx.CreateBucketIfNotExists(BucketMarkers)
if err != nil { if err != nil {
@@ -385,7 +400,7 @@ func (s *Store) CreateMarkersBuckets(tx *bbolt.Tx) (*bbolt.Bucket, *bbolt.Bucket
return grid, idB, nil 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) { func (s *Store) MarkersNextSequence(tx *bbolt.Tx) (uint64, error) {
mb := tx.Bucket(BucketMarkers) mb := tx.Bucket(BucketMarkers)
if mb == nil { if mb == nil {
@@ -400,7 +415,7 @@ func (s *Store) MarkersNextSequence(tx *bbolt.Tx) (uint64, error) {
// --- OAuth states --- // --- 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 { func (s *Store) GetOAuthState(tx *bbolt.Tx, state string) []byte {
b := tx.Bucket(BucketOAuthStates) b := tx.Bucket(BucketOAuthStates)
if b == nil { if b == nil {
@@ -409,7 +424,7 @@ func (s *Store) GetOAuthState(tx *bbolt.Tx, state string) []byte {
return b.Get([]byte(state)) 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 { func (s *Store) PutOAuthState(tx *bbolt.Tx, state string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketOAuthStates) b, err := tx.CreateBucketIfNotExists(BucketOAuthStates)
if err != nil { 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) 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 { func (s *Store) DeleteOAuthState(tx *bbolt.Tx, state string) error {
b := tx.Bucket(BucketOAuthStates) b := tx.Bucket(BucketOAuthStates)
if b == nil { if b == nil {
@@ -429,16 +444,15 @@ func (s *Store) DeleteOAuthState(tx *bbolt.Tx, state string) error {
// --- Bucket existence (for wipe) --- // --- 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 { func (s *Store) BucketExists(tx *bbolt.Tx, name []byte) bool {
return tx.Bucket(name) != nil 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 { func (s *Store) DeleteBucket(tx *bbolt.Tx, name []byte) error {
if tx.Bucket(name) == nil { if tx.Bucket(name) == nil {
return nil return nil
} }
return tx.DeleteBucket(name) return tx.DeleteBucket(name)
} }

View File

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

View File

@@ -1,270 +0,0 @@
package app
import (
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
var transparentPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
type TileData struct {
MapID int
Coord Coord
Zoom int
File string
Cache int64
}
func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
a.db.View(func(tx *bbolt.Tx) error {
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
mapb := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapb == nil {
return nil
}
zoom := mapb.Bucket([]byte(strconv.Itoa(z)))
if zoom == nil {
return nil
}
tileraw := zoom.Get([]byte(c.Name()))
if tileraw == nil {
return nil
}
json.Unmarshal(tileraw, &td)
return nil
})
return
}
func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
a.db.Update(func(tx *bbolt.Tx) error {
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapid)))
if err != nil {
return err
}
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(z)))
if err != nil {
return err
}
td := &TileData{
MapID: mapid,
Coord: c,
Zoom: z,
File: f,
Cache: t,
}
raw, err := json.Marshal(td)
if err != nil {
return err
}
a.gridUpdates.Send(td)
return zoom.Put([]byte(c.Name()), raw)
})
return
}
func (a *App) reportMerge(from, to int, shift Coord) {
a.mergeUpdates.Send(&Merge{
From: from,
To: to,
Shift: shift,
})
}
type TileCache struct {
M, X, Y, Z, T int
}
func (a *App) watchGridUpdates(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Access-Control-Allow-Origin", "*")
rw.Header().Set("X-Accel-Buffering", "no")
flusher, ok := rw.(http.Flusher)
if !ok {
http.Error(rw, "Streaming unsupported!", http.StatusInternalServerError)
return
}
c := make(chan *TileData, 1000)
mc := make(chan *Merge, 5)
a.gridUpdates.Watch(c)
a.mergeUpdates.Watch(mc)
tileCache := make([]TileCache, 0, 100)
a.db.View(func(tx *bbolt.Tx) error {
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
return tiles.ForEach(func(mk, mv []byte) error {
mapb := tiles.Bucket(mk)
if mapb == nil {
return nil
}
return mapb.ForEach(func(k, v []byte) error {
zoom := mapb.Bucket(k)
if zoom == nil {
return nil
}
return zoom.ForEach(func(tk, tv []byte) error {
td := TileData{}
json.Unmarshal(tv, &td)
tileCache = append(tileCache, TileCache{
M: td.MapID,
X: td.Coord.X,
Y: td.Coord.Y,
Z: td.Zoom,
T: int(td.Cache),
})
return nil
})
})
})
})
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0]
flusher.Flush()
ticker := time.NewTicker(5 * time.Second)
for {
select {
case e, ok := <-c:
if !ok {
return
}
found := false
for i := range tileCache {
if tileCache[i].M == e.MapID && tileCache[i].X == e.Coord.X && tileCache[i].Y == e.Coord.Y && tileCache[i].Z == e.Zoom {
tileCache[i].T = int(e.Cache)
found = true
}
}
if !found {
tileCache = append(tileCache, TileCache{
M: e.MapID,
X: e.Coord.X,
Y: e.Coord.Y,
Z: e.Zoom,
T: int(e.Cache),
})
}
case e, ok := <-mc:
log.Println(e, ok)
if !ok {
return
}
raw, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
log.Println(string(raw))
fmt.Fprint(rw, "event: merge\n")
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
flusher.Flush()
case <-ticker.C:
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
rw.Write(raw)
fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0]
flusher.Flush()
}
}
}
var tileRegex = regexp.MustCompile("([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+).png")
func (a *App) gridTile(rw http.ResponseWriter, req *http.Request) {
s := a.getSession(req)
if !a.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
tile := tileRegex.FindStringSubmatch(req.URL.Path)
if tile == nil || len(tile) < 5 {
http.Error(rw, "invalid path", http.StatusBadRequest)
return
}
mapid, err := strconv.Atoi(tile[1])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
z, err := strconv.Atoi(tile[2])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
x, err := strconv.Atoi(tile[3])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
y, err := strconv.Atoi(tile[4])
if err != nil {
http.Error(rw, "request parsing error", http.StatusInternalServerError)
return
}
// Map frontend Leaflet zoom (1…6) to storage level (0…5): z=6 → 0 (max detail), z=1..5 → same
storageZ := z
if storageZ == 6 {
storageZ = 0
}
if storageZ < 0 || storageZ > 5 {
storageZ = 0
}
td := a.GetTile(mapid, Coord{X: x, Y: y}, storageZ)
if td == nil {
rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private, max-age=3600")
rw.WriteHeader(http.StatusOK)
rw.Write(transparentPNG)
return
}
rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private immutable")
http.ServeFile(rw, req, filepath.Join(a.gridStorage, td.File))
}

View File

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