From 82cb8a13f5a3d8847b18e212d1512f6d54bd5cc7 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Tue, 24 Feb 2026 23:32:50 +0300 Subject: [PATCH] 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. --- .gitignore | 1 + CONTRIBUTING.md | 2 +- README.md | 2 +- docs/api.md | 5 +- docs/architecture.md | 30 +- docs/development.md | 2 +- frontend-nuxt/components/MapView.vue | 51 +- frontend-nuxt/composables/useMapApi.ts | 24 +- .../lib/{Character.js => Character.ts} | 40 +- ...etCustomTypes.js => LeafletCustomTypes.ts} | 12 +- frontend-nuxt/lib/{Marker.js => Marker.ts} | 53 +- .../{SmartTileLayer.js => SmartTileLayer.ts} | 25 +- frontend-nuxt/lib/UniqueList.js | 39 - frontend-nuxt/lib/UniqueList.ts | 50 + frontend-nuxt/package-lock.json | 16 + frontend-nuxt/package.json | 1 + frontend-nuxt/types/api.ts | 30 + internal/app/admin.go | 1124 ----------------- internal/app/admin_export.go | 133 ++ internal/app/admin_markers.go | 48 + internal/app/admin_merge.go | 305 +++++ internal/app/admin_rebuild.go | 52 + internal/app/admin_tiles.go | 188 +++ internal/app/api.go | 71 -- internal/app/app.go | 136 +- internal/app/auth.go | 154 +++ internal/app/client.go | 600 --------- internal/app/client_grid.go | 437 +++++++ internal/app/client_markers.go | 82 ++ internal/app/client_positions.go | 97 ++ internal/app/handlers_redirects.go | 41 + internal/app/manage.go | 175 --- webapp/templates/admin/index.tmpl | 151 --- webapp/templates/admin/map.tmpl | 44 - webapp/templates/admin/user.tmpl | 63 - webapp/templates/index.tmpl | 38 - webapp/templates/login.tmpl | 31 - webapp/templates/password.tmpl | 27 - webapp/webapp.go | 39 - 39 files changed, 1788 insertions(+), 2631 deletions(-) rename frontend-nuxt/lib/{Character.js => Character.ts} (61%) rename frontend-nuxt/lib/{LeafletCustomTypes.js => LeafletCustomTypes.ts} (90%) rename frontend-nuxt/lib/{Marker.js => Marker.ts} (63%) rename frontend-nuxt/lib/{SmartTileLayer.js => SmartTileLayer.ts} (68%) delete mode 100644 frontend-nuxt/lib/UniqueList.js create mode 100644 frontend-nuxt/lib/UniqueList.ts create mode 100644 frontend-nuxt/types/api.ts delete mode 100644 internal/app/admin.go create mode 100644 internal/app/admin_export.go create mode 100644 internal/app/admin_markers.go create mode 100644 internal/app/admin_merge.go create mode 100644 internal/app/admin_rebuild.go create mode 100644 internal/app/admin_tiles.go create mode 100644 internal/app/auth.go create mode 100644 internal/app/client_grid.go create mode 100644 internal/app/client_markers.go create mode 100644 internal/app/client_positions.go create mode 100644 internal/app/handlers_redirects.go delete mode 100644 internal/app/manage.go delete mode 100644 webapp/templates/admin/index.tmpl delete mode 100644 webapp/templates/admin/map.tmpl delete mode 100644 webapp/templates/admin/user.tmpl delete mode 100644 webapp/templates/index.tmpl delete mode 100644 webapp/templates/login.tmpl delete mode 100644 webapp/templates/password.tmpl delete mode 100644 webapp/webapp.go diff --git a/.gitignore b/.gitignore index 8e1e5cd..1131938 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ frontend/dist # OS / IDE .DS_Store .cursor/ +.vscode/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85821d9..8f89a8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Clone the repository and run the project locally (see [Development](docs/develop ## 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/`. ## Formatting and tests diff --git a/README.md b/README.md index d2aeb6d..4b563d9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ point your auto-mapping supported client at it (like Purus pasta). ## Documentation -- [Architecture](docs/architecture.md) — high-level design and backend layout (`cmd/hnh-map`, `internal/app`, `webapp`) +- [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) - [Configuration](docs/configuration.md) — environment variables and flags - [Development](docs/development.md) — local run, Docker Compose dev, build diff --git a/docs/api.md b/docs/api.md index f8f6277..c015773 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,8 +36,9 @@ API доступно по префиксу `/map/api/`. Для запросов, - **POST /map/api/admin/rebuildZooms** — пересобрать зум-уровни тайлов. - **GET /map/api/admin/export** — скачать экспорт данных (ZIP). - **POST /map/api/admin/merge** — загрузить и применить merge (ZIP с гридами и маркерами). - -Дополнительные админ-действия (формы или внутренние вызовы): wipeTile, setCoords, hideMarker — см. реализацию в `internal/app/api.go` и `admin.go`. +- **GET /map/api/admin/wipeTile** — удалить тайл. Query: `map`, `x`, `y`. +- **GET /map/api/admin/setCoords** — сдвинуть координаты гридов. Query: `map`, `fx`, `fy`, `tx`, `ty`. +- **GET /map/api/admin/hideMarker** — скрыть маркер. Query: `id`. ## Коды ответов diff --git a/docs/architecture.md b/docs/architecture.md index d33bb4d..a5910dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ┌──────────────────────────────────────┐ │ Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │ │ (Nuxt SPA │ /map/, /map/api │ • bbolt (users, sessions, grids, │ │ по /map/) │ /map/updates │ markers, tiles, maps, config) │ -│ │ /map/grids/ │ • Шаблоны (embed в webapp) │ -└─────────────┘ │ • Статика фронта (frontend/) │ - │ • internal/app — вся логика │ - │ • webapp — рендер шаблонов │ +│ │ /map/grids/ │ • Статика фронта (frontend/) │ +└─────────────┘ │ • internal/app — вся логика │ └──────────────────────────────────────┘ ``` ## Структура бэкенда -- **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` и всей логикой: - - **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), хелперы сессий и страниц (`getSession`, `getUser`, `serveMapFrontend`, `CleanChars`). - - **api.go** — HTTP API: авторизация (login, me, logout, setup), кабинет (tokens, password), админ (users, settings, maps, wipe, rebuildZooms, export, merge), редиректы и роутер `/map/api/...`. - - **admin.go** — админка: HTML-страницы и действия (wipe, setPrefix, setDefaultHide, setTitle, rebuildZooms, export, merge, adminMap, wipeTile, setCoords, hideMarker и т.д.). - - **client.go** — хендлеры клиента маппера (`/client/{token}/...`): locate, gridUpdate, gridUpload, positionUpdate, markerUpdate, updateZoomLevel. + - **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), `serveMapFrontend`, `CleanChars`. + - **auth.go** — сессии и авторизация: `getSession`, `deleteSession`, `saveSession`, `getUser`, `getPage`, `createSession`, `setupRequired`, `requireAdmin`. + - **api.go** — HTTP API: авторизация (login, me, logout, setup), кабинет (tokens, password), админ (users, settings, maps, wipe, rebuildZooms, export, merge), роутер `/map/api/...`. + - **handlers_redirects.go** — редиректы: `/` → `/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`. - **tile.go** — тайлы и гриды: `GetTile`, `SaveTile`, `watchGridUpdates` (SSE), `gridTile`, `reportMerge`. - **topic.go** — типы `topic` и `mergeTopic` для рассылки обновлений тайлов и слияний карт. - - **manage.go** — страницы управления: index, login, logout, generateToken, changePassword. - **migrations.go** — миграции bbolt; из main вызывается `app.RunMigrations(db)`. -- **webapp/** — отдельный пакет в корне репозитория: загрузка и выполнение HTML-шаблонов. Импортируется из `cmd/hnh-map` и из `internal/app`. - Сборка из корня репозитория: ```bash diff --git a/docs/development.md b/docs/development.md index 1f06e5a..179f07f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,7 +17,7 @@ go build -o hnh-map ./cmd/hnh-map go run ./cmd/hnh-map -grids=./grids -port=8080 ``` -Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). HTML-шаблоны встроены в бинарник (пакет webapp). +Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). ### Фронтенд (Nuxt) diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index 6d6ea96..18a3f17 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -99,10 +99,10 @@ 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' }" > - - @@ -112,7 +112,7 @@ 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' }" > - @@ -157,7 +157,6 @@ const props = withDefaults( ) const mapRef = ref(null) -const router = useRouter() const route = useRoute() const api = useMapApi() @@ -191,8 +190,8 @@ let markerLayer: L.LayerGroup | null = null let source: EventSource | null = null let intervalId: ReturnType | null = null let mapid = 0 -let markers: InstanceType | null = null -let characters: InstanceType | null = null +let markers: UniqueList> | null = null +let characters: UniqueList> | null = null let markersHidden = false let autoMode = false @@ -305,7 +304,7 @@ onMounted(async () => { maps.value = mapsList 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?.auths) auths.value = config.auths @@ -322,7 +321,7 @@ onMounted(async () => { }) 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 const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/' @@ -336,10 +335,10 @@ onMounted(async () => { updateWhenIdle: true, keepBuffer: 2, }) as any - layer.map = initialMapId - layer.invalidTile = + layer!.map = initialMapId + layer!.invalidTile = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' - layer.addTo(map) + layer!.addTo(map) overlayLayer = new SmartTileLayer(tileUrl, { minZoom: 1, @@ -351,10 +350,10 @@ onMounted(async () => { updateWhenIdle: true, keepBuffer: 2, }) as any - overlayLayer.map = -1 - overlayLayer.invalidTile = + overlayLayer!.map = -1 + overlayLayer!.invalidTile = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' - overlayLayer.addTo(map) + overlayLayer!.addTo(map) coordLayer = new GridCoordLayer({ tileSize: TileSize, @@ -362,7 +361,7 @@ onMounted(async () => { maxZoom: HnHMaxZoom, opacity: 0, visible: false, - }) + } as any) coordLayer.addTo(map) coordLayer.setZIndex(500) @@ -398,9 +397,9 @@ onMounted(async () => { if (!Array.isArray(updates)) return for (const u of updates) { const key = `${u.M}:${u.X}:${u.Y}:${u.Z}` - layer.cache[key] = u.T + layer!.cache[key] = u.T if (overlayLayer) overlayLayer.cache[key] = u.T - if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z) + if (layer!.map === u.M) layer!.refresh(u.X, u.Y, u.Z) if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z) } // After initial batch (or any batch), redraw so tiles re-request with filled cache @@ -436,8 +435,8 @@ onMounted(async () => { } }) - markers = new UniqueList() - characters = new UniqueList() + markers = new UniqueList>() + characters = new UniqueList>() updateCharacters(charactersData as any[]) @@ -449,9 +448,12 @@ onMounted(async () => { selectedMapId.value = props.mapId map.setView(latLng, props.zoom) } else if (mapsList.length > 0) { - changeMap(mapsList[0].ID) - selectedMapId.value = mapsList[0].ID - map.setView([0, 0], HnHDefaultZoom) + const first = mapsList[0] + if (first) { + changeMap(first.ID) + selectedMapId.value = first.ID + map.setView([0, 0], HnHDefaultZoom) + } } // Recompute map size after layout (fixes grid/container height chain in Nuxt) @@ -518,13 +520,13 @@ onMounted(async () => { watch(showGridCoordinates, (v) => { if (coordLayer) { - coordLayer.options.visible = v + ;(coordLayer.options as { visible?: boolean }).visible = v coordLayer.setOpacity(v ? 1 : 0) if (v && map) { coordLayer.bringToFront?.() coordLayer.redraw?.() map.invalidateSize() - } else { + } else if (coordLayer) { coordLayer.redraw?.() } } @@ -549,7 +551,6 @@ onMounted(async () => { changeMap(character.map) const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom) map!.setView(latlng, HnHMaxZoom) - router.push(`/character/${value}`) autoMode = true } else { map!.setView([0, 0], HnHMinZoom) diff --git a/frontend-nuxt/composables/useMapApi.ts b/frontend-nuxt/composables/useMapApi.ts index 64ec66c..1c68bc6 100644 --- a/frontend-nuxt/composables/useMapApi.ts +++ b/frontend-nuxt/composables/useMapApi.ts @@ -1,22 +1,6 @@ -export interface MeResponse { - username: string - auths: string[] - tokens?: string[] - prefix?: string -} +import type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse } from '~/types/api' -export interface MapInfoAdmin { - ID: number - Name: string - Hidden: boolean - Priority: boolean -} - -export interface SettingsResponse { - prefix: string - defaultHide: boolean - title: string -} +export type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse } // Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue) const onApiErrorCallbacks: (() => void)[] = [] @@ -46,7 +30,7 @@ export function useMapApi() { } async function getConfig() { - return request<{ title?: string; auths?: string[] }>('config') + return request('config') } async function getCharacters() { @@ -58,7 +42,7 @@ export function useMapApi() { } async function getMaps() { - return request>('maps') + return request>('maps') } // Auth diff --git a/frontend-nuxt/lib/Character.js b/frontend-nuxt/lib/Character.ts similarity index 61% rename from frontend-nuxt/lib/Character.js rename to frontend-nuxt/lib/Character.ts index 9f79839..06f68c0 100644 --- a/frontend-nuxt/lib/Character.js +++ b/frontend-nuxt/lib/Character.ts @@ -1,24 +1,46 @@ import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' 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 { - 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.position = characterData.position this.type = characterData.type this.id = characterData.id this.map = characterData.map - this.marker = null this.text = this.name this.value = this.id - this.onClick = null } - getId() { + getId(): string { return `${this.name}` } - remove(mapview) { + remove(mapview: MapViewRef): void { if (this.marker) { const layer = mapview.markerLayer ?? mapview.map layer.removeLayer(this.marker) @@ -26,7 +48,7 @@ export class Character { } } - add(mapview) { + add(mapview: MapViewRef): void { if (this.map === mapview.mapid) { const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom) this.marker = L.marker(position, { title: this.name }) @@ -36,7 +58,7 @@ export class Character { } } - update(mapview, updated) { + update(mapview: MapViewRef, updated: CharacterData): void { if (this.map !== updated.map) { this.remove(mapview) } @@ -51,11 +73,11 @@ export class Character { } } - setClickCallback(callback) { + setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { this.onClick = callback } - callCallback(e) { + callCallback(e: L.LeafletMouseEvent): void { if (this.onClick != null) this.onClick(e) } } diff --git a/frontend-nuxt/lib/LeafletCustomTypes.js b/frontend-nuxt/lib/LeafletCustomTypes.ts similarity index 90% rename from frontend-nuxt/lib/LeafletCustomTypes.js rename to frontend-nuxt/lib/LeafletCustomTypes.ts index bb81417..0200e18 100644 --- a/frontend-nuxt/lib/LeafletCustomTypes.js +++ b/frontend-nuxt/lib/LeafletCustomTypes.ts @@ -12,7 +12,7 @@ export const GridCoordLayer = L.GridLayer.extend({ options: { visible: true, }, - createTile(coords) { + createTile(coords: { x: number; y: number; z: number }) { if (!this.options.visible) { const element = document.createElement('div') element.style.width = TileSize + 'px' @@ -61,23 +61,23 @@ export const GridCoordLayer = L.GridLayer.extend({ } return element }, -}) +}) as unknown as new (options?: L.GridLayerOptions) => L.GridLayer export const ImageIcon = L.Icon.extend({ options: { iconSize: [32, 32], iconAnchor: [16, 16], }, -}) +}) as unknown as new (options?: L.IconOptions) => L.Icon const latNormalization = (90.0 * TileSize) / 2500000.0 const lngNormalization = (180.0 * TileSize) / 2500000.0 const HnHProjection = { - project(latlng) { + project(latlng: LatLng) { return new Point(latlng.lat / latNormalization, latlng.lng / lngNormalization) }, - unproject(point) { + unproject(point: Point) { return new LatLng(point.x * latNormalization, point.y * lngNormalization) }, bounds: (() => new Bounds([-latNormalization, -lngNormalization], [latNormalization, lngNormalization]))(), @@ -85,4 +85,4 @@ const HnHProjection = { export const HnHCRS = L.extend({}, L.CRS.Simple, { projection: HnHProjection, -}) +}) as L.CRS diff --git a/frontend-nuxt/lib/Marker.js b/frontend-nuxt/lib/Marker.ts similarity index 63% rename from frontend-nuxt/lib/Marker.js rename to frontend-nuxt/lib/Marker.ts index 1a87d5e..226a9d1 100644 --- a/frontend-nuxt/lib/Marker.js +++ b/frontend-nuxt/lib/Marker.ts @@ -1,38 +1,63 @@ import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes' 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 === 'custom') return 'custom' return name.substring('gfx/terobjs/mm/'.length) } 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.position = markerData.position this.name = markerData.name this.image = markerData.image this.type = detectType(this.image) - this.marker = null this.text = this.name this.value = this.id this.hidden = markerData.hidden this.map = markerData.map - this.onClick = null - this.onContext = null } - remove(mapview) { + remove(_mapview: MapViewRef): void { if (this.marker) { this.marker.remove() this.marker = null } } - add(mapview) { + add(mapview: MapViewRef): void { if (!this.hidden) { - let icon + let icon: L.Icon if (this.image === 'gfx/terobjs/mm/custom') { icon = new ImageIcon({ 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.name = updated.name this.hidden = updated.hidden @@ -64,26 +89,26 @@ export class Marker { } } - jumpTo(map) { + jumpTo(map: L.Map): void { if (this.marker) { const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom) this.marker.setLatLng(position) } } - setClickCallback(callback) { + setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { this.onClick = callback } - callClickCallback(e) { + callClickCallback(e: L.LeafletMouseEvent): void { if (this.onClick != null) this.onClick(e) } - setContextMenu(callback) { + setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void { this.onContext = callback } - callContextCallback(e) { + callContextCallback(e: L.LeafletMouseEvent): void { if (this.onContext != null) this.onContext(e) } } diff --git a/frontend-nuxt/lib/SmartTileLayer.js b/frontend-nuxt/lib/SmartTileLayer.ts similarity index 68% rename from frontend-nuxt/lib/SmartTileLayer.js rename to frontend-nuxt/lib/SmartTileLayer.ts index 83a137c..afa4064 100644 --- a/frontend-nuxt/lib/SmartTileLayer.js +++ b/frontend-nuxt/lib/SmartTileLayer.ts @@ -1,11 +1,15 @@ import L, { Util, Browser } from 'leaflet' +interface SmartTileLayerCache { + [key: string]: number | undefined +} + export const SmartTileLayer = L.TileLayer.extend({ - cache: {}, + cache: {} as SmartTileLayerCache, invalidTile: '', map: 0, - getTileUrl(coords) { + getTileUrl(coords: { x: number; y: number; z: number }) { if (!this._map) return this.invalidTile let zoom try { @@ -16,8 +20,8 @@ export const SmartTileLayer = L.TileLayer.extend({ return this.getTrueTileUrl(coords, zoom) }, - getTrueTileUrl(coords, zoom) { - const data = { + getTrueTileUrl(coords: { x: number; y: number }, zoom: number) { + const data: Record = { r: Browser.retina ? '@2x' : '', s: this._getSubdomain(coords), x: coords.x, @@ -37,7 +41,8 @@ export const SmartTileLayer = L.TileLayer.extend({ data.cache = this.cache[cacheKey] // Don't request tiles for invalid/unknown map (avoids 404 spam in console) - if (data.map === undefined || data.map === null || data.map < 1) { + const mapId = Number(data.map) + if (data.map === undefined || data.map === null || mapId < 1) { return this.invalidTile } // 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)) }, - refresh(x, y, z) { + refresh(x: number, y: number, z: number) { let zoom = z const maxZoom = this.options.maxZoom const zoomReverse = this.options.zoomReverse @@ -70,4 +75,10 @@ export const SmartTileLayer = L.TileLayer.extend({ 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 +} diff --git a/frontend-nuxt/lib/UniqueList.js b/frontend-nuxt/lib/UniqueList.js deleted file mode 100644 index cc0b356..0000000 --- a/frontend-nuxt/lib/UniqueList.js +++ /dev/null @@ -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] - } -} diff --git a/frontend-nuxt/lib/UniqueList.ts b/frontend-nuxt/lib/UniqueList.ts new file mode 100644 index 0000000..0ef9f07 --- /dev/null +++ b/frontend-nuxt/lib/UniqueList.ts @@ -0,0 +1,50 @@ +/** + * Elements should have unique field "id" + */ +export interface Identifiable { + id: number | string +} + +export class UniqueList { + elements: Record = {} + + 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)] + } +} diff --git a/frontend-nuxt/package-lock.json b/frontend-nuxt/package-lock.json index ac1e242..08e161c 100644 --- a/frontend-nuxt/package-lock.json +++ b/frontend-nuxt/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@nuxtjs/tailwindcss": "^6.12.2", + "@types/leaflet": "^1.9.21", "daisyui": "^3.9.4", "tailwindcss": "^3.4.17", "typescript": "^5.6.3" @@ -2579,6 +2580,21 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "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": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", diff --git a/frontend-nuxt/package.json b/frontend-nuxt/package.json index 7bb87c4..f5be539 100644 --- a/frontend-nuxt/package.json +++ b/frontend-nuxt/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@nuxtjs/tailwindcss": "^6.12.2", + "@types/leaflet": "^1.9.21", "daisyui": "^3.9.4", "tailwindcss": "^3.4.17", "typescript": "^5.6.3" diff --git a/frontend-nuxt/types/api.ts b/frontend-nuxt/types/api.ts new file mode 100644 index 0000000..15a2905 --- /dev/null +++ b/frontend-nuxt/types/api.ts @@ -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 +} diff --git a/internal/app/admin.go b/internal/app/admin.go deleted file mode 100644 index 8b9720e..0000000 --- a/internal/app/admin.go +++ /dev/null @@ -1,1124 +0,0 @@ -package app - -import ( - "archive/zip" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "go.etcd.io/bbolt" - "golang.org/x/crypto/bcrypt" -) - -func (a *App) admin(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - - users := []string{} - prefix := "" - maps := []MapInfo{} - defaultHide := false - a.db.View(func(tx *bbolt.Tx) error { - b := tx.Bucket([]byte("users")) - if b == nil { - return nil - } - config := tx.Bucket([]byte("config")) - if config != nil { - prefix = string(config.Get([]byte("prefix"))) - defaultHide = config.Get([]byte("defaultHide")) != nil - } - mapB := tx.Bucket([]byte("maps")) - if mapB != nil { - mapB.ForEach(func(k, v []byte) error { - mi := MapInfo{} - json.Unmarshal(v, &mi) - maps = append(maps, mi) - return nil - }) - } - return b.ForEach(func(k, v []byte) error { - users = append(users, string(k)) - return nil - }) - }) - - a.ExecuteTemplate(rw, "admin/index.tmpl", struct { - Page Page - Session *Session - Users []string - Prefix string - DefaultHide bool - Maps []MapInfo - }{ - Page: a.getPage(req), - Session: s, - Users: users, - Prefix: prefix, - DefaultHide: defaultHide, - Maps: maps, - }) -} - -func (a *App) adminUser(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - - if req.Method == "POST" { - req.ParseForm() - username := req.FormValue("user") - password := req.FormValue("pass") - auths := req.Form["auths"] - tempAdmin := false - a.db.Update(func(tx *bbolt.Tx) error { - users, err := tx.CreateBucketIfNotExists([]byte("users")) - if err != nil { - return err - } - if s.Username == "admin" && users.Get([]byte("admin")) == nil { - tempAdmin = true - } - u := User{} - raw := users.Get([]byte(username)) - if raw != nil { - json.Unmarshal(raw, &u) - } - if password != "" { - u.Pass, _ = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - } - u.Auths = auths - raw, _ = json.Marshal(u) - users.Put([]byte(username), raw) - return nil - }) - if username == s.Username { - s.Auths = auths - } - if tempAdmin { - a.deleteSession(s) - } - http.Redirect(rw, req, "/admin", 302) - return - } - - user := req.FormValue("user") - u := User{} - a.db.View(func(tx *bbolt.Tx) error { - b := tx.Bucket([]byte("users")) - if b == nil { - return nil - } - userRaw := b.Get([]byte(user)) - if userRaw == nil { - return nil - } - return json.Unmarshal(userRaw, &u) - }) - - a.ExecuteTemplate(rw, "admin/user.tmpl", struct { - Page Page - Session *Session - User User - Username string - }{ - Page: a.getPage(req), - Session: s, - User: u, - Username: user, - }) -} - -func (a *App) wipe(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - err := a.db.Update(func(tx *bbolt.Tx) error { - if tx.Bucket([]byte("grids")) != nil { - err := tx.DeleteBucket([]byte("grids")) - if err != nil { - return err - } - } - if tx.Bucket([]byte("markers")) != nil { - err := tx.DeleteBucket([]byte("markers")) - if err != nil { - return err - } - } - if tx.Bucket([]byte("tiles")) != nil { - err := tx.DeleteBucket([]byte("tiles")) - if err != nil { - return err - } - } - if tx.Bucket([]byte("maps")) != nil { - err := tx.DeleteBucket([]byte("maps")) - if err != nil { - return err - } - } - return nil - }) - if err != nil { - log.Println(err) - } - /*for z := 0; z <= 5; z++ { - os.RemoveAll(fmt.Sprintf("%s/%d", a.gridStorage, z)) - }*/ - http.Redirect(rw, req, "/admin/", 302) -} - -func (a *App) setPrefix(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - a.db.Update(func(tx *bbolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte("config")) - if err != nil { - return err - } - return b.Put([]byte("prefix"), []byte(req.FormValue("prefix"))) - }) - http.Redirect(rw, req, "/admin/", 302) -} - -func (a *App) setDefaultHide(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - a.db.Update(func(tx *bbolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte("config")) - if err != nil { - return err - } - if req.FormValue("defaultHide") != "" { - return b.Put([]byte("defaultHide"), []byte(req.FormValue("defaultHide"))) - } else { - return b.Delete([]byte("defaultHide")) - } - }) - http.Redirect(rw, req, "/admin/", 302) -} - -func (a *App) setTitle(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - a.db.Update(func(tx *bbolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte("config")) - if err != nil { - return err - } - return b.Put([]byte("title"), []byte(req.FormValue("title"))) - }) - http.Redirect(rw, req, "/admin/", 302) -} - -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{}{} - } - } -} - -func (a *App) rebuildZooms(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - a.doRebuildZooms() - http.Redirect(rw, req, "/admin/", 302) -} - -func (a *App) deleteUser(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - - username := req.FormValue("user") - a.db.Update(func(tx *bbolt.Tx) error { - users, err := tx.CreateBucketIfNotExists([]byte("users")) - if err != nil { - return err - } - u := User{} - raw := users.Get([]byte(username)) - if raw != nil { - json.Unmarshal(raw, &u) - } - tokens, err := tx.CreateBucketIfNotExists([]byte("tokens")) - if err != nil { - return err - } - for _, tok := range u.Tokens { - err = tokens.Delete([]byte(tok)) - if err != nil { - return err - } - } - err = users.Delete([]byte(username)) - if err != nil { - return err - } - return nil - }) - if username == s.Username { - a.deleteSession(s) - } - http.Redirect(rw, req, "/admin", 302) - return -} - -var errFound = errors.New("found tile") - -func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - 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) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - 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) -} - -func (a *App) backup(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=\"backup.zip\"") - - zw := zip.NewWriter(rw) - defer zw.Close() - - err := a.db.Update(func(tx *bbolt.Tx) error { - w, err := zw.Create("grids.db") - if err != nil { - return err - } - err = tx.Copy(w) - if err != nil { - return err - } - - tiles := tx.Bucket([]byte("tiles")) - if tiles == nil { - return nil - } - zoom := tiles.Bucket([]byte("0")) - if zoom == nil { - return nil - } - return zoom.ForEach(func(k, v []byte) error { - td := TileData{} - json.Unmarshal(v, &td) - if td.File == "" { - return nil - } - f, err := os.Open(a.gridStorage + "/" + td.File) - if err != nil { - return nil - } - w, err := zw.Create(td.File) - f.Close() - if err != nil { - return err - } - _, err = io.Copy(w, f) - return err - }) - }) - if err != nil { - log.Println(err) - } - -} - -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) - } - -} - -func (a *App) hideMarker(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - 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) - } - return -} - -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() - if !strings.HasPrefix(req.URL.Path, "/map/api") { - http.Redirect(rw, req, "/admin/", 302) - } -} - -func (a *App) adminICMap(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - - mraw := req.FormValue("map") - mapid, err := strconv.Atoi(mraw) - if err != nil { - http.Error(rw, "map parse failed", http.StatusBadRequest) - return - } - - action := req.FormValue("action") - - a.db.Update(func(tx *bbolt.Tx) error { - maps, err := tx.CreateBucketIfNotExists([]byte("maps")) - if err != nil { - return err - } - rawmap := maps.Get([]byte(strconv.Itoa(mapid))) - mapinfo := MapInfo{} - if rawmap != nil { - json.Unmarshal(rawmap, &mapinfo) - } - switch action { - case "toggle-hidden": - mapinfo.Hidden = !mapinfo.Hidden - a.ExecuteTemplate(rw, "admin/index.tmpl:toggle-hidden", mapinfo) - } - rawmap, err = json.Marshal(mapinfo) - if err != nil { - return err - } - return maps.Put([]byte(strconv.Itoa(mapid)), rawmap) - }) -} - -func (a *App) adminMap(rw http.ResponseWriter, req *http.Request) { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - http.Redirect(rw, req, "/", 302) - return - } - - mraw := req.FormValue("map") - mapid, err := strconv.Atoi(mraw) - if err != nil { - http.Error(rw, "map parse failed", http.StatusBadRequest) - return - } - - if req.Method == "POST" { - req.ParseForm() - - name := req.FormValue("name") - hidden := !(req.FormValue("hidden") == "") - priority := !(req.FormValue("priority") == "") - - a.db.Update(func(tx *bbolt.Tx) error { - maps, err := tx.CreateBucketIfNotExists([]byte("maps")) - if err != nil { - return err - } - rawmap := maps.Get([]byte(strconv.Itoa(mapid))) - mapinfo := MapInfo{} - if rawmap != nil { - json.Unmarshal(rawmap, &mapinfo) - } - mapinfo.Name = name - mapinfo.Hidden = hidden - mapinfo.Priority = priority - rawmap, err = json.Marshal(mapinfo) - if err != nil { - return err - } - return maps.Put([]byte(strconv.Itoa(mapid)), rawmap) - }) - - http.Redirect(rw, req, "/admin", 302) - return - } - mi := MapInfo{} - a.db.View(func(tx *bbolt.Tx) error { - mapB := tx.Bucket([]byte("maps")) - if mapB == nil { - return nil - } - mraw := mapB.Get([]byte(strconv.Itoa(mapid))) - return json.Unmarshal(mraw, &mi) - }) - - a.ExecuteTemplate(rw, "admin/map.tmpl", struct { - Page Page - Session *Session - MapInfo MapInfo - }{ - Page: a.getPage(req), - Session: s, - MapInfo: mi, - }) -} diff --git a/internal/app/admin_export.go b/internal/app/admin_export.go new file mode 100644 index 0000000..6b8c50c --- /dev/null +++ b/internal/app/admin_export.go @@ -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) + } +} diff --git a/internal/app/admin_markers.go b/internal/app/admin_markers.go new file mode 100644 index 0000000..5cffb69 --- /dev/null +++ b/internal/app/admin_markers.go @@ -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) + } +} diff --git a/internal/app/admin_merge.go b/internal/app/admin_merge.go new file mode 100644 index 0000000..1341ba0 --- /dev/null +++ b/internal/app/admin_merge.go @@ -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() +} diff --git a/internal/app/admin_rebuild.go b/internal/app/admin_rebuild.go new file mode 100644 index 0000000..acc5586 --- /dev/null +++ b/internal/app/admin_rebuild.go @@ -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{}{} + } + } +} diff --git a/internal/app/admin_tiles.go b/internal/app/admin_tiles.go new file mode 100644 index 0000000..99f61f3 --- /dev/null +++ b/internal/app/admin_tiles.go @@ -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) +} diff --git a/internal/app/api.go b/internal/app/api.go index 4604296..18a3e0a 100644 --- a/internal/app/api.go +++ b/internal/app/api.go @@ -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) { if req.Method != http.MethodGet { 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) } -// createSession creates a session for username, returns session ID or empty string. -func (a *App) createSession(username string, tempAdmin bool) string { - session := make([]byte, 32) - if _, err := rand.Read(session); err != nil { - return "" - } - sid := hex.EncodeToString(session) - s := &Session{ - ID: sid, - Username: username, - TempAdmin: tempAdmin, - } - a.saveSession(s) - return sid -} - // --- Cabinet API --- func (a *App) apiMeTokens(rw http.ResponseWriter, req *http.Request) { @@ -283,15 +249,6 @@ func (a *App) setUserPassword(username, pass string) error { // --- Admin API (require admin auth) --- -func (a *App) requireAdmin(rw http.ResponseWriter, req *http.Request) *Session { - s := a.getSession(req) - if s == nil || !s.Auths.Has(AUTH_ADMIN) { - rw.WriteHeader(http.StatusUnauthorized) - return nil - } - return s -} - func (a *App) apiAdminUsers(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) @@ -691,28 +648,6 @@ func (a *App) apiAdminMerge(rw http.ResponseWriter, req *http.Request) { a.merge(rw, req) } -// --- Redirects (for old URLs) --- - -func (a *App) redirectRoot(rw http.ResponseWriter, req *http.Request) { - if req.URL.Path != "/" { - http.NotFound(rw, req) - return - } - if a.setupRequired() { - http.Redirect(rw, req, "/map/setup", http.StatusFound) - return - } - http.Redirect(rw, req, "/map/profile", http.StatusFound) -} - -func (a *App) redirectLogin(rw http.ResponseWriter, req *http.Request) { - if req.URL.Path != "/login" { - http.NotFound(rw, req) - return - } - http.Redirect(rw, req, "/map/login", http.StatusFound) -} - // --- API router: /map/api/... --- func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) { @@ -822,11 +757,5 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) { 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) } diff --git a/internal/app/app.go b/internal/app/app.go index bff091c..0ad469e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,17 +1,13 @@ package app import ( - "encoding/json" "fmt" "net/http" "path/filepath" "sync" "time" - "github.com/andyleap/hnh-map/webapp" - "go.etcd.io/bbolt" - "golang.org/x/crypto/bcrypt" ) // App is the main application (map server) state. @@ -23,8 +19,6 @@ type App struct { characters map[string]Character chmu sync.RWMutex - *webapp.WebApp - gridUpdates topic mergeUpdates mergeTopic } @@ -32,16 +26,11 @@ type App struct { // NewApp creates an App with the given storage paths and database. // frontendRoot is the directory for the map SPA (e.g. "frontend"). func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { - w, err := webapp.New() - if err != nil { - return nil, err - } return &App{ gridStorage: gridStorage, - frontendRoot: frontendRoot, - db: db, - characters: make(map[string]Character), - WebApp: w, + frontendRoot: frontendRoot, + db: db, + characters: make(map[string]Character), }, nil } @@ -144,110 +133,10 @@ type User struct { Tokens []string } -func (a *App) getSession(req *http.Request) *Session { - c, err := req.Cookie("session") - if err != nil { - return nil - } - var s *Session - a.db.View(func(tx *bbolt.Tx) error { - sessions := tx.Bucket([]byte("sessions")) - if sessions == nil { - return nil - } - session := sessions.Get([]byte(c.Value)) - if session == nil { - return nil - } - err := json.Unmarshal(session, &s) - if err != nil { - return err - } - if s.TempAdmin { - s.Auths = Auths{AUTH_ADMIN} - return nil - } - users := tx.Bucket([]byte("users")) - if users == nil { - return nil - } - raw := users.Get([]byte(s.Username)) - if raw == nil { - s = nil - return nil - } - u := User{} - err = json.Unmarshal(raw, &u) - if err != nil { - s = nil - return err - } - s.Auths = u.Auths - return nil - }) - return s -} - -func (a *App) deleteSession(s *Session) { - a.db.Update(func(tx *bbolt.Tx) error { - sessions, err := tx.CreateBucketIfNotExists([]byte("sessions")) - if err != nil { - return err - } - return sessions.Delete([]byte(s.ID)) - }) -} - -func (a *App) saveSession(s *Session) { - a.db.Update(func(tx *bbolt.Tx) error { - sessions, err := tx.CreateBucketIfNotExists([]byte("sessions")) - if err != nil { - return err - } - buf, err := json.Marshal(s) - if err != nil { - return err - } - return sessions.Put([]byte(s.ID), buf) - }) -} - type Page struct { Title string `json:"title"` } -func (a *App) getPage(req *http.Request) Page { - p := Page{} - a.db.View(func(tx *bbolt.Tx) error { - c := tx.Bucket([]byte("config")) - if c == nil { - return nil - } - p.Title = string(c.Get([]byte("title"))) - return nil - }) - return p -} - -func (a *App) getUser(user, pass string) (u *User) { - a.db.View(func(tx *bbolt.Tx) error { - users := tx.Bucket([]byte("users")) - if users == nil { - return nil - } - raw := users.Get([]byte(user)) - if raw != nil { - json.Unmarshal(raw, &u) - if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil { - u = nil - return nil - } - } - return nil - }) - return u -} - // serveMapFrontend serves the map SPA: static files from frontend, fallback to index.html for client-side routes. func (a *App) serveMapFrontend(rw http.ResponseWriter, req *http.Request) { path := req.URL.Path @@ -306,23 +195,10 @@ func (a *App) RegisterRoutes() { http.HandleFunc("/client/", a.client) 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("/generateToken", a.generateToken) - http.HandleFunc("/password", a.changePassword) - - http.HandleFunc("/admin/", a.admin) - http.HandleFunc("/admin/user", a.adminUser) - http.HandleFunc("/admin/deleteUser", a.deleteUser) - http.HandleFunc("/admin/wipe", a.wipe) - http.HandleFunc("/admin/setPrefix", a.setPrefix) - http.HandleFunc("/admin/setDefaultHide", a.setDefaultHide) - http.HandleFunc("/admin/setTitle", a.setTitle) - http.HandleFunc("/admin/rebuildZooms", a.rebuildZooms) - http.HandleFunc("/admin/export", a.export) - http.HandleFunc("/admin/merge", a.merge) - http.HandleFunc("/admin/map", a.adminMap) - http.HandleFunc("/admin/mapic", a.adminICMap) http.HandleFunc("/map/api/", a.apiRouter) http.HandleFunc("/map/updates", a.watchGridUpdates) diff --git a/internal/app/auth.go b/internal/app/auth.go new file mode 100644 index 0000000..e614699 --- /dev/null +++ b/internal/app/auth.go @@ -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 +} diff --git a/internal/app/client.go b/internal/app/client.go index fcbe874..4f8395f 100644 --- a/internal/app/client.go +++ b/internal/app/client.go @@ -4,20 +4,8 @@ import ( "context" "encoding/json" "fmt" - "image" - "image/png" - "io" - "io/ioutil" - "log" "net/http" - "os" - "path/filepath" "regexp" - "strconv" - "strings" - "time" - - "golang.org/x/image/draw" "go.etcd.io/bbolt" ) @@ -80,8 +68,6 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) { a.updatePositions(rw, req) case "markerUpdate": a.uploadMarkers(rw, req) - /*case "mapData": - a.mapdataIndex(rw, req)*/ case "": http.Redirect(rw, req, "/map/", 302) case "checkVersion": @@ -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) { grid := req.FormValue("gridID") 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) } } - -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) -} diff --git a/internal/app/client_grid.go b/internal/app/client_grid.go new file mode 100644 index 0000000..d635a44 --- /dev/null +++ b/internal/app/client_grid.go @@ -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) +} diff --git a/internal/app/client_markers.go b/internal/app/client_markers.go new file mode 100644 index 0000000..101bde6 --- /dev/null +++ b/internal/app/client_markers.go @@ -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 + } +} diff --git a/internal/app/client_positions.go b/internal/app/client_positions.go new file mode 100644 index 0000000..ae21938 --- /dev/null +++ b/internal/app/client_positions.go @@ -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 + } + } + } +} diff --git a/internal/app/handlers_redirects.go b/internal/app/handlers_redirects.go new file mode 100644 index 0000000..98f6b1e --- /dev/null +++ b/internal/app/handlers_redirects.go @@ -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) +} diff --git a/internal/app/manage.go b/internal/app/manage.go deleted file mode 100644 index 05ac6d1..0000000 --- a/internal/app/manage.go +++ /dev/null @@ -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, - }) -} diff --git a/webapp/templates/admin/index.tmpl b/webapp/templates/admin/index.tmpl deleted file mode 100644 index a3ff076..0000000 --- a/webapp/templates/admin/index.tmpl +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - {{.Page.Title}} - Admin - - -
- - - - - - - - - - {{range .Users}} - - - - - {{end}} - -
UserActions
{{.}}Edit
- Add user -
- - - - - - - - - {{range .Maps}} - - - - - - {{end}} - -
MapActions
{{.Name}}{{block "admin/index.tmpl:toggle-hidden" .}}{{if .Hidden}}Show{{else}}Hide{{end}}{{end}}Edit
-
-
-
Default maps to hidden
-

