Refactor routing and documentation for SPA deployment

- Updated the application to serve the SPA from the root path instead of /map/, enhancing accessibility.
- Modified redirect logic to ensure backward compatibility with old /map/* URLs.
- Adjusted documentation across multiple files to reflect the new routing structure and API endpoints.
- Improved handling of OAuth redirects and session management in the backend.
- Updated frontend configuration to align with the new base URL settings.
This commit is contained in:
2026-02-25 00:32:59 +03:00
parent 2c7bf48719
commit fea17e6bac
11 changed files with 73 additions and 69 deletions

View File

@@ -10,7 +10,7 @@ Clone the repository and run the project locally (see [Development](docs/develop
## Code layout ## Code layout
- **Backend:** Entry point is `cmd/hnh-map/main.go`. All application logic lives in `internal/app/` (package `app`): `app.go`, `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`. - **Backend:** Entry point is `cmd/hnh-map/main.go`. All application logic lives in `internal/app/` (package `app`): `app.go`, `auth.go`, `api.go`, `handlers_redirects.go`, `client.go`, `client_grid.go`, `client_positions.go`, `client_markers.go`, `admin_*.go`, `map.go`, `tile.go`, `topic.go`, `migrations.go`.
- **Frontend:** Nuxt 3 app in `frontend-nuxt/` (pages, components, composables, layouts, server, plugins, `public/gfx`). It is served by the Go backend under `/map/` with baseURL `/map/`. - **Frontend:** Nuxt 3 app in `frontend-nuxt/` (pages, components, composables, layouts, server, plugins, `public/gfx`). It is served by the Go backend at root `/` with baseURL `/`.
## Formatting and tests ## Formatting and tests

View File

@@ -12,8 +12,8 @@ API доступно по префиксу `/map/api/`. Для запросов,
### OAuth ### OAuth
- **GET /map/api/oauth/providers** — список настроенных OAuth-провайдеров. Ответ: `["google", ...]`. - **GET /map/api/oauth/providers** — список настроенных OAuth-провайдеров. Ответ: `["google", ...]`.
- **GET /map/api/oauth/{provider}/login** — редирект на страницу авторизации провайдера. Query: `redirect` — путь для редиректа после успешного входа (например `/map/profile`). - **GET /map/api/oauth/{provider}/login** — редирект на страницу авторизации провайдера. Query: `redirect` — путь для редиректа после успешного входа (например `/profile`).
- **GET /map/api/oauth/{provider}/callback** — callback от провайдера (вызывается автоматически). Обменивает `code` на токены, создаёт или находит пользователя, создаёт сессию и редиректит на `/map/profile` или `redirect` из state. - **GET /map/api/oauth/{provider}/callback** — callback от провайдера (вызывается автоматически). Обменивает `code` на токены, создаёт или находит пользователя, создаёт сессию и редиректит на `/profile` или `redirect` из state.
## Кабинет ## Кабинет

View File

@@ -2,16 +2,16 @@
## Обзор ## Обзор
hnh-map — сервер автомаппера для HnH: Go-бэкенд с хранилищем bbolt, сессиями и Nuxt 3 SPA по пути `/map/`. Данные гридов и тайлов хранятся в каталоге `grids/` и в БД. hnh-map — сервер автомаппера для HnH: Go-бэкенд с хранилищем bbolt, сессиями и Nuxt 3 SPA по корню `/`. API, SSE и тайлы — по `/map/api/`, `/map/updates`, `/map/grids/`. Данные гридов и тайлов хранятся в каталоге `grids/` и в БД.
``` ```
┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐ ┌─────────────┐ HTTP/SSE ┌──────────────────────────────────────┐
│ Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │ │ Браузер │ ◄────────────────► │ Go-сервер (cmd/hnh-map) │
│ (Nuxt SPA │ /map/, /map/api │ • bbolt (users, sessions, grids, │ │ (Nuxt SPA │ /, /login, │ • bbolt (users, sessions, grids, │
│ по /map/) │ /map/updates │ markers, tiles, maps, config) │ │ по /) │ /map/api, │ markers, tiles, maps, config) │
│ │ /map/grids/ │ • Статика фронта (frontend/) │ │ │ /map/updates, │ • Статика фронта (frontend/) │
└─────────────┘ │ • internal/app — вся логика │ │ │ /map/grids/ │ • internal/app — вся логика │
└──────────────────────────────────────┘ └─────────────┘ └──────────────────────────────────────┘
``` ```
## Структура бэкенда ## Структура бэкенда
@@ -19,10 +19,10 @@ hnh-map — сервер автомаппера для HnH: Go-бэкенд с
- **cmd/hnh-map/main.go** — единственная точка входа (`package main`): парсинг флагов (`-grids`, `-port`) и переменных окружения (`HNHMAP_PORT`), открытие bbolt, запуск миграций, создание `App`, регистрация маршрутов, запуск HTTP-сервера. Пути к `frontend/` и `public/` задаются из рабочей директории при старте. - **cmd/hnh-map/main.go** — единственная точка входа (`package main`): парсинг флагов (`-grids`, `-port`) и переменных окружения (`HNHMAP_PORT`), открытие bbolt, запуск миграций, создание `App`, регистрация маршрутов, запуск HTTP-сервера. Пути к `frontend/` и `public/` задаются из рабочей директории при старте.
- **internal/app/** — пакет `app` с типом `App` и всей логикой: - **internal/app/** — пакет `app` с типом `App` и всей логикой:
- **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), `serveMapFrontend`, `CleanChars`. - **app.go** — структура `App`, общие типы (`Character`, `Session`, `Coord`, `Position`, `Marker`, `User`, `MapInfo`, `GridData` и т.д.), регистрация маршрутов (`RegisterRoutes`), `serveSPARoot`, `CleanChars`.
- **auth.go** — сессии и авторизация: `getSession`, `deleteSession`, `saveSession`, `getUser`, `getPage`, `createSession`, `setupRequired`, `requireAdmin`. - **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/...`. - **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`. - **handlers_redirects.go** — редирект `/logout``/login` (после удаления сессии).
- **client.go** — роутер клиента маппера (`/client/{token}/...`), `locate`. - **client.go** — роутер клиента маппера (`/client/{token}/...`), `locate`.
- **client_grid.go** — `gridUpdate`, `gridUpload`, `updateZoomLevel`. - **client_grid.go** — `gridUpdate`, `gridUpload`, `updateZoomLevel`.
- **client_positions.go** — `updatePositions`. - **client_positions.go** — `updatePositions`.

View File

@@ -35,7 +35,7 @@ docker run -v /srv/hnh-map:/map -p 8080:8080 \
## Reverse proxy ## Reverse proxy
Разместите сервис за nginx, Traefik, Caddy и т.п. на нужном домене. Проксируйте весь трафик на порт 8080 контейнера (или тот порт, на котором слушает приложение). Отдельная настройка для `/map` не обязательна: приложение само отдаёт SPA и API по путям `/map/`, `/map/api/`, `/map/updates`, `/map/grids/`. Разместите сервис за nginx, Traefik, Caddy и т.п. на нужном домене. Проксируйте весь трафик на порт 8080 контейнера (или тот порт, на котором слушает приложение). Приложение отдаёт SPA по корню `/` (/, /login, /profile, /admin и т.д.), API — по `/map/api/`, SSE — `/map/updates`, тайлы — `/map/grids/`.
## Обновление и бэкапы ## Обновление и бэкапы

View File

@@ -27,7 +27,7 @@ npm install
npm run dev npm run dev
``` ```
В dev-режиме приложение доступно по адресу с baseURL `/map/` (например `http://localhost:3000/map/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`). В dev-режиме приложение доступно по корню (например `http://localhost:3000/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`).
### Docker Compose (разработка) ### Docker Compose (разработка)
@@ -38,7 +38,7 @@ docker compose -f docker-compose.dev.yml up
- Фронт: порт **3000** (Nuxt dev-сервер). - Фронт: порт **3000** (Nuxt dev-сервер).
- Бэкенд: порт **3080** (чтобы не конфликтовать с другими сервисами на 8080). - Бэкенд: порт **3080** (чтобы не конфликтовать с другими сервисами на 8080).
Откройте http://localhost:3000/map/. Запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд (host `backend`, порт 3080). Откройте http://localhost:3000/. Запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд (host `backend`, порт 3080).
### Сборка образа и prod-композ ### Сборка образа и prod-композ

View File

@@ -355,8 +355,11 @@ onMounted(async () => {
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0 props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapid = initialMapId mapid = initialMapId
const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/' // Tiles are served at /map/grids/ (backend path, not SPA baseURL)
const tileUrl = tileBase.endsWith('/') ? `${tileBase}grids/{map}/{z}/{x}_{y}.png?{cache}` : `${tileBase}/grids/{map}/{z}/{x}_{y}.png?{cache}` const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
layer = new SmartTileLayer(tileUrl, { layer = new SmartTileLayer(tileUrl, {
minZoom: 1, minZoom: 1,
maxZoom: 6, maxZoom: 6,
@@ -416,9 +419,8 @@ onMounted(async () => {
} }
}) })
// Use same origin as page so SSE connects to correct host/port (e.g. 3080 not 3088) // SSE is at /map/updates (backend path, not SPA baseURL). Same origin so it connects to correct host/port.
const base = (useRuntimeConfig().app.baseURL as string) ?? '/' const updatesPath = `${backendBase}/updates`
const updatesPath = base.endsWith('/') ? `${base}updates` : `${base}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
source = new EventSource(updatesUrl) source = new EventSource(updatesUrl)
source.onmessage = (event: MessageEvent) => { source.onmessage = (event: MessageEvent) => {

View File

@@ -6,7 +6,7 @@ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
app: { app: {
baseURL: '/map/', baseURL: '/',
pageTransition: { name: 'page', mode: 'out-in' }, pageTransition: { name: 'page', mode: 'out-in' },
head: { head: {
title: 'HnH Map', title: 'HnH Map',

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -139,41 +140,69 @@ type Page struct {
Title string `json:"title"` Title string `json:"title"`
} }
// serveMapFrontend serves the map SPA: static files from frontend, fallback to index.html for client-side routes. // serveSPARoot serves the map SPA from root: static files from frontend, fallback to index.html for client-side routes.
func (a *App) serveMapFrontend(rw http.ResponseWriter, req *http.Request) { // Handles redirects from old /map/* URLs for backward compatibility.
func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path path := req.URL.Path
if len(path) <= len("/map/") {
path = "" // Redirect old /map/* URLs to flat routes
} else { if path == "/map" || path == "/map/" {
path = path[len("/map/"):] http.Redirect(rw, req, "/", http.StatusFound)
return
} }
root := a.frontendRoot if strings.HasPrefix(path, "/map/") {
if path == "" { rest := path[len("/map/"):]
path = "index.html" switch {
case rest == "login":
http.Redirect(rw, req, "/login", http.StatusFound)
return
case rest == "profile":
http.Redirect(rw, req, "/profile", http.StatusFound)
return
case rest == "admin" || strings.HasPrefix(rest, "admin/"):
http.Redirect(rw, req, "/"+rest, http.StatusFound)
return
case rest == "setup":
http.Redirect(rw, req, "/setup", http.StatusFound)
return
case strings.HasPrefix(rest, "character/"):
http.Redirect(rw, req, "/"+rest, http.StatusFound)
return
case strings.HasPrefix(rest, "grid/"):
http.Redirect(rw, req, "/"+rest, http.StatusFound)
return
} }
path = filepath.Clean(path) }
if path == "." || path == ".." || (len(path) >= 2 && path[:2] == "..") {
// File serving: path relative to frontend root (with baseURL /, files are at root)
filePath := strings.TrimPrefix(path, "/")
if filePath == "" {
filePath = "index.html"
}
filePath = filepath.Clean(filePath)
if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") {
http.NotFound(rw, req) http.NotFound(rw, req)
return return
} }
tryPaths := []string{filepath.Join("map", path), path} // Try both root and map/ for backward compatibility with old builds
tryPaths := []string{filePath, filepath.Join("map", filePath)}
var f http.File var f http.File
for _, p := range tryPaths { for _, p := range tryPaths {
var err error var err error
f, err = http.Dir(root).Open(p) f, err = http.Dir(a.frontendRoot).Open(p)
if err == nil { if err == nil {
path = p filePath = p
break break
} }
} }
if f == nil { if f == nil {
http.ServeFile(rw, req, filepath.Join(root, "index.html")) http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html"))
return return
} }
defer f.Close() defer f.Close()
stat, err := f.Stat() stat, err := f.Stat()
if err != nil || stat.IsDir() { if err != nil || stat.IsDir() {
http.ServeFile(rw, req, filepath.Join(root, "index.html")) http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html"))
return return
} }
http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f) http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f)
@@ -195,15 +224,12 @@ func (a *App) CleanChars() {
// RegisterRoutes registers all HTTP handlers for the app. // RegisterRoutes registers all HTTP handlers for the app.
func (a *App) RegisterRoutes() { func (a *App) RegisterRoutes() {
http.HandleFunc("/client/", a.client) http.HandleFunc("/client/", a.client)
http.HandleFunc("/login", a.redirectLogin)
http.HandleFunc("/logout", a.redirectLogout) http.HandleFunc("/logout", a.redirectLogout)
http.HandleFunc("/admin", a.redirectAdmin)
http.HandleFunc("/admin/", a.redirectAdmin)
http.HandleFunc("/", a.redirectRoot)
http.HandleFunc("/map/api/", a.apiRouter) http.HandleFunc("/map/api/", a.apiRouter)
http.HandleFunc("/map/updates", a.watchGridUpdates) http.HandleFunc("/map/updates", a.watchGridUpdates)
http.HandleFunc("/map/grids/", a.gridTile) http.HandleFunc("/map/grids/", a.gridTile)
http.HandleFunc("/map/", a.serveMapFrontend)
// SPA catch-all: must be last
http.HandleFunc("/", a.serveSPARoot)
} }

View File

@@ -69,7 +69,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
case "markerUpdate": case "markerUpdate":
a.uploadMarkers(rw, req) a.uploadMarkers(rw, req)
case "": case "":
http.Redirect(rw, req, "/map/", 302) http.Redirect(rw, req, "/", 302)
case "checkVersion": case "checkVersion":
if req.FormValue("version") == VERSION { if req.FormValue("version") == VERSION {
rw.WriteHeader(200) rw.WriteHeader(200)

View File

@@ -4,26 +4,6 @@ import (
"net/http" "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) { func (a *App) redirectLogout(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/logout" { if req.URL.Path != "/logout" {
http.NotFound(rw, req) http.NotFound(rw, req)
@@ -33,9 +13,5 @@ func (a *App) redirectLogout(rw http.ResponseWriter, req *http.Request) {
if s != nil { if s != nil {
a.deleteSession(s) a.deleteSession(s)
} }
http.Redirect(rw, req, "/map/login", http.StatusFound) http.Redirect(rw, req, "/login", http.StatusFound)
}
func (a *App) redirectAdmin(rw http.ResponseWriter, req *http.Request) {
http.Redirect(rw, req, "/map/admin", http.StatusFound)
} }

View File

@@ -186,7 +186,7 @@ func (a *App) oauthCallback(rw http.ResponseWriter, req *http.Request, provider
Secure: req.TLS != nil, Secure: req.TLS != nil,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
redirectTo := "/map/profile" redirectTo := "/profile"
if st.RedirectURI != "" { if st.RedirectURI != "" {
if u, err := url.Parse(st.RedirectURI); err == nil && u.Path != "" && !strings.HasPrefix(u.Path, "//") { if u, err := url.Parse(st.RedirectURI); err == nil && u.Path != "" && !strings.HasPrefix(u.Path, "//") {
redirectTo = u.Path redirectTo = u.Path