Add initial project structure with backend and frontend setup
- Created backend structure with Go, including main application logic and API endpoints. - Added Docker support for both development and production environments. - Introduced frontend using Nuxt 3 with Tailwind CSS for styling. - Included configuration files for Docker and environment variables. - Established basic documentation for contributing, development, and deployment processes. - Set up .gitignore and .dockerignore files to manage ignored files in the repository.
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.git/
|
||||||
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Git and IDE
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.cursor
|
||||||
|
.cursorignore
|
||||||
|
*.md
|
||||||
|
*.plan.md
|
||||||
|
|
||||||
|
# Old Vue 2 frontend (not used in build)
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
|
||||||
|
# Nuxt (built in frontendbuilder stage)
|
||||||
|
frontend-nuxt/node_modules
|
||||||
|
frontend-nuxt/.nuxt
|
||||||
|
frontend-nuxt/.output
|
||||||
|
|
||||||
|
# Runtime data (mounted at run time, not needed for build)
|
||||||
|
grids
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Backend (Go)
|
||||||
|
# HNHMAP_PORT=8080
|
||||||
|
# HNHMAP_BOOTSTRAP_PASSWORD= # Set once for first run: login as admin with this password to create the first admin user (then unset or leave empty)
|
||||||
|
# Grids directory (default: grids); in Docker often /map
|
||||||
|
# HNHMAP_GRIDS=grids
|
||||||
|
|
||||||
|
# Frontend (Nuxt dev) — used in docker-compose
|
||||||
|
# NUXT_PUBLIC_API_BASE=/map/api
|
||||||
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Go build
|
||||||
|
/hnh-map
|
||||||
|
/hnh-map.exe
|
||||||
|
|
||||||
|
# Data / runtime
|
||||||
|
grids/
|
||||||
|
|
||||||
|
# Nuxt / Node (frontend-nuxt)
|
||||||
|
frontend-nuxt/node_modules
|
||||||
|
frontend-nuxt/.nuxt
|
||||||
|
frontend-nuxt/.output
|
||||||
|
|
||||||
|
# Old Vue 2 frontend (if present)
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
|
||||||
|
# Env and logs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS / IDE
|
||||||
|
.DS_Store
|
||||||
|
.cursor/
|
||||||
23
CONTRIBUTING.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Contributing to hnh-map
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
|
- **Backend:** Entry point is `cmd/hnh-map/main.go`. All application logic lives in `internal/app/` (package `app`): `app.go` (App, types, routes, session helpers), `api.go`, `admin.go`, `client.go`, `map.go`, `tile.go`, `topic.go`, `manage.go`, `migrations.go`. The `webapp/` package in the repo root handles HTML template loading and execution.
|
||||||
|
- **Frontend:** Nuxt 3 app in `frontend-nuxt/` (pages, components, composables, layouts, server, plugins, `public/gfx`). It is served by the Go backend under `/map/` with baseURL `/map/`.
|
||||||
|
|
||||||
|
## Formatting and tests
|
||||||
|
|
||||||
|
- Go: run `go fmt ./...` before committing.
|
||||||
|
- Add or update tests if you change behaviour; run `go test ./...` if tests exist.
|
||||||
|
|
||||||
|
## Submitting changes
|
||||||
|
|
||||||
|
- Open a pull request with a clear description of the change.
|
||||||
|
- Mention compatibility with [hnh-auto-mapper-server](https://github.com/APXEOLOG/hnh-auto-mapper-server) if relevant (e.g. client protocol or API).
|
||||||
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM golang:1.21-alpine AS gobuilder
|
||||||
|
|
||||||
|
RUN mkdir /hnh-map
|
||||||
|
WORKDIR /hnh-map
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN go build -o hnh-map ./cmd/hnh-map
|
||||||
|
|
||||||
|
FROM node:20-alpine AS frontendbuilder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend-nuxt/package.json frontend-nuxt/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend-nuxt/ ./
|
||||||
|
RUN npm run generate
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
|
||||||
|
RUN mkdir /hnh-map
|
||||||
|
WORKDIR /hnh-map
|
||||||
|
|
||||||
|
COPY --from=gobuilder /hnh-map/hnh-map ./
|
||||||
|
COPY --from=frontendbuilder /app/.output/public ./frontend
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["/hnh-map/hnh-map", "-grids=/map"]
|
||||||
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# hnh-map
|
||||||
|
|
||||||
|
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
|
||||||
|
(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).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Architecture](docs/architecture.md) — high-level design and backend layout (`cmd/hnh-map`, `internal/app`, `webapp`)
|
||||||
|
- [API](docs/api.md) — HTTP API (auth, cabinet, map data, admin)
|
||||||
|
- [Configuration](docs/configuration.md) — environment variables and flags
|
||||||
|
- [Development](docs/development.md) — local run, Docker Compose dev, build
|
||||||
|
- [Deployment](docs/deployment.md) — Docker, reverse proxy, backups
|
||||||
|
|
||||||
|
See also [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow.
|
||||||
|
|
||||||
|
Only other thing you need to do is setup users and set your zero grid.
|
||||||
|
|
||||||
|
First login: username **admin**, password from `HNHMAP_BOOTSTRAP_PASSWORD` (in dev Compose it defaults to `admin`). Go to the admin portal and hit "ADD USER". Don't forget to toggle on all the roles (you'll need admin, at least)
|
||||||
|
|
||||||
|
Once you create your first user, you'll get kicked out and have to log in as it.
|
||||||
|
The admin user will be gone at this point. Next you'll want to add users for anyone else, and then you'll need to create your tokens to upload stuff.
|
||||||
|
|
||||||
|
You'll probably want to set the prefix (this gets put at the front of the tokens, and should be something like `http://example.com`) to make it easier to configure clients.
|
||||||
|
|
||||||
|
The first client to connect will set the 0,0 grid, but you can wipe the data in the admin portal to reset (and the next client to connect should set a new 0,0 grid)
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
- **Map**: View the map
|
||||||
|
- **Upload**: Send character, marker, and tile data to the server
|
||||||
|
- **Admin**: Modify server settings, create and edit users, wipe data
|
||||||
65
cmd/hnh-map/main.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
gridStorage = flag.String("grids", "grids", "directory to store grids in")
|
||||||
|
port = flag.Int("port", func() int {
|
||||||
|
if port, ok := os.LookupEnv("HNHMAP_PORT"); ok {
|
||||||
|
p, err := strconv.Atoi(port)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return 8080
|
||||||
|
}(), "Port to listen on")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
db, err := bbolt.Open(*gridStorage+"/grids.db", 0600, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := app.RunMigrations(db); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workDir := "."
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
workDir = wd
|
||||||
|
}
|
||||||
|
frontendRoot := filepath.Join(workDir, "frontend")
|
||||||
|
|
||||||
|
a, err := app.NewApp(*gridStorage, frontendRoot, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go a.CleanChars()
|
||||||
|
|
||||||
|
a.RegisterRoutes()
|
||||||
|
|
||||||
|
// Static assets under /js/ (e.g. from public/)
|
||||||
|
publicDir := filepath.Join(workDir, "public")
|
||||||
|
http.Handle("/js/", http.FileServer(http.Dir(publicDir)))
|
||||||
|
|
||||||
|
log.Printf("Listening on port %d", *port)
|
||||||
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
|
||||||
|
}
|
||||||
30
docker-compose.dev.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Development: backend (Go) on 8080, frontend (Nuxt dev) on 3000 with proxy to backend.
|
||||||
|
# Open http://localhost:3000/map/ — /map/api, /map/updates, /map/grids are proxied to backend.
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3080:3080"
|
||||||
|
volumes:
|
||||||
|
- ./grids:/map
|
||||||
|
environment:
|
||||||
|
- HNHMAP_PORT=3080
|
||||||
|
- HNHMAP_BOOTSTRAP_PASSWORD=admin
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "npm ci && npm run dev"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./frontend-nuxt:/app
|
||||||
|
# Prevent overwriting node_modules from host
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NUXT_PUBLIC_API_BASE=/map/api
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
13
docker-compose.prod.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- hnhmap_grids:/map
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hnhmap_grids:
|
||||||
51
docs/api.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# HTTP API
|
||||||
|
|
||||||
|
API доступно по префиксу `/map/api/`. Для запросов, требующих авторизации, используется cookie `session` (устанавливается при логине).
|
||||||
|
|
||||||
|
## Авторизация
|
||||||
|
|
||||||
|
- **POST /map/api/login** — вход. Тело: `{"user":"...","pass":"..."}`. При успехе возвращается JSON с данными пользователя и выставляется cookie сессии. При первом запуске возможен bootstrap: логин `admin` и пароль из `HNHMAP_BOOTSTRAP_PASSWORD` создают первого админа.
|
||||||
|
- **GET /map/api/me** — текущий пользователь (по сессии). Ответ: `username`, `auths`, при необходимости `tokens`, `prefix`.
|
||||||
|
- **POST /map/api/logout** — выход (инвалидация сессии).
|
||||||
|
- **GET /map/api/setup** — проверка, нужна ли первичная настройка. Ответ: `{"setupRequired": true|false}`.
|
||||||
|
|
||||||
|
## Кабинет
|
||||||
|
|
||||||
|
- **POST /map/api/me/tokens** — сгенерировать новый токен загрузки (требуется право `upload`). Ответ: `{"tokens": ["...", ...]}`.
|
||||||
|
- **POST /map/api/me/password** — сменить пароль. Тело: `{"pass":"..."}`.
|
||||||
|
|
||||||
|
## Карта и данные
|
||||||
|
|
||||||
|
- **GET /map/api/config** — конфиг для клиента (title, auths). Требуется сессия.
|
||||||
|
- **GET /map/api/v1/characters** — список персонажей на карте (требуется право `map` и при необходимости `markers`).
|
||||||
|
- **GET /map/api/v1/markers** — маркеры (требуется право `map` и при необходимости `markers`).
|
||||||
|
- **GET /map/api/maps** — список карт (с учётом прав и скрытых карт).
|
||||||
|
|
||||||
|
## Админ (все эндпоинты ниже требуют право `admin`)
|
||||||
|
|
||||||
|
- **GET /map/api/admin/users** — список имён пользователей.
|
||||||
|
- **POST /map/api/admin/users** — создать/обновить пользователя. Тело: `{"user":"...","pass":"...","auths":["admin","map",...]}`.
|
||||||
|
- **GET /map/api/admin/users/:name** — данные пользователя.
|
||||||
|
- **DELETE /map/api/admin/users/:name** — удалить пользователя.
|
||||||
|
- **GET /map/api/admin/settings** — настройки (prefix, defaultHide, title).
|
||||||
|
- **POST /map/api/admin/settings** — сохранить настройки. Тело: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (поля опциональны).
|
||||||
|
- **GET /map/api/admin/maps** — список карт для админки.
|
||||||
|
- **POST /map/api/admin/maps/:id** — обновить карту (name, hidden, priority).
|
||||||
|
- **POST /map/api/admin/maps/:id/toggle-hidden** — переключить скрытие карты.
|
||||||
|
- **POST /map/api/admin/wipe** — очистить гриды, маркеры, тайлы, карты в БД.
|
||||||
|
- **POST /map/api/admin/rebuildZooms** — пересобрать зум-уровни тайлов.
|
||||||
|
- **GET /map/api/admin/export** — скачать экспорт данных (ZIP).
|
||||||
|
- **POST /map/api/admin/merge** — загрузить и применить merge (ZIP с гридами и маркерами).
|
||||||
|
|
||||||
|
Дополнительные админ-действия (формы или внутренние вызовы): wipeTile, setCoords, hideMarker — см. реализацию в `internal/app/api.go` и `admin.go`.
|
||||||
|
|
||||||
|
## Коды ответов
|
||||||
|
|
||||||
|
- **200** — успех.
|
||||||
|
- **400** — неверный запрос (метод, тело, параметры).
|
||||||
|
- **401** — не авторизован (нет или недействительная сессия).
|
||||||
|
- **403** — нет прав.
|
||||||
|
- **404** — не найдено.
|
||||||
|
- **500** — внутренняя ошибка.
|
||||||
|
|
||||||
|
Формат ошибок — текст в теле ответа или стандартные HTTP-статусы без тела.
|
||||||
40
docs/architecture.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Архитектура hnh-map
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
hnh-map — сервер автомаппера для HnH: Go-бэкенд с хранилищем bbolt, сессиями, HTML-шаблонами и Nuxt 3 SPA по пути `/map/`. Данные гридов и тайлов хранятся в каталоге `grids/` и в БД.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐
|
||||||
|
│ Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │
|
||||||
|
│ (Nuxt SPA │ /map/, /map/api │ • bbolt (users, sessions, grids, │
|
||||||
|
│ по /map/) │ /map/updates │ markers, tiles, maps, config) │
|
||||||
|
│ │ /map/grids/ │ • Шаблоны (embed в webapp) │
|
||||||
|
└─────────────┘ │ • Статика фронта (frontend/) │
|
||||||
|
│ • internal/app — вся логика │
|
||||||
|
│ • webapp — рендер шаблонов │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура бэкенда
|
||||||
|
|
||||||
|
- **cmd/hnh-map/main.go** — единственная точка входа (`package main`): парсинг флагов (`-grids`, `-port`) и переменных окружения (`HNHMAP_PORT`), открытие bbolt, запуск миграций, создание `App`, регистрация маршрутов, запуск HTTP-сервера. Пути к `frontend/` и `public/` задаются из рабочей директории при старте; шаблоны встроены в бинарник (webapp, `//go:embed`).
|
||||||
|
|
||||||
|
- **internal/app/** — пакет `app` с типом `App` и всей логикой:
|
||||||
|
- **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), хелперы сессий и страниц (`getSession`, `getUser`, `serveMapFrontend`, `CleanChars`).
|
||||||
|
- **api.go** — HTTP API: авторизация (login, me, logout, setup), кабинет (tokens, password), админ (users, settings, maps, wipe, rebuildZooms, export, merge), редиректы и роутер `/map/api/...`.
|
||||||
|
- **admin.go** — админка: HTML-страницы и действия (wipe, setPrefix, setDefaultHide, setTitle, rebuildZooms, export, merge, adminMap, wipeTile, setCoords, hideMarker и т.д.).
|
||||||
|
- **client.go** — хендлеры клиента маппера (`/client/{token}/...`): locate, gridUpdate, gridUpload, positionUpdate, markerUpdate, updateZoomLevel.
|
||||||
|
- **map.go** — доступ к карте: `canAccessMap`, `getChars`, `getMarkers`, `getMaps`, `config`.
|
||||||
|
- **tile.go** — тайлы и гриды: `GetTile`, `SaveTile`, `watchGridUpdates` (SSE), `gridTile`, `reportMerge`.
|
||||||
|
- **topic.go** — типы `topic` и `mergeTopic` для рассылки обновлений тайлов и слияний карт.
|
||||||
|
- **manage.go** — страницы управления: index, login, logout, generateToken, changePassword.
|
||||||
|
- **migrations.go** — миграции bbolt; из main вызывается `app.RunMigrations(db)`.
|
||||||
|
|
||||||
|
- **webapp/** — отдельный пакет в корне репозитория: загрузка и выполнение HTML-шаблонов. Импортируется из `cmd/hnh-map` и из `internal/app`.
|
||||||
|
|
||||||
|
Сборка из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o hnh-map ./cmd/hnh-map
|
||||||
|
```
|
||||||
27
docs/configuration.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Настройка
|
||||||
|
|
||||||
|
## Переменные окружения и флаги
|
||||||
|
|
||||||
|
| Переменная / флаг | Описание | По умолчанию |
|
||||||
|
|-------------------|----------|--------------|
|
||||||
|
| `HNHMAP_PORT` | Порт HTTP-сервера | 8080 |
|
||||||
|
| `-port` | То же (флаг командной строки) | значение `HNHMAP_PORT` или 8080 |
|
||||||
|
| `HNHMAP_BOOTSTRAP_PASSWORD` | Пароль для первой настройки: при отсутствии пользователей вход как `admin` с этим паролем создаёт первого админа | — |
|
||||||
|
| `-grids` | Каталог гридов (флаг командной строки; в Docker обычно `-grids=/map`) | `grids` |
|
||||||
|
|
||||||
|
Пример для первого запуска:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HNHMAP_BOOTSTRAP_PASSWORD=your-secure-password
|
||||||
|
./hnh-map -grids=./grids -port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
В Docker часто монтируют том в `/map` и запускают с `-grids=/map`.
|
||||||
|
|
||||||
|
Для фронта (Nuxt) в режиме разработки:
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|------------|----------|
|
||||||
|
| `NUXT_PUBLIC_API_BASE` | Базовый путь к API (например `/map/api` при прокси к бэкенду) |
|
||||||
|
|
||||||
|
См. также [.env.example](../.env.example) в корне репозитория.
|
||||||
31
docs/deployment.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Деплой
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Образ собирается из репозитория. Внутри контейнера приложение слушает порт **8080** и ожидает, что каталог данных смонтирован в `/map` (база и изображения гридов).
|
||||||
|
|
||||||
|
Пример запуска:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v /srv/hnh-map:/map -p 80:8080 andyleap/hnh-auto-mapper:v-4
|
||||||
|
```
|
||||||
|
|
||||||
|
Или с переменными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v /srv/hnh-map:/map -p 8080:8080 \
|
||||||
|
-e HNHMAP_PORT=8080 \
|
||||||
|
-e HNHMAP_BOOTSTRAP_PASSWORD=your-secure-password \
|
||||||
|
andyleap/hnh-auto-mapper:v-4
|
||||||
|
```
|
||||||
|
|
||||||
|
Рекомендуется после первой настройки убрать или не передавать `HNHMAP_BOOTSTRAP_PASSWORD`.
|
||||||
|
|
||||||
|
## Reverse proxy
|
||||||
|
|
||||||
|
Разместите сервис за nginx, Traefik, Caddy и т.п. на нужном домене. Проксируйте весь трафик на порт 8080 контейнера (или тот порт, на котором слушает приложение). Отдельная настройка для `/map` не обязательна: приложение само отдаёт SPA и API по путям `/map/`, `/map/api/`, `/map/updates`, `/map/grids/`.
|
||||||
|
|
||||||
|
## Обновление и бэкапы
|
||||||
|
|
||||||
|
- При обновлении образа сохраняйте volume с `/map`: в нём лежат `grids.db` и каталоги с тайлами.
|
||||||
|
- Регулярно делайте бэкапы каталога данных (и при необходимости экспорт через админку «Export»).
|
||||||
50
docs/development.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Разработка
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
### Бэкенд (Go)
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o hnh-map ./cmd/hnh-map
|
||||||
|
./hnh-map -grids=./grids -port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Или без сборки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/hnh-map -grids=./grids -port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). HTML-шаблоны встроены в бинарник (пакет webapp).
|
||||||
|
|
||||||
|
### Фронтенд (Nuxt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend-nuxt
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
В dev-режиме приложение доступно по адресу с baseURL `/map/` (например `http://localhost:3000/map/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`).
|
||||||
|
|
||||||
|
### Docker Compose (разработка)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
- Фронт: порт **3000** (Nuxt dev-сервер).
|
||||||
|
- Бэкенд: порт **3080** (чтобы не конфликтовать с другими сервисами на 8080).
|
||||||
|
|
||||||
|
Откройте http://localhost:3000/map/. Запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд (host `backend`, порт 3080).
|
||||||
|
|
||||||
|
### Сборка образа и prod-композ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t hnh-map .
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
В prod фронт собран в образ и отдаётся бэкендом из каталога `frontend/`; порт 8080.
|
||||||
41
frontend-nuxt/.cursorrules
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
You have extensive expertise in Vue 3, Nuxt 3, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, Nuxt UI, and Tailwind CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
|
||||||
|
|
||||||
|
Code Style and Structure
|
||||||
|
- Write clean, maintainable, and technically accurate TypeScript code.
|
||||||
|
- Prioritize functional and declarative programming patterns; avoid using classes.
|
||||||
|
- Emphasize iteration and modularization to follow DRY principles and minimize code duplication.
|
||||||
|
- Prefer Composition API <script setup> style.
|
||||||
|
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
|
||||||
|
|
||||||
|
Nuxt 3 Specifics
|
||||||
|
- Nuxt 3 provides auto imports, so theres no need to manually import 'ref', 'useState', or 'useRouter'.
|
||||||
|
- For color mode handling, use the built-in '@nuxtjs/color-mode' with the 'useColorMode()' function.
|
||||||
|
- Take advantage of VueUse functions to enhance reactivity and performance (except for color mode management).
|
||||||
|
- Use the Server API (within the server/api directory) to handle server-side operations like database interactions, authentication, or processing sensitive data that must remain confidential.
|
||||||
|
- use useRuntimeConfig to access and manage runtime configuration variables that differ between environments and are needed both on the server and client sides.
|
||||||
|
- For SEO use useHead and useSeoMeta.
|
||||||
|
- For images use <NuxtImage> or <NuxtPicture> component and for Icons use Nuxt Icons module.
|
||||||
|
- use app.config.ts for app theme configuration.
|
||||||
|
|
||||||
|
Fetching Data
|
||||||
|
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
|
||||||
|
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
|
||||||
|
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
|
||||||
|
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
|
||||||
|
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
|
||||||
|
|
||||||
|
Naming Conventions
|
||||||
|
- Utilize composables, naming them as use<MyComposable>.
|
||||||
|
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
|
||||||
|
- Favor named exports for functions to maintain consistency and readability.
|
||||||
|
|
||||||
|
TypeScript Usage
|
||||||
|
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
|
||||||
|
- Avoid enums, opting for maps for improved type safety and flexibility.
|
||||||
|
- Use functional components with TypeScript interfaces.
|
||||||
|
|
||||||
|
UI and Styling
|
||||||
|
- Use Nuxt UI and Tailwind CSS for components and styling.
|
||||||
|
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||||
|
|
||||||
1
frontend-nuxt/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
20
|
||||||
16
frontend-nuxt/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Use Node 20+ for build (required by Tailwind/PostCSS toolchain)
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run generate
|
||||||
|
|
||||||
|
# Output: .output/public is the static site root (for Go http.Dir("frontend"))
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
COPY --from=builder /app/.output/public /frontend
|
||||||
|
# Optional: when integrating with main Dockerfile, copy /frontend into the image
|
||||||
66
frontend-nuxt/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# HnH Map – Nuxt 3 frontend
|
||||||
|
|
||||||
|
Nuxt 3 + Tailwind + DaisyUI frontend for the HnH map. Served by the Go backend under `/map/`.
|
||||||
|
|
||||||
|
In dev mode the app is available at the path with baseURL **`/map/`** (e.g. `http://localhost:3000/map/`). The Go backend must be reachable (directly or via the dev proxy in `nuxt.config.ts`).
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- **pages/** — route pages (e.g. map view, profile, login)
|
||||||
|
- **components/** — Vue components
|
||||||
|
- **composables/** — shared composition functions
|
||||||
|
- **layouts/** — layout components
|
||||||
|
- **server/** — Nitro server (if used)
|
||||||
|
- **plugins/** — Nuxt plugins
|
||||||
|
- **public/gfx/** — static assets (sprites, terrain, etc.)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Node.js 20+** (required for build; `engines` in package.json). Use `nvm use` if you have `.nvmrc`, or build via Docker (see below).
|
||||||
|
- npm
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the app at the path shown (e.g. `http://localhost:3000/map/`). Ensure the Go backend is running and proxying or serving this app if needed.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Static export (for Go `http.Dir`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is in `.output/public`. To serve from the existing `frontend` directory, copy contents to `../frontend` after generate, or set `nitro.output.dir` in `nuxt.config.ts` and build from the repo root.
|
||||||
|
|
||||||
|
## Build with Docker (Node 20)
|
||||||
|
|
||||||
|
Build requires Node 20+. If your host has an older version (e.g. Node 18), build the frontend in Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t frontend-nuxt .
|
||||||
|
docker create --name fn frontend-nuxt
|
||||||
|
docker cp fn:/frontend ./output-public
|
||||||
|
docker rm fn
|
||||||
|
# Copy output-public/* into repo frontend/ and run Go server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cutover from Vue 2 frontend
|
||||||
|
|
||||||
|
1. Build this app (`npm run generate`).
|
||||||
|
2. Copy `.output/public/*` into the repo’s `frontend` directory (or point Go at the Nuxt output directory).
|
||||||
|
3. Restart the Go server. The same `/map/` routes and API remain.
|
||||||
14
frontend-nuxt/app.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Global error handling: on API auth failure, redirect to login
|
||||||
|
const { onApiError } = useMapApi()
|
||||||
|
const { fullUrl } = useAppPaths()
|
||||||
|
onApiError(() => {
|
||||||
|
if (import.meta.client) window.location.href = fullUrl('/login')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
9
frontend-nuxt/assets/css/app.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__nuxt {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
10
frontend-nuxt/assets/css/leaflet-overrides.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* Map container background from theme (DaisyUI base-200) */
|
||||||
|
.leaflet-container {
|
||||||
|
background: hsl(var(--b2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
|
||||||
|
(e.g. due to cache, Nuxt hydration, or load event order). */
|
||||||
|
.leaflet-tile {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
15
frontend-nuxt/components/MapPageWrapper.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-screen flex items-center justify-center bg-base-200">Loading map…</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
651
frontend-nuxt/components/MapView.vue
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative h-full w-full" @click="contextMenu.tile.show = false; contextMenu.marker.show = false">
|
||||||
|
<div
|
||||||
|
v-if="mapsLoaded && maps.length === 0"
|
||||||
|
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
|
||||||
|
>
|
||||||
|
<p class="text-center text-lg">Map list is empty.</p>
|
||||||
|
<p class="text-center text-sm opacity-80">
|
||||||
|
Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed).
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<NuxtLink to="/login" class="btn btn-sm">Login</NuxtLink>
|
||||||
|
<NuxtLink to="/admin" class="btn btn-sm btn-primary">Admin</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="mapRef" class="map h-full w-full" />
|
||||||
|
<!-- Grid coords & zoom (bottom-right) -->
|
||||||
|
<div
|
||||||
|
v-if="displayCoords"
|
||||||
|
class="absolute bottom-2 right-2 z-[501] rounded bg-base-100/90 px-2 py-1 font-mono text-xs shadow"
|
||||||
|
aria-label="Current grid position and zoom"
|
||||||
|
>
|
||||||
|
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
|
||||||
|
</div>
|
||||||
|
<!-- Control panel -->
|
||||||
|
<div class="absolute left-3 top-[10%] z-[502] card card-compact bg-base-100 shadow-xl w-56">
|
||||||
|
<div class="card-body p-3 gap-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-neutral btn-sm btn-square"
|
||||||
|
title="Zoom in"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
@click="zoomIn"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-neutral btn-sm btn-square"
|
||||||
|
title="Zoom out"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
@click="zoomOutControl"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2 py-0">
|
||||||
|
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
|
||||||
|
<span class="label-text">Show grid coordinates</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2 py-0">
|
||||||
|
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
||||||
|
<span class="label-text">Hide markers</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-neutral btn-sm"
|
||||||
|
title="Center map and minimum zoom"
|
||||||
|
@click="zoomOut"
|
||||||
|
>
|
||||||
|
Reset view
|
||||||
|
</button>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
|
||||||
|
<select v-model="selectedMapId" class="select select-bordered select-sm w-full">
|
||||||
|
<option :value="null">Select map</option>
|
||||||
|
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
|
||||||
|
<select v-model="overlayMapId" class="select select-bordered select-sm w-full">
|
||||||
|
<option :value="-1">None</option>
|
||||||
|
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
|
||||||
|
<select v-model="selectedMarkerId" class="select select-bordered select-sm w-full">
|
||||||
|
<option :value="null">Select quest giver</option>
|
||||||
|
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
|
||||||
|
<select v-model="selectedPlayerId" class="select select-bordered select-sm w-full">
|
||||||
|
<option :value="null">Select player</option>
|
||||||
|
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Context menu (tile) -->
|
||||||
|
<div
|
||||||
|
v-show="contextMenu.tile.show"
|
||||||
|
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
|
||||||
|
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="wipeTile(contextMenu.tile.data); contextMenu.tile.show = false">
|
||||||
|
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="openCoordSet(contextMenu.tile.data); contextMenu.tile.show = false">
|
||||||
|
Rewrite tile coords
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Context menu (marker) -->
|
||||||
|
<div
|
||||||
|
v-show="contextMenu.marker.show"
|
||||||
|
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
|
||||||
|
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="hideMarkerById(contextMenu.marker.data?.id); contextMenu.marker.show = false">
|
||||||
|
Hide marker {{ contextMenu.marker.data?.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Coord-set modal: close via Cancel, backdrop click, or Escape -->
|
||||||
|
<dialog ref="coordSetModal" class="modal" @cancel="closeCoordSetModal">
|
||||||
|
<div class="modal-box" @click.stop>
|
||||||
|
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
|
||||||
|
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input v-model.number="coordSet.x" type="number" class="input input-bordered flex-1" placeholder="X" />
|
||||||
|
<input v-model.number="coordSet.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog" @submit="submitCoordSet">
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
<button type="button" class="btn" @click="closeCoordSetModal">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="closeCoordSetModal" />
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||||
|
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||||
|
import { Marker } from '~/lib/Marker'
|
||||||
|
import { Character } from '~/lib/Character'
|
||||||
|
import { UniqueList } from '~/lib/UniqueList'
|
||||||
|
import type L from 'leaflet'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
characterId?: number
|
||||||
|
mapId?: number
|
||||||
|
gridX?: number
|
||||||
|
gridY?: number
|
||||||
|
zoom?: number
|
||||||
|
}>(),
|
||||||
|
{ characterId: -1, mapId: undefined, gridX: 0, gridY: 0, zoom: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const mapRef = ref<HTMLElement | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const api = useMapApi()
|
||||||
|
|
||||||
|
const showGridCoordinates = ref(false)
|
||||||
|
const hideMarkers = ref(false)
|
||||||
|
const trackingCharacterId = ref(-1)
|
||||||
|
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
|
||||||
|
const mapsLoaded = ref(false)
|
||||||
|
const selectedMapId = ref<number | null>(null)
|
||||||
|
const overlayMapId = ref<number>(-1)
|
||||||
|
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
|
||||||
|
const players = ref<{ id: number; name: string }[]>([])
|
||||||
|
const selectedMarkerId = ref<number | null>(null)
|
||||||
|
const selectedPlayerId = ref<number | null>(null)
|
||||||
|
const auths = ref<string[]>([])
|
||||||
|
const coordSetFrom = ref({ x: 0, y: 0 })
|
||||||
|
const coordSet = ref({ x: 0, y: 0 })
|
||||||
|
const coordSetModal = ref<HTMLDialogElement | null>(null)
|
||||||
|
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
|
||||||
|
|
||||||
|
const contextMenu = reactive({
|
||||||
|
tile: { show: false, x: 0, y: 0, data: null as { coords: { x: number; y: number } } | null },
|
||||||
|
marker: { show: false, x: 0, y: 0, data: null as { id: number; name: string } | null },
|
||||||
|
})
|
||||||
|
|
||||||
|
let map: L.Map | null = null
|
||||||
|
let layer: InstanceType<typeof SmartTileLayer> | null = null
|
||||||
|
let overlayLayer: InstanceType<typeof SmartTileLayer> | null = null
|
||||||
|
let coordLayer: InstanceType<typeof GridCoordLayer> | null = null
|
||||||
|
let markerLayer: L.LayerGroup | null = null
|
||||||
|
let source: EventSource | null = null
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
let mapid = 0
|
||||||
|
let markers: InstanceType<typeof UniqueList> | null = null
|
||||||
|
let characters: InstanceType<typeof UniqueList> | null = null
|
||||||
|
let markersHidden = false
|
||||||
|
let autoMode = false
|
||||||
|
|
||||||
|
function toLatLng(x: number, y: number) {
|
||||||
|
return map!.unproject([x, y], HnHMaxZoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMap(id: number) {
|
||||||
|
if (id === mapid) return
|
||||||
|
mapid = id
|
||||||
|
if (layer) {
|
||||||
|
layer.map = mapid
|
||||||
|
layer.redraw()
|
||||||
|
}
|
||||||
|
if (overlayLayer) {
|
||||||
|
overlayLayer.map = -1
|
||||||
|
overlayLayer.redraw()
|
||||||
|
}
|
||||||
|
if (markers && !markersHidden) {
|
||||||
|
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid }))
|
||||||
|
markers.getElements().filter((it: any) => it.map === mapid).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid }))
|
||||||
|
}
|
||||||
|
if (characters) {
|
||||||
|
characters.getElements().forEach((it: any) => {
|
||||||
|
it.remove({ map: map! })
|
||||||
|
it.add({ map: map!, mapid })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
map?.zoomIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOutControl() {
|
||||||
|
map?.zoomOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
trackingCharacterId.value = -1
|
||||||
|
map?.setView([0, 0], HnHMinZoom, { animate: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeTile(data: { coords: { x: number; y: number } }) {
|
||||||
|
if (!data?.coords) return
|
||||||
|
api.adminWipeTile({ map: mapid, x: data.coords.x, y: data.coords.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCoordSetModal() {
|
||||||
|
coordSetModal.value?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCoordSet(data: { coords: { x: number; y: number } }) {
|
||||||
|
if (!data?.coords) return
|
||||||
|
coordSetFrom.value = { ...data.coords }
|
||||||
|
coordSet.value = { x: data.coords.x, y: data.coords.y }
|
||||||
|
coordSetModal.value?.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCoordSet() {
|
||||||
|
api.adminSetCoords({
|
||||||
|
map: mapid,
|
||||||
|
fx: coordSetFrom.value.x,
|
||||||
|
fy: coordSetFrom.value.y,
|
||||||
|
tx: coordSet.value.x,
|
||||||
|
ty: coordSet.value.y,
|
||||||
|
})
|
||||||
|
coordSetModal.value?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMarkerById(id: number) {
|
||||||
|
if (id == null) return
|
||||||
|
api.adminHideMarker({ id })
|
||||||
|
const m = markers?.byId(id)
|
||||||
|
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenus() {
|
||||||
|
contextMenu.tile.show = false
|
||||||
|
contextMenu.marker.show = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') closeContextMenus()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
if (!import.meta.client || !mapRef.value) return
|
||||||
|
const L = (await import('leaflet')).default
|
||||||
|
|
||||||
|
const [charactersData, mapsData] = await Promise.all([
|
||||||
|
api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []),
|
||||||
|
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
|
||||||
|
])
|
||||||
|
|
||||||
|
const mapsList: { 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) {
|
||||||
|
const m = raw[id]
|
||||||
|
if (!m || typeof m !== 'object') continue
|
||||||
|
const idVal = m.ID ?? m.id
|
||||||
|
const nameVal = m.Name ?? m.name
|
||||||
|
if (idVal == null || nameVal == null) continue
|
||||||
|
mapsList.push({ ID: Number(idVal), Name: String(nameVal), size: m.size })
|
||||||
|
}
|
||||||
|
mapsList.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
|
||||||
|
maps.value = mapsList
|
||||||
|
mapsLoaded.value = true
|
||||||
|
|
||||||
|
const config = await api.getConfig().catch(() => ({}))
|
||||||
|
if (config?.title) document.title = config.title
|
||||||
|
if (config?.auths) auths.value = 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 =
|
||||||
|
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? mapsList[0].ID : 0
|
||||||
|
mapid = initialMapId
|
||||||
|
|
||||||
|
const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/'
|
||||||
|
const tileUrl = tileBase.endsWith('/') ? `${tileBase}grids/{map}/{z}/{x}_{y}.png?{cache}` : `${tileBase}/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,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
||||||
|
map.on('contextmenu', (mev: L.LeafletMouseEvent) => {
|
||||||
|
if (auths.value.includes('admin')) {
|
||||||
|
const point = map!.project(mev.latlng, 6)
|
||||||
|
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
|
||||||
|
contextMenu.tile.show = true
|
||||||
|
contextMenu.tile.x = mev.originalEvent.clientX
|
||||||
|
contextMenu.tile.y = mev.originalEvent.clientY
|
||||||
|
contextMenu.tile.data = { coords }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use same origin as page so SSE connects to correct host/port (e.g. 3080 not 3088)
|
||||||
|
const base = (useRuntimeConfig().app.baseURL as string) ?? '/'
|
||||||
|
const updatesPath = base.endsWith('/') ? `${base}updates` : `${base}/updates`
|
||||||
|
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
||||||
|
source = new EventSource(updatesUrl)
|
||||||
|
source.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const raw = event?.data
|
||||||
|
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
|
||||||
|
const updates = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(updates)) return
|
||||||
|
for (const u of updates) {
|
||||||
|
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
||||||
|
layer.cache[key] = u.T
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// After initial batch (or any batch), redraw so tiles re-request with filled cache
|
||||||
|
if (updates.length > 0 && layer) {
|
||||||
|
layer.redraw()
|
||||||
|
if (overlayLayer) overlayLayer.redraw()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors (e.g. empty SSE message or non-JSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source.onerror = () => {
|
||||||
|
// Connection lost or 401; avoid uncaught errors
|
||||||
|
}
|
||||||
|
source.addEventListener('merge', (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const merge = JSON.parse(e?.data ?? '{}')
|
||||||
|
if (mapid === 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()
|
||||||
|
characters = new UniqueList()
|
||||||
|
|
||||||
|
updateCharacters(charactersData as any[])
|
||||||
|
|
||||||
|
if (props.characterId !== undefined && props.characterId >= 0) {
|
||||||
|
trackingCharacterId.value = props.characterId
|
||||||
|
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
|
||||||
|
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
|
||||||
|
if (mapid !== props.mapId) changeMap(props.mapId)
|
||||||
|
selectedMapId.value = props.mapId
|
||||||
|
map.setView(latLng, props.zoom)
|
||||||
|
} else if (mapsList.length > 0) {
|
||||||
|
changeMap(mapsList[0].ID)
|
||||||
|
selectedMapId.value = mapsList[0].ID
|
||||||
|
map.setView([0, 0], HnHDefaultZoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
|
||||||
|
nextTick(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (map) map.invalidateSize()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
api.getCharacters().then((body) => updateCharacters(Array.isArray(body) ? body : [])).catch(() => clearInterval(intervalId!))
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
|
||||||
|
|
||||||
|
function updateMarkers(markersData: any[]) {
|
||||||
|
if (!markers || !map || !markerLayer) return
|
||||||
|
const list = Array.isArray(markersData) ? markersData : []
|
||||||
|
const ctx = { map, markerLayer, mapid, overlayLayer, auths: auths.value }
|
||||||
|
markers.update(
|
||||||
|
list.map((it) => new Marker(it)),
|
||||||
|
(marker: InstanceType<typeof Marker>) => {
|
||||||
|
if (marker.map === mapid || 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')) {
|
||||||
|
contextMenu.marker.show = true
|
||||||
|
contextMenu.marker.x = mev.originalEvent.clientX
|
||||||
|
contextMenu.marker.y = mev.originalEvent.clientY
|
||||||
|
contextMenu.marker.data = { id: marker.id, name: 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 }
|
||||||
|
characters.update(
|
||||||
|
list.map((it) => new Character(it)),
|
||||||
|
(character: InstanceType<typeof Character>) => {
|
||||||
|
character.add(ctx)
|
||||||
|
character.setClickCallback(() => (trackingCharacterId.value = character.id))
|
||||||
|
},
|
||||||
|
(character: InstanceType<typeof Character>) => character.remove(ctx),
|
||||||
|
(character: InstanceType<typeof Character>, updated: any) => {
|
||||||
|
if (trackingCharacterId.value === updated.id) {
|
||||||
|
if (mapid !== 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(showGridCoordinates, (v) => {
|
||||||
|
if (coordLayer) {
|
||||||
|
coordLayer.options.visible = v
|
||||||
|
coordLayer.setOpacity(v ? 1 : 0)
|
||||||
|
if (v && map) {
|
||||||
|
coordLayer.bringToFront?.()
|
||||||
|
coordLayer.redraw?.()
|
||||||
|
map.invalidateSize()
|
||||||
|
} else {
|
||||||
|
coordLayer.redraw?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(hideMarkers, (v) => {
|
||||||
|
markersHidden = v
|
||||||
|
if (!markers) return
|
||||||
|
const ctx = { map: map!, markerLayer: markerLayer!, mapid, 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 === mapid || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(trackingCharacterId, (value) => {
|
||||||
|
if (value === -1) return
|
||||||
|
const character = characters?.byId(value)
|
||||||
|
if (character) {
|
||||||
|
changeMap(character.map)
|
||||||
|
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||||
|
map!.setView(latlng, HnHMaxZoom)
|
||||||
|
router.push(`/character/${value}`)
|
||||||
|
autoMode = true
|
||||||
|
} else {
|
||||||
|
map!.setView([0, 0], HnHMinZoom)
|
||||||
|
trackingCharacterId.value = -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedMapId, (value) => {
|
||||||
|
if (value == null) return
|
||||||
|
changeMap(value)
|
||||||
|
const zoom = map!.getZoom()
|
||||||
|
map!.setView([0, 0], zoom)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(overlayMapId, (value) => {
|
||||||
|
if (overlayLayer) overlayLayer.map = value ?? -1
|
||||||
|
overlayLayer?.redraw()
|
||||||
|
if (!markers) return
|
||||||
|
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
|
||||||
|
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||||
|
markers.getElements().filter((it: any) => it.map === mapid || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedMarkerId, (value) => {
|
||||||
|
if (value == null) return
|
||||||
|
const marker = markers?.byId(value)
|
||||||
|
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedPlayerId, (value) => {
|
||||||
|
if (value != null) trackingCharacterId.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateDisplayCoords() {
|
||||||
|
if (!map) return
|
||||||
|
const point = map.project(map.getCenter(), 6)
|
||||||
|
displayCoords.value = {
|
||||||
|
x: Math.floor(point.x / TileSize),
|
||||||
|
y: Math.floor(point.y / TileSize),
|
||||||
|
z: map.getZoom(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('moveend', updateDisplayCoords)
|
||||||
|
updateDisplayCoords()
|
||||||
|
map.on('zoomend', () => {
|
||||||
|
if (map) map.invalidateSize()
|
||||||
|
})
|
||||||
|
map.on('drag', () => {
|
||||||
|
trackingCharacterId.value = -1
|
||||||
|
})
|
||||||
|
map.on('zoom', () => {
|
||||||
|
if (autoMode) {
|
||||||
|
autoMode = false
|
||||||
|
} else {
|
||||||
|
trackingCharacterId.value = -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
if (intervalId) clearInterval(intervalId)
|
||||||
|
if (source) source.close()
|
||||||
|
if (map) map.remove()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
:deep(.leaflet-container .leaflet-tile-pane img.leaflet-tile) {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
:deep(.map-tile) {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
min-width: 100px;
|
||||||
|
min-height: 100px;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
pointer-events: none;
|
||||||
|
text-shadow: 0 0 2px #000, 0 1px 2px #000;
|
||||||
|
}
|
||||||
|
:deep(.map-tile-text) {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
48
frontend-nuxt/components/PasswordInput.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-control">
|
||||||
|
<label v-if="label" class="label" :for="inputId">
|
||||||
|
<span class="label-text">{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative flex">
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:value="modelValue"
|
||||||
|
:type="showPass ? 'text' : 'password'"
|
||||||
|
class="input input-bordered flex-1 pr-10"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:required="required"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:readonly="readonly"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-0 h-8 w-8"
|
||||||
|
:aria-label="showPass ? 'Hide password' : 'Show password'"
|
||||||
|
@click="showPass = !showPass"
|
||||||
|
>
|
||||||
|
{{ showPass ? '🙈' : '👁' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
autocomplete?: string
|
||||||
|
readonly?: boolean
|
||||||
|
inputId?: string
|
||||||
|
}>(),
|
||||||
|
{ required: false, autocomplete: 'off', inputId: undefined }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
|
||||||
|
const showPass = ref(false)
|
||||||
|
const inputId = computed(() => props.inputId ?? `password-${Math.random().toString(36).slice(2, 9)}`)
|
||||||
|
</script>
|
||||||
52
frontend-nuxt/composables/useAppPaths.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for app base path. Use instead of hardcoded `/map/`.
|
||||||
|
* All path checks and redirects should use this composable.
|
||||||
|
*/
|
||||||
|
export function useAppPaths() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const baseURL = (config.app?.baseURL as string) ?? '/'
|
||||||
|
const base = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
|
||||||
|
|
||||||
|
/** Path without base prefix, for comparison with route names like 'login', 'setup'. */
|
||||||
|
function pathWithoutBase(fullPath: string): string {
|
||||||
|
if (!base || base === '/') return fullPath
|
||||||
|
const normalized = fullPath.replace(new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/?`), '') || '/'
|
||||||
|
return normalized.startsWith('/') ? normalized : `/${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full URL for redirects (e.g. window.location.href). */
|
||||||
|
function fullUrl(relativePath: string): string {
|
||||||
|
if (import.meta.server) return base + (relativePath.startsWith('/') ? relativePath : `/${relativePath}`)
|
||||||
|
const path = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath
|
||||||
|
const withBase = base ? `${base}/${path}` : `/${path}`
|
||||||
|
return `${window.location.origin}${withBase}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path with base for internal navigation (e.g. navigateTo). Nuxt router uses paths without origin. */
|
||||||
|
function resolvePath(relativePath: string): string {
|
||||||
|
const path = relativePath.startsWith('/') ? relativePath : `/${relativePath}`
|
||||||
|
return base ? `${base}${path}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the path is login page (path without base is /login or ends with /login). */
|
||||||
|
function isLoginPath(fullPath: string): boolean {
|
||||||
|
const p = pathWithoutBase(fullPath)
|
||||||
|
return p === '/login' || p.endsWith('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the path is setup page. */
|
||||||
|
function isSetupPath(fullPath: string): boolean {
|
||||||
|
const p = pathWithoutBase(fullPath)
|
||||||
|
return p === '/setup' || p.endsWith('/setup')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseURL,
|
||||||
|
base,
|
||||||
|
pathWithoutBase,
|
||||||
|
fullUrl,
|
||||||
|
resolvePath,
|
||||||
|
isLoginPath,
|
||||||
|
isSetupPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
222
frontend-nuxt/composables/useMapApi.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
export interface MeResponse {
|
||||||
|
username: string
|
||||||
|
auths: string[]
|
||||||
|
tokens?: string[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapInfoAdmin {
|
||||||
|
ID: number
|
||||||
|
Name: string
|
||||||
|
Hidden: boolean
|
||||||
|
Priority: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
prefix: string
|
||||||
|
defaultHide: boolean
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
|
||||||
|
const onApiErrorCallbacks: (() => void)[] = []
|
||||||
|
|
||||||
|
export function useMapApi() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const apiBase = config.public.apiBase as string
|
||||||
|
|
||||||
|
function onApiError(cb: () => void) {
|
||||||
|
onApiErrorCallbacks.push(cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||||
|
const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}`
|
||||||
|
const res = await fetch(url, { credentials: 'include', ...opts })
|
||||||
|
// Only redirect to login on 401 (session invalid); 403 = forbidden (no permission)
|
||||||
|
if (res.status === 401) {
|
||||||
|
onApiErrorCallbacks.forEach((cb) => cb())
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
if (res.status === 403) throw new Error('Forbidden')
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||||
|
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConfig() {
|
||||||
|
return request<{ title?: string; auths?: string[] }>('config')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCharacters() {
|
||||||
|
return request<unknown[]>('v1/characters')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMarkers() {
|
||||||
|
return request<unknown[]>('v1/markers')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMaps() {
|
||||||
|
return request<Record<string, { ID: number; Name: string; size?: number }>>('maps')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async function login(user: string, pass: string) {
|
||||||
|
const res = await fetch(`${apiBase}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user, pass }),
|
||||||
|
})
|
||||||
|
if (res.status === 401) throw new Error('Unauthorized')
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||||
|
return res.json() as Promise<MeResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function me() {
|
||||||
|
return request<MeResponse>('me')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public: whether first-time setup (no users) is required. */
|
||||||
|
async function setupRequired(): Promise<{ setupRequired: boolean }> {
|
||||||
|
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||||
|
return res.json() as Promise<{ setupRequired: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
async function meTokens() {
|
||||||
|
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
|
||||||
|
return data!.tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mePassword(pass: string) {
|
||||||
|
await request('me/password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pass }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
async function adminUsers() {
|
||||||
|
return request<string[]>('admin/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminUserByName(name: string) {
|
||||||
|
return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) {
|
||||||
|
await request('admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminUserDelete(name: string) {
|
||||||
|
await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminSettings() {
|
||||||
|
return request<SettingsResponse>('admin/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) {
|
||||||
|
await request('admin/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminMaps() {
|
||||||
|
return request<MapInfoAdmin[]>('admin/maps')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) {
|
||||||
|
await request(`admin/maps/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminMapToggleHidden(id: number) {
|
||||||
|
return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminWipe() {
|
||||||
|
await request('admin/wipe', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminRebuildZooms() {
|
||||||
|
await request('admin/rebuildZooms', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminExportUrl() {
|
||||||
|
return `${apiBase}/admin/export`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminMerge(formData: FormData) {
|
||||||
|
const res = await fetch(`${apiBase}/admin/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
onApiErrorCallbacks.forEach((cb) => cb())
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminWipeTile(params: { map: number; x: number; y: number }) {
|
||||||
|
return request(`admin/wipeTile?${new URLSearchParams(params as any)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
|
||||||
|
return request(`admin/setCoords?${new URLSearchParams(params as any)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminHideMarker(params: { id: number }) {
|
||||||
|
return request(`admin/hideMarker?${new URLSearchParams(params as any)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiBase,
|
||||||
|
onApiError,
|
||||||
|
getConfig,
|
||||||
|
getCharacters,
|
||||||
|
getMarkers,
|
||||||
|
getMaps,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
me,
|
||||||
|
setupRequired,
|
||||||
|
meTokens,
|
||||||
|
mePassword,
|
||||||
|
adminUsers,
|
||||||
|
adminUserByName,
|
||||||
|
adminUserPost,
|
||||||
|
adminUserDelete,
|
||||||
|
adminSettings,
|
||||||
|
adminSettingsPost,
|
||||||
|
adminMaps,
|
||||||
|
adminMapPost,
|
||||||
|
adminMapToggleHidden,
|
||||||
|
adminWipe,
|
||||||
|
adminRebuildZooms,
|
||||||
|
adminExportUrl,
|
||||||
|
adminMerge,
|
||||||
|
adminWipeTile,
|
||||||
|
adminSetCoords,
|
||||||
|
adminHideMarker,
|
||||||
|
}
|
||||||
|
}
|
||||||
103
frontend-nuxt/layouts/default.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
|
||||||
|
<header class="navbar bg-base-200/80 backdrop-blur px-4 gap-2 shrink-0">
|
||||||
|
<NuxtLink to="/" class="text-lg font-semibold hover:opacity-80">{{ title }}</NuxtLink>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<NuxtLink v-if="!isLogin" to="/" class="btn btn-ghost btn-sm">Map</NuxtLink>
|
||||||
|
<NuxtLink v-if="!isLogin" to="/profile" class="btn btn-ghost btn-sm">Profile</NuxtLink>
|
||||||
|
<NuxtLink v-if="!isLogin && isAdmin" to="/admin" class="btn btn-ghost btn-sm">Admin</NuxtLink>
|
||||||
|
<button
|
||||||
|
v-if="!isLogin && me"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm btn-outline"
|
||||||
|
@click="doLogout"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
<label class="swap swap-rotate btn btn-ghost btn-sm">
|
||||||
|
<input type="checkbox" v-model="dark" @change="toggleTheme" />
|
||||||
|
<span class="swap-off">☀️</span>
|
||||||
|
<span class="swap-on">🌙</span>
|
||||||
|
</label>
|
||||||
|
<span v-if="live" class="badge badge-success badge-sm">Live</span>
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 min-h-0 relative">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const THEME_KEY = 'hnh-map-theme'
|
||||||
|
|
||||||
|
function getInitialDark(): boolean {
|
||||||
|
if (import.meta.client) {
|
||||||
|
const stored = localStorage.getItem(THEME_KEY)
|
||||||
|
if (stored === 'dark') return true
|
||||||
|
if (stored === 'light') return false
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = ref('HnH Map')
|
||||||
|
const dark = ref(false)
|
||||||
|
const live = ref(false)
|
||||||
|
const me = ref<{ username?: string; auths?: string[] } | null>(null)
|
||||||
|
|
||||||
|
const { isLoginPath } = useAppPaths()
|
||||||
|
const isLogin = computed(() => isLoginPath(route.path))
|
||||||
|
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
|
||||||
|
|
||||||
|
async function loadMe() {
|
||||||
|
if (isLogin.value) return
|
||||||
|
try {
|
||||||
|
me.value = await useMapApi().me()
|
||||||
|
} catch {
|
||||||
|
me.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
if (isLogin.value) return
|
||||||
|
try {
|
||||||
|
const config = await useMapApi().getConfig()
|
||||||
|
if (config?.title) title.value = config.title
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
dark.value = getInitialDark()
|
||||||
|
const html = document.documentElement
|
||||||
|
html.setAttribute('data-theme', dark.value ? 'dark' : 'light')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => {
|
||||||
|
if (!isLoginPath(path)) loadMe().then(loadConfig)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const html = document.documentElement
|
||||||
|
if (dark.value) {
|
||||||
|
html.setAttribute('data-theme', 'dark')
|
||||||
|
localStorage.setItem(THEME_KEY, 'dark')
|
||||||
|
} else {
|
||||||
|
html.setAttribute('data-theme', 'light')
|
||||||
|
localStorage.setItem(THEME_KEY, 'light')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
await useMapApi().logout()
|
||||||
|
await router.push('/login')
|
||||||
|
me.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ setLive: (v: boolean) => { live.value = v } })
|
||||||
|
</script>
|
||||||
61
frontend-nuxt/lib/Character.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||||
|
import * as L from 'leaflet'
|
||||||
|
|
||||||
|
export class Character {
|
||||||
|
constructor(characterData) {
|
||||||
|
this.name = characterData.name
|
||||||
|
this.position = characterData.position
|
||||||
|
this.type = characterData.type
|
||||||
|
this.id = characterData.id
|
||||||
|
this.map = characterData.map
|
||||||
|
this.marker = null
|
||||||
|
this.text = this.name
|
||||||
|
this.value = this.id
|
||||||
|
this.onClick = null
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return `${this.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(mapview) {
|
||||||
|
if (this.marker) {
|
||||||
|
const layer = mapview.markerLayer ?? mapview.map
|
||||||
|
layer.removeLayer(this.marker)
|
||||||
|
this.marker = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(mapview) {
|
||||||
|
if (this.map === mapview.mapid) {
|
||||||
|
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||||
|
this.marker = L.marker(position, { title: this.name })
|
||||||
|
this.marker.on('click', this.callCallback.bind(this))
|
||||||
|
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||||
|
this.marker.addTo(targetLayer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(mapview, updated) {
|
||||||
|
if (this.map !== updated.map) {
|
||||||
|
this.remove(mapview)
|
||||||
|
}
|
||||||
|
this.map = updated.map
|
||||||
|
this.position = updated.position
|
||||||
|
if (!this.marker && this.map === mapview.mapid) {
|
||||||
|
this.add(mapview)
|
||||||
|
}
|
||||||
|
if (this.marker) {
|
||||||
|
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||||
|
this.marker.setLatLng(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setClickCallback(callback) {
|
||||||
|
this.onClick = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
callCallback(e) {
|
||||||
|
if (this.onClick != null) this.onClick(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend-nuxt/lib/LeafletCustomTypes.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import L, { Bounds, LatLng, Point } from 'leaflet'
|
||||||
|
|
||||||
|
export const TileSize = 100
|
||||||
|
export const HnHMaxZoom = 6
|
||||||
|
export const HnHMinZoom = 1
|
||||||
|
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). */
|
||||||
|
const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8
|
||||||
|
|
||||||
|
export const GridCoordLayer = L.GridLayer.extend({
|
||||||
|
options: {
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
createTile(coords) {
|
||||||
|
if (!this.options.visible) {
|
||||||
|
const element = document.createElement('div')
|
||||||
|
element.style.width = TileSize + 'px'
|
||||||
|
element.style.height = TileSize + 'px'
|
||||||
|
element.classList.add('map-tile')
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
const element = document.createElement('div')
|
||||||
|
element.style.width = TileSize + 'px'
|
||||||
|
element.style.height = TileSize + 'px'
|
||||||
|
element.style.position = 'relative'
|
||||||
|
element.classList.add('map-tile')
|
||||||
|
|
||||||
|
const scaleFactor = Math.pow(2, HnHMaxZoom - coords.z)
|
||||||
|
const topLeft = { x: coords.x * scaleFactor, y: coords.y * scaleFactor }
|
||||||
|
const bottomRight = { x: topLeft.x + scaleFactor - 1, y: topLeft.y + scaleFactor - 1 }
|
||||||
|
|
||||||
|
if (scaleFactor > GRID_COORD_SCALE_FACTOR_THRESHOLD) {
|
||||||
|
// Low zoom: one label per tile to avoid hundreds of thousands of DOM nodes (Reset view freeze fix)
|
||||||
|
const textElement = document.createElement('div')
|
||||||
|
textElement.classList.add('map-tile-text')
|
||||||
|
textElement.textContent = `(${topLeft.x}, ${topLeft.y})`
|
||||||
|
textElement.style.position = 'absolute'
|
||||||
|
textElement.style.left = '2px'
|
||||||
|
textElement.style.top = '2px'
|
||||||
|
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
|
||||||
|
element.appendChild(textElement)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let gx = topLeft.x; gx <= bottomRight.x; gx++) {
|
||||||
|
for (let gy = topLeft.y; gy <= bottomRight.y; gy++) {
|
||||||
|
const leftPx = ((gx - topLeft.x) / scaleFactor) * TileSize
|
||||||
|
const topPx = ((gy - topLeft.y) / scaleFactor) * TileSize
|
||||||
|
const textElement = document.createElement('div')
|
||||||
|
textElement.classList.add('map-tile-text')
|
||||||
|
textElement.textContent = `(${gx}, ${gy})`
|
||||||
|
textElement.style.position = 'absolute'
|
||||||
|
textElement.style.left = leftPx + 2 + 'px'
|
||||||
|
textElement.style.top = topPx + 2 + 'px'
|
||||||
|
if (scaleFactor > 1) {
|
||||||
|
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
|
||||||
|
}
|
||||||
|
element.appendChild(textElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return element
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ImageIcon = L.Icon.extend({
|
||||||
|
options: {
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const latNormalization = (90.0 * TileSize) / 2500000.0
|
||||||
|
const lngNormalization = (180.0 * TileSize) / 2500000.0
|
||||||
|
|
||||||
|
const HnHProjection = {
|
||||||
|
project(latlng) {
|
||||||
|
return new Point(latlng.lat / latNormalization, latlng.lng / lngNormalization)
|
||||||
|
},
|
||||||
|
unproject(point) {
|
||||||
|
return new LatLng(point.x * latNormalization, point.y * lngNormalization)
|
||||||
|
},
|
||||||
|
bounds: (() => new Bounds([-latNormalization, -lngNormalization], [latNormalization, lngNormalization]))(),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HnHCRS = L.extend({}, L.CRS.Simple, {
|
||||||
|
projection: HnHProjection,
|
||||||
|
})
|
||||||
89
frontend-nuxt/lib/Marker.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
|
||||||
|
import * as L from 'leaflet'
|
||||||
|
|
||||||
|
function detectType(name) {
|
||||||
|
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
|
||||||
|
if (name === 'custom') return 'custom'
|
||||||
|
return name.substring('gfx/terobjs/mm/'.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Marker {
|
||||||
|
constructor(markerData) {
|
||||||
|
this.id = markerData.id
|
||||||
|
this.position = markerData.position
|
||||||
|
this.name = markerData.name
|
||||||
|
this.image = markerData.image
|
||||||
|
this.type = detectType(this.image)
|
||||||
|
this.marker = null
|
||||||
|
this.text = this.name
|
||||||
|
this.value = this.id
|
||||||
|
this.hidden = markerData.hidden
|
||||||
|
this.map = markerData.map
|
||||||
|
this.onClick = null
|
||||||
|
this.onContext = null
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(mapview) {
|
||||||
|
if (this.marker) {
|
||||||
|
this.marker.remove()
|
||||||
|
this.marker = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(mapview) {
|
||||||
|
if (!this.hidden) {
|
||||||
|
let icon
|
||||||
|
if (this.image === 'gfx/terobjs/mm/custom') {
|
||||||
|
icon = new ImageIcon({
|
||||||
|
iconUrl: 'gfx/terobjs/mm/custom.png',
|
||||||
|
iconSize: [21, 23],
|
||||||
|
iconAnchor: [11, 21],
|
||||||
|
popupAnchor: [1, 3],
|
||||||
|
tooltipAnchor: [1, 3],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
icon = new ImageIcon({ iconUrl: `${this.image}.png`, iconSize: [32, 32] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||||
|
this.marker = L.marker(position, { icon, title: this.name })
|
||||||
|
this.marker.addTo(mapview.markerLayer)
|
||||||
|
this.marker.on('click', this.callClickCallback.bind(this))
|
||||||
|
this.marker.on('contextmenu', this.callContextCallback.bind(this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(mapview, updated) {
|
||||||
|
this.position = updated.position
|
||||||
|
this.name = updated.name
|
||||||
|
this.hidden = updated.hidden
|
||||||
|
this.map = updated.map
|
||||||
|
if (this.marker) {
|
||||||
|
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||||
|
this.marker.setLatLng(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpTo(map) {
|
||||||
|
if (this.marker) {
|
||||||
|
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||||
|
this.marker.setLatLng(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setClickCallback(callback) {
|
||||||
|
this.onClick = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
callClickCallback(e) {
|
||||||
|
if (this.onClick != null) this.onClick(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContextMenu(callback) {
|
||||||
|
this.onContext = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
callContextCallback(e) {
|
||||||
|
if (this.onContext != null) this.onContext(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend-nuxt/lib/SmartTileLayer.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import L, { Util, Browser } from 'leaflet'
|
||||||
|
|
||||||
|
export const SmartTileLayer = L.TileLayer.extend({
|
||||||
|
cache: {},
|
||||||
|
invalidTile: '',
|
||||||
|
map: 0,
|
||||||
|
|
||||||
|
getTileUrl(coords) {
|
||||||
|
if (!this._map) return this.invalidTile
|
||||||
|
let zoom
|
||||||
|
try {
|
||||||
|
zoom = this._getZoomForUrl()
|
||||||
|
} catch {
|
||||||
|
return this.invalidTile
|
||||||
|
}
|
||||||
|
return this.getTrueTileUrl(coords, zoom)
|
||||||
|
},
|
||||||
|
|
||||||
|
getTrueTileUrl(coords, zoom) {
|
||||||
|
const data = {
|
||||||
|
r: Browser.retina ? '@2x' : '',
|
||||||
|
s: this._getSubdomain(coords),
|
||||||
|
x: coords.x,
|
||||||
|
y: coords.y,
|
||||||
|
map: this.map,
|
||||||
|
z: zoom,
|
||||||
|
}
|
||||||
|
if (this._map && !this._map.options.crs.infinite) {
|
||||||
|
const invertedY = this._globalTileRange.max.y - coords.y
|
||||||
|
if (this.options.tms) {
|
||||||
|
data.y = invertedY
|
||||||
|
}
|
||||||
|
data['-y'] = invertedY
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${data.map}:${data.x}:${data.y}:${data.z}`
|
||||||
|
data.cache = this.cache[cacheKey]
|
||||||
|
|
||||||
|
// Don't request tiles for invalid/unknown map (avoids 404 spam in console)
|
||||||
|
if (data.map === undefined || data.map === null || data.map < 1) {
|
||||||
|
return this.invalidTile
|
||||||
|
}
|
||||||
|
// Only use placeholder when server explicitly marks tile as invalid (-1)
|
||||||
|
if (data.cache === -1) {
|
||||||
|
return this.invalidTile
|
||||||
|
}
|
||||||
|
// Allow tile request when map is valid even if SSE snapshot hasn't arrived yet
|
||||||
|
// (avoids empty map when proxy/SSE delays or drops first message)
|
||||||
|
if (data.cache === undefined || data.cache === null) {
|
||||||
|
data.cache = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Util.template(this._url, Util.extend(data, this.options))
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh(x, y, z) {
|
||||||
|
let zoom = z
|
||||||
|
const maxZoom = this.options.maxZoom
|
||||||
|
const zoomReverse = this.options.zoomReverse
|
||||||
|
const zoomOffset = this.options.zoomOffset
|
||||||
|
|
||||||
|
if (zoomReverse) {
|
||||||
|
zoom = maxZoom - zoom
|
||||||
|
}
|
||||||
|
zoom += zoomOffset
|
||||||
|
|
||||||
|
const key = `${x}:${y}:${zoom}`
|
||||||
|
const tile = this._tiles[key]
|
||||||
|
if (tile) {
|
||||||
|
tile.el.src = this.getTrueTileUrl({ x, y }, z)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
39
frontend-nuxt/lib/UniqueList.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Elements should have unique field "id"
|
||||||
|
*/
|
||||||
|
export class UniqueList {
|
||||||
|
constructor() {
|
||||||
|
this.elements = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dataList, addCallback, removeCallback, updateCallback) {
|
||||||
|
const elementsToAdd = dataList.filter((it) => this.elements[it.id] === undefined)
|
||||||
|
const elementsToRemove = Object.keys(this.elements)
|
||||||
|
.filter((it) => dataList.find((up) => String(up.id) === it) === undefined)
|
||||||
|
.map((id) => this.elements[id])
|
||||||
|
if (removeCallback) {
|
||||||
|
elementsToRemove.forEach((it) => removeCallback(it))
|
||||||
|
}
|
||||||
|
if (updateCallback) {
|
||||||
|
dataList.forEach((newElement) => {
|
||||||
|
const oldElement = this.elements[newElement.id]
|
||||||
|
if (oldElement) {
|
||||||
|
updateCallback(oldElement, newElement)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (addCallback) {
|
||||||
|
elementsToAdd.forEach((it) => addCallback(it))
|
||||||
|
}
|
||||||
|
elementsToRemove.forEach((it) => delete this.elements[it.id])
|
||||||
|
elementsToAdd.forEach((it) => (this.elements[it.id] = it))
|
||||||
|
}
|
||||||
|
|
||||||
|
getElements() {
|
||||||
|
return Object.values(this.elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
byId(id) {
|
||||||
|
return this.elements[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend-nuxt/middleware/admin.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
|
const api = useMapApi()
|
||||||
|
try {
|
||||||
|
const me = await api.me()
|
||||||
|
if (!me.auths?.includes('admin')) {
|
||||||
|
return navigateTo('/profile')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
14
frontend-nuxt/middleware/auth.global.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const { isLoginPath, isSetupPath } = useAppPaths()
|
||||||
|
if (isLoginPath(to.path)) return
|
||||||
|
if (isSetupPath(to.path)) return
|
||||||
|
|
||||||
|
const api = useMapApi()
|
||||||
|
try {
|
||||||
|
await api.me()
|
||||||
|
} catch {
|
||||||
|
const { setupRequired } = await api.setupRequired()
|
||||||
|
if (setupRequired) return navigateTo('/setup')
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
48
frontend-nuxt/nuxt.config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
import { viteUriGuard } from './plugins/vite-uri-guard'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2024-11-01',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
app: {
|
||||||
|
baseURL: '/map/',
|
||||||
|
head: {
|
||||||
|
title: 'HnH Map',
|
||||||
|
meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: '/map/api',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
|
tailwindcss: {
|
||||||
|
cssPath: '~/assets/css/app.css',
|
||||||
|
},
|
||||||
|
css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'],
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
plugins: [viteUriGuard()],
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['leaflet'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml)
|
||||||
|
nitro: {
|
||||||
|
devProxy: {
|
||||||
|
'/map/api': { target: 'http://backend:3080', changeOrigin: true },
|
||||||
|
'/map/updates': { target: 'http://backend:3080', changeOrigin: true },
|
||||||
|
'/map/grids': { target: 'http://backend:3080', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// For cutover: set nitro.preset to 'static' and optionally copy .output/public to ../frontend
|
||||||
|
// nitro: { output: { dir: '../frontend' } },
|
||||||
|
})
|
||||||
10438
frontend-nuxt/package-lock.json
generated
Normal file
26
frontend-nuxt/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "hnh-map-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"nuxt": "^3.14.1593",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||||
|
"daisyui": "^3.9.4",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
278
frontend-nuxt/pages/admin/index.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-4 max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Admin</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="message.text"
|
||||||
|
class="mb-4 rounded-lg px-4 py-2"
|
||||||
|
:class="message.type === 'error' ? 'bg-error/20 text-error' : 'bg-success/20 text-success'"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Users</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="u in users"
|
||||||
|
:key="u"
|
||||||
|
class="flex justify-between items-center gap-3 py-1 border-b border-base-300 last:border-0"
|
||||||
|
>
|
||||||
|
<span>{{ u }}</span>
|
||||||
|
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-ghost btn-xs">Edit</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li v-if="!users.length" class="py-1 text-base-content/60">
|
||||||
|
No users yet.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2">Add user</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Maps</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="map in maps" :key="map.ID">
|
||||||
|
<td>{{ map.ID }}</td>
|
||||||
|
<td>{{ map.Name }}</td>
|
||||||
|
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
|
||||||
|
<td>{{ map.Priority ? 'Yes' : 'No' }}</td>
|
||||||
|
<td>
|
||||||
|
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-ghost btn-xs">Edit</NuxtLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!maps.length">
|
||||||
|
<td colspan="5" class="text-base-content/60">No maps.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="form-control w-full max-w-xs">
|
||||||
|
<label class="label" for="admin-settings-prefix">Prefix</label>
|
||||||
|
<input
|
||||||
|
id="admin-settings-prefix"
|
||||||
|
v-model="settings.prefix"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full max-w-xs">
|
||||||
|
<label class="label" for="admin-settings-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="admin-settings-title"
|
||||||
|
v-model="settings.title"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
|
||||||
|
<input
|
||||||
|
id="admin-settings-default-hide"
|
||||||
|
v-model="settings.defaultHide"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
/>
|
||||||
|
Default hide new maps
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-2">
|
||||||
|
<button class="btn btn-sm" :disabled="savingSettings" @click="saveSettings">
|
||||||
|
{{ savingSettings ? '…' : 'Save settings' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Actions</h2>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm">
|
||||||
|
Export zip
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm" :disabled="rebuilding" @click="rebuildZooms">
|
||||||
|
{{ rebuilding ? '…' : 'Rebuild zooms' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" />
|
||||||
|
<button type="button" class="btn btn-sm" @click="mergeFileRef?.click()">
|
||||||
|
Choose merge file
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-base-content/70">
|
||||||
|
{{ mergeFile ? mergeFile.name : 'No file chosen' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="doMerge">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary" :disabled="!mergeFile || merging">
|
||||||
|
{{ merging ? '…' : 'Merge' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-base-300 pt-4 mt-1">
|
||||||
|
<p class="text-sm font-medium text-error/90 mb-2">Danger zone</p>
|
||||||
|
<button class="btn btn-sm btn-error" :disabled="wiping" @click="confirmWipe">
|
||||||
|
{{ wiping ? '…' : 'Wipe all data' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2>
|
||||||
|
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn">Cancel</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const api = useMapApi()
|
||||||
|
const users = ref<string[]>([])
|
||||||
|
const maps = ref<Array<{ ID: number; Name: string; Hidden: boolean; Priority: boolean }>>([])
|
||||||
|
const settings = ref({ prefix: '', defaultHide: false, title: '' })
|
||||||
|
const savingSettings = ref(false)
|
||||||
|
const rebuilding = ref(false)
|
||||||
|
const wiping = ref(false)
|
||||||
|
const merging = ref(false)
|
||||||
|
const mergeFile = ref<File | null>(null)
|
||||||
|
const mergeFileRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const wipeModalRef = ref<HTMLDialogElement | null>(null)
|
||||||
|
|
||||||
|
const message = ref<{ type: 'success' | 'error'; text: string }>({ type: 'success', text: '' })
|
||||||
|
let messageTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function setMessage(type: 'success' | 'error', text: string) {
|
||||||
|
message.value = { type, text }
|
||||||
|
if (messageTimeout) clearTimeout(messageTimeout)
|
||||||
|
messageTimeout = setTimeout(() => {
|
||||||
|
message.value = { type: 'success', text: '' }
|
||||||
|
messageTimeout = null
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
users.value = await api.adminUsers()
|
||||||
|
} catch {
|
||||||
|
users.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMaps() {
|
||||||
|
try {
|
||||||
|
maps.value = await api.adminMaps()
|
||||||
|
} catch {
|
||||||
|
maps.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const s = await api.adminSettings()
|
||||||
|
settings.value = { prefix: s.prefix ?? '', defaultHide: s.defaultHide ?? false, title: s.title ?? '' }
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
savingSettings.value = true
|
||||||
|
try {
|
||||||
|
await api.adminSettingsPost(settings.value)
|
||||||
|
setMessage('success', 'Settings saved.')
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('error', (e as Error)?.message ?? 'Failed to save settings.')
|
||||||
|
} finally {
|
||||||
|
savingSettings.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildZooms() {
|
||||||
|
rebuilding.value = true
|
||||||
|
try {
|
||||||
|
await api.adminRebuildZooms()
|
||||||
|
setMessage('success', 'Zooms rebuilt.')
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('error', (e as Error)?.message ?? 'Failed to rebuild zooms.')
|
||||||
|
} finally {
|
||||||
|
rebuilding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmWipe() {
|
||||||
|
wipeModalRef.value?.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doWipe() {
|
||||||
|
wiping.value = true
|
||||||
|
try {
|
||||||
|
await api.adminWipe()
|
||||||
|
wipeModalRef.value?.close()
|
||||||
|
await loadMaps()
|
||||||
|
setMessage('success', 'All data wiped.')
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('error', (e as Error)?.message ?? 'Failed to wipe.')
|
||||||
|
} finally {
|
||||||
|
wiping.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMergeFile(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
mergeFile.value = input.files?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMerge() {
|
||||||
|
if (!mergeFile.value) return
|
||||||
|
merging.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('merge', mergeFile.value)
|
||||||
|
await api.adminMerge(fd)
|
||||||
|
mergeFile.value = null
|
||||||
|
if (mergeFileRef.value) mergeFileRef.value.value = ''
|
||||||
|
await loadMaps()
|
||||||
|
setMessage('success', 'Merge completed.')
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('error', (e as Error)?.message ?? 'Merge failed.')
|
||||||
|
} finally {
|
||||||
|
merging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
77
frontend-nuxt/pages/admin/maps/[id].vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-4 max-w-lg">
|
||||||
|
<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">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="name">Name</label>
|
||||||
|
<input id="name" v-model="form.name" type="text" class="input input-bordered" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input v-model="form.hidden" type="checkbox" class="checkbox" />
|
||||||
|
<span class="label-text">Hidden</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input v-model="form.priority" type="checkbox" class="checkbox" />
|
||||||
|
<span class="label-text">Priority</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">{{ loading ? '…' : 'Save' }}</button>
|
||||||
|
<NuxtLink to="/admin" class="btn btn-ghost">Back</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<template v-else-if="mapsLoaded">
|
||||||
|
<p class="text-base-content/70">Map not found.</p>
|
||||||
|
<NuxtLink to="/admin" class="btn btn-ghost mt-2">Back to Admin</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-base-content/70">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useMapApi()
|
||||||
|
const id = computed(() => parseInt(route.params.id as string, 10))
|
||||||
|
const map = ref<{ ID: number; Name: string; Hidden: boolean; Priority: boolean } | null>(null)
|
||||||
|
const mapsLoaded = ref(false)
|
||||||
|
const form = ref({ name: '', hidden: false, priority: false })
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const maps = await api.adminMaps()
|
||||||
|
mapsLoaded.value = true
|
||||||
|
const found = maps.find((m) => m.ID === id.value)
|
||||||
|
if (found) {
|
||||||
|
map.value = found
|
||||||
|
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
mapsLoaded.value = true
|
||||||
|
error.value = 'Failed to load map'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!map.value) return
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.adminMapPost(map.value.ID, form.value)
|
||||||
|
await router.push('/admin')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
123
frontend-nuxt/pages/admin/users/[username].vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-4 max-w-lg">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="user">Username</label>
|
||||||
|
<input
|
||||||
|
id="user"
|
||||||
|
v-model="form.user"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
required
|
||||||
|
:readonly="!isNew"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PasswordInput
|
||||||
|
v-model="form.pass"
|
||||||
|
label="Password (leave blank to keep)"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">Auths</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<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" />
|
||||||
|
<span class="label-text">{{ a }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">{{ loading ? '…' : 'Save' }}</button>
|
||||||
|
<NuxtLink to="/admin" class="btn btn-ghost">Back</NuxtLink>
|
||||||
|
<button
|
||||||
|
v-if="!isNew"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-outline ml-auto"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<dialog ref="deleteModalRef" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<p>Delete user {{ form.user }}?</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn">Cancel</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-error" :disabled="deleting" @click="doDelete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useMapApi()
|
||||||
|
const username = computed(() => (route.params.username as string) ?? '')
|
||||||
|
const isNew = computed(() => username.value === 'new')
|
||||||
|
|
||||||
|
const form = ref({ user: '', pass: '', auths: [] as string[] })
|
||||||
|
const authOptions = ['admin', 'map', 'markers', 'upload']
|
||||||
|
const loading = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const deleteModalRef = ref<HTMLDialogElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isNew.value) {
|
||||||
|
form.value.user = username.value
|
||||||
|
try {
|
||||||
|
const u = await api.adminUserByName(username.value)
|
||||||
|
form.value.auths = u.auths ?? []
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to load user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.adminUserPost({
|
||||||
|
user: form.value.user,
|
||||||
|
pass: form.value.pass || undefined,
|
||||||
|
auths: form.value.auths,
|
||||||
|
})
|
||||||
|
await router.push('/admin')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
deleteModalRef.value?.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await api.adminUserDelete(form.value.user)
|
||||||
|
deleteModalRef.value?.close()
|
||||||
|
await router.push('/admin')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Delete failed'
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
12
frontend-nuxt/pages/character/[characterId].vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full min-h-0">
|
||||||
|
<MapPageWrapper>
|
||||||
|
<MapView :character-id="characterId" />
|
||||||
|
</MapPageWrapper>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const characterId = computed(() => Number(route.params.characterId) || -1)
|
||||||
|
</script>
|
||||||
18
frontend-nuxt/pages/grid/[map]/[gridX]/[gridY]/[zoom].vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<MapPageWrapper>
|
||||||
|
<MapView
|
||||||
|
:map-id="mapId"
|
||||||
|
:grid-x="gridX"
|
||||||
|
:grid-y="gridY"
|
||||||
|
:zoom="zoom"
|
||||||
|
/>
|
||||||
|
</MapPageWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const mapId = computed(() => Number(route.params.map) || 0)
|
||||||
|
const gridX = computed(() => Number(route.params.gridX) || 0)
|
||||||
|
const gridY = computed(() => Number(route.params.gridY) || 0)
|
||||||
|
const zoom = computed(() => Number(route.params.zoom) || 1)
|
||||||
|
</script>
|
||||||
8
frontend-nuxt/pages/index.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<MapPageWrapper>
|
||||||
|
<MapView />
|
||||||
|
</MapPageWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
56
frontend-nuxt/pages/login.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4 overflow-hidden">
|
||||||
|
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="card-title justify-center">Log in</h1>
|
||||||
|
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="user">User</label>
|
||||||
|
<input
|
||||||
|
id="user"
|
||||||
|
v-model="user"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PasswordInput
|
||||||
|
v-model="pass"
|
||||||
|
label="Password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? '…' : 'Log in' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No auth required; auth.global skips this path
|
||||||
|
|
||||||
|
const user = ref('')
|
||||||
|
const pass = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useMapApi()
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.login(user.value, pass.value)
|
||||||
|
await router.push('/profile')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Login failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
111
frontend-nuxt/pages/profile.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-4 max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Upload tokens</h2>
|
||||||
|
<p class="text-sm opacity-80">Tokens for upload API. Generate and copy as needed.</p>
|
||||||
|
<ul v-if="tokens?.length" class="list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li v-for="t in tokens" :key="t" class="font-mono text-sm flex items-center gap-2">
|
||||||
|
<span class="break-all">{{ uploadTokenDisplay(t) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0"
|
||||||
|
aria-label="Copy token"
|
||||||
|
@click="copyToken(t)"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="text-sm mt-2">No tokens yet.</p>
|
||||||
|
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
|
||||||
|
<button class="btn btn-primary btn-sm mt-2" :disabled="loadingTokens" @click="generateToken">
|
||||||
|
{{ loadingTokens ? '…' : 'Generate token' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Change password</h2>
|
||||||
|
<form @submit.prevent="changePass" class="flex flex-col gap-2">
|
||||||
|
<PasswordInput
|
||||||
|
v-model="newPass"
|
||||||
|
placeholder="New password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
|
||||||
|
<button type="submit" class="btn btn-sm" :disabled="loadingPass">Save password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const api = useMapApi()
|
||||||
|
const tokens = ref<string[]>([])
|
||||||
|
const uploadPrefix = ref('')
|
||||||
|
const newPass = ref('')
|
||||||
|
const loadingTokens = ref(false)
|
||||||
|
const loadingPass = ref(false)
|
||||||
|
const passMsg = ref('')
|
||||||
|
const passOk = ref(false)
|
||||||
|
const tokenError = ref('')
|
||||||
|
|
||||||
|
function uploadTokenDisplay(token: string): string {
|
||||||
|
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
|
||||||
|
return base ? `${base}/client/${token}` : `client/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken(token: string) {
|
||||||
|
navigator.clipboard.writeText(uploadTokenDisplay(token)).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const me = await api.me()
|
||||||
|
tokens.value = me.tokens ?? []
|
||||||
|
uploadPrefix.value = me.prefix ?? ''
|
||||||
|
} catch {
|
||||||
|
tokens.value = []
|
||||||
|
uploadPrefix.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function generateToken() {
|
||||||
|
tokenError.value = ''
|
||||||
|
loadingTokens.value = true
|
||||||
|
try {
|
||||||
|
await api.meTokens()
|
||||||
|
const me = await api.me()
|
||||||
|
tokens.value = me.tokens ?? []
|
||||||
|
uploadPrefix.value = me.prefix ?? ''
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : ''
|
||||||
|
tokenError.value = msg === 'Forbidden'
|
||||||
|
? 'You need "upload" permission to generate tokens. Ask an admin to add it to your account.'
|
||||||
|
: (msg || 'Failed to generate token')
|
||||||
|
} finally {
|
||||||
|
loadingTokens.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePass() {
|
||||||
|
passMsg.value = ''
|
||||||
|
loadingPass.value = true
|
||||||
|
try {
|
||||||
|
await api.mePassword(newPass.value)
|
||||||
|
passMsg.value = 'Password updated.'
|
||||||
|
passOk.value = true
|
||||||
|
newPass.value = ''
|
||||||
|
} catch (e: unknown) {
|
||||||
|
passMsg.value = e instanceof Error ? e.message : 'Failed'
|
||||||
|
passOk.value = false
|
||||||
|
} finally {
|
||||||
|
loadingPass.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
60
frontend-nuxt/pages/setup.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4">
|
||||||
|
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="card-title justify-center">First-time setup</h1>
|
||||||
|
<p class="text-sm text-base-content/80">
|
||||||
|
This is the first run. Create the administrator account using the bootstrap password
|
||||||
|
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||||
|
<PasswordInput
|
||||||
|
v-model="pass"
|
||||||
|
label="Bootstrap password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? '…' : 'Create and log in' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No auth required; auth.global skips this path
|
||||||
|
|
||||||
|
const pass = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useMapApi()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { setupRequired: required } = await api.setupRequired()
|
||||||
|
if (!required) await navigateTo('/login')
|
||||||
|
} catch {
|
||||||
|
// If API fails, stay on page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.login('admin', pass.value)
|
||||||
|
await router.push('/profile')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = (e as Error)?.message === 'Unauthorized'
|
||||||
|
? 'Invalid bootstrap password.'
|
||||||
|
: (e as Error)?.message || 'Setup failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
31
frontend-nuxt/plugins/vite-uri-guard.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only: reject requests with malformed URIs before Vite's static/transform
|
||||||
|
* middleware runs decodeURI(), which would throw and crash the server.
|
||||||
|
* See: https://github.com/vitejs/vite/issues/6482
|
||||||
|
*/
|
||||||
|
export function viteUriGuard(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite-uri-guard',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
const guard = (req: any, res: any, next: () => void) => {
|
||||||
|
const raw = req.url ?? req.originalUrl ?? ''
|
||||||
|
try {
|
||||||
|
decodeURI(raw)
|
||||||
|
const path = raw.includes('?') ? raw.slice(0, raw.indexOf('?')) : raw
|
||||||
|
if (path) decodeURI(path)
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 400
|
||||||
|
res.setHeader('Content-Type', 'text/plain')
|
||||||
|
res.end('Bad Request: malformed URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
// Prepend so we run before Vite's static/transform middleware (which calls decodeURI)
|
||||||
|
server.middlewares.stack.unshift({ route: '', handle: guard })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend-nuxt/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend-nuxt/public/gfx/invobjs/small/bumling.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend-nuxt/public/gfx/invobjs/small/bush.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/abyssalchasm.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/algaeblob.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/caveorgan.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/claypit.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/coralreef.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/crystalpatch.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/custom.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/fairystone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/geyser.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/guanopile.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/headwaters.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/icespire.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/irminsul.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/jotunmussel.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/lilypadlotus.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/monolith.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/saltbasin.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/tarpit.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/watervortex.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/windthrow.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/woodheart.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
20
frontend-nuxt/public/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"
|
||||||
|
integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="
|
||||||
|
crossorigin=""/>
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but map-frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
661
frontend-nuxt/public/leaflet.css
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend-nuxt/public/map/marker-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend-nuxt/public/map/marker-shadow.png
Normal file
|
After Width: | Height: | Size: 618 B |
12
frontend-nuxt/server/middleware/0.uri-guard.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Reject requests with malformed URI before they reach Vite.
|
||||||
|
* Vite's static middleware calls decodeURI() and throws "URI malformed" on invalid sequences (e.g. %2: or %91).
|
||||||
|
*/
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const path = event.path ?? event.node.req.url ?? ''
|
||||||
|
try {
|
||||||
|
decodeURI(path)
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
|
||||||
|
}
|
||||||
|
})
|
||||||
15
frontend-nuxt/tailwind.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./components/**/*.{js,vue,ts}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./plugins/**/*.{js,ts}',
|
||||||
|
'./app.vue',
|
||||||
|
'./lib/**/*.js',
|
||||||
|
],
|
||||||
|
plugins: [require('daisyui')],
|
||||||
|
daisyui: {
|
||||||
|
themes: ['light', 'dark'],
|
||||||
|
},
|
||||||
|
}
|
||||||
3
frontend-nuxt/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module github.com/andyleap/hnh-map
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
go.etcd.io/bbolt v1.3.3
|
||||||
|
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
|
||||||
|
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect
|
||||||
|
)
|
||||||
15
go.sum
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY=
|
||||||
|
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||||
|
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
|
||||||
|
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM=
|
||||||
|
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
|
||||||
|
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So=
|
||||||
|
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=
|
||||||
1124
internal/app/admin.go
Normal file
832
internal/app/api.go
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Auth API ---
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Auths []string `json:"auths"`
|
||||||
|
Tokens []string `json:"tokens,omitempty"`
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiLogin(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body loginRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := a.getUser(body.User, body.Pass)
|
||||||
|
if u == nil {
|
||||||
|
// Bootstrap: first admin via env HNHMAP_BOOTSTRAP_PASSWORD when no users exist
|
||||||
|
if body.User == "admin" && body.Pass != "" {
|
||||||
|
bootstrap := os.Getenv("HNHMAP_BOOTSTRAP_PASSWORD")
|
||||||
|
if bootstrap != "" && body.Pass == bootstrap {
|
||||||
|
var created bool
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if users.Get([]byte("admin")) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Pass), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := User{Pass: hash, Auths: Auths{AUTH_ADMIN, AUTH_MAP, AUTH_MARKERS, AUTH_UPLOAD}}
|
||||||
|
raw, _ := json.Marshal(u)
|
||||||
|
users.Put([]byte("admin"), raw)
|
||||||
|
created = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if created {
|
||||||
|
u = &User{Auths: Auths{AUTH_ADMIN, AUTH_MAP, AUTH_MARKERS, AUTH_UPLOAD}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionID := a.createSession(body.User, u.Auths.Has("tempadmin"))
|
||||||
|
if sessionID == "" {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(rw, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: sessionID,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 24 * 7 * 3600,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: req.TLS != nil,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(meResponse{
|
||||||
|
Username: body.User,
|
||||||
|
Auths: u.Auths,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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([]byte("users"))
|
||||||
|
if ub == nil {
|
||||||
|
required = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ub.Stats().KeyN == 0 {
|
||||||
|
required = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return required
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiSetup(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(struct {
|
||||||
|
SetupRequired bool `json:"setupRequired"`
|
||||||
|
}{SetupRequired: a.setupRequired()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiLogout(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s != nil {
|
||||||
|
a.deleteSession(s)
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil {
|
||||||
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := meResponse{Username: s.Username, Auths: s.Auths}
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
ub := tx.Bucket([]byte("users"))
|
||||||
|
if ub != nil {
|
||||||
|
uRaw := ub.Get([]byte(s.Username))
|
||||||
|
if uRaw != nil {
|
||||||
|
u := User{}
|
||||||
|
json.Unmarshal(uRaw, &u)
|
||||||
|
out.Tokens = u.Tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config := tx.Bucket([]byte("config"))
|
||||||
|
if config != nil {
|
||||||
|
out.Prefix = string(config.Get([]byte("prefix")))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cabinet API ---
|
||||||
|
|
||||||
|
func (a *App) apiMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil {
|
||||||
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Auths.Has(AUTH_UPLOAD) {
|
||||||
|
rw.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokens := a.generateTokenForUser(s.Username)
|
||||||
|
if tokens == nil {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(map[string][]string{"tokens": tokens})
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordRequest struct {
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiMePassword(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil {
|
||||||
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body passwordRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.setUserPassword(s.Username, body.Pass); err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTokenForUser adds a new token for user and returns the full list.
|
||||||
|
func (a *App) generateTokenForUser(username string) []string {
|
||||||
|
tokenRaw := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(tokenRaw); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(tokenRaw)
|
||||||
|
var tokens []string
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
ub, _ := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
uRaw := ub.Get([]byte(username))
|
||||||
|
u := User{}
|
||||||
|
if uRaw != nil {
|
||||||
|
json.Unmarshal(uRaw, &u)
|
||||||
|
}
|
||||||
|
u.Tokens = append(u.Tokens, token)
|
||||||
|
tokens = u.Tokens
|
||||||
|
buf, _ := json.Marshal(u)
|
||||||
|
ub.Put([]byte(username), buf)
|
||||||
|
tb, _ := tx.CreateBucketIfNotExists([]byte("tokens"))
|
||||||
|
return tb.Put([]byte(token), []byte(username))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// setUserPassword sets password for user (empty pass = no change).
|
||||||
|
func (a *App) setUserPassword(username, pass string) error {
|
||||||
|
if pass == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := User{}
|
||||||
|
raw := users.Get([]byte(username))
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &u)
|
||||||
|
}
|
||||||
|
u.Pass, _ = bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||||
|
raw, _ = json.Marshal(u)
|
||||||
|
return users.Put([]byte(username), raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin API (require admin auth) ---
|
||||||
|
|
||||||
|
func (a *App) requireAdmin(rw http.ResponseWriter, req *http.Request) *Session {
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
|
||||||
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("users"))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.ForEach(func(k, _ []byte) error {
|
||||||
|
list = append(list, string(k))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Auths []string `json:"auths"`
|
||||||
|
}
|
||||||
|
out.Username = name
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("users"))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := b.Get([]byte(name))
|
||||||
|
if raw != nil {
|
||||||
|
u := User{}
|
||||||
|
json.Unmarshal(raw, &u)
|
||||||
|
out.Auths = u.Auths
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminUserBody struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
Auths []string `json:"auths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminUserPost(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.requireAdmin(rw, req)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body adminUserBody
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempAdmin := false
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Username == "admin" && users.Get([]byte("admin")) == nil {
|
||||||
|
tempAdmin = true
|
||||||
|
}
|
||||||
|
u := User{}
|
||||||
|
raw := users.Get([]byte(body.User))
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &u)
|
||||||
|
}
|
||||||
|
if body.Pass != "" {
|
||||||
|
u.Pass, _ = bcrypt.GenerateFromPassword([]byte(body.Pass), bcrypt.DefaultCost)
|
||||||
|
}
|
||||||
|
u.Auths = body.Auths
|
||||||
|
raw, _ = json.Marshal(u)
|
||||||
|
return users.Put([]byte(body.User), raw)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.User == s.Username {
|
||||||
|
s.Auths = body.Auths
|
||||||
|
}
|
||||||
|
if tempAdmin {
|
||||||
|
a.deleteSession(s)
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
|
||||||
|
if req.Method != http.MethodDelete {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.requireAdmin(rw, req)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
users, _ := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
u := User{}
|
||||||
|
raw := users.Get([]byte(name))
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &u)
|
||||||
|
}
|
||||||
|
tokens, _ := tx.CreateBucketIfNotExists([]byte("tokens"))
|
||||||
|
for _, tok := range u.Tokens {
|
||||||
|
tokens.Delete([]byte(tok))
|
||||||
|
}
|
||||||
|
return users.Delete([]byte(name))
|
||||||
|
})
|
||||||
|
if name == s.Username {
|
||||||
|
a.deleteSession(s)
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsResponse struct {
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
DefaultHide bool `json:"defaultHide"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := settingsResponse{}
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
c := tx.Bucket([]byte("config"))
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out.Prefix = string(c.Get([]byte("prefix")))
|
||||||
|
out.DefaultHide = c.Get([]byte("defaultHide")) != nil
|
||||||
|
out.Title = string(c.Get([]byte("title")))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsBody struct {
|
||||||
|
Prefix *string `json:"prefix"`
|
||||||
|
DefaultHide *bool `json:"defaultHide"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body settingsBody
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body.Prefix != nil {
|
||||||
|
b.Put([]byte("prefix"), []byte(*body.Prefix))
|
||||||
|
}
|
||||||
|
if body.DefaultHide != nil {
|
||||||
|
if *body.DefaultHide {
|
||||||
|
b.Put([]byte("defaultHide"), []byte("1"))
|
||||||
|
} else {
|
||||||
|
b.Delete([]byte("defaultHide"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Title != nil {
|
||||||
|
b.Put([]byte("title"), []byte(*body.Title))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapInfoJSON struct {
|
||||||
|
ID int `json:"ID"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Hidden bool `json:"Hidden"`
|
||||||
|
Priority bool `json:"Priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var maps []mapInfoJSON
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
mapB := tx.Bucket([]byte("maps"))
|
||||||
|
if mapB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mapB.ForEach(func(k, v []byte) error {
|
||||||
|
mi := MapInfo{}
|
||||||
|
json.Unmarshal(v, &mi)
|
||||||
|
if id, err := strconv.Atoi(string(k)); err == nil {
|
||||||
|
mi.ID = id
|
||||||
|
}
|
||||||
|
maps = append(maps, mapInfoJSON{
|
||||||
|
ID: mi.ID,
|
||||||
|
Name: mi.Name,
|
||||||
|
Hidden: mi.Hidden,
|
||||||
|
Priority: mi.Priority,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(maps)
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminMapBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost {
|
||||||
|
// update map
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body adminMapBody
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw := maps.Get([]byte(strconv.Itoa(id)))
|
||||||
|
mi := MapInfo{}
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &mi)
|
||||||
|
}
|
||||||
|
mi.ID = id
|
||||||
|
mi.Name = body.Name
|
||||||
|
mi.Hidden = body.Hidden
|
||||||
|
mi.Priority = body.Priority
|
||||||
|
raw, _ = json.Marshal(mi)
|
||||||
|
return maps.Put([]byte(strconv.Itoa(id)), raw)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var mi MapInfo
|
||||||
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw := maps.Get([]byte(strconv.Itoa(id)))
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &mi)
|
||||||
|
}
|
||||||
|
mi.ID = id
|
||||||
|
mi.Hidden = !mi.Hidden
|
||||||
|
raw, _ = json.Marshal(mi)
|
||||||
|
return maps.Put([]byte(strconv.Itoa(id)), raw)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(mapInfoJSON{
|
||||||
|
ID: mi.ID,
|
||||||
|
Name: mi.Name,
|
||||||
|
Hidden: mi.Hidden,
|
||||||
|
Priority: mi.Priority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminWipe(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, bname := range []string{"grids", "markers", "tiles", "maps"} {
|
||||||
|
if tx.Bucket([]byte(bname)) != nil {
|
||||||
|
if err := tx.DeleteBucket([]byte(bname)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.doRebuildZooms()
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminExport(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.export(rw, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) apiAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.merge(rw, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Redirects (for old URLs) ---
|
||||||
|
|
||||||
|
func (a *App) redirectRoot(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/" {
|
||||||
|
http.NotFound(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.setupRequired() {
|
||||||
|
http.Redirect(rw, req, "/map/setup", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(rw, req, "/map/profile", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) redirectLogin(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/login" {
|
||||||
|
http.NotFound(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(rw, req, "/map/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API router: /map/api/... ---
|
||||||
|
|
||||||
|
func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
path := strings.TrimPrefix(req.URL.Path, "/map/api")
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
|
||||||
|
// Delegate to existing handlers
|
||||||
|
switch path {
|
||||||
|
case "config":
|
||||||
|
a.config(rw, req)
|
||||||
|
return
|
||||||
|
case "v1/characters":
|
||||||
|
a.getChars(rw, req)
|
||||||
|
return
|
||||||
|
case "v1/markers":
|
||||||
|
a.getMarkers(rw, req)
|
||||||
|
return
|
||||||
|
case "maps":
|
||||||
|
a.getMaps(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" {
|
||||||
|
switch path {
|
||||||
|
case "admin/wipeTile":
|
||||||
|
a.wipeTile(rw, req)
|
||||||
|
case "admin/setCoords":
|
||||||
|
a.setCoords(rw, req)
|
||||||
|
case "admin/hideMarker":
|
||||||
|
a.hideMarker(rw, req)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case path == "setup":
|
||||||
|
a.apiSetup(rw, req)
|
||||||
|
return
|
||||||
|
case path == "login":
|
||||||
|
a.apiLogin(rw, req)
|
||||||
|
return
|
||||||
|
case path == "logout":
|
||||||
|
a.apiLogout(rw, req)
|
||||||
|
return
|
||||||
|
case path == "me":
|
||||||
|
a.apiMe(rw, req)
|
||||||
|
return
|
||||||
|
case path == "me/tokens":
|
||||||
|
a.apiMeTokens(rw, req)
|
||||||
|
return
|
||||||
|
case path == "me/password":
|
||||||
|
a.apiMePassword(rw, req)
|
||||||
|
return
|
||||||
|
case path == "admin/users":
|
||||||
|
if req.Method == http.MethodPost {
|
||||||
|
a.apiAdminUserPost(rw, req)
|
||||||
|
} else {
|
||||||
|
a.apiAdminUsers(rw, req)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case strings.HasPrefix(path, "admin/users/"):
|
||||||
|
name := strings.TrimPrefix(path, "admin/users/")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(rw, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodDelete {
|
||||||
|
a.apiAdminUserDelete(rw, req, name)
|
||||||
|
} else {
|
||||||
|
a.apiAdminUserByName(rw, req, name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case path == "admin/settings":
|
||||||
|
if req.Method == http.MethodGet {
|
||||||
|
a.apiAdminSettingsGet(rw, req)
|
||||||
|
} else {
|
||||||
|
a.apiAdminSettingsPost(rw, req)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case path == "admin/maps":
|
||||||
|
a.apiAdminMaps(rw, req)
|
||||||
|
return
|
||||||
|
case strings.HasPrefix(path, "admin/maps/"):
|
||||||
|
rest := strings.TrimPrefix(path, "admin/maps/")
|
||||||
|
parts := strings.SplitN(rest, "/", 2)
|
||||||
|
idStr := parts[0]
|
||||||
|
if len(parts) == 2 && parts[1] == "toggle-hidden" {
|
||||||
|
a.apiAdminMapToggleHidden(rw, req, idStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
a.apiAdminMapByID(rw, req, idStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(rw, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
case path == "admin/wipe":
|
||||||
|
a.apiAdminWipe(rw, req)
|
||||||
|
return
|
||||||
|
case path == "admin/rebuildZooms":
|
||||||
|
a.apiAdminRebuildZooms(rw, req)
|
||||||
|
return
|
||||||
|
case path == "admin/export":
|
||||||
|
a.apiAdminExport(rw, req)
|
||||||
|
return
|
||||||
|
case path == "admin/merge":
|
||||||
|
a.apiAdminMerge(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST admin/users (create)
|
||||||
|
if path == "admin/users" && req.Method == http.MethodPost {
|
||||||
|
a.apiAdminUserPost(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(rw, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
331
internal/app/app.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/webapp"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the main application (map server) state.
|
||||||
|
type App struct {
|
||||||
|
gridStorage string
|
||||||
|
frontendRoot string
|
||||||
|
db *bbolt.DB
|
||||||
|
|
||||||
|
characters map[string]Character
|
||||||
|
chmu sync.RWMutex
|
||||||
|
|
||||||
|
*webapp.WebApp
|
||||||
|
|
||||||
|
gridUpdates topic
|
||||||
|
mergeUpdates mergeTopic
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApp creates an App with the given storage paths and database.
|
||||||
|
// frontendRoot is the directory for the map SPA (e.g. "frontend").
|
||||||
|
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
|
||||||
|
w, err := webapp.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &App{
|
||||||
|
gridStorage: gridStorage,
|
||||||
|
frontendRoot: frontendRoot,
|
||||||
|
db: db,
|
||||||
|
characters: make(map[string]Character),
|
||||||
|
WebApp: w,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Auths Auths `json:"-"`
|
||||||
|
TempAdmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Character struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Map int `json:"map"`
|
||||||
|
Position Position `json:"position"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
updated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Marker struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
GridID string `json:"gridID"`
|
||||||
|
Position Position `json:"position"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrontendMarker struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Map int `json:"map"`
|
||||||
|
Position Position `json:"position"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapInfo struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Hidden bool
|
||||||
|
Priority bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridData struct {
|
||||||
|
ID string
|
||||||
|
Coord Coord
|
||||||
|
NextUpdate time.Time
|
||||||
|
Map int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Coord struct {
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Position struct {
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Coord) Name() string {
|
||||||
|
return fmt.Sprintf("%d_%d", c.X, c.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Coord) Parent() Coord {
|
||||||
|
if c.X < 0 {
|
||||||
|
c.X--
|
||||||
|
}
|
||||||
|
if c.Y < 0 {
|
||||||
|
c.Y--
|
||||||
|
}
|
||||||
|
return Coord{
|
||||||
|
X: c.X / 2,
|
||||||
|
Y: c.Y / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Auths []string
|
||||||
|
|
||||||
|
func (a Auths) Has(auth string) bool {
|
||||||
|
for _, v := range a {
|
||||||
|
if v == auth {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
AUTH_ADMIN = "admin"
|
||||||
|
AUTH_MAP = "map"
|
||||||
|
AUTH_MARKERS = "markers"
|
||||||
|
AUTH_UPLOAD = "upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Pass []byte
|
||||||
|
Auths Auths
|
||||||
|
Tokens []string
|
||||||
|
}
|
||||||
|
|
||||||
|
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([]byte("sessions"))
|
||||||
|
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([]byte("users"))
|
||||||
|
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([]byte("sessions"))
|
||||||
|
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([]byte("sessions"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sessions.Put([]byte(s.ID), buf)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) getPage(req *http.Request) Page {
|
||||||
|
p := Page{}
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
c := tx.Bucket([]byte("config"))
|
||||||
|
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([]byte("users"))
|
||||||
|
if users == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := users.Get([]byte(user))
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &u)
|
||||||
|
if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil {
|
||||||
|
u = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveMapFrontend serves the map SPA: static files from frontend, fallback to index.html for client-side routes.
|
||||||
|
func (a *App) serveMapFrontend(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
path := req.URL.Path
|
||||||
|
if len(path) <= len("/map/") {
|
||||||
|
path = ""
|
||||||
|
} else {
|
||||||
|
path = path[len("/map/"):]
|
||||||
|
}
|
||||||
|
root := a.frontendRoot
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
if path == "." || path == ".." || (len(path) >= 2 && path[:2] == "..") {
|
||||||
|
http.NotFound(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tryPaths := []string{filepath.Join("map", path), path}
|
||||||
|
var f http.File
|
||||||
|
for _, p := range tryPaths {
|
||||||
|
var err error
|
||||||
|
f, err = http.Dir(root).Open(p)
|
||||||
|
if err == nil {
|
||||||
|
path = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
http.ServeFile(rw, req, filepath.Join(root, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil || stat.IsDir() {
|
||||||
|
http.ServeFile(rw, req, filepath.Join(root, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanChars runs a background loop that removes stale character entries. Call once as a goroutine.
|
||||||
|
func (a *App) CleanChars() {
|
||||||
|
for range time.Tick(time.Second * 10) {
|
||||||
|
a.chmu.Lock()
|
||||||
|
for n, c := range a.characters {
|
||||||
|
if c.updated.Before(time.Now().Add(-10 * time.Second)) {
|
||||||
|
delete(a.characters, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.chmu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all HTTP handlers for the app.
|
||||||
|
func (a *App) RegisterRoutes() {
|
||||||
|
http.HandleFunc("/client/", a.client)
|
||||||
|
|
||||||
|
http.HandleFunc("/login", a.redirectLogin)
|
||||||
|
http.HandleFunc("/logout", a.logout)
|
||||||
|
http.HandleFunc("/", a.redirectRoot)
|
||||||
|
http.HandleFunc("/generateToken", a.generateToken)
|
||||||
|
http.HandleFunc("/password", a.changePassword)
|
||||||
|
|
||||||
|
http.HandleFunc("/admin/", a.admin)
|
||||||
|
http.HandleFunc("/admin/user", a.adminUser)
|
||||||
|
http.HandleFunc("/admin/deleteUser", a.deleteUser)
|
||||||
|
http.HandleFunc("/admin/wipe", a.wipe)
|
||||||
|
http.HandleFunc("/admin/setPrefix", a.setPrefix)
|
||||||
|
http.HandleFunc("/admin/setDefaultHide", a.setDefaultHide)
|
||||||
|
http.HandleFunc("/admin/setTitle", a.setTitle)
|
||||||
|
http.HandleFunc("/admin/rebuildZooms", a.rebuildZooms)
|
||||||
|
http.HandleFunc("/admin/export", a.export)
|
||||||
|
http.HandleFunc("/admin/merge", a.merge)
|
||||||
|
http.HandleFunc("/admin/map", a.adminMap)
|
||||||
|
http.HandleFunc("/admin/mapic", a.adminICMap)
|
||||||
|
|
||||||
|
http.HandleFunc("/map/api/", a.apiRouter)
|
||||||
|
http.HandleFunc("/map/updates", a.watchGridUpdates)
|
||||||
|
http.HandleFunc("/map/grids/", a.gridTile)
|
||||||
|
http.HandleFunc("/map/", a.serveMapFrontend)
|
||||||
|
}
|
||||||
706
internal/app/client.go
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
|
||||||
|
"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([]byte("tokens"))
|
||||||
|
if tb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
userName := tb.Get([]byte(matches[1]))
|
||||||
|
if userName == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ub := tx.Bucket([]byte("users"))
|
||||||
|
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 "mapData":
|
||||||
|
a.mapdataIndex(rw, req)*/
|
||||||
|
case "":
|
||||||
|
http.Redirect(rw, req, "/map/", 302)
|
||||||
|
case "checkVersion":
|
||||||
|
if req.FormValue("version") == VERSION {
|
||||||
|
rw.WriteHeader(200)
|
||||||
|
} else {
|
||||||
|
rw.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
rw.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := ioutil.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
|
||||||
|
}
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
if grids == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a.chmu.Lock()
|
||||||
|
defer a.chmu.Unlock()
|
||||||
|
for id, craw := range craws {
|
||||||
|
grid := grids.Get([]byte(craw.GridID))
|
||||||
|
if grid == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
gd := GridData{}
|
||||||
|
json.Unmarshal(grid, &gd)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := ioutil.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([]byte("markers"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
grid, err := mb.CreateBucketIfNotExists([]byte("grid"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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([]byte("grids"))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridUpdate struct {
|
||||||
|
Grids [][]string `json:"grids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridRequest struct {
|
||||||
|
GridRequests []string `json:"gridRequests"`
|
||||||
|
Map int `json:"map"`
|
||||||
|
Coords Coord `json:"coords"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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([]byte("grids"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
mapB, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configb, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||||
|
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) mapdataIndex(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
err := a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
if grids == nil {
|
||||||
|
return fmt.Errorf("grid not found")
|
||||||
|
}
|
||||||
|
return grids.ForEach(func(k, v []byte) error {
|
||||||
|
cur := GridData{}
|
||||||
|
err := json.Unmarshal(v, &cur)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(rw, "%s,%d,%d,%d\n", cur.ID, cur.Map, cur.Coord.X, cur.Coord.Y)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rw.WriteHeader(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ExtraData struct {
|
||||||
|
Season int
|
||||||
|
}
|
||||||
|
|
||||||
|
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([]byte("grids"))
|
||||||
|
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([]byte("tiles"))
|
||||||
|
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([]byte("grids"))
|
||||||
|
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)
|
||||||
|
}
|
||||||
175
internal/app/manage.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) index(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil {
|
||||||
|
http.Redirect(rw, req, "/login", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := []string{}
|
||||||
|
prefix := "http://example.com"
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("users"))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
uRaw := b.Get([]byte(s.Username))
|
||||||
|
if uRaw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u := User{}
|
||||||
|
json.Unmarshal(uRaw, &u)
|
||||||
|
tokens = u.Tokens
|
||||||
|
|
||||||
|
config := tx.Bucket([]byte("config"))
|
||||||
|
if config != nil {
|
||||||
|
prefix = string(config.Get([]byte("prefix")))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
a.ExecuteTemplate(rw, "index.tmpl", struct {
|
||||||
|
Page Page
|
||||||
|
Session *Session
|
||||||
|
UploadTokens []string
|
||||||
|
Prefix string
|
||||||
|
}{
|
||||||
|
Page: a.getPage(req),
|
||||||
|
Session: s,
|
||||||
|
UploadTokens: tokens,
|
||||||
|
Prefix: prefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) login(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method == "POST" {
|
||||||
|
u := a.getUser(req.FormValue("user"), req.FormValue("pass"))
|
||||||
|
if u != nil {
|
||||||
|
session := make([]byte, 32)
|
||||||
|
rand.Read(session)
|
||||||
|
http.SetCookie(rw, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Expires: time.Now().Add(time.Hour * 24 * 7),
|
||||||
|
Value: hex.EncodeToString(session),
|
||||||
|
})
|
||||||
|
s := &Session{
|
||||||
|
ID: hex.EncodeToString(session),
|
||||||
|
Username: req.FormValue("user"),
|
||||||
|
TempAdmin: u.Auths.Has("tempadmin"),
|
||||||
|
}
|
||||||
|
a.saveSession(s)
|
||||||
|
http.Redirect(rw, req, "/", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.ExecuteTemplate(rw, "login.tmpl", struct {
|
||||||
|
Page Page
|
||||||
|
}{
|
||||||
|
Page: a.getPage(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) logout(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s != nil {
|
||||||
|
a.deleteSession(s)
|
||||||
|
}
|
||||||
|
http.Redirect(rw, req, "/login", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateToken(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil || !s.Auths.Has(AUTH_UPLOAD) {
|
||||||
|
http.Redirect(rw, req, "/", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenRaw := make([]byte, 16)
|
||||||
|
_, err := rand.Read(tokenRaw)
|
||||||
|
if err != nil {
|
||||||
|
rw.WriteHeader(500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(tokenRaw)
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
ub, err := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uRaw := ub.Get([]byte(s.Username))
|
||||||
|
if uRaw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u := User{}
|
||||||
|
err = json.Unmarshal(uRaw, &u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Tokens = append(u.Tokens, token)
|
||||||
|
buf, err := json.Marshal(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = ub.Put([]byte(s.Username), buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte("tokens"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put([]byte(token), []byte(s.Username))
|
||||||
|
})
|
||||||
|
http.Redirect(rw, req, "/", 302)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) changePassword(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil {
|
||||||
|
http.Redirect(rw, req, "/", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Method == "POST" {
|
||||||
|
req.ParseForm()
|
||||||
|
password := req.FormValue("pass")
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := User{}
|
||||||
|
raw := users.Get([]byte(s.Username))
|
||||||
|
if raw != nil {
|
||||||
|
json.Unmarshal(raw, &u)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
u.Pass, _ = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
}
|
||||||
|
raw, _ = json.Marshal(u)
|
||||||
|
users.Put([]byte(s.Username), raw)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
http.Redirect(rw, req, "/", 302)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ExecuteTemplate(rw, "password.tmpl", struct {
|
||||||
|
Page Page
|
||||||
|
Session *Session
|
||||||
|
}{
|
||||||
|
Page: a.getPage(req),
|
||||||
|
Session: s,
|
||||||
|
})
|
||||||
|
}
|
||||||
144
internal/app/map.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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([]byte("markers"))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
grid := b.Bucket([]byte("grid"))
|
||||||
|
if grid == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
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([]byte("maps"))
|
||||||
|
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([]byte("config"))
|
||||||
|
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)
|
||||||
|
}
|
||||||
198
internal/app/migrations.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrations = []func(tx *bbolt.Tx) error{
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
if tx.Bucket([]byte("markers")) != nil {
|
||||||
|
return tx.DeleteBucket([]byte("markers"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zoom, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(0)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return grids.ForEach(func(k, v []byte) error {
|
||||||
|
g := GridData{}
|
||||||
|
err := json.Unmarshal(v, &g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
td := &TileData{
|
||||||
|
Coord: g.Coord,
|
||||||
|
Zoom: 0,
|
||||||
|
File: fmt.Sprintf("0/%s", g.Coord.Name()),
|
||||||
|
Cache: time.Now().UnixNano(),
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(td)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zoom.Put([]byte(g.Coord.Name()), raw)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put([]byte("title"), []byte("HnH Automapper Server"))
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
if tx.Bucket([]byte("markers")) != nil {
|
||||||
|
return tx.DeleteBucket([]byte("markers"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
if tx.Bucket([]byte("tiles")) != nil {
|
||||||
|
allTiles := map[string]map[string]TileData{}
|
||||||
|
tiles := tx.Bucket([]byte("tiles"))
|
||||||
|
err := tiles.ForEach(func(k, v []byte) error {
|
||||||
|
zoom := tiles.Bucket(k)
|
||||||
|
zoomTiles := map[string]TileData{}
|
||||||
|
|
||||||
|
allTiles[string(k)] = zoomTiles
|
||||||
|
return zoom.ForEach(func(tk, tv []byte) error {
|
||||||
|
td := TileData{}
|
||||||
|
json.Unmarshal(tv, &td)
|
||||||
|
zoomTiles[string(tk)] = td
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.DeleteBucket([]byte("tiles"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tiles, err = tx.CreateBucket([]byte("tiles"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maptiles, err := tiles.CreateBucket([]byte("0"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for k, v := range allTiles {
|
||||||
|
zoom, err := maptiles.CreateBucket([]byte(k))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for tk, tv := range v {
|
||||||
|
raw, _ := json.Marshal(tv)
|
||||||
|
err = zoom.Put([]byte(strings.TrimSuffix(tk, ".png")), raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tiles.SetSequence(1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
if tx.Bucket([]byte("markers")) != nil {
|
||||||
|
return tx.DeleteBucket([]byte("markers"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
highest := uint64(0)
|
||||||
|
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mapsFound := map[int]struct{}{}
|
||||||
|
err = grids.ForEach(func(k, v []byte) error {
|
||||||
|
gd := GridData{}
|
||||||
|
err := json.Unmarshal(v, &gd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := mapsFound[gd.Map]; !ok {
|
||||||
|
if uint64(gd.Map) > highest {
|
||||||
|
highest = uint64(gd.Map)
|
||||||
|
}
|
||||||
|
mi := MapInfo{
|
||||||
|
ID: gd.Map,
|
||||||
|
Name: strconv.Itoa(gd.Map),
|
||||||
|
Hidden: false,
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(mi)
|
||||||
|
return maps.Put([]byte(strconv.Itoa(gd.Map)), raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return maps.SetSequence(highest + 1)
|
||||||
|
},
|
||||||
|
func(tx *bbolt.Tx) error {
|
||||||
|
users := tx.Bucket([]byte("users"))
|
||||||
|
if users == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return users.ForEach(func(k, v []byte) error {
|
||||||
|
u := User{}
|
||||||
|
json.Unmarshal(v, &u)
|
||||||
|
if u.Auths.Has(AUTH_MAP) && !u.Auths.Has(AUTH_MARKERS) {
|
||||||
|
u.Auths = append(u.Auths, AUTH_MARKERS)
|
||||||
|
raw, err := json.Marshal(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users.Put(k, raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations runs all pending migrations on the database.
|
||||||
|
func RunMigrations(db *bbolt.DB) error {
|
||||||
|
return db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vraw := b.Get([]byte("version"))
|
||||||
|
v, _ := strconv.Atoi(string(vraw))
|
||||||
|
if v < len(migrations) {
|
||||||
|
for _, f := range migrations[v:] {
|
||||||
|
if err := f(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.Put([]byte("version"), []byte(strconv.Itoa(len(migrations))))
|
||||||
|
})
|
||||||
|
}
|
||||||
269
internal/app/tile.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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([]byte("tiles"))
|
||||||
|
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([]byte("tiles"))
|
||||||
|
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([]byte("tiles"))
|
||||||
|
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))
|
||||||
|
}
|
||||||
72
internal/app/topic.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type topic struct {
|
||||||
|
c []chan *TileData
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) watch(c chan *TileData) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.c = append(t.c, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) send(b *TileData) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
for i := 0; i < len(t.c); i++ {
|
||||||
|
select {
|
||||||
|
case t.c[i] <- b:
|
||||||
|
default:
|
||||||
|
close(t.c[i])
|
||||||
|
t.c[i] = t.c[len(t.c)-1]
|
||||||
|
t.c = t.c[:len(t.c)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) close() {
|
||||||
|
for _, c := range t.c {
|
||||||
|
close(c)
|
||||||
|
}
|
||||||
|
t.c = t.c[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Merge struct {
|
||||||
|
From, To int
|
||||||
|
Shift Coord
|
||||||
|
}
|
||||||
|
|
||||||
|
type mergeTopic struct {
|
||||||
|
c []chan *Merge
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *mergeTopic) watch(c chan *Merge) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.c = append(t.c, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *mergeTopic) send(b *Merge) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
for i := 0; i < len(t.c); i++ {
|
||||||
|
select {
|
||||||
|
case t.c[i] <- b:
|
||||||
|
default:
|
||||||
|
close(t.c[i])
|
||||||
|
t.c[i] = t.c[len(t.c)-1]
|
||||||
|
t.c = t.c[:len(t.c)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *mergeTopic) close() {
|
||||||
|
for _, c := range t.c {
|
||||||
|
close(c)
|
||||||
|
}
|
||||||
|
t.c = t.c[:0]
|
||||||
|
}
|
||||||
151
webapp/templates/admin/index.tmpl
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<script src="/js/zepto.min.js"></script>
|
||||||
|
<script src="/js/intercooler-1.2.1.min.js"></script>
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
<title>{{.Page.Title}} - Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.}}</td>
|
||||||
|
<td><a href="/admin/user?user={{.}}" class="waves-effect waves-light btn">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<a href="/admin/user" class="waves-effect waves-light btn">Add user</a>
|
||||||
|
<br>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Map</th>
|
||||||
|
<th colspan="2">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Maps}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td><a ic-post-to="/admin/mapic?map={{.ID}}&action=toggle-hidden" class="waves-effect waves-light btn">{{block "admin/index.tmpl:toggle-hidden" .}}{{if .Hidden}}Show{{else}}Hide{{end}}{{end}}</a></td>
|
||||||
|
<td><a href="/admin/map?map={{.ID}}" class="waves-effect waves-light btn">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Default maps to hidden</h5>
|
||||||
|
<p>This makes new map layers hidden by default</p>
|
||||||
|
<form action="/admin/setDefaultHide" method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="defaultHide" value="true"{{if .DefaultHide}} checked="checked"{{end}}/>
|
||||||
|
<span>Default Hidden</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Set prefix for tokens</h5>
|
||||||
|
<p>This is used for making the client tokens a "copy/paste" straight into client</p>
|
||||||
|
<form action="/admin/setPrefix" method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="prefix" type="text" class="validate" name="prefix" value="{{.Prefix}}">
|
||||||
|
<label for="prefix">Prefix</label>
|
||||||
|
</div>
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Set title for pages</h5>
|
||||||
|
<form action="/admin/setTitle" method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="title" type="text" class="validate" name="title" value="{{.Page.Title}}">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
</div>
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Wipe all data</h5>
|
||||||
|
<a class="waves-effect waves-light red btn modal-trigger" href="#wipe">Wipe!</a>
|
||||||
|
<div id="wipe" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4>Wipe</h4>
|
||||||
|
<p>This will remove all grids and markers, and reset the 0,0 grid</p>
|
||||||
|
<h5>THIS CANNOT BE UNDONE!</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<a href="#!" class="modal-close waves-effect waves-light green btn">Cancel</a>
|
||||||
|
<a href="/admin/wipe" class="waves-effect waves-light red btn">WIPE</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Rebuild zooms</h5>
|
||||||
|
<a href="/admin/rebuildZooms" class="waves-effect waves-light red btn">Rebuild Zooms</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Export</h5>
|
||||||
|
<p>Export grids and markers</p>
|
||||||
|
<a href="/admin/export" class="waves-effect waves-light blue btn">Download export</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h5>Merge</h5>
|
||||||
|
<p>Note, merge is experimental at this time, use at your own risk!</p>
|
||||||
|
<form action="/admin/merge" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="file-field input-field">
|
||||||
|
<div class="btn">
|
||||||
|
<span>File</span>
|
||||||
|
<input type="file" name="merge">
|
||||||
|
</div>
|
||||||
|
<div class="file-path-wrapper">
|
||||||
|
<input class="file-path validate" type="text">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Merge</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||||
|
<script>M.AutoInit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
webapp/templates/admin/map.tmpl
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
<title>{{.Page.Title}} - Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="map" value="{{.MapInfo.ID}}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-field col s12">
|
||||||
|
<input id="name" type="text" class="validate" name="name" value="{{.MapInfo.Name}}">
|
||||||
|
<label for="username">Name</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col s12">
|
||||||
|
<ul class="collection with-header">
|
||||||
|
<li class="collection-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="hidden" value="true"{{if .MapInfo.Hidden}} checked="checked"{{end}}/>
|
||||||
|
<span>Hidden</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li class="collection-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="priority" value="true"{{if .MapInfo.Priority}} checked="checked"{{end}}/>
|
||||||
|
<span>Priority</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||||
|
<script>M.AutoInit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
webapp/templates/admin/user.tmpl
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
<title>{{.Page.Title}} - Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="username" type="text" class="validate" name="user"{{if ne .Username ""}} value="{{.Username}}" disabled{{end}}>
|
||||||
|
<label for="username">Username</label>
|
||||||
|
</div>
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="password" type="password" class="validate" name="pass">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col s12">
|
||||||
|
<ul class="collection with-header">
|
||||||
|
<li class="collection-header">
|
||||||
|
<h6>Roles</h6>
|
||||||
|
</li>
|
||||||
|
<li class="collection-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="auths" value="map"{{if .User.Auths.Has "map"}} checked="checked"{{end}}/>
|
||||||
|
<span>Map</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li class="collection-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="auths" value="markers"{{if .User.Auths.Has "markers"}} checked="checked"{{end}}/>
|
||||||
|
<span>Markers</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li class="collection-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="auths" value="upload"{{if .User.Auths.Has "upload"}} checked="checked"{{end}}/>
|
||||||
|
<span>Upload</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li class="collection-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="auths" value="admin"{{if .User.Auths.Has "admin"}} checked="checked"{{end}}/>
|
||||||
|
<span>Admin</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Save</button>
|
||||||
|
{{if ne .Username ""}}<a class="waves-effect waves-light red btn" href="/admin/deleteUser?user={{.Username}}">Delete</a>{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||||
|
<script>M.AutoInit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
webapp/templates/index.tmpl
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
<title>{{.Page.Title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s3">
|
||||||
|
{{if .Session.Auths.Has "map" }}<a class="waves-effect waves-light btn-large" href="/map">Map</a><br>{{end}}
|
||||||
|
{{if .Session.Auths.Has "admin" }}<a class="waves-effect waves-light btn" href="/admin">Admin portal</a><br>{{end}}
|
||||||
|
<a class="waves-effect waves-light btn" href="/password">Change Password</a><br>
|
||||||
|
<a class="waves-effect waves-light btn" href="/logout">Logout</a><br>
|
||||||
|
</div>
|
||||||
|
<div class="col s9">
|
||||||
|
{{if .Session.Auths.Has "upload" }}
|
||||||
|
<ul class="collection with-header">
|
||||||
|
<li class="collection-header">Here are your existing upload tokens.</li>
|
||||||
|
{{range .UploadTokens}}
|
||||||
|
<li class="collection-item">{{$.Prefix}}/client/{{.}}</li>
|
||||||
|
{{else}}
|
||||||
|
<li class="collection-item">You have no tokens, generate one now!</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<a class="waves-effect waves-light btn" href="/generateToken">Generate Token</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
webapp/templates/login.tmpl
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
<title>{{.Page.Title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="username" type="text" class="validate" name="user">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
</div>
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="password" type="password" class="validate" name="pass">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||||
|
<script>M.AutoInit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
webapp/templates/password.tmpl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
<title>{{.Page.Title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="input-field col s6">
|
||||||
|
<input id="password" type="password" class="validate" name="pass">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn waves-effect waves-light" type="submit" name="action">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||||
|
<script>M.AutoInit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
webapp/webapp.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var embedFS embed.FS
|
||||||
|
|
||||||
|
type WebApp struct {
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a WebApp with templates loaded from the embedded filesystem.
|
||||||
|
func New() (*WebApp, error) {
|
||||||
|
sub, err := fs.Sub(embedFS, "templates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := template.ParseFS(sub, "*.tmpl", "admin/*.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WebApp{templates: t}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Must(w *WebApp, err error) *WebApp {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebApp) ExecuteTemplate(wr io.Writer, t string, data interface{}) error {
|
||||||
|
return w.templates.ExecuteTemplate(wr, t, data)
|
||||||
|
}
|
||||||