This makes new map layers hidden by default

-
-
- -
- -
- -
-
-
-
-
Set prefix for tokens
-

This is used for making the client tokens a "copy/paste" straight into client

-
-
-
- - -
-
- -
- -
-
-
-
-
Set title for pages
-
-
-
- - -
-
- -
- -
-
-
-
-
Wipe all data
- Wipe! - -
-
-
-
-
Rebuild zooms
- Rebuild Zooms -
-
-
-
-
Export
-

Export grids and markers

- Download export -
-
-
-
-
Merge
-

Note, merge is experimental at this time, use at your own risk!

-
-
-
- File - -
-
- -
-
- -
-
-
-
- - - - diff --git a/webapp/templates/admin/map.tmpl b/webapp/templates/admin/map.tmpl deleted file mode 100644 index c36380d..0000000 --- a/webapp/templates/admin/map.tmpl +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - {{.Page.Title}} - Admin - - -
-
- -
-
- - -
-
-
-
    -
  • - -
  • -
  • - -
  • -
-
- -
-
- - - - diff --git a/webapp/templates/admin/user.tmpl b/webapp/templates/admin/user.tmpl deleted file mode 100644 index 60bd009..0000000 --- a/webapp/templates/admin/user.tmpl +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - {{.Page.Title}} - Admin - - -
-
-
-
- - -
-
- - -
-
-
-
    -
  • -
    Roles
    -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
