Update project documentation and improve frontend functionality
- Updated the backend documentation in CONTRIBUTING.md and README.md to reflect changes in application structure and API endpoints. - Enhanced the frontend components in MapView.vue for better handling of context menu actions. - Added new types and interfaces in TypeScript for improved type safety in the frontend. - Introduced new utility classes for managing characters and markers in the map. - Updated .gitignore to include .vscode directory for better development environment management.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ frontend/dist
|
|||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.vscode/
|
||||||
@@ -9,7 +9,7 @@ Clone the repository and run the project locally (see [Development](docs/develop
|
|||||||
|
|
||||||
## Code layout
|
## 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.
|
- **Backend:** Entry point is `cmd/hnh-map/main.go`. All application logic lives in `internal/app/` (package `app`): `app.go`, `auth.go`, `api.go`, `handlers_redirects.go`, `client.go`, `client_grid.go`, `client_positions.go`, `client_markers.go`, `admin_*.go`, `map.go`, `tile.go`, `topic.go`, `migrations.go`.
|
||||||
- **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/`.
|
- **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
|
## Formatting and tests
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ point your auto-mapping supported client at it (like Purus pasta).
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Architecture](docs/architecture.md) — high-level design and backend layout (`cmd/hnh-map`, `internal/app`, `webapp`)
|
- [Architecture](docs/architecture.md) — high-level design and backend layout (`cmd/hnh-map`, `internal/app`)
|
||||||
- [API](docs/api.md) — HTTP API (auth, cabinet, map data, admin)
|
- [API](docs/api.md) — HTTP API (auth, cabinet, map data, admin)
|
||||||
- [Configuration](docs/configuration.md) — environment variables and flags
|
- [Configuration](docs/configuration.md) — environment variables and flags
|
||||||
- [Development](docs/development.md) — local run, Docker Compose dev, build
|
- [Development](docs/development.md) — local run, Docker Compose dev, build
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ API доступно по префиксу `/map/api/`. Для запросов,
|
|||||||
- **POST /map/api/admin/rebuildZooms** — пересобрать зум-уровни тайлов.
|
- **POST /map/api/admin/rebuildZooms** — пересобрать зум-уровни тайлов.
|
||||||
- **GET /map/api/admin/export** — скачать экспорт данных (ZIP).
|
- **GET /map/api/admin/export** — скачать экспорт данных (ZIP).
|
||||||
- **POST /map/api/admin/merge** — загрузить и применить merge (ZIP с гридами и маркерами).
|
- **POST /map/api/admin/merge** — загрузить и применить merge (ZIP с гридами и маркерами).
|
||||||
|
- **GET /map/api/admin/wipeTile** — удалить тайл. Query: `map`, `x`, `y`.
|
||||||
Дополнительные админ-действия (формы или внутренние вызовы): wipeTile, setCoords, hideMarker — см. реализацию в `internal/app/api.go` и `admin.go`.
|
- **GET /map/api/admin/setCoords** — сдвинуть координаты гридов. Query: `map`, `fx`, `fy`, `tx`, `ty`.
|
||||||
|
- **GET /map/api/admin/hideMarker** — скрыть маркер. Query: `id`.
|
||||||
|
|
||||||
## Коды ответов
|
## Коды ответов
|
||||||
|
|
||||||
|
|||||||
@@ -2,37 +2,41 @@
|
|||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
hnh-map — сервер автомаппера для HnH: Go-бэкенд с хранилищем bbolt, сессиями, HTML-шаблонами и Nuxt 3 SPA по пути `/map/`. Данные гридов и тайлов хранятся в каталоге `grids/` и в БД.
|
hnh-map — сервер автомаппера для HnH: Go-бэкенд с хранилищем bbolt, сессиями и Nuxt 3 SPA по пути `/map/`. Данные гридов и тайлов хранятся в каталоге `grids/` и в БД.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐
|
┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐
|
||||||
│ Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │
|
│ Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │
|
||||||
│ (Nuxt SPA │ /map/, /map/api │ • bbolt (users, sessions, grids, │
|
│ (Nuxt SPA │ /map/, /map/api │ • bbolt (users, sessions, grids, │
|
||||||
│ по /map/) │ /map/updates │ markers, tiles, maps, config) │
|
│ по /map/) │ /map/updates │ markers, tiles, maps, config) │
|
||||||
│ │ /map/grids/ │ • Шаблоны (embed в webapp) │
|
│ │ /map/grids/ │ • Статика фронта (frontend/) │
|
||||||
└─────────────┘ │ • Статика фронта (frontend/) │
|
└─────────────┘ │ • internal/app — вся логика │
|
||||||
│ • internal/app — вся логика │
|
|
||||||
│ • webapp — рендер шаблонов │
|
|
||||||
└──────────────────────────────────────┘
|
└──────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Структура бэкенда
|
## Структура бэкенда
|
||||||
|
|
||||||
- **cmd/hnh-map/main.go** — единственная точка входа (`package main`): парсинг флагов (`-grids`, `-port`) и переменных окружения (`HNHMAP_PORT`), открытие bbolt, запуск миграций, создание `App`, регистрация маршрутов, запуск HTTP-сервера. Пути к `frontend/` и `public/` задаются из рабочей директории при старте; шаблоны встроены в бинарник (webapp, `//go:embed`).
|
- **cmd/hnh-map/main.go** — единственная точка входа (`package main`): парсинг флагов (`-grids`, `-port`) и переменных окружения (`HNHMAP_PORT`), открытие bbolt, запуск миграций, создание `App`, регистрация маршрутов, запуск HTTP-сервера. Пути к `frontend/` и `public/` задаются из рабочей директории при старте.
|
||||||
|
|
||||||
- **internal/app/** — пакет `app` с типом `App` и всей логикой:
|
- **internal/app/** — пакет `app` с типом `App` и всей логикой:
|
||||||
- **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), хелперы сессий и страниц (`getSession`, `getUser`, `serveMapFrontend`, `CleanChars`).
|
- **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), `serveMapFrontend`, `CleanChars`.
|
||||||
- **api.go** — HTTP API: авторизация (login, me, logout, setup), кабинет (tokens, password), админ (users, settings, maps, wipe, rebuildZooms, export, merge), редиректы и роутер `/map/api/...`.
|
- **auth.go** — сессии и авторизация: `getSession`, `deleteSession`, `saveSession`, `getUser`, `getPage`, `createSession`, `setupRequired`, `requireAdmin`.
|
||||||
- **admin.go** — админка: HTML-страницы и действия (wipe, setPrefix, setDefaultHide, setTitle, rebuildZooms, export, merge, adminMap, wipeTile, setCoords, hideMarker и т.д.).
|
- **api.go** — HTTP API: авторизация (login, me, logout, setup), кабинет (tokens, password), админ (users, settings, maps, wipe, rebuildZooms, export, merge), роутер `/map/api/...`.
|
||||||
- **client.go** — хендлеры клиента маппера (`/client/{token}/...`): locate, gridUpdate, gridUpload, positionUpdate, markerUpdate, updateZoomLevel.
|
- **handlers_redirects.go** — редиректы: `/` → `/map/profile` или `/map/setup`, `/login` → `/map/login`, `/logout` → `/map/login`, `/admin` → `/map/admin`.
|
||||||
|
- **client.go** — роутер клиента маппера (`/client/{token}/...`), `locate`.
|
||||||
|
- **client_grid.go** — `gridUpdate`, `gridUpload`, `updateZoomLevel`.
|
||||||
|
- **client_positions.go** — `updatePositions`.
|
||||||
|
- **client_markers.go** — `uploadMarkers`.
|
||||||
|
- **admin_rebuild.go** — `doRebuildZooms`.
|
||||||
|
- **admin_tiles.go** — `wipeTile`, `setCoords`.
|
||||||
|
- **admin_markers.go** — `hideMarker`.
|
||||||
|
- **admin_export.go** — `export`.
|
||||||
|
- **admin_merge.go** — `merge`.
|
||||||
- **map.go** — доступ к карте: `canAccessMap`, `getChars`, `getMarkers`, `getMaps`, `config`.
|
- **map.go** — доступ к карте: `canAccessMap`, `getChars`, `getMarkers`, `getMaps`, `config`.
|
||||||
- **tile.go** — тайлы и гриды: `GetTile`, `SaveTile`, `watchGridUpdates` (SSE), `gridTile`, `reportMerge`.
|
- **tile.go** — тайлы и гриды: `GetTile`, `SaveTile`, `watchGridUpdates` (SSE), `gridTile`, `reportMerge`.
|
||||||
- **topic.go** — типы `topic` и `mergeTopic` для рассылки обновлений тайлов и слияний карт.
|
- **topic.go** — типы `topic` и `mergeTopic` для рассылки обновлений тайлов и слияний карт.
|
||||||
- **manage.go** — страницы управления: index, login, logout, generateToken, changePassword.
|
|
||||||
- **migrations.go** — миграции bbolt; из main вызывается `app.RunMigrations(db)`.
|
- **migrations.go** — миграции bbolt; из main вызывается `app.RunMigrations(db)`.
|
||||||
|
|
||||||
- **webapp/** — отдельный пакет в корне репозитория: загрузка и выполнение HTML-шаблонов. Импортируется из `cmd/hnh-map` и из `internal/app`.
|
|
||||||
|
|
||||||
Сборка из корня репозитория:
|
Сборка из корня репозитория:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ go build -o hnh-map ./cmd/hnh-map
|
|||||||
go run ./cmd/hnh-map -grids=./grids -port=8080
|
go run ./cmd/hnh-map -grids=./grids -port=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). HTML-шаблоны встроены в бинарник (пакет webapp).
|
Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже).
|
||||||
|
|
||||||
### Фронтенд (Nuxt)
|
### Фронтенд (Nuxt)
|
||||||
|
|
||||||
|
|||||||
@@ -99,10 +99,10 @@
|
|||||||
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
|
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' }"
|
: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">
|
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="contextMenu.tile.data && (wipeTile(contextMenu.tile.data), (contextMenu.tile.show = false))">
|
||||||
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
|
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="openCoordSet(contextMenu.tile.data); contextMenu.tile.show = false">
|
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="contextMenu.tile.data && (openCoordSet(contextMenu.tile.data), (contextMenu.tile.show = false))">
|
||||||
Rewrite tile coords
|
Rewrite tile coords
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
|
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' }"
|
: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">
|
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="contextMenu.marker.data?.id != null && (hideMarkerById(contextMenu.marker.data.id), (contextMenu.marker.show = false))">
|
||||||
Hide marker {{ contextMenu.marker.data?.name }}
|
Hide marker {{ contextMenu.marker.data?.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +157,6 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const mapRef = ref<HTMLElement | null>(null)
|
const mapRef = ref<HTMLElement | null>(null)
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
|
|
||||||
@@ -191,8 +190,8 @@ let markerLayer: L.LayerGroup | null = null
|
|||||||
let source: EventSource | null = null
|
let source: EventSource | null = null
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let mapid = 0
|
let mapid = 0
|
||||||
let markers: InstanceType<typeof UniqueList> | null = null
|
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
|
||||||
let characters: InstanceType<typeof UniqueList> | null = null
|
let characters: UniqueList<InstanceType<typeof Character>> | null = null
|
||||||
let markersHidden = false
|
let markersHidden = false
|
||||||
let autoMode = false
|
let autoMode = false
|
||||||
|
|
||||||
@@ -305,7 +304,7 @@ onMounted(async () => {
|
|||||||
maps.value = mapsList
|
maps.value = mapsList
|
||||||
mapsLoaded.value = true
|
mapsLoaded.value = true
|
||||||
|
|
||||||
const config = await api.getConfig().catch(() => ({}))
|
const config = (await api.getConfig().catch(() => ({}))) as { title?: string; auths?: string[] }
|
||||||
if (config?.title) document.title = config.title
|
if (config?.title) document.title = config.title
|
||||||
if (config?.auths) auths.value = config.auths
|
if (config?.auths) auths.value = config.auths
|
||||||
|
|
||||||
@@ -322,7 +321,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const initialMapId =
|
const initialMapId =
|
||||||
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? mapsList[0].ID : 0
|
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
|
||||||
mapid = initialMapId
|
mapid = initialMapId
|
||||||
|
|
||||||
const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/'
|
const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/'
|
||||||
@@ -336,10 +335,10 @@ onMounted(async () => {
|
|||||||
updateWhenIdle: true,
|
updateWhenIdle: true,
|
||||||
keepBuffer: 2,
|
keepBuffer: 2,
|
||||||
}) as any
|
}) as any
|
||||||
layer.map = initialMapId
|
layer!.map = initialMapId
|
||||||
layer.invalidTile =
|
layer!.invalidTile =
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
||||||
layer.addTo(map)
|
layer!.addTo(map)
|
||||||
|
|
||||||
overlayLayer = new SmartTileLayer(tileUrl, {
|
overlayLayer = new SmartTileLayer(tileUrl, {
|
||||||
minZoom: 1,
|
minZoom: 1,
|
||||||
@@ -351,10 +350,10 @@ onMounted(async () => {
|
|||||||
updateWhenIdle: true,
|
updateWhenIdle: true,
|
||||||
keepBuffer: 2,
|
keepBuffer: 2,
|
||||||
}) as any
|
}) as any
|
||||||
overlayLayer.map = -1
|
overlayLayer!.map = -1
|
||||||
overlayLayer.invalidTile =
|
overlayLayer!.invalidTile =
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||||
overlayLayer.addTo(map)
|
overlayLayer!.addTo(map)
|
||||||
|
|
||||||
coordLayer = new GridCoordLayer({
|
coordLayer = new GridCoordLayer({
|
||||||
tileSize: TileSize,
|
tileSize: TileSize,
|
||||||
@@ -362,7 +361,7 @@ onMounted(async () => {
|
|||||||
maxZoom: HnHMaxZoom,
|
maxZoom: HnHMaxZoom,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
visible: false,
|
visible: false,
|
||||||
})
|
} as any)
|
||||||
coordLayer.addTo(map)
|
coordLayer.addTo(map)
|
||||||
coordLayer.setZIndex(500)
|
coordLayer.setZIndex(500)
|
||||||
|
|
||||||
@@ -398,9 +397,9 @@ onMounted(async () => {
|
|||||||
if (!Array.isArray(updates)) return
|
if (!Array.isArray(updates)) return
|
||||||
for (const u of updates) {
|
for (const u of updates) {
|
||||||
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
||||||
layer.cache[key] = u.T
|
layer!.cache[key] = u.T
|
||||||
if (overlayLayer) overlayLayer.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 (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)
|
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
|
// After initial batch (or any batch), redraw so tiles re-request with filled cache
|
||||||
@@ -436,8 +435,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
markers = new UniqueList()
|
markers = new UniqueList<InstanceType<typeof Marker>>()
|
||||||
characters = new UniqueList()
|
characters = new UniqueList<InstanceType<typeof Character>>()
|
||||||
|
|
||||||
updateCharacters(charactersData as any[])
|
updateCharacters(charactersData as any[])
|
||||||
|
|
||||||
@@ -449,10 +448,13 @@ onMounted(async () => {
|
|||||||
selectedMapId.value = props.mapId
|
selectedMapId.value = props.mapId
|
||||||
map.setView(latLng, props.zoom)
|
map.setView(latLng, props.zoom)
|
||||||
} else if (mapsList.length > 0) {
|
} else if (mapsList.length > 0) {
|
||||||
changeMap(mapsList[0].ID)
|
const first = mapsList[0]
|
||||||
selectedMapId.value = mapsList[0].ID
|
if (first) {
|
||||||
|
changeMap(first.ID)
|
||||||
|
selectedMapId.value = first.ID
|
||||||
map.setView([0, 0], HnHDefaultZoom)
|
map.setView([0, 0], HnHDefaultZoom)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
|
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -518,13 +520,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(showGridCoordinates, (v) => {
|
watch(showGridCoordinates, (v) => {
|
||||||
if (coordLayer) {
|
if (coordLayer) {
|
||||||
coordLayer.options.visible = v
|
;(coordLayer.options as { visible?: boolean }).visible = v
|
||||||
coordLayer.setOpacity(v ? 1 : 0)
|
coordLayer.setOpacity(v ? 1 : 0)
|
||||||
if (v && map) {
|
if (v && map) {
|
||||||
coordLayer.bringToFront?.()
|
coordLayer.bringToFront?.()
|
||||||
coordLayer.redraw?.()
|
coordLayer.redraw?.()
|
||||||
map.invalidateSize()
|
map.invalidateSize()
|
||||||
} else {
|
} else if (coordLayer) {
|
||||||
coordLayer.redraw?.()
|
coordLayer.redraw?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,7 +551,6 @@ onMounted(async () => {
|
|||||||
changeMap(character.map)
|
changeMap(character.map)
|
||||||
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||||
map!.setView(latlng, HnHMaxZoom)
|
map!.setView(latlng, HnHMaxZoom)
|
||||||
router.push(`/character/${value}`)
|
|
||||||
autoMode = true
|
autoMode = true
|
||||||
} else {
|
} else {
|
||||||
map!.setView([0, 0], HnHMinZoom)
|
map!.setView([0, 0], HnHMinZoom)
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
export interface MeResponse {
|
import type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse } from '~/types/api'
|
||||||
username: string
|
|
||||||
auths: string[]
|
|
||||||
tokens?: string[]
|
|
||||||
prefix?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MapInfoAdmin {
|
export type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse }
|
||||||
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)
|
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
|
||||||
const onApiErrorCallbacks: (() => void)[] = []
|
const onApiErrorCallbacks: (() => void)[] = []
|
||||||
@@ -46,7 +30,7 @@ export function useMapApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
return request<{ title?: string; auths?: string[] }>('config')
|
return request<ConfigResponse>('config')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCharacters() {
|
async function getCharacters() {
|
||||||
@@ -58,7 +42,7 @@ export function useMapApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMaps() {
|
async function getMaps() {
|
||||||
return request<Record<string, { ID: number; Name: string; size?: number }>>('maps')
|
return request<Record<string, MapInfo>>('maps')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||||
import * as L from 'leaflet'
|
import * as L from 'leaflet'
|
||||||
|
|
||||||
|
export interface CharacterData {
|
||||||
|
name: string
|
||||||
|
position: { x: number; y: number }
|
||||||
|
type: string
|
||||||
|
id: number
|
||||||
|
map: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewRef {
|
||||||
|
map: L.Map
|
||||||
|
mapid: number
|
||||||
|
markerLayer?: L.LayerGroup
|
||||||
|
}
|
||||||
|
|
||||||
export class Character {
|
export class Character {
|
||||||
constructor(characterData) {
|
name: string
|
||||||
|
position: { x: number; y: number }
|
||||||
|
type: string
|
||||||
|
id: number
|
||||||
|
map: number
|
||||||
|
marker: L.Marker | null = null
|
||||||
|
text: string
|
||||||
|
value: number
|
||||||
|
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
|
|
||||||
|
constructor(characterData: CharacterData) {
|
||||||
this.name = characterData.name
|
this.name = characterData.name
|
||||||
this.position = characterData.position
|
this.position = characterData.position
|
||||||
this.type = characterData.type
|
this.type = characterData.type
|
||||||
this.id = characterData.id
|
this.id = characterData.id
|
||||||
this.map = characterData.map
|
this.map = characterData.map
|
||||||
this.marker = null
|
|
||||||
this.text = this.name
|
this.text = this.name
|
||||||
this.value = this.id
|
this.value = this.id
|
||||||
this.onClick = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getId() {
|
getId(): string {
|
||||||
return `${this.name}`
|
return `${this.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(mapview) {
|
remove(mapview: MapViewRef): void {
|
||||||
if (this.marker) {
|
if (this.marker) {
|
||||||
const layer = mapview.markerLayer ?? mapview.map
|
const layer = mapview.markerLayer ?? mapview.map
|
||||||
layer.removeLayer(this.marker)
|
layer.removeLayer(this.marker)
|
||||||
@@ -26,7 +48,7 @@ export class Character {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add(mapview) {
|
add(mapview: MapViewRef): void {
|
||||||
if (this.map === mapview.mapid) {
|
if (this.map === mapview.mapid) {
|
||||||
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||||
this.marker = L.marker(position, { title: this.name })
|
this.marker = L.marker(position, { title: this.name })
|
||||||
@@ -36,7 +58,7 @@ export class Character {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(mapview, updated) {
|
update(mapview: MapViewRef, updated: CharacterData): void {
|
||||||
if (this.map !== updated.map) {
|
if (this.map !== updated.map) {
|
||||||
this.remove(mapview)
|
this.remove(mapview)
|
||||||
}
|
}
|
||||||
@@ -51,11 +73,11 @@ export class Character {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setClickCallback(callback) {
|
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||||
this.onClick = callback
|
this.onClick = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
callCallback(e) {
|
callCallback(e: L.LeafletMouseEvent): void {
|
||||||
if (this.onClick != null) this.onClick(e)
|
if (this.onClick != null) this.onClick(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ export const GridCoordLayer = L.GridLayer.extend({
|
|||||||
options: {
|
options: {
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
createTile(coords) {
|
createTile(coords: { x: number; y: number; z: number }) {
|
||||||
if (!this.options.visible) {
|
if (!this.options.visible) {
|
||||||
const element = document.createElement('div')
|
const element = document.createElement('div')
|
||||||
element.style.width = TileSize + 'px'
|
element.style.width = TileSize + 'px'
|
||||||
@@ -61,23 +61,23 @@ export const GridCoordLayer = L.GridLayer.extend({
|
|||||||
}
|
}
|
||||||
return element
|
return element
|
||||||
},
|
},
|
||||||
})
|
}) as unknown as new (options?: L.GridLayerOptions) => L.GridLayer
|
||||||
|
|
||||||
export const ImageIcon = L.Icon.extend({
|
export const ImageIcon = L.Icon.extend({
|
||||||
options: {
|
options: {
|
||||||
iconSize: [32, 32],
|
iconSize: [32, 32],
|
||||||
iconAnchor: [16, 16],
|
iconAnchor: [16, 16],
|
||||||
},
|
},
|
||||||
})
|
}) as unknown as new (options?: L.IconOptions) => L.Icon
|
||||||
|
|
||||||
const latNormalization = (90.0 * TileSize) / 2500000.0
|
const latNormalization = (90.0 * TileSize) / 2500000.0
|
||||||
const lngNormalization = (180.0 * TileSize) / 2500000.0
|
const lngNormalization = (180.0 * TileSize) / 2500000.0
|
||||||
|
|
||||||
const HnHProjection = {
|
const HnHProjection = {
|
||||||
project(latlng) {
|
project(latlng: LatLng) {
|
||||||
return new Point(latlng.lat / latNormalization, latlng.lng / lngNormalization)
|
return new Point(latlng.lat / latNormalization, latlng.lng / lngNormalization)
|
||||||
},
|
},
|
||||||
unproject(point) {
|
unproject(point: Point) {
|
||||||
return new LatLng(point.x * latNormalization, point.y * lngNormalization)
|
return new LatLng(point.x * latNormalization, point.y * lngNormalization)
|
||||||
},
|
},
|
||||||
bounds: (() => new Bounds([-latNormalization, -lngNormalization], [latNormalization, lngNormalization]))(),
|
bounds: (() => new Bounds([-latNormalization, -lngNormalization], [latNormalization, lngNormalization]))(),
|
||||||
@@ -85,4 +85,4 @@ const HnHProjection = {
|
|||||||
|
|
||||||
export const HnHCRS = L.extend({}, L.CRS.Simple, {
|
export const HnHCRS = L.extend({}, L.CRS.Simple, {
|
||||||
projection: HnHProjection,
|
projection: HnHProjection,
|
||||||
})
|
}) as L.CRS
|
||||||
@@ -1,38 +1,63 @@
|
|||||||
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
|
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
|
||||||
import * as L from 'leaflet'
|
import * as L from 'leaflet'
|
||||||
|
|
||||||
function detectType(name) {
|
export interface MarkerData {
|
||||||
|
id: number
|
||||||
|
position: { x: number; y: number }
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
hidden: boolean
|
||||||
|
map: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewRef {
|
||||||
|
map: L.Map
|
||||||
|
mapid: number
|
||||||
|
markerLayer: L.LayerGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectType(name: string): string {
|
||||||
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
|
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
|
||||||
if (name === 'custom') return 'custom'
|
if (name === 'custom') return 'custom'
|
||||||
return name.substring('gfx/terobjs/mm/'.length)
|
return name.substring('gfx/terobjs/mm/'.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Marker {
|
export class Marker {
|
||||||
constructor(markerData) {
|
id: number
|
||||||
|
position: { x: number; y: number }
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
type: string
|
||||||
|
marker: L.Marker | null = null
|
||||||
|
text: string
|
||||||
|
value: number
|
||||||
|
hidden: boolean
|
||||||
|
map: number
|
||||||
|
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
|
onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
|
|
||||||
|
constructor(markerData: MarkerData) {
|
||||||
this.id = markerData.id
|
this.id = markerData.id
|
||||||
this.position = markerData.position
|
this.position = markerData.position
|
||||||
this.name = markerData.name
|
this.name = markerData.name
|
||||||
this.image = markerData.image
|
this.image = markerData.image
|
||||||
this.type = detectType(this.image)
|
this.type = detectType(this.image)
|
||||||
this.marker = null
|
|
||||||
this.text = this.name
|
this.text = this.name
|
||||||
this.value = this.id
|
this.value = this.id
|
||||||
this.hidden = markerData.hidden
|
this.hidden = markerData.hidden
|
||||||
this.map = markerData.map
|
this.map = markerData.map
|
||||||
this.onClick = null
|
|
||||||
this.onContext = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(mapview) {
|
remove(_mapview: MapViewRef): void {
|
||||||
if (this.marker) {
|
if (this.marker) {
|
||||||
this.marker.remove()
|
this.marker.remove()
|
||||||
this.marker = null
|
this.marker = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add(mapview) {
|
add(mapview: MapViewRef): void {
|
||||||
if (!this.hidden) {
|
if (!this.hidden) {
|
||||||
let icon
|
let icon: L.Icon
|
||||||
if (this.image === 'gfx/terobjs/mm/custom') {
|
if (this.image === 'gfx/terobjs/mm/custom') {
|
||||||
icon = new ImageIcon({
|
icon = new ImageIcon({
|
||||||
iconUrl: 'gfx/terobjs/mm/custom.png',
|
iconUrl: 'gfx/terobjs/mm/custom.png',
|
||||||
@@ -53,7 +78,7 @@ export class Marker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(mapview, updated) {
|
update(mapview: MapViewRef, updated: MarkerData): void {
|
||||||
this.position = updated.position
|
this.position = updated.position
|
||||||
this.name = updated.name
|
this.name = updated.name
|
||||||
this.hidden = updated.hidden
|
this.hidden = updated.hidden
|
||||||
@@ -64,26 +89,26 @@ export class Marker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jumpTo(map) {
|
jumpTo(map: L.Map): void {
|
||||||
if (this.marker) {
|
if (this.marker) {
|
||||||
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||||
this.marker.setLatLng(position)
|
this.marker.setLatLng(position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setClickCallback(callback) {
|
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||||
this.onClick = callback
|
this.onClick = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
callClickCallback(e) {
|
callClickCallback(e: L.LeafletMouseEvent): void {
|
||||||
if (this.onClick != null) this.onClick(e)
|
if (this.onClick != null) this.onClick(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
setContextMenu(callback) {
|
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||||
this.onContext = callback
|
this.onContext = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
callContextCallback(e) {
|
callContextCallback(e: L.LeafletMouseEvent): void {
|
||||||
if (this.onContext != null) this.onContext(e)
|
if (this.onContext != null) this.onContext(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import L, { Util, Browser } from 'leaflet'
|
import L, { Util, Browser } from 'leaflet'
|
||||||
|
|
||||||
|
interface SmartTileLayerCache {
|
||||||
|
[key: string]: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export const SmartTileLayer = L.TileLayer.extend({
|
export const SmartTileLayer = L.TileLayer.extend({
|
||||||
cache: {},
|
cache: {} as SmartTileLayerCache,
|
||||||
invalidTile: '',
|
invalidTile: '',
|
||||||
map: 0,
|
map: 0,
|
||||||
|
|
||||||
getTileUrl(coords) {
|
getTileUrl(coords: { x: number; y: number; z: number }) {
|
||||||
if (!this._map) return this.invalidTile
|
if (!this._map) return this.invalidTile
|
||||||
let zoom
|
let zoom
|
||||||
try {
|
try {
|
||||||
@@ -16,8 +20,8 @@ export const SmartTileLayer = L.TileLayer.extend({
|
|||||||
return this.getTrueTileUrl(coords, zoom)
|
return this.getTrueTileUrl(coords, zoom)
|
||||||
},
|
},
|
||||||
|
|
||||||
getTrueTileUrl(coords, zoom) {
|
getTrueTileUrl(coords: { x: number; y: number }, zoom: number) {
|
||||||
const data = {
|
const data: Record<string, string | number | undefined> = {
|
||||||
r: Browser.retina ? '@2x' : '',
|
r: Browser.retina ? '@2x' : '',
|
||||||
s: this._getSubdomain(coords),
|
s: this._getSubdomain(coords),
|
||||||
x: coords.x,
|
x: coords.x,
|
||||||
@@ -37,7 +41,8 @@ export const SmartTileLayer = L.TileLayer.extend({
|
|||||||
data.cache = this.cache[cacheKey]
|
data.cache = this.cache[cacheKey]
|
||||||
|
|
||||||
// Don't request tiles for invalid/unknown map (avoids 404 spam in console)
|
// Don't request tiles for invalid/unknown map (avoids 404 spam in console)
|
||||||
if (data.map === undefined || data.map === null || data.map < 1) {
|
const mapId = Number(data.map)
|
||||||
|
if (data.map === undefined || data.map === null || mapId < 1) {
|
||||||
return this.invalidTile
|
return this.invalidTile
|
||||||
}
|
}
|
||||||
// Only use placeholder when server explicitly marks tile as invalid (-1)
|
// Only use placeholder when server explicitly marks tile as invalid (-1)
|
||||||
@@ -53,7 +58,7 @@ export const SmartTileLayer = L.TileLayer.extend({
|
|||||||
return Util.template(this._url, Util.extend(data, this.options))
|
return Util.template(this._url, Util.extend(data, this.options))
|
||||||
},
|
},
|
||||||
|
|
||||||
refresh(x, y, z) {
|
refresh(x: number, y: number, z: number) {
|
||||||
let zoom = z
|
let zoom = z
|
||||||
const maxZoom = this.options.maxZoom
|
const maxZoom = this.options.maxZoom
|
||||||
const zoomReverse = this.options.zoomReverse
|
const zoomReverse = this.options.zoomReverse
|
||||||
@@ -70,4 +75,10 @@ export const SmartTileLayer = L.TileLayer.extend({
|
|||||||
tile.el.src = this.getTrueTileUrl({ x, y }, z)
|
tile.el.src = this.getTrueTileUrl({ x, y }, z)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & {
|
||||||
|
cache: SmartTileLayerCache
|
||||||
|
invalidTile: string
|
||||||
|
map: number
|
||||||
|
getTrueTileUrl: (coords: { x: number; y: number }, zoom: number) => string
|
||||||
|
refresh: (x: number, y: number, z: number) => void
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
frontend-nuxt/lib/UniqueList.ts
Normal file
50
frontend-nuxt/lib/UniqueList.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Elements should have unique field "id"
|
||||||
|
*/
|
||||||
|
export interface Identifiable {
|
||||||
|
id: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UniqueList<T extends Identifiable> {
|
||||||
|
elements: Record<string, T> = {}
|
||||||
|
|
||||||
|
update(
|
||||||
|
dataList: T[],
|
||||||
|
addCallback?: (it: T) => void,
|
||||||
|
removeCallback?: (it: T) => void,
|
||||||
|
updateCallback?: (oldElement: T, newElement: T) => void
|
||||||
|
): void {
|
||||||
|
const elementsToAdd = dataList.filter((it) => this.elements[String(it.id)] === undefined)
|
||||||
|
const elementsToRemove: T[] = []
|
||||||
|
for (const id of Object.keys(this.elements)) {
|
||||||
|
if (dataList.find((up) => String(up.id) === id) === undefined) {
|
||||||
|
const el = this.elements[id]
|
||||||
|
if (el) elementsToRemove.push(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removeCallback) {
|
||||||
|
elementsToRemove.forEach((it) => removeCallback(it))
|
||||||
|
}
|
||||||
|
if (updateCallback) {
|
||||||
|
dataList.forEach((newElement) => {
|
||||||
|
const oldElement = this.elements[String(newElement.id)]
|
||||||
|
if (oldElement) {
|
||||||
|
updateCallback(oldElement, newElement)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (addCallback) {
|
||||||
|
elementsToAdd.forEach((it) => addCallback(it))
|
||||||
|
}
|
||||||
|
elementsToRemove.forEach((it) => delete this.elements[String(it.id)])
|
||||||
|
elementsToAdd.forEach((it) => (this.elements[String(it.id)] = it))
|
||||||
|
}
|
||||||
|
|
||||||
|
getElements(): T[] {
|
||||||
|
return Object.values(this.elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
byId(id: number | string): T | undefined {
|
||||||
|
return this.elements[String(id)]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
frontend-nuxt/package-lock.json
generated
16
frontend-nuxt/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"daisyui": "^3.9.4",
|
"daisyui": "^3.9.4",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
@@ -2579,6 +2580,21 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"daisyui": "^3.9.4",
|
"daisyui": "^3.9.4",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
|
|||||||
30
frontend-nuxt/types/api.ts
Normal file
30
frontend-nuxt/types/api.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigResponse {
|
||||||
|
title?: string
|
||||||
|
auths?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapInfo {
|
||||||
|
ID: number
|
||||||
|
Name: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
133
internal/app/admin_export.go
Normal file
133
internal/app/admin_export.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapData struct {
|
||||||
|
Grids map[string]string
|
||||||
|
Markers map[string][]Marker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) export(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
|
||||||
|
http.Error(rw, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "application/zip")
|
||||||
|
rw.Header().Set("Content-Disposition", "attachment; filename=\"griddata.zip\"")
|
||||||
|
|
||||||
|
zw := zip.NewWriter(rw)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
maps := map[int]mapData{}
|
||||||
|
gridMap := map[string]int{}
|
||||||
|
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
if grids == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tiles := tx.Bucket([]byte("tiles"))
|
||||||
|
if tiles == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := grids.ForEach(func(k, v []byte) error {
|
||||||
|
gd := GridData{}
|
||||||
|
err := json.Unmarshal(v, &gd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
md, ok := maps[gd.Map]
|
||||||
|
if !ok {
|
||||||
|
md = mapData{
|
||||||
|
Grids: map[string]string{},
|
||||||
|
Markers: map[string][]Marker{},
|
||||||
|
}
|
||||||
|
maps[gd.Map] = md
|
||||||
|
}
|
||||||
|
md.Grids[gd.Coord.Name()] = gd.ID
|
||||||
|
gridMap[gd.ID] = gd.Map
|
||||||
|
mapb := tiles.Bucket([]byte(strconv.Itoa(gd.Map)))
|
||||||
|
if mapb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
zoom := mapb.Bucket([]byte("0"))
|
||||||
|
if zoom == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tdraw := zoom.Get([]byte(gd.Coord.Name()))
|
||||||
|
if tdraw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
td := TileData{}
|
||||||
|
err = json.Unmarshal(tdraw, &td)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := zw.Create(fmt.Sprintf("%d/%s.png", gd.Map, gd.ID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Open(filepath.Join(a.gridStorage, td.File))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, f)
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = func() error {
|
||||||
|
markersb := tx.Bucket([]byte("markers"))
|
||||||
|
if markersb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
markersgrid := markersb.Bucket([]byte("grid"))
|
||||||
|
if markersgrid == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return markersgrid.ForEach(func(k, v []byte) error {
|
||||||
|
marker := Marker{}
|
||||||
|
err := json.Unmarshal(v, &marker)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := maps[gridMap[marker.GridID]]; ok {
|
||||||
|
maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for mapid, mapdata := range maps {
|
||||||
|
w, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(mapdata)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/app/admin_markers.go
Normal file
48
internal/app/admin_markers.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) hideMarker(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
mb, err := tx.CreateBucketIfNotExists([]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
|
||||||
|
}
|
||||||
|
key := idB.Get([]byte(req.FormValue("id")))
|
||||||
|
if key == nil {
|
||||||
|
return fmt.Errorf("Could not find key %s", req.FormValue("id"))
|
||||||
|
}
|
||||||
|
raw := grid.Get(key)
|
||||||
|
if raw == nil {
|
||||||
|
return fmt.Errorf("Could not find key %s", string(key))
|
||||||
|
}
|
||||||
|
m := Marker{}
|
||||||
|
json.Unmarshal(raw, &m)
|
||||||
|
m.Hidden = true
|
||||||
|
raw, _ = json.Marshal(m)
|
||||||
|
grid.Put(key, raw)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
305
internal/app/admin_merge.go
Normal file
305
internal/app/admin_merge.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) merge(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if s := a.getSession(req); s == nil || !s.Auths.Has(AUTH_ADMIN) {
|
||||||
|
http.Error(rw, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := req.ParseMultipartForm(1024 * 1024 * 500)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergef, hdr, err := req.FormFile("merge")
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
http.Error(rw, "request error", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zr, err := zip.NewReader(mergef, hdr.Size)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
http.Error(rw, "request error", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := []struct {
|
||||||
|
mapid int
|
||||||
|
x, y int
|
||||||
|
f string
|
||||||
|
}{}
|
||||||
|
newTiles := map[string]struct{}{}
|
||||||
|
|
||||||
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgrid, err := mb.CreateBucketIfNotExists([]byte("grid"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configb, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, fhdr := range zr.File {
|
||||||
|
if strings.HasSuffix(fhdr.Name, ".json") {
|
||||||
|
f, err := fhdr.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
md := mapData{}
|
||||||
|
err = json.NewDecoder(f).Decode(&md)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ms := range md.Markers {
|
||||||
|
for _, mraw := range ms {
|
||||||
|
key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.Position.X, mraw.Position.Y))
|
||||||
|
if mgrid.Get(key) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mraw.Image == "" {
|
||||||
|
mraw.Image = "gfx/terobjs/mm/custom"
|
||||||
|
}
|
||||||
|
id, err := idB.NextSequence()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idKey := []byte(strconv.Itoa(int(id)))
|
||||||
|
m := Marker{
|
||||||
|
Name: mraw.Name,
|
||||||
|
ID: int(id),
|
||||||
|
GridID: mraw.GridID,
|
||||||
|
Position: Position{
|
||||||
|
X: mraw.Position.X,
|
||||||
|
Y: mraw.Position.Y,
|
||||||
|
},
|
||||||
|
Image: mraw.Image,
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
mgrid.Put(key, raw)
|
||||||
|
idB.Put(idKey, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapB, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newGrids := map[Coord]string{}
|
||||||
|
maps := map[int]struct{ X, Y int }{}
|
||||||
|
for k, v := range md.Grids {
|
||||||
|
c := Coord{}
|
||||||
|
_, err := fmt.Sscanf(k, "%d_%d", &c.X, &c.Y)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newGrids[c] = v
|
||||||
|
gridRaw := grids.Get([]byte(v))
|
||||||
|
if gridRaw != nil {
|
||||||
|
gd := GridData{}
|
||||||
|
json.Unmarshal(gridRaw, &gd)
|
||||||
|
maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - c.X, gd.Coord.Y - c.Y}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(maps) == 0 {
|
||||||
|
seq, err := mapB.NextSequence()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mi := MapInfo{
|
||||||
|
ID: int(seq),
|
||||||
|
Name: strconv.Itoa(int(seq)),
|
||||||
|
Hidden: configb.Get([]byte("defaultHide")) != nil,
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(mi)
|
||||||
|
err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for c, grid := range newGrids {
|
||||||
|
cur := GridData{}
|
||||||
|
cur.ID = grid
|
||||||
|
cur.Map = int(seq)
|
||||||
|
cur.Coord = c
|
||||||
|
|
||||||
|
raw, err := json.Marshal(cur)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
grids.Put([]byte(grid), raw)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mapid := -1
|
||||||
|
offset := struct{ X, Y int }{}
|
||||||
|
for id, off := range maps {
|
||||||
|
mi := MapInfo{}
|
||||||
|
mraw := mapB.Get([]byte(strconv.Itoa(id)))
|
||||||
|
if mraw != nil {
|
||||||
|
json.Unmarshal(mraw, &mi)
|
||||||
|
}
|
||||||
|
if mi.Priority {
|
||||||
|
mapid = id
|
||||||
|
offset = off
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if id < mapid || mapid == -1 {
|
||||||
|
mapid = id
|
||||||
|
offset = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for c, grid := range newGrids {
|
||||||
|
cur := GridData{}
|
||||||
|
if curRaw := grids.Get([]byte(grid)); curRaw != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.ID = grid
|
||||||
|
cur.Map = mapid
|
||||||
|
cur.Coord.X = c.X + offset.X
|
||||||
|
cur.Coord.Y = c.Y + offset.Y
|
||||||
|
raw, err := json.Marshal(cur)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
grids.Put([]byte(grid), raw)
|
||||||
|
}
|
||||||
|
if len(maps) > 1 {
|
||||||
|
grids.ForEach(func(k, v []byte) error {
|
||||||
|
gd := GridData{}
|
||||||
|
json.Unmarshal(v, &gd)
|
||||||
|
if gd.Map == mapid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if merge, ok := maps[gd.Map]; ok {
|
||||||
|
var td *TileData
|
||||||
|
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tileraw := zoom.Get([]byte(gd.Coord.Name()))
|
||||||
|
if tileraw != nil {
|
||||||
|
json.Unmarshal(tileraw, &td)
|
||||||
|
}
|
||||||
|
|
||||||
|
gd.Map = mapid
|
||||||
|
gd.Coord.X += offset.X - merge.X
|
||||||
|
gd.Coord.Y += offset.Y - merge.Y
|
||||||
|
raw, _ := json.Marshal(gd)
|
||||||
|
if td != nil {
|
||||||
|
ops = append(ops, struct {
|
||||||
|
mapid int
|
||||||
|
x int
|
||||||
|
y int
|
||||||
|
f string
|
||||||
|
}{
|
||||||
|
mapid: mapid,
|
||||||
|
x: gd.Coord.X,
|
||||||
|
y: gd.Coord.Y,
|
||||||
|
f: td.File,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
grids.Put(k, raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for mergeid, merge := range maps {
|
||||||
|
if mapid == mergeid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mapB.Delete([]byte(strconv.Itoa(mergeid)))
|
||||||
|
log.Println("Reporting merge", mergeid, mapid)
|
||||||
|
a.reportMerge(mergeid, mapid, Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if strings.HasSuffix(fhdr.Name, ".png") {
|
||||||
|
os.MkdirAll(filepath.Join(a.gridStorage, "grids"), 0777)
|
||||||
|
f, err := os.Create(filepath.Join(a.gridStorage, "grids", filepath.Base(fhdr.Name)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r, err := fhdr.Open()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
io.Copy(f, r)
|
||||||
|
r.Close()
|
||||||
|
f.Close()
|
||||||
|
newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for gid := range newTiles {
|
||||||
|
gridRaw := grids.Get([]byte(gid))
|
||||||
|
if gridRaw != nil {
|
||||||
|
gd := GridData{}
|
||||||
|
json.Unmarshal(gridRaw, &gd)
|
||||||
|
ops = append(ops, struct {
|
||||||
|
mapid int
|
||||||
|
x int
|
||||||
|
y int
|
||||||
|
f string
|
||||||
|
}{
|
||||||
|
mapid: gd.Map,
|
||||||
|
x: gd.Coord.X,
|
||||||
|
y: gd.Coord.Y,
|
||||||
|
f: filepath.Join("grids", gid+".png"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
http.Error(rw, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
a.SaveTile(op.mapid, Coord{X: op.x, Y: op.y}, 0, op.f, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
a.doRebuildZooms()
|
||||||
|
}
|
||||||
52
internal/app/admin_rebuild.go
Normal file
52
internal/app/admin_rebuild.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type zoomproc struct {
|
||||||
|
c Coord
|
||||||
|
m int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) doRebuildZooms() {
|
||||||
|
needProcess := map[zoomproc]struct{}{}
|
||||||
|
saveGrid := map[zoomproc]string{}
|
||||||
|
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("grids"))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.ForEach(func(k, v []byte) error {
|
||||||
|
grid := GridData{}
|
||||||
|
json.Unmarshal(v, &grid)
|
||||||
|
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
||||||
|
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
tx.DeleteBucket([]byte("tiles"))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for g, id := range saveGrid {
|
||||||
|
f := fmt.Sprintf("%s/grids/%s.png", a.gridStorage, id)
|
||||||
|
if _, err := os.Stat(f); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.SaveTile(g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
for z := 1; z <= 5; z++ {
|
||||||
|
process := needProcess
|
||||||
|
needProcess = map[zoomproc]struct{}{}
|
||||||
|
for p := range process {
|
||||||
|
a.updateZoomLevel(p.m, p.c, z)
|
||||||
|
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
internal/app/admin_tiles.go
Normal file
188
internal/app/admin_tiles.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mraw := req.FormValue("map")
|
||||||
|
mapid, err := strconv.Atoi(mraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xraw := req.FormValue("x")
|
||||||
|
x, err := strconv.Atoi(xraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yraw := req.FormValue("y")
|
||||||
|
y, err := strconv.Atoi(yraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := Coord{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
}
|
||||||
|
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
if grids == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := [][]byte{}
|
||||||
|
err := grids.ForEach(func(k, v []byte) error {
|
||||||
|
g := GridData{}
|
||||||
|
err := json.Unmarshal(v, &g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.Coord == c && g.Map == mapid {
|
||||||
|
ids = append(ids, k)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
grids.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
a.SaveTile(mapid, c, 0, "", -1)
|
||||||
|
for z := 1; z <= 5; z++ {
|
||||||
|
c = c.Parent()
|
||||||
|
a.updateZoomLevel(mapid, c, z)
|
||||||
|
}
|
||||||
|
rw.WriteHeader(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setCoords(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if a.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mraw := req.FormValue("map")
|
||||||
|
mapid, err := strconv.Atoi(mraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fxraw := req.FormValue("fx")
|
||||||
|
fx, err := strconv.Atoi(fxraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fyraw := req.FormValue("fy")
|
||||||
|
fy, err := strconv.Atoi(fyraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fc := Coord{
|
||||||
|
X: fx,
|
||||||
|
Y: fy,
|
||||||
|
}
|
||||||
|
|
||||||
|
txraw := req.FormValue("tx")
|
||||||
|
tx, err := strconv.Atoi(txraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tyraw := req.FormValue("ty")
|
||||||
|
ty, err := strconv.Atoi(tyraw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "coord parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tc := Coord{
|
||||||
|
X: tx,
|
||||||
|
Y: ty,
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := Coord{
|
||||||
|
X: tc.X - fc.X,
|
||||||
|
Y: tc.Y - fc.Y,
|
||||||
|
}
|
||||||
|
tds := []*TileData{}
|
||||||
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
if grids == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tiles := tx.Bucket([]byte("tiles"))
|
||||||
|
if tiles == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
|
||||||
|
if mapZooms == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mapTiles := mapZooms.Bucket([]byte("0"))
|
||||||
|
err := grids.ForEach(func(k, v []byte) error {
|
||||||
|
g := GridData{}
|
||||||
|
err := json.Unmarshal(v, &g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.Map == mapid {
|
||||||
|
g.Coord.X += diff.X
|
||||||
|
g.Coord.Y += diff.Y
|
||||||
|
raw, _ := json.Marshal(g)
|
||||||
|
grids.Put(k, raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = mapTiles.ForEach(func(k, v []byte) error {
|
||||||
|
td := &TileData{}
|
||||||
|
err := json.Unmarshal(v, &td)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
td.Coord.X += diff.X
|
||||||
|
td.Coord.Y += diff.Y
|
||||||
|
tds = append(tds, td)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
needProcess := map[zoomproc]struct{}{}
|
||||||
|
for _, td := range tds {
|
||||||
|
a.SaveTile(td.MapID, td.Coord, td.Zoom, td.File, time.Now().UnixNano())
|
||||||
|
needProcess[zoomproc{c: Coord{X: td.Coord.X, Y: td.Coord.Y}.Parent(), m: td.MapID}] = struct{}{}
|
||||||
|
}
|
||||||
|
for z := 1; z <= 5; z++ {
|
||||||
|
process := needProcess
|
||||||
|
needProcess = map[zoomproc]struct{}{}
|
||||||
|
for p := range process {
|
||||||
|
a.updateZoomLevel(p.m, p.c, z)
|
||||||
|
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rw.WriteHeader(200)
|
||||||
|
}
|
||||||
@@ -93,24 +93,6 @@ func (a *App) apiLogin(rw http.ResponseWriter, req *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
func (a *App) apiSetup(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -165,22 +147,6 @@ func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) {
|
|||||||
json.NewEncoder(rw).Encode(out)
|
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 ---
|
// --- Cabinet API ---
|
||||||
|
|
||||||
func (a *App) apiMeTokens(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) apiMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||||
@@ -283,15 +249,6 @@ func (a *App) setUserPassword(username, pass string) error {
|
|||||||
|
|
||||||
// --- Admin API (require admin auth) ---
|
// --- 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) {
|
func (a *App) apiAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -691,28 +648,6 @@ func (a *App) apiAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
|||||||
a.merge(rw, req)
|
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/... ---
|
// --- API router: /map/api/... ---
|
||||||
|
|
||||||
func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
||||||
@@ -822,11 +757,5 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
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)
|
http.Error(rw, "not found", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/andyleap/hnh-map/webapp"
|
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// App is the main application (map server) state.
|
// App is the main application (map server) state.
|
||||||
@@ -23,8 +19,6 @@ type App struct {
|
|||||||
characters map[string]Character
|
characters map[string]Character
|
||||||
chmu sync.RWMutex
|
chmu sync.RWMutex
|
||||||
|
|
||||||
*webapp.WebApp
|
|
||||||
|
|
||||||
gridUpdates topic
|
gridUpdates topic
|
||||||
mergeUpdates mergeTopic
|
mergeUpdates mergeTopic
|
||||||
}
|
}
|
||||||
@@ -32,16 +26,11 @@ type App struct {
|
|||||||
// NewApp creates an App with the given storage paths and database.
|
// NewApp creates an App with the given storage paths and database.
|
||||||
// frontendRoot is the directory for the map SPA (e.g. "frontend").
|
// frontendRoot is the directory for the map SPA (e.g. "frontend").
|
||||||
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
|
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
|
||||||
w, err := webapp.New()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &App{
|
return &App{
|
||||||
gridStorage: gridStorage,
|
gridStorage: gridStorage,
|
||||||
frontendRoot: frontendRoot,
|
frontendRoot: frontendRoot,
|
||||||
db: db,
|
db: db,
|
||||||
characters: make(map[string]Character),
|
characters: make(map[string]Character),
|
||||||
WebApp: w,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,110 +133,10 @@ type User struct {
|
|||||||
Tokens []string
|
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 {
|
type Page struct {
|
||||||
Title string `json:"title"`
|
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.
|
// 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) {
|
func (a *App) serveMapFrontend(rw http.ResponseWriter, req *http.Request) {
|
||||||
path := req.URL.Path
|
path := req.URL.Path
|
||||||
@@ -306,23 +195,10 @@ func (a *App) RegisterRoutes() {
|
|||||||
http.HandleFunc("/client/", a.client)
|
http.HandleFunc("/client/", a.client)
|
||||||
|
|
||||||
http.HandleFunc("/login", a.redirectLogin)
|
http.HandleFunc("/login", a.redirectLogin)
|
||||||
http.HandleFunc("/logout", a.logout)
|
http.HandleFunc("/logout", a.redirectLogout)
|
||||||
|
http.HandleFunc("/admin", a.redirectAdmin)
|
||||||
|
http.HandleFunc("/admin/", a.redirectAdmin)
|
||||||
http.HandleFunc("/", a.redirectRoot)
|
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/api/", a.apiRouter)
|
||||||
http.HandleFunc("/map/updates", a.watchGridUpdates)
|
http.HandleFunc("/map/updates", a.watchGridUpdates)
|
||||||
|
|||||||
154
internal/app/auth.go
Normal file
154
internal/app/auth.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) getSession(req *http.Request) *Session {
|
||||||
|
c, err := req.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s *Session
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
sessions := tx.Bucket([]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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSession creates a session for username, returns session ID or empty string.
|
||||||
|
func (a *App) createSession(username string, tempAdmin bool) string {
|
||||||
|
session := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(session); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sid := hex.EncodeToString(session)
|
||||||
|
s := &Session{
|
||||||
|
ID: sid,
|
||||||
|
Username: username,
|
||||||
|
TempAdmin: tempAdmin,
|
||||||
|
}
|
||||||
|
a.saveSession(s)
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRequired returns true if no users exist (first run).
|
||||||
|
func (a *App) setupRequired() bool {
|
||||||
|
var required bool
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
ub := tx.Bucket([]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) 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
|
||||||
|
}
|
||||||
@@ -4,20 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
"image/png"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/image/draw"
|
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
@@ -80,8 +68,6 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
|
|||||||
a.updatePositions(rw, req)
|
a.updatePositions(rw, req)
|
||||||
case "markerUpdate":
|
case "markerUpdate":
|
||||||
a.uploadMarkers(rw, req)
|
a.uploadMarkers(rw, req)
|
||||||
/*case "mapData":
|
|
||||||
a.mapdataIndex(rw, req)*/
|
|
||||||
case "":
|
case "":
|
||||||
http.Redirect(rw, req, "/map/", 302)
|
http.Redirect(rw, req, "/map/", 302)
|
||||||
case "checkVersion":
|
case "checkVersion":
|
||||||
@@ -95,151 +81,6 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func (a *App) locate(rw http.ResponseWriter, req *http.Request) {
|
||||||
grid := req.FormValue("gridID")
|
grid := req.FormValue("gridID")
|
||||||
err := a.db.View(func(tx *bbolt.Tx) error {
|
err := a.db.View(func(tx *bbolt.Tx) error {
|
||||||
@@ -263,444 +104,3 @@ func (a *App) locate(rw http.ResponseWriter, req *http.Request) {
|
|||||||
rw.WriteHeader(404)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
437
internal/app/client_grid.go
Normal file
437
internal/app/client_grid.go
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GridUpdate struct {
|
||||||
|
Grids [][]string `json:"grids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridRequest struct {
|
||||||
|
GridRequests []string `json:"gridRequests"`
|
||||||
|
Map int `json:"map"`
|
||||||
|
Coords Coord `json:"coords"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtraData struct {
|
||||||
|
Season int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) gridUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
defer req.Body.Close()
|
||||||
|
dec := json.NewDecoder(req.Body)
|
||||||
|
grup := GridUpdate{}
|
||||||
|
err := dec.Decode(&grup)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error decoding grid request json: ", err)
|
||||||
|
http.Error(rw, "Error decoding request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println(grup)
|
||||||
|
|
||||||
|
ops := []struct {
|
||||||
|
mapid int
|
||||||
|
x, y int
|
||||||
|
f string
|
||||||
|
}{}
|
||||||
|
|
||||||
|
greq := GridRequest{}
|
||||||
|
|
||||||
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
grids, err := tx.CreateBucketIfNotExists([]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) 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)
|
||||||
|
}
|
||||||
82
internal/app/client_markers.go
Normal file
82
internal/app/client_markers.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) uploadMarkers(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
defer req.Body.Close()
|
||||||
|
markers := []struct {
|
||||||
|
Name string
|
||||||
|
GridID string
|
||||||
|
X, Y int
|
||||||
|
Image string
|
||||||
|
Type string
|
||||||
|
Color string
|
||||||
|
}{}
|
||||||
|
buf, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error reading marker json: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(buf, &markers)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error decoding marker json: ", err)
|
||||||
|
log.Println("Original json: ", string(buf))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
mb, err := tx.CreateBucketIfNotExists([]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
|
||||||
|
}
|
||||||
|
}
|
||||||
97
internal/app/client_positions.go
Normal file
97
internal/app/client_positions.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) updatePositions(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
defer req.Body.Close()
|
||||||
|
craws := map[string]struct {
|
||||||
|
Name string
|
||||||
|
GridID string
|
||||||
|
Coords struct {
|
||||||
|
X, Y int
|
||||||
|
}
|
||||||
|
Type string
|
||||||
|
}{}
|
||||||
|
buf, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error reading position update json: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(buf, &craws)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error decoding position update json: ", err)
|
||||||
|
log.Println("Original json: ", string(buf))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read grid data first (inside db.View), then update characters (with chmu only).
|
||||||
|
// Avoid holding db.View and chmu simultaneously to prevent deadlock.
|
||||||
|
gridDataByID := make(map[string]GridData)
|
||||||
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
grids := tx.Bucket([]byte("grids"))
|
||||||
|
if grids == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, craw := range craws {
|
||||||
|
grid := grids.Get([]byte(craw.GridID))
|
||||||
|
if grid != nil {
|
||||||
|
var gd GridData
|
||||||
|
if json.Unmarshal(grid, &gd) == nil {
|
||||||
|
gridDataByID[craw.GridID] = gd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
a.chmu.Lock()
|
||||||
|
defer a.chmu.Unlock()
|
||||||
|
for id, craw := range craws {
|
||||||
|
gd, ok := gridDataByID[craw.GridID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idnum, _ := strconv.Atoi(id)
|
||||||
|
c := Character{
|
||||||
|
Name: craw.Name,
|
||||||
|
ID: idnum,
|
||||||
|
Map: gd.Map,
|
||||||
|
Position: Position{
|
||||||
|
X: craw.Coords.X + (gd.Coord.X * 100),
|
||||||
|
Y: craw.Coords.Y + (gd.Coord.Y * 100),
|
||||||
|
},
|
||||||
|
Type: craw.Type,
|
||||||
|
updated: time.Now(),
|
||||||
|
}
|
||||||
|
old, ok := a.characters[id]
|
||||||
|
if !ok {
|
||||||
|
a.characters[id] = c
|
||||||
|
} else {
|
||||||
|
if old.Type == "player" {
|
||||||
|
if c.Type == "player" {
|
||||||
|
a.characters[id] = c
|
||||||
|
} else {
|
||||||
|
old.Position = c.Position
|
||||||
|
a.characters[id] = old
|
||||||
|
}
|
||||||
|
} else if old.Type != "unknown" {
|
||||||
|
if c.Type != "unknown" {
|
||||||
|
a.characters[id] = c
|
||||||
|
} else {
|
||||||
|
old.Position = c.Position
|
||||||
|
a.characters[id] = old
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.characters[id] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/app/handlers_redirects.go
Normal file
41
internal/app/handlers_redirects.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) redirectLogout(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/logout" {
|
||||||
|
http.NotFound(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := a.getSession(req)
|
||||||
|
if s != nil {
|
||||||
|
a.deleteSession(s)
|
||||||
|
}
|
||||||
|
http.Redirect(rw, req, "/map/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) redirectAdmin(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(rw, req, "/map/admin", http.StatusFound)
|
||||||
|
}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user