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.
This commit is contained in:
2026-02-24 22:27:05 +03:00
commit 605a31567e
97 changed files with 18350 additions and 0 deletions

1
.cursorignore Normal file
View File

@@ -0,0 +1 @@
.git/

24
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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
View File

@@ -0,0 +1 @@
20

16
frontend-nuxt/Dockerfile Normal file
View 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
View 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 repos `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
View 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>

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#__nuxt {
height: 100%;
}

View 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;
}

View 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>

View 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>

View 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>

View 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,
}
}

View 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,
}
}

View 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>

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

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

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

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

View 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]
}
}

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

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

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,8 @@
<template>
<MapPageWrapper>
<MapView />
</MapPageWrapper>
</template>
<script setup lang="ts">
</script>

View 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>

View 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>

View 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>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View 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>

View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

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

View 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'],
},
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

10
go.mod Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

832
internal/app/api.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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]
}

View 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>

View 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>

View 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>

View 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>

View 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>

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