- - {{if ne .Username ""}}Delete{{end}} -
-
- - - - diff --git a/webapp/templates/index.tmpl b/webapp/templates/index.tmpl deleted file mode 100644 index 42cc663..0000000 --- a/webapp/templates/index.tmpl +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - {{.Page.Title}} - - -
-
-
- {{if .Session.Auths.Has "map" }}Map
{{end}} - {{if .Session.Auths.Has "admin" }}Admin portal
{{end}} - Change Password
- Logout
-
-
- {{if .Session.Auths.Has "upload" }} -
    -
  • Here are your existing upload tokens.
  • - {{range .UploadTokens}} -
  • {{$.Prefix}}/client/{{.}}
  • - {{else}} -
  • You have no tokens, generate one now!
  • - {{end}} -
- Generate Token - {{end}} -
-
-
- - - diff --git a/webapp/templates/login.tmpl b/webapp/templates/login.tmpl deleted file mode 100644 index 276dd1e..0000000 --- a/webapp/templates/login.tmpl +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - {{.Page.Title}} - - -
-
-
-
- - -
-
- - -
-
- -
-
- - - - diff --git a/webapp/templates/password.tmpl b/webapp/templates/password.tmpl deleted file mode 100644 index e7ab6a8..0000000 --- a/webapp/templates/password.tmpl +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - {{.Page.Title}} - - -
-
-
-
- - -
-
- -
-
- - - - diff --git a/webapp/webapp.go b/webapp/webapp.go deleted file mode 100644 index bde05a7..0000000 --- a/webapp/webapp.go +++ /dev/null @@ -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) -}