# Conflicts:
#	meshmap_frontend/src/App.vue
#	web.go
This commit is contained in:
2026-06-12 18:15:30 +08:00
31 changed files with 3785 additions and 758 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kevin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+208 -308
View File
@@ -1,42 +1,144 @@
# Meshtastic MQTT Server
本程序启动一个本地 MQTT broker,并在转发客户端发布的消息前校验 Meshtastic MQTT payload
Meshtastic MQTT Server 是一个面向 Meshtastic MQTT 数据的本地服务:内置 MQTT broker,接收并校验 Meshtastic MQTT payload,同时提供 Web 管理与地图前端,用于查看节点、消息、位置、遥测、转发状态和管理配置
每条传入的 `PUBLISH` 都会先进入:
[meshmap.lmve.net](https://meshmap.lmve.net/)
```go
valid, _, record := mqtpp.MQTTPP(topic, payload, key, mqtpp.Options{})
```
![mainpage](doc/main_page.png)
- `valid == true`:保留原始 topic、payload、QoS、retain 等字段,正常转发给订阅匹配 topic 的客户端
- `valid == false`:丢弃该消息,不转发给订阅客户端
## 后端功能
当前不桥接到 `mqtt.meshtastic.org` 等上游 broker。
后端提供 MQTT broker、Meshtastic 数据校验、数据入库、Web API 和管理后台能力,主要功能包括:
## 运行
- 用户管理:支持创建管理员用户、修改管理员密码。
- 屏蔽规则管理:支持设置节点屏蔽、IP 屏蔽和屏蔽词;屏蔽词可设置匹配方式、是否区分大小写、启用状态和原因。
- 消息拦截:命中被屏蔽节点、被屏蔽 IP 或屏蔽词的消息会被拒绝,并写入丢弃记录。
- MQTT 转发管理:支持配置多个 MQTT 转发器,设置源端、目标端、TLS、认证信息、转发 topic、方向、QoS、retain,并可查看转发运行状态或重启转发器。
- 运行时设置:支持动态设置无法解密的加密 MQTT 包是否允许继续转发。
- 地图源管理:支持配置地图瓦片源、默认地图源、启用状态、最大缩放级别、attribution 和是否通过后端代理地图瓦片。
- 地图瓦片代理与缓存:可通过后端代理地图瓦片请求,并使用本地目录缓存。
- 帮助内容管理:支持在管理后台编辑 Markdown 帮助内容,并提供预览与展示。
- 数据库支持:支持 SQLite 和 MySQL。
- Meshtastic payload 校验:在消息转发前校验 Meshtastic MQTT 数据包,无效数据会被拒绝并记录。
- 数据解析与存储:解析并保存节点信息、地图上报、文本消息、位置、遥测、路由、traceroute 等数据。
## 运行环境
### 后端
- Go`1.25.0` 或更高版本
- 默认监听:
- MQTT`0.0.0.0:1883`
- Web`0.0.0.0:8080`
### 前端
- Node.js:满足 Vite 8 要求
- `^20.19.0`,或
- `>=22.12.0`
- npm:随 Node.js 安装即可
建议生产环境使用当前 LTS 版本的 Node.js,并确保版本满足上述要求。
## 快速部署
### Linux 一键部署
在 Linux 下进入项目目录后,直接执行:
```bash
go run .
sudo bash install.sh
```
默认监听:
安装脚本会自动拉取最新代码、安装前端依赖、构建前端、编译后端、安装到 `/opt/mesh_mqtt_go`,并创建和启动 `mesh_mqtt_go` systemd 服务。
- host`0.0.0.0`
- port`1883`
- PSK`AQ==`
- TLS:关闭
- Web`0.0.0.0:8080`,静态目录 `./dist`
- 数据库:SQLite
- SQLite 文件:Unix/Linux 为 `/srv/mesh_mqtt_go/mesh_mqtt_go.db`Windows 测试为 `./win/etc/mesh_mqtt_go/mesh_mqtt_go.db`
### 手动构建前端
首次启动会自动生成配置文件;之后每次启动都会检查配置项,缺失项会自动补全并写回。
```bash
cd meshmap_frontend
npm install
npm run build
cd ..
```
配置文件路径:
构建完成后,前端静态文件会生成到项目根目录的 `dist`,也就是从 `meshmap_frontend` 目录看是 `../dist`
- Unix/Linux`/etc/mesh_mqtt_go/config.yaml`
- Windows 测试:`./win/etc/mesh_mqtt_go/config.yaml`
### 手动构建后端
默认配置内容:
```bash
go build -o meshtastic_mqtt_server .
```
### 手动启动
```bash
./meshtastic_mqtt_server -web-static-dir ./dist
```
首次启动时,程序会自动生成默认配置文件。
默认配置路径:
- Linux`/etc/mesh_mqtt_go/config.yaml`
- Windows`./win/etc/mesh_mqtt_go/config.yaml`
默认数据路径:
- Linux SQLite`/srv/mesh_mqtt_go/mesh_mqtt_go.db`
- Windows SQLite`./win/etc/mesh_mqtt_go/mesh_mqtt_go.db`
默认地图瓦片缓存目录:
- Linux`/srv/mesh_mqtt_go`
- Windows`./win/srv/mesh_mqtt_go`
## 常用启动参数
```bash
./meshtastic_mqtt_server \
-host 0.0.0.0 \
-port 1883 \
-web-host 0.0.0.0 \
-web-port 8080 \
-web-static-dir ./dist
```
常用参数说明:
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `-host` | MQTT broker 监听地址 | `0.0.0.0` |
| `-port` | MQTT broker 监听端口 | `1883` |
| `-psk` | Meshtastic channel PSKBase64 格式 | `AQ==` |
| `-tls` | 启用 MQTT TLS | `false` |
| `-tls-cert` | MQTT TLS 证书文件 | 空 |
| `-tls-key` | MQTT TLS 私钥文件 | 空 |
| `-db-driver` | 数据库类型:`sqlite``mysql` | `sqlite` |
| `-sqlite-path` | SQLite 数据库文件路径 | 见默认数据路径 |
| `-mysql-dsn` | MySQL DSN | 空 |
| `-web` | 启用 Web 服务 | `true` |
| `-web-host` | Web 服务监听地址 | `0.0.0.0` |
| `-web-port` | Web 服务监听端口 | `8080` |
| `-web-socket-path` | Web Unix Socket 路径,Windows 不支持 | Linux 默认 `/opt/mesh_mqtt_go/web.sock` |
| `-web-static-dir` | 前端静态文件目录 | `./dist` |
| `-web-map-tile-cache-dir` | 地图瓦片缓存目录 | 见默认地图瓦片缓存目录 |
| `-admin-username` | Web 管理员用户名 | `admin` |
管理员密码与会话密钥建议通过环境变量传入:
```bash
export MESH_ADMIN_PASSWORD='change-me'
export MESH_ADMIN_SESSION_SECRET='replace-with-a-long-random-string'
./meshtastic_mqtt_server
```
## 配置文件示例
程序会自动生成并补全配置文件,也可以手动维护 `config.yaml`
```yaml
mqtt:
@@ -46,19 +148,24 @@ mqtt:
enabled: false
cert_file: ""
key_file: ""
meshtastic:
psk: AQ==
database:
driver: sqlite
sqlite:
path: /srv/mesh_mqtt_go/mesh_mqtt_go.db
mysql:
dsn: ""
web:
enabled: true
host: 0.0.0.0
port: 8080
socket_path: ""
static_dir: ./dist
map_tile_cache_dir: /srv/mesh_mqtt_go
admin:
username: admin
password: admin
@@ -66,319 +173,112 @@ web:
session_secure: false
```
配置优先级:
> 生产环境请修改默认管理员密码,并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。
```text
内置默认值 < 配置文件 < 环境变量 < 命令行参数
```
## 使用 SQLite 部署
也可以用命令行临时覆盖监听地址、PSK 和 TLS 设置
SQLite 是默认数据库,适合单机部署
```bash
go run . --host 127.0.0.1 --port 1883 --psk AQ==
mkdir -p /srv/mesh_mqtt_go
./meshtastic_mqtt_server \
-db-driver sqlite \
-sqlite-path /srv/mesh_mqtt_go/mesh_mqtt_go.db \
-web-map-tile-cache-dir /srv/mesh_mqtt_go
```
## 参数
## 使用 MySQL 部署
```text
--host MQTT broker listen host
--port MQTT broker listen port
--psk Base64 channel PSK used to try decrypting encrypted packets
--tls Enable MQTT TLS listener
--tls-cert MQTT TLS certificate file
--tls-key MQTT TLS private key file
--db-driver Database driver: sqlite or mysql
--sqlite-path SQLite database file path
--mysql-dsn MySQL database DSN
--web Enable Gin web server
--web-host Web server listen host
--web-port Web server listen port
--web-static-dir Web frontend static files directory
```
## Web 前端
开发模式:
如果需要使用 MySQL,启动时指定数据库驱动和 DSN:
```bash
go run . --web-host 127.0.0.1 --web-port 8080
cd meshmap_frontend
npm run dev
./meshtastic_mqtt_server \
-db-driver mysql \
-mysql-dsn 'user:password@tcp(127.0.0.1:3306)/meshtastic?charset=utf8mb4&parseTime=True&loc=Local'
```
生产构建:
## 启用 MQTT TLS
准备证书和私钥后启动:
```bash
cd meshmap_frontend
npm run build
cd ..
go run .
./meshtastic_mqtt_server \
-tls \
-tls-cert /path/to/server.crt \
-tls-key /path/to/server.key
```
构建后的文件位于项目根目录 `dist/`Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
## 访问服务
管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret``MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志、`/admin/discard_details` 丢弃数据。`/admin` 中的“丢弃消息”统计来自 `discard_details` 表记录数,点击可进入丢弃数据分页页。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo``map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。
启动后可访问:
常用 API
- Web 前端:`http://服务器地址:8080/`
- 健康检查:`http://服务器地址:8080/api/health`
- MQTT broker`服务器地址:1883`
```text
GET /api/health
POST /api/admin/login
POST /api/admin/logout
GET /api/admin/me
GET /api/admin/mqtt/status
GET /api/admin/log/login
GET /api/admin/users
POST /api/admin/users
PUT /api/admin/users/:id/password
DELETE /api/admin/text-messages/:id
DELETE /api/admin/nodes/:id
GET /api/nodeinfo
GET /api/nodeinfo/:id
GET /api/map-reports
GET /api/map-reports/:id
GET /api/nodes # /api/nodeinfo 的兼容别名
GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名
GET /api/text-messages
GET /api/discard-details
GET /api/positions
GET /api/telemetry
GET /api/routing
GET /api/traceroute
Web 管理后台默认账号:
- 用户名:`admin`
- 密码:`admin`
生产环境请务必修改默认密码。
## systemd 部署示例
以下示例假设:
- 后端可执行文件位于 `/opt/mesh_mqtt_go/meshtastic_mqtt_server`
- 前端静态文件位于 `/opt/mesh_mqtt_go/dist`
- 数据与缓存目录位于 `/srv/mesh_mqtt_go`
- 配置文件位于 `/etc/mesh_mqtt_go/config.yaml`
创建服务文件 `/etc/systemd/system/mesh_mqtt_go.service`
```ini
[Unit]
Description=Meshtastic MQTT Server
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/mesh_mqtt_go
ExecStart=/opt/mesh_mqtt_go/meshtastic_mqtt_server -web-static-dir /opt/mesh_mqtt_go/dist
Environment=MESH_ADMIN_PASSWORD=change-me
Environment=MESH_ADMIN_SESSION_SECRET=replace-with-a-long-random-string
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
## TLS 配置示例
```yaml
mqtt:
host: 0.0.0.0
port: 8883
tls:
enabled: true
cert_file: ./certs/server.crt
key_file: ./certs/server.key
meshtastic:
psk: AQ==
```
启用 TLS 后,`cert_file``key_file` 必须指向可读取的证书和私钥文件。
## 数据库持久化
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
- `login_log`:追加保存后台登录成功和失败日志
- `discard_details`:追加保存 `MQTTPP` 判定无效而被 broker 丢弃的数据,raw payload 使用 base64 保存
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
- `position`:追加保存 `type == "position"` 的位置包
- `telemetry`:追加保存 `type == "telemetry"` 的遥测包
- `routing`:追加保存 `type == "routing"` 的路由控制包
- `traceroute`:追加保存 `type == "traceroute"` 的路径追踪包
`nodeinfo` / `map_report` 规则:
- 两张表都以 `node_id`(即解析结果中的 `from`,例如 `!a8dfd867`)作为主键
- `nodeinfo` 只保存节点身份和设备字段,例如 `user_id`、名称、硬件型号、角色、授权状态和公钥
- `map_report` 只保存地图报告字段,例如名称、硬件型号、角色、固件版本、区域、调制预设、经纬度、海拔、位置精度和在线节点数
- 重复收到同一节点时不会插入重复行,只更新 `updated_at``content_json` 和本次记录中有值的字段
- `first_seen_at` 保留第一次写入时间
- `content_json` 分别保存最新一次 `nodeinfo``map_report` 的完整解析结果 JSON
- 旧版本创建的 `nodeinfo_map` 融合表不会被自动删除,新版本不再写入该表;新表会从新收到的数据开始填充
`text_message` 规则:
- 使用自增 `id` 作为主键
- 每条聊天消息都会新增一行,不做去重
- 保存 `from_id``from_num``text``payload_hex`、topic、packet 元数据和完整 `content_json`
- 保存 MQTT 客户端信息:`mqtt_client_id``mqtt_username``mqtt_listener``mqtt_remote_addr``mqtt_remote_host``mqtt_remote_port`
`position` / `telemetry` / `routing` / `traceroute` 规则:
- 都使用自增 `id` 作为主键
- 每条有效记录都会新增一行,不做去重
- 保存通用 packet 元数据、MQTT 客户端信息和完整 `content_json`
- `position` 额外保存经纬度、海拔、时间、定位来源、精度、速度、卫星数等字段
- `telemetry` 额外保存 `telemetry_type`,并把动态 `metrics` 对象保存为 `metrics_json`
- `routing``traceroute` 当前保存通用元数据和完整 JSON;后续如果解析更多 payload 字段,可继续扩展列
查询最近聊天消息示例:
```sql
SELECT id, created_at, from_id, text, mqtt_remote_host
FROM text_message
ORDER BY id DESC
LIMIT 20;
```
查询位置包示例:
```sql
SELECT id, created_at, from_id, latitude, longitude, altitude
FROM position
ORDER BY id DESC
LIMIT 20;
```
查询遥测包示例:
```sql
SELECT id, created_at, from_id, telemetry_type, metrics_json
FROM telemetry
ORDER BY id DESC
LIMIT 20;
```
SQLite 默认路径:
- Unix/Linux`/srv/mesh_mqtt_go/mesh_mqtt_go.db`
- Windows 测试:`./win/etc/mesh_mqtt_go/mesh_mqtt_go.db`
MySQL 配置示例:
```yaml
database:
driver: mysql
sqlite:
path: /srv/mesh_mqtt_go/mesh_mqtt_go.db
mysql:
dsn: mesh_user:mesh_pass@tcp(127.0.0.1:3306)/mesh_mqtt_go?parseTime=true&charset=utf8mb4,utf8
```
使用 MySQL 时,需要提前创建好 database/schema。
## 转发规则
程序监听所有传入 publish。payload 能被 `mqtpp.MQTTPP` 解析时,认为 `valid == true`broker 会继续把原始 MQTT 消息转发给订阅者;解析失败时,认为 `valid == false`broker 会拒绝并丢弃该 publish。
`empty_packet` 仍然属于 `valid == true`,会被转发;只是控制台默认不显示它。
无法解密的加密包会输出为 `encrypted_packet`,属于 `valid == false`,因此会被拒绝并丢弃。
丢弃的 publish 会写入 `discard_details`,记录 topic、错误原因、payload 长度、base64 raw payload、MQTT 客户端信息和完整 `content_json`
## 本地验证
一个终端启动 broker
启用并启动服务:
```bash
go run . --host 127.0.0.1 --port 1883 --psk AQ==
sudo systemctl daemon-reload
sudo systemctl enable --now mesh_mqtt_go
sudo systemctl status mesh_mqtt_go
```
另一个终端订阅
查看日志
```bash
mosquitto_sub -h 127.0.0.1 -p 1883 -t '#'
sudo journalctl -u mesh_mqtt_go -f
```
发布非法 payload
## 生产环境建议
```bash
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'msh/US/test' -m 'not protobuf'
```
- 修改默认管理员密码。
- 设置随机且足够长的 `MESH_ADMIN_SESSION_SECRET`
- 使用反向代理提供 HTTPS。
- 如果 Web 管理后台通过 HTTPS 访问,启用安全 Cookie。
- 根据实际情况开放防火墙端口:
- MQTT`1883`
- MQTT TLS:自定义端口或仍使用 `1883`
- Web`8080` 或反向代理端口
- 定期备份数据库文件或 MySQL 数据库。
- 为地图瓦片缓存目录预留足够磁盘空间。
订阅端应该收不到该消息。
## 开源协议
要验证 valid 消息转发,请使用真实 Meshtastic MQTT payload 发布到本 broker;订阅匹配 topic 的客户端应收到原始消息,broker 控制台会打印解析后的 `record`
## 控制台颜色说明
程序会按数据包类型使用不同背景色,方便快速区分消息类型。
| 背景色 | type | portnum | 含义 |
|---|---|---|---|
| 绿色 | `nodeinfo` | `NODEINFO_APP` | 节点信息包,包含节点 ID、长名称、短名称、硬件型号、角色、公钥等 |
| 蓝色 | `map_report` | `MAP_REPORT_APP` | 地图报告包,包含节点名称、硬件、固件版本、区域、调制预设、位置等地图信息 |
| 紫色 | `text_message` | `TEXT_MESSAGE_APP` | 聊天文本消息 |
| 青色 | `position` | `POSITION_APP` | 位置包,会展开解析经纬度、海拔、时间、定位来源、精度、速度、卫星数等字段 |
| 黄色 | `telemetry` | `TELEMETRY_APP` | 遥测包,会展开解析设备、电源、环境、空气质量、本地统计、健康、主机和流量管理指标 |
| 灰色 | `routing` | `ROUTING_APP` | 路由控制包,常见于 ACK、NAK、路由错误等控制信息 |
| 灰色 | `traceroute` | `TRACEROUTE_APP` | 路径追踪包,用于 mesh 网络路径探测 |
| 红色 | error record | - | protobuf 解析失败、payload 解码失败或其他处理错误 |
| 无颜色 | `encrypted_packet` | - | 加密包但当前 PSK/频道 hash 无法解密;这不一定是错误 |
| 无颜色 | `decoded_packet` | 其他 portnum | 已解码/已解密,但程序尚未细分的其他应用包 |
## 已展开解析的数据包
### `position` / `POSITION_APP`
位置包会从 Meshtastic `Position` payload 中展开常用字段,包括:
- `latitude` / `longitude`:经纬度,已从 `latitude_i` / `longitude_i` 转换为浮点角度
- `altitude`:海拔,单位米
- `time` / `timestamp`:位置相关时间戳
- `location_source`:定位来源,例如 `LOC_MANUAL``LOC_INTERNAL``LOC_EXTERNAL`
- `altitude_source`:海拔来源,例如 `ALT_MANUAL``ALT_INTERNAL``ALT_BAROMETRIC`
- `altitude_hae` / `altitude_geoidal_separation`HAE 海拔和大地水准面分离值
- `pdop` / `hdop` / `vdop`:定位精度因子,已从 1/100 单位转换为浮点值
- `gps_accuracy`GPS 精度,单位 mm
- `ground_speed`:地面速度,单位 m/s
- `ground_track`:地面航迹角,已从 1/100 度转换为度
- `fix_quality` / `fix_type` / `sats_in_view`:GPS fix 质量、类型和可见卫星数
- `sensor_id` / `next_update` / `seq_number` / `precision_bits`:传感器、更新间隔、序列号和位置精度位数
### `telemetry` / `TELEMETRY_APP`
遥测包会输出:
- `time`:遥测时间戳
- `telemetry_type`:具体 telemetry variant
- `metrics`:展开后的指标对象
当前支持的 `telemetry_type`
| telemetry_type | 含义 | 常见 metrics |
|---|---|---|
| `device_metrics` | 设备状态 | `battery_level``voltage``channel_utilization``air_util_tx``uptime_seconds` |
| `environment_metrics` | 环境传感器 | `temperature``relative_humidity``barometric_pressure``gas_resistance``lux``wind_speed``rainfall_1h` 等 |
| `air_quality_metrics` | 空气质量 | `pm25_standard``pm100_standard``co2``pm_temperature``pm_humidity``pm_voc_idx` 等 |
| `power_metrics` | 多通道电源数据 | `ch1_voltage``ch1_current``ch8_voltage``ch8_current` |
| `local_stats` | 本地 mesh 统计 | `num_packets_tx``num_packets_rx``num_online_nodes``heap_free_bytes``noise_floor` 等 |
| `health_metrics` | 健康数据 | `heart_bpm``spO2``temperature` |
| `host_metrics` | Linux/Portduino 主机指标 | `uptime_seconds``freemem_bytes``diskfree1_bytes``load1``load5``load15``user_string` |
| `traffic_management_stats` | 流量管理统计 | `packets_inspected``position_dedup_drops``rate_limit_drops``unknown_packet_drops` 等 |
## 过滤规则
程序默认不显示 `empty_packet`
`empty_packet``MeshPacket` 中没有 `decoded``encrypted` payload 的包,只包含类似 `from``to``id``via_mqtt` 等包头信息。根据固件源码分析,这类包通常不是普通业务数据,更多是 MQTT 回显/隐式 ACK 相关的元信息,对查看节点信息、地图报告和聊天内容价值较低。
## 输出示例
节点信息包:
```json
{"type":"nodeinfo","portnum":"NODEINFO_APP","from":"!a8dfd867","long_name":"Kabi Matrix 🖥️","short_name":"KaMX","hw_model":"PRIVATE_HW","role":"CLIENT_MUTE"}
```
地图报告包:
```json
{"type":"map_report","portnum":"MAP_REPORT_APP","from":"!675c9803","long_name":"PaulHome","latitude":42.51043,"longitude":-83.08624999999999,"hw_model":"PORTDUINO"}
```
聊天消息包:
```json
{"type":"text_message","portnum":"TEXT_MESSAGE_APP","from":"!12345678","text":"hello mesh"}
```
位置包:
```json
{"type":"position","portnum":"POSITION_APP","from":"!12345678","latitude":42.51043,"longitude":-83.08625,"altitude":192,"location_source":"LOC_INTERNAL","sats_in_view":8}
```
遥测包:
```json
{"type":"telemetry","portnum":"TELEMETRY_APP","from":"!12345678","telemetry_type":"device_metrics","metrics":{"battery_level":85,"voltage":4.1,"channel_utilization":2.3,"air_util_tx":0.5,"uptime_seconds":12345}}
```
解密失败的加密包:
```json
{"type":"encrypted_packet","decrypt_success":false,"decrypt_status":"channel hash mismatch","encrypted_len":43}
```
本项目采用 MIT License 开源。详见项目许可证文件。
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type mapTileSourceRequest struct {
Name string `json:"name"`
URLTemplate string `json:"url_template"`
Attribution string `json:"attribution"`
MaxZoom int `json:"max_zoom"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"is_default"`
ProxyEnabled bool `json:"proxy_enabled"`
}
func registerMapSourceRoutes(r gin.IRouter, store *store) {
r.GET("/map-source/default", func(c *gin.Context) {
row, err := store.GetDefaultMapTileSource()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
})
r.GET("/map-source/enabled", func(c *gin.Context) {
rows, err := store.ListEnabledMapTileSources()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, publicMapTileSourceDTO(row))
}
c.JSON(http.StatusOK, gin.H{"items": items})
})
}
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
r.GET("/map-source", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListMapTileSources(opts)
if err != nil {
writeListResponse(c, rows, opts, err, mapTileSourceDTO)
return
}
total, err := store.CountMapTileSources(opts)
writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO)
})
r.POST("/map-source", func(c *gin.Context) {
var req mapTileSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
return
}
row, err := store.CreateMapTileSource(mapTileSourceInputFromRequest(req))
writeMapTileSourceMutationResponse(c, http.StatusCreated, row, err)
})
r.PUT("/map-source/:id", func(c *gin.Context) {
id, ok := parseMapTileSourceID(c)
if !ok {
return
}
var req mapTileSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
return
}
row, err := store.UpdateMapTileSource(id, mapTileSourceInputFromRequest(req))
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
})
r.DELETE("/map-source/:id", func(c *gin.Context) {
id, ok := parseMapTileSourceID(c)
if !ok {
return
}
writeMapTileSourceDeleteResponse(c, store.DeleteMapTileSource(id))
})
r.POST("/map-source/:id/default", func(c *gin.Context) {
id, ok := parseMapTileSourceID(c)
if !ok {
return
}
row, err := store.SetDefaultMapTileSource(id)
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
})
}
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput {
return mapTileSourceInput{
Name: req.Name,
URLTemplate: req.URLTemplate,
Attribution: req.Attribution,
MaxZoom: req.MaxZoom,
Enabled: req.Enabled,
IsDefault: req.IsDefault,
ProxyEnabled: req.ProxyEnabled,
}
}
func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source id"})
return 0, false
}
return id, true
}
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) {
if errors.Is(err, errMapTileSourceAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)})
}
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if errors.Is(err, errMapTileSourceCannotDeleteDefault) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H {
urlTemplate := row.URLTemplate
if row.ProxyEnabled {
hash := row.URLTemplateHash
if hash == "" {
hash = mapTileSourceHash(row.URLTemplate)
}
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
}
return gin.H{"id": row.ID, "name": row.Name, "url_template": urlTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom}
}
+22
View File
@@ -56,6 +56,7 @@ type webConfig struct {
Port int `yaml:"port"`
SocketPath string `yaml:"socket_path"`
StaticDir string `yaml:"static_dir"`
MapTileCacheDir string `yaml:"map_tile_cache_dir"`
Admin webAdminConfig `yaml:"admin"`
}
@@ -109,6 +110,7 @@ type rawWebConfig struct {
Port *int `yaml:"port"`
SocketPath *string `yaml:"socket_path"`
StaticDir *string `yaml:"static_dir"`
MapTileCacheDir *string `yaml:"map_tile_cache_dir"`
Admin *rawWebAdminConfig `yaml:"admin"`
}
@@ -145,6 +147,7 @@ func defaultConfig() *config {
Port: 8080,
SocketPath: defaultWebSocketPath(),
StaticDir: "./dist",
MapTileCacheDir: defaultMapTileCacheDir(),
Admin: webAdminConfig{
Username: "admin",
Password: "admin",
@@ -176,6 +179,17 @@ func defaultWebSocketPath() string {
return defaultWebSocketPathForGOOS(runtime.GOOS)
}
func defaultMapTileCacheDir() string {
return defaultMapTileCacheDirForGOOS(runtime.GOOS)
}
func defaultMapTileCacheDirForGOOS(goos string) string {
if goos == "windows" {
return filepath.Join(".", "win", "srv", "mesh_mqtt_go")
}
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
}
func defaultWebSocketPathForGOOS(goos string) string {
if goos == "windows" {
return ""
@@ -342,6 +356,11 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
} else {
cfg.Web.StaticDir = *raw.Web.StaticDir
}
if raw.Web.MapTileCacheDir == nil {
changed = true
} else {
cfg.Web.MapTileCacheDir = *raw.Web.MapTileCacheDir
}
if raw.Web.Admin == nil {
changed = true
} else {
@@ -394,6 +413,9 @@ func validateConfig(cfg *config) error {
if cfg.Web.StaticDir == "" {
return fmt.Errorf("web.static_dir is required when web is enabled")
}
if cfg.Web.MapTileCacheDir == "" {
return fmt.Errorf("web.map_tile_cache_dir is required when web is enabled")
}
if cfg.Web.Admin.Username == "" {
return fmt.Errorf("web.admin.username is required when web is enabled")
}
+25 -1
View File
@@ -44,6 +44,9 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
if cfg.Web.StaticDir != "./dist" {
t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir)
}
if cfg.Web.MapTileCacheDir != defaultMapTileCacheDir() {
t.Fatalf("web map tile cache dir = %q, want %q", cfg.Web.MapTileCacheDir, defaultMapTileCacheDir())
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("default config was not written: %v", err)
}
@@ -80,7 +83,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
t.Fatal(err)
}
text := string(data)
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:"} {
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} {
if !strings.Contains(text, want) {
t.Fatalf("completed config missing %q in:\n%s", want, text)
}
@@ -154,6 +157,20 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
}
}
func TestDefaultMapTileCacheDirForGOOS(t *testing.T) {
windowsPath := defaultMapTileCacheDirForGOOS("windows")
wantWindows := filepath.Join(".", "win", "srv", "mesh_mqtt_go")
if windowsPath != wantWindows {
t.Fatalf("windows map tile cache dir = %q, want %q", windowsPath, wantWindows)
}
linuxPath := defaultMapTileCacheDirForGOOS("linux")
wantLinux := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
if linuxPath != wantLinux {
t.Fatalf("linux map tile cache dir = %q, want %q", linuxPath, wantLinux)
}
}
func TestDefaultWebSocketPathForGOOS(t *testing.T) {
if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" {
t.Fatalf("windows web socket path = %q, want empty", windowsPath)
@@ -228,6 +245,7 @@ func TestValidateConfigWeb(t *testing.T) {
}
cfg = defaultConfig()
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock")
cfg.Web.Port = 0
if err := validateConfig(cfg); err != nil {
t.Fatalf("web socket with invalid port error = %v, want nil", err)
@@ -246,6 +264,12 @@ func TestValidateConfigWeb(t *testing.T) {
t.Fatalf("missing web static dir error = %v, want web.static_dir error", err)
}
cfg = defaultConfig()
cfg.Web.MapTileCacheDir = ""
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") {
t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err)
}
cfg = defaultConfig()
cfg.Web.Enabled = false
cfg.Web.Port = 0
+60 -1
View File
@@ -115,6 +115,24 @@ func (runtimeSettingRecord) TableName() string {
return "runtime_settings"
}
type mapTileSourceRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;not null;uniqueIndex"`
URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"`
URLTemplateHash string `gorm:"column:url_template_hash;size:64;not null;uniqueIndex"`
Attribution string `gorm:"column:attribution"`
MaxZoom int `gorm:"column:max_zoom;not null"`
Enabled bool `gorm:"column:enabled;not null;index"`
IsDefault bool `gorm:"column:is_default;not null;index"`
ProxyEnabled bool `gorm:"column:proxy_enabled;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (mapTileSourceRecord) TableName() string {
return "map_tile_sources"
}
type discardDetailsRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Topic string `gorm:"column:topic"`
@@ -458,6 +476,7 @@ func (s *store) migrate() error {
{label: "login_log", model: &loginLogRecord{}},
{label: "help_content", model: &helpContentRecord{}},
{label: "runtime_settings", model: &runtimeSettingRecord{}},
{label: "map_tile_sources", model: &mapTileSourceRecord{}},
{label: "discard_details", model: &discardDetailsRecord{}},
{label: "node_blocking", model: &nodeBlockingRecord{}},
{label: "ip_blocking", model: &ipBlockingRecord{}},
@@ -491,10 +510,50 @@ func (s *store) migrate() error {
return err
}
}
return nil
if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil {
return err
}
return (&store{db: tx, driver: s.driver}).EnsureDefaultMapTileSource()
})
}
func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") {
if driver == databaseDriverSQLite {
if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN proxy_enabled numeric NOT NULL DEFAULT true").Error; err != nil {
return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err)
}
} else if err := migrator.AddColumn(&mapTileSourceRecord{}, "ProxyEnabled"); err != nil {
return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err)
}
}
if !migrator.HasColumn(&mapTileSourceRecord{}, "URLTemplateHash") {
if driver == databaseDriverSQLite {
if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN url_template_hash TEXT NOT NULL DEFAULT ''").Error; err != nil {
return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err)
}
} else if err := migrator.AddColumn(&mapTileSourceRecord{}, "URLTemplateHash"); err != nil {
return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err)
}
}
var rows []mapTileSourceRecord
if err := tx.Model(&mapTileSourceRecord{}).Where("url_template_hash = '' OR url_template_hash IS NULL").Find(&rows).Error; err != nil {
return fmt.Errorf("list map_tile_sources missing url_template_hash: %w", err)
}
for _, row := range rows {
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", row.ID).Update("url_template_hash", mapTileSourceHash(row.URLTemplate)).Error; err != nil {
return fmt.Errorf("backfill map_tile_sources url_template_hash: %w", err)
}
}
if !migrator.HasIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash") {
if err := migrator.CreateIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash"); err != nil {
return fmt.Errorf("migrate map_tile_sources index idx_map_tile_sources_url_template_hash: %w", err)
}
}
return nil
}
func createMissingIndexes(migrator gorm.Migrator, model any, label string, indexNames []string) error {
for _, indexName := range indexNames {
if !migrator.HasIndex(model, indexName) {
+1 -1
View File
@@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
st := openTestStore(t)
defer st.Close()
for _, table := range []string{"users", "login_log", "runtime_settings", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
for _, table := range []string{"users", "login_log", "runtime_settings", "map_tile_sources", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
var name string
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
t.Fatalf("%s table missing: %v", table, err)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

+4 -4
View File
@@ -3,9 +3,13 @@ module meshtastic_mqtt_server
go 1.25.0
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mochi-mqtt/server/v2 v2.7.9
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.48.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
@@ -20,7 +24,6 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
@@ -39,7 +42,6 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -50,10 +52,8 @@ require (
github.com/rs/xid v1.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
+1
View File
@@ -175,6 +175,7 @@ func parseArgs() (*config, error) {
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port")
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows")
flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory")
flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory")
flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
flag.Parse()
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
"unicode"
"gorm.io/gorm"
)
const (
defaultMapTileSourceName = "OpenStreetMap Japan"
defaultMapTileSourceURLTemplate = "https://tile.openstreetmap.jp/{z}/{x}/{y}.png"
defaultMapTileSourceAttribution = "&copy; OpenStreetMap contributors"
defaultMapTileSourceMaxZoom = 19
maxMapTileSourceURLLength = 2048
)
var (
errMapTileSourceAlreadyExists = errors.New("map source already exists")
errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
)
type mapTileSourceInput struct {
Name string
URLTemplate string
Attribution string
MaxZoom int
Enabled bool
IsDefault bool
ProxyEnabled bool
}
func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) {
opts = normalizeListOptions(opts)
var rows []mapTileSourceRecord
q := s.db.Model(&mapTileSourceRecord{}).
Order("is_default DESC").
Order("updated_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
}
func (s *store) CountMapTileSources(opts listOptions) (int64, error) {
var total int64
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
}
func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
var rows []mapTileSourceRecord
if err := s.db.Model(&mapTileSourceRecord{}).
Where("enabled = ?", true).
Order("is_default DESC").
Order("updated_at DESC").
Order("id DESC").
Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil
}
return rows, nil
}
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
var row mapTileSourceRecord
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
fallback := defaultMapTileSourceRecord()
return &fallback, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *store) GetEnabledMapTileSourceByHash(hash string) (*mapTileSourceRecord, error) {
var row mapTileSourceRecord
if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) {
row, err := mapTileSourceFromInput(input)
if err != nil {
return nil, err
}
if row.IsDefault && !row.Enabled {
return nil, errMapTileSourceDefaultMustBeEnabled
}
if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil {
return nil, err
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if row.IsDefault {
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Create(row).Error
}); err != nil {
return nil, err
}
return row, nil
}
func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) {
if id == 0 {
return nil, fmt.Errorf("map source id is required")
}
row, err := mapTileSourceFromInput(input)
if err != nil {
return nil, err
}
var updated mapTileSourceRecord
if err := s.db.Transaction(func(tx *gorm.DB) error {
var existing mapTileSourceRecord
if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil {
return err
}
if existing.IsDefault && !row.Enabled {
return errMapTileSourceCannotDisableDefault
}
if row.IsDefault && !row.Enabled {
return errMapTileSourceDefaultMustBeEnabled
}
if !row.IsDefault && existing.IsDefault {
row.IsDefault = true
}
if err := ensureMapTileSourceUniqueTx(tx, id, row.Name, row.URLTemplate); err != nil {
return err
}
if row.IsDefault {
if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
return err
}
}
updates := map[string]any{
"name": row.Name,
"url_template": row.URLTemplate,
"url_template_hash": row.URLTemplateHash,
"attribution": row.Attribution,
"max_zoom": row.MaxZoom,
"enabled": row.Enabled,
"is_default": row.IsDefault,
"proxy_enabled": row.ProxyEnabled,
"updated_at": time.Now(),
}
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Take(&updated).Error
}); err != nil {
return nil, err
}
return &updated, nil
}
func (s *store) DeleteMapTileSource(id uint64) error {
if id == 0 {
return fmt.Errorf("map source id is required")
}
return s.db.Transaction(func(tx *gorm.DB) error {
var row mapTileSourceRecord
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
return err
}
if row.IsDefault {
return errMapTileSourceCannotDeleteDefault
}
result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) {
if id == 0 {
return nil, fmt.Errorf("map source id is required")
}
var row mapTileSourceRecord
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
return err
}
if !row.Enabled {
return errMapTileSourceDefaultMustBeEnabled
}
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
return err
}
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Take(&row).Error
}); err != nil {
return nil, err
}
return &row, nil
}
func (s *store) EnsureDefaultMapTileSource() error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil {
return err
}
if count == 0 {
row := defaultMapTileSourceRecord()
return tx.Create(&row).Error
}
var defaults []mapTileSourceRecord
if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil {
return err
}
if len(defaults) > 0 {
return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
}
var enabled mapTileSourceRecord
err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error
if err == nil {
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
row := defaultMapTileSourceRecord()
var existing mapTileSourceRecord
err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error
if err == nil {
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return tx.Create(&row).Error
})
}
func mapTileSourceHash(urlTemplate string) string {
h := sha256.Sum256([]byte(urlTemplate))
return hex.EncodeToString(h[:])
}
func defaultMapTileSourceRecord() mapTileSourceRecord {
return mapTileSourceRecord{
Name: defaultMapTileSourceName,
URLTemplate: defaultMapTileSourceURLTemplate,
URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate),
Attribution: defaultMapTileSourceAttribution,
MaxZoom: defaultMapTileSourceMaxZoom,
Enabled: true,
IsDefault: true,
ProxyEnabled: true,
}
}
func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, fmt.Errorf("map source name is required")
}
urlTemplate, err := normalizeMapTileSourceURLTemplate(input.URLTemplate)
if err != nil {
return nil, err
}
maxZoom := input.MaxZoom
if maxZoom == 0 {
maxZoom = defaultMapTileSourceMaxZoom
}
if maxZoom < 1 || maxZoom > 30 {
return nil, fmt.Errorf("max zoom must be between 1 and 30")
}
return &mapTileSourceRecord{
Name: name,
URLTemplate: urlTemplate,
URLTemplateHash: mapTileSourceHash(urlTemplate),
Attribution: strings.TrimSpace(input.Attribution),
MaxZoom: maxZoom,
Enabled: input.Enabled,
IsDefault: input.IsDefault,
ProxyEnabled: input.ProxyEnabled,
}, nil
}
func normalizeMapTileSourceURLTemplate(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("map source url template is required")
}
if len(value) > maxMapTileSourceURLLength {
return "", fmt.Errorf("map source url template is too long")
}
for _, r := range value {
if unicode.IsControl(r) || unicode.IsSpace(r) {
return "", fmt.Errorf("map source url template must not contain whitespace or control characters")
}
}
for _, placeholder := range []string{"{z}", "{x}", "{y}"} {
if strings.Count(value, placeholder) != 1 {
return "", fmt.Errorf("map source url template must contain %s exactly once", placeholder)
}
}
parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("map source url template is invalid")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("map source url template must use http or https")
}
if parsed.Host == "" {
return "", fmt.Errorf("map source url template host is required")
}
if parsed.User != nil {
return "", fmt.Errorf("map source url template must not contain credentials")
}
return value, nil
}
func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate)
}
func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error {
var existing mapTileSourceRecord
q := tx.Where("name = ? OR url_template = ?", name, urlTemplate)
if id != 0 {
q = q.Where("id <> ?", id)
}
err := q.Take(&existing).Error
if err == nil {
return errMapTileSourceAlreadyExists
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
+258
View File
@@ -0,0 +1,258 @@
package main
import (
"errors"
"strings"
"testing"
"gorm.io/gorm"
)
func TestMapTileSourceDefaultSeeded(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.GetDefaultMapTileSource()
if err != nil {
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
}
if row.Name != defaultMapTileSourceName || row.URLTemplate != defaultMapTileSourceURLTemplate || !row.Enabled || !row.IsDefault {
t.Fatalf("default map source = %+v, want built-in default", row)
}
}
func TestCreateMapTileSourceValidation(t *testing.T) {
st := openTestStore(t)
defer st.Close()
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error")
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error")
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
t.Fatal("CreateMapTileSource() credentials error = nil, want error")
}
}
func TestListEnabledMapTileSources(t *testing.T) {
st := openTestStore(t)
defer st.Close()
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
}
custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(custom) error = %v", err)
}
if _, err := st.SetDefaultMapTileSource(custom.ID); err != nil {
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
}
rows, err := st.ListEnabledMapTileSources()
if err != nil {
t.Fatalf("ListEnabledMapTileSources() error = %v", err)
}
if len(rows) < 2 {
t.Fatalf("ListEnabledMapTileSources() length = %d, want at least 2", len(rows))
}
if rows[0].ID != custom.ID {
t.Fatalf("first enabled source id = %d, want default %d", rows[0].ID, custom.ID)
}
for _, row := range rows {
if row.ID == disabled.ID {
t.Fatalf("disabled source was returned: %+v", row)
}
if !row.Enabled {
t.Fatalf("disabled row returned: %+v", row)
}
}
}
func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
st := openTestStore(t)
defer st.Close()
first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err)
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err)
}
updated, err := st.SetDefaultMapTileSource(first.ID)
if err != nil {
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
}
if !updated.IsDefault {
t.Fatalf("updated default = %+v, want is_default", updated)
}
oldDefault, err := st.GetDefaultMapTileSource()
if err != nil {
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
}
if oldDefault.ID != first.ID {
t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID)
}
if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) {
t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err)
}
if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) {
t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err)
}
}
func TestMapTileSourceHashIsSetOnCreate(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
want := mapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png")
if row.URLTemplateHash != want {
t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
}
if !row.ProxyEnabled {
t.Fatal("ProxyEnabled = false, want true")
}
}
func TestMapTileSourceDefaultHasHash(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.GetDefaultMapTileSource()
if err != nil {
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
}
want := mapTileSourceHash(defaultMapTileSourceURLTemplate)
if row.URLTemplateHash != want {
t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
}
}
func TestGetEnabledMapTileSourceByHash(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
found, err := st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
if err != nil {
t.Fatalf("GetEnabledMapTileSourceByHash() error = %v", err)
}
if found.ID != row.ID {
t.Fatalf("found ID = %d, want %d", found.ID, row.ID)
}
}
func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
_, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("GetEnabledMapTileSourceByHash(disabled) = %v, want gorm.ErrRecordNotFound", err)
}
}
func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
_, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("GetEnabledMapTileSourceByHash(proxy disabled) = %v, want gorm.ErrRecordNotFound", err)
}
}
func TestGetEnabledMapTileSourceByHashUnknown(t *testing.T) {
st := openTestStore(t)
defer st.Close()
_, err := st.GetEnabledMapTileSourceByHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("GetEnabledMapTileSourceByHash(unknown) = %v, want gorm.ErrRecordNotFound", err)
}
}
func TestPublicMapTileSourceDTOProxyURL(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
dto := publicMapTileSourceDTO(*row)
urlTemplate, ok := dto["url_template"].(string)
if !ok {
t.Fatal("url_template is not a string")
}
wantPrefix := "/api/map/" + row.URLTemplateHash + "?x={x}&y={y}&z={z}"
if urlTemplate != wantPrefix {
t.Fatalf("url_template = %q, want %q", urlTemplate, wantPrefix)
}
if strings.Contains(urlTemplate, "proxy.example.com") {
t.Fatal("url_template should not contain upstream hostname")
}
}
func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
dto := publicMapTileSourceDTO(*row)
urlTemplate, ok := dto["url_template"].(string)
if !ok {
t.Fatal("url_template is not a string")
}
if urlTemplate != row.URLTemplate {
t.Fatalf("url_template = %q, want raw %q", urlTemplate, row.URLTemplate)
}
}
func TestMapTileSourceHashFunction(t *testing.T) {
hash1 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
hash2 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
hash3 := mapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png")
if hash1 != hash2 {
t.Fatal("hash should be deterministic")
}
if len(hash1) != 64 {
t.Fatalf("hash length = %d, want 64", len(hash1))
}
if hash1 == hash3 {
t.Fatal("different URLs should produce different hashes")
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
mapTileCacheControl = "public, max-age=86400"
maxMapTileBytes = 10 << 20
)
type mapTileProxy struct {
store *store
cacheDir string
client *http.Client
}
func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) {
proxy := &mapTileProxy{
store: store,
cacheDir: cacheDir,
client: &http.Client{Timeout: 15 * time.Second},
}
r.GET("/map/:sourceHash", proxy.handle)
}
func (p *mapTileProxy) handle(c *gin.Context) {
sourceHash := strings.ToLower(c.Param("sourceHash"))
if !isMapTileSourceHash(sourceHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source hash"})
return
}
row, err := p.store.GetEnabledMapTileSourceByHash(sourceHash)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tile, ok := parseMapTileCoordinates(c, row.MaxZoom)
if !ok {
return
}
cachePath := mapTileCachePath(p.cacheDir, sourceHash, tile)
if data, err := os.ReadFile(cachePath); err == nil {
writeMapTile(c, data)
return
} else if !os.IsNotExist(err) {
// Fall through to upstream fetch. A broken cache file should not prevent map rendering.
}
data, status, err := p.fetchRemoteTile(c.Request, row.URLTemplate, tile)
if err != nil {
c.JSON(status, gin.H{"error": err.Error()})
return
}
_ = writeMapTileCacheFile(cachePath, data)
writeMapTile(c, data)
}
func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) {
remoteURL := expandMapTileURLTemplate(template, tile)
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("build upstream map tile request: %w", err)
}
upstreamReq.Header.Set("User-Agent", "mesh_mqtt_go map tile cache")
resp, err := p.client.Do(upstreamReq)
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("fetch upstream map tile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, http.StatusNotFound, fmt.Errorf("upstream map tile not found")
}
if resp.StatusCode != http.StatusOK {
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxMapTileBytes+1))
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("read upstream map tile: %w", err)
}
if len(data) > maxMapTileBytes {
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile is too large")
}
return data, http.StatusOK, nil
}
type mapTileCoordinates struct {
x int64
y int64
z int64
}
func parseMapTileCoordinates(c *gin.Context, maxZoom int) (mapTileCoordinates, bool) {
x, ok := parseMapTileCoordinate(c, "x")
if !ok {
return mapTileCoordinates{}, false
}
y, ok := parseMapTileCoordinate(c, "y")
if !ok {
return mapTileCoordinates{}, false
}
z, ok := parseMapTileCoordinate(c, "z")
if !ok {
return mapTileCoordinates{}, false
}
if z > int64(maxZoom) {
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile z exceeds max zoom"})
return mapTileCoordinates{}, false
}
limit := int64(1) << z
if x >= limit || y >= limit {
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile coordinates out of range"})
return mapTileCoordinates{}, false
}
return mapTileCoordinates{x: x, y: y, z: z}, true
}
func parseMapTileCoordinate(c *gin.Context, name string) (int64, bool) {
value := c.Query(name)
if value == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing map tile " + name})
return 0, false
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 || parsed > 30_000_000_000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map tile " + name})
return 0, false
}
return parsed, true
}
func isMapTileSourceHash(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
func expandMapTileURLTemplate(template string, tile mapTileCoordinates) string {
result := strings.ReplaceAll(template, "{x}", strconv.FormatInt(tile.x, 10))
result = strings.ReplaceAll(result, "{y}", strconv.FormatInt(tile.y, 10))
result = strings.ReplaceAll(result, "{z}", strconv.FormatInt(tile.z, 10))
return result
}
func mapTileCachePath(cacheDir, sourceHash string, tile mapTileCoordinates) string {
return filepath.Join(cacheDir, sourceHash, strconv.FormatInt(tile.z, 10), strconv.FormatInt(tile.x, 10), strconv.FormatInt(tile.y, 10)+".tile")
}
func writeMapTileCacheFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func writeMapTile(c *gin.Context, data []byte) {
contentType := http.DetectContentType(data)
c.Header("Cache-Control", mapTileCacheControl)
c.Data(http.StatusOK, contentType, data)
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
st := openTestStore(t)
defer st.Close()
requests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != "/3/1/2.png" {
t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("tile-data"))
}))
defer upstream.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
cacheDir := t.TempDir()
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil)
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
for i := 0; i < 2; i++ {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "tile-data" {
t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String())
}
}
if requests != 1 {
t.Fatalf("upstream requests = %d, want 1", requests)
}
cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile")
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatalf("read cache file %s: %v", cachePath, err)
}
if string(data) != "tile-data" {
t.Fatalf("cache file = %q, want tile-data", string(data))
}
}
func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4",
"/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1",
}
for _, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String())
}
}
}
func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
st := openTestStore(t)
defer st.Close()
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
}
proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/not-a-hash?x=0&y=0&z=0",
"/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0",
"/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0",
"/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0",
}
wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound}
for i, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != wantStatus[i] {
t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String())
}
}
}
func TestMapTileProxyUpstreamStatus(t *testing.T) {
st := openTestStore(t)
defer st.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/404/") {
http.NotFound(w, r)
return
}
http.Error(w, "upstream error", http.StatusInternalServerError)
}))
defer upstream.Close()
row404, err := st.CreateMapTileSource(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(404) error = %v", err)
}
row500, err := st.CreateMapTileSource(mapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(500) error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil)
cases := []struct {
url string
want int
}{
{url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound},
{url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway},
}
for _, tc := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != tc.want {
t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.String())
}
}
}
+369
View File
@@ -12,10 +12,12 @@
"vue": "^3.5.34"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
@@ -101,12 +103,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -400,6 +445,278 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"tailwindcss": "4.3.0"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -642,6 +959,20 @@
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.23.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
"integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -693,6 +1024,23 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -1091,6 +1439,27 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+2
View File
@@ -13,10 +13,12 @@
"vue": "^3.5.34"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
+24 -1
View File
@@ -8,6 +8,7 @@ import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
import AdminHelpEdit from './components/AdminHelpEdit.vue'
import AdminLogin from './components/AdminLogin.vue'
import AdminLoginLogs from './components/AdminLoginLogs.vue'
import AdminMapSource from './components/AdminMapSource.vue'
import AdminMqttForward from './components/AdminMqttForward.vue'
import AdminUsers from './components/AdminUsers.vue'
import ChatPanel from './components/ChatPanel.vue'
@@ -16,7 +17,8 @@ import HelpPage from './components/HelpPage.vue'
import MeshMap from './components/MeshMap.vue'
import NodeDetailedPage from './components/NodeDetailedPage.vue'
import NodeListPanel from './components/NodeListPanel.vue'
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
import { fallbackMapSource, loadEnabledMapSources } from './mapSource'
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
const currentPath = window.location.pathname
const adminPath = currentPath
@@ -52,6 +54,8 @@ const currentMapBounds = ref<MapBoundsQuery | null>(null)
const currentMapZoom = ref(2)
const mapReportsLoading = ref(false)
const mapReportTotal = ref(0)
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage }
@@ -294,6 +298,19 @@ async function refresh(showLoading = true) {
}
}
async function loadMapSource() {
const sources = await loadEnabledMapSources()
mapSources.value = sources
mapSource.value = sources[0] ?? fallbackMapSource
}
function selectMapSource(sourceId: number) {
const source = mapSources.value.find((item) => item.id === sourceId)
if (source) {
mapSource.value = source
}
}
async function checkAdminSession() {
adminChecking.value = true
try {
@@ -465,6 +482,7 @@ onMounted(() => {
if (isDetailedPage || isHelpPage) {
return
}
loadMapSource()
refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000)
})
@@ -496,6 +514,7 @@ onBeforeUnmount(() => {
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
@@ -536,6 +555,7 @@ onBeforeUnmount(() => {
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
<AdminBot v-else-if="isBotAdminPage" />
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
@@ -574,6 +594,9 @@ onBeforeUnmount(() => {
:is-admin="!!adminUser"
:auto-fit="false"
:loading="mapReportsLoading"
:map-source="mapSource"
:map-sources="mapSources"
@map-source-change="selectMapSource"
@bounds-change="handleMapBoundsChange"
@select-node="selectedNodeId = $event"
@clear-node="selectedNodeId = null"
+51 -5
View File
@@ -24,6 +24,9 @@ import type {
ListResponse,
MapBoundsQuery,
MapReport,
MapTileSource,
MapTileSourcePayload,
MapTileSourceResponse,
MapViewportResponse,
MQTTForwarder,
MQTTForwarderPayload,
@@ -35,6 +38,8 @@ import type {
NodeBlockingRulePayload,
NodeInfo,
PositionRecord,
PublicMapTileSourceResponse,
PublicMapTileSourcesResponse,
TelemetryRecord,
TextMessage,
} from './types'
@@ -56,10 +61,23 @@ async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>
}
function listPath(path: string, limit: number, offset: number, nodeId = ''): string {
type ListQueryOptions = {
nodeId?: string
since?: string
until?: string
}
function listPath(path: string, limit: number, offset: number, nodeIdOrOptions: string | ListQueryOptions = ''): string {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (nodeId) {
params.set('node_id', nodeId)
const options = typeof nodeIdOrOptions === 'string' ? { nodeId: nodeIdOrOptions } : nodeIdOrOptions
if (options.nodeId) {
params.set('node_id', options.nodeId)
}
if (options.since) {
params.set('since', options.since)
}
if (options.until) {
params.set('until', options.until)
}
return `${path}?${params.toString()}`
}
@@ -131,6 +149,14 @@ export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit
return getJSON<MapViewportResponse>(`/api/map-reports/viewport?${params.toString()}`)
}
export function getDefaultMapSource(): Promise<PublicMapTileSourceResponse> {
return getJSON<PublicMapTileSourceResponse>('/api/map-source/default')
}
export function getEnabledMapSources(): Promise<PublicMapTileSourcesResponse> {
return getJSON<PublicMapTileSourcesResponse>('/api/map-source/enabled')
}
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
}
@@ -143,8 +169,8 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
}
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
}
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
@@ -207,6 +233,26 @@ export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLo
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
}
export function getAdminMapSources(limit = 100, offset = 0): Promise<ListResponse<MapTileSource>> {
return getJSON<ListResponse<MapTileSource>>(listPath('/api/admin/map-source', limit, offset))
}
export function createAdminMapSource(payload: MapTileSourcePayload): Promise<MapTileSourceResponse> {
return postJSON<MapTileSourceResponse>('/api/admin/map-source', payload)
}
export function updateAdminMapSource(id: number, payload: MapTileSourcePayload): Promise<MapTileSourceResponse> {
return putJSON<MapTileSourceResponse>(`/api/admin/map-source/${id}`, payload)
}
export function deleteAdminMapSource(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/map-source/${id}`)
}
export function setDefaultAdminMapSource(id: number): Promise<MapTileSourceResponse> {
return postJSON<MapTileSourceResponse>(`/api/admin/map-source/${id}/default`)
}
export function getNodeBlockingRules(limit = 100, offset = 0): Promise<ListResponse<NodeBlockingRule>> {
return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset))
}
@@ -418,7 +418,7 @@ onMounted(() => {
<h2>屏蔽管理</h2>
</div>
</div>
<p class="empty">管理节点IP/CIDR违禁词三类屏蔽规则当前页面只维护规则不改变 MQTT 转发行为</p>
<p class="empty">管理节点IP/CIDR违禁词三类屏蔽规则</p>
</div>
<div class="panel admin-status-panel">
@@ -190,10 +190,8 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid rgba(37, 99, 235, 0.14);
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.16), transparent 32%),
linear-gradient(135deg, #ffffff 0%, #f8fbff 52%, #eef6ff 100%);
border: 1px solid var(--color-border);
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
}
.control-header {
@@ -210,19 +208,19 @@ onBeforeUnmount(() => {
.control-badge {
display: inline-flex;
align-items: center;
border: 1px solid #cbd5e1;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 6px 12px;
color: #475569;
color: var(--color-muted);
font-size: 12px;
font-weight: 800;
background: rgba(255, 255, 255, 0.8);
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
}
.control-badge.active {
border-color: rgba(22, 163, 74, 0.32);
color: #15803d;
background: #dcfce7;
border-color: color-mix(in srgb, var(--color-success) 36%, white);
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: var(--color-success-soft);
}
.control-body {
@@ -235,10 +233,10 @@ onBeforeUnmount(() => {
.control-copy,
.switch-card {
border: 1px solid rgba(203, 213, 225, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
box-shadow: var(--shadow-sm);
}
.control-copy {
@@ -247,13 +245,13 @@ onBeforeUnmount(() => {
.control-copy h3 {
margin: 0 0 0.45rem;
color: #0f172a;
color: var(--color-heading);
font-size: 18px;
}
.control-copy p {
margin: 0;
color: #64748b;
color: var(--color-muted);
line-height: 1.7;
}
@@ -269,20 +267,20 @@ onBeforeUnmount(() => {
gap: 1rem;
min-height: 108px;
padding: 1rem;
color: #334155;
color: var(--color-text);
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
}
.switch-card:hover {
transform: translateY(-1px);
border-color: rgba(37, 99, 235, 0.35);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.09);
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.switch-card.enabled {
border-color: rgba(22, 163, 74, 0.35);
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
border-color: color-mix(in srgb, var(--color-success) 42%, white);
background: var(--color-success-soft);
}
.switch-card.saving {
@@ -303,12 +301,12 @@ onBeforeUnmount(() => {
}
.switch-text strong {
color: #0f172a;
color: var(--color-heading);
font-size: 15px;
}
.switch-text small {
color: #64748b;
color: var(--color-muted);
font-size: 12px;
line-height: 1.45;
}
@@ -319,9 +317,9 @@ onBeforeUnmount(() => {
width: 54px;
height: 30px;
border-radius: 999px;
background: #cbd5e1;
box-shadow: inset 0 2px 4px rgba(15, 23, 42, 0.14);
transition: background 0.15s ease;
background: var(--color-border-strong);
box-shadow: inset 0 2px 4px rgba(47, 52, 50, 0.12);
transition: background-color 0.16s ease;
}
.switch-toggle::after {
@@ -333,12 +331,12 @@ onBeforeUnmount(() => {
height: 22px;
border-radius: 999px;
background: #fff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24);
transition: transform 0.15s ease;
box-shadow: 0 4px 10px rgba(47, 52, 50, 0.18);
transition: transform 0.16s ease;
}
.switch-card.enabled .switch-toggle {
background: linear-gradient(135deg, #16a34a, #22c55e);
background: var(--color-success);
}
.switch-card.enabled .switch-toggle::after {
@@ -0,0 +1,549 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { createAdminMapSource, deleteAdminMapSource, getAdminMapSources, setDefaultAdminMapSource, updateAdminMapSource } from '../api'
import type { MapTileSource, MapTileSourcePayload } from '../types'
const items = ref<MapTileSource[]>([])
const loading = ref(false)
const error = ref('')
const message = ref('')
const page = ref(1)
const pageSize = 25
const newSource = ref<MapTileSourcePayload>({
name: '',
url_template: 'https://tile.openstreetmap.jp/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap contributors',
max_zoom: 19,
enabled: true,
is_default: false,
proxy_enabled: true,
})
const canPrev = () => page.value > 1
const canNext = () => items.value.length === pageSize
const enabledCount = computed(() => items.value.filter((item) => item.enabled).length)
const defaultSource = computed(() => items.value.find((item) => item.is_default) ?? null)
function editableCopy(item: MapTileSource): MapTileSourcePayload {
return {
name: item.name,
url_template: item.url_template,
attribution: item.attribution,
max_zoom: item.max_zoom,
enabled: item.enabled,
is_default: item.is_default,
proxy_enabled: item.proxy_enabled,
}
}
const drafts = ref<Record<number, MapTileSourcePayload>>({})
function resetNewSource() {
newSource.value = {
name: '',
url_template: '',
attribution: '&copy; OpenStreetMap contributors',
max_zoom: 19,
enabled: true,
is_default: false,
proxy_enabled: true,
}
}
function validatePayload(payload: MapTileSourcePayload): string {
if (!payload.name.trim()) {
return '请输入图源名称'
}
const url = payload.url_template.trim()
if (!url) {
return '请输入图源 URL 模板'
}
for (const placeholder of ['{z}', '{x}', '{y}']) {
if (!url.includes(placeholder)) {
return `URL 模板必须包含 ${placeholder}`
}
}
if (!Number.isInteger(payload.max_zoom) || payload.max_zoom < 1 || payload.max_zoom > 30) {
return '最大缩放级别必须是 1 到 30 之间的整数'
}
if (payload.is_default && !payload.enabled) {
return '默认图源必须启用'
}
return ''
}
async function refreshItems() {
loading.value = true
error.value = ''
try {
const response = await getAdminMapSources(pageSize, (page.value - 1) * pageSize)
items.value = response.items
drafts.value = Object.fromEntries(response.items.map((item) => [item.id, editableCopy(item)]))
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
function changePage(nextPage: number) {
page.value = Math.max(1, nextPage)
refreshItems()
}
async function createSource() {
const validation = validatePayload(newSource.value)
if (validation) {
error.value = validation
return
}
loading.value = true
error.value = ''
message.value = ''
try {
await createAdminMapSource({ ...newSource.value })
message.value = '图源已添加'
resetNewSource()
page.value = 1
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function saveSource(item: MapTileSource) {
const draft = drafts.value[item.id]
if (!draft) {
return
}
const validation = validatePayload(draft)
if (validation) {
error.value = validation
return
}
loading.value = true
error.value = ''
message.value = ''
try {
await updateAdminMapSource(item.id, { ...draft })
message.value = '图源已保存'
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function setDefaultSource(item: MapTileSource) {
loading.value = true
error.value = ''
message.value = ''
try {
await setDefaultAdminMapSource(item.id)
message.value = '默认图源已更新'
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function removeSource(item: MapTileSource) {
if (!window.confirm(`确定要删除图源「${item.name}」吗?`)) {
return
}
loading.value = true
error.value = ''
message.value = ''
try {
await deleteAdminMapSource(item.id)
message.value = '图源已删除'
if (items.value.length === 1 && page.value > 1) {
page.value -= 1
}
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
onMounted(refreshItems)
</script>
<template>
<section class="map-source-page">
<div class="map-source-hero panel">
<div class="hero-copy">
<p class="eyebrow">Map source</p>
<h2>地图图源</h2>
<p class="muted">集中维护 Leaflet 瓦片图源URL 模板必须包含 <code>{z}</code><code>{x}</code><code>{y}</code></p>
</div>
<div class="hero-stats">
<div>
<strong>{{ items.length }}</strong>
<span>当前图源</span>
</div>
<div>
<strong>{{ enabledCount }}</strong>
<span>已启用</span>
</div>
<div>
<strong>{{ defaultSource?.name || '-' }}</strong>
<span>默认图源</span>
</div>
</div>
</div>
<div class="panel map-source-create-panel">
<div class="panel-heading compact">
<div>
<p class="eyebrow">Create</p>
<h2>新增图源</h2>
</div>
<button class="admin-button ghost" type="button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
</div>
<form class="map-source-form" @submit.prevent="createSource">
<label class="field">名称<input v-model="newSource.name" placeholder="OpenStreetMap Japan" /></label>
<label class="field url-field">URL 模板<input v-model="newSource.url_template" placeholder="https://tile.example.com/{z}/{x}/{y}.png" /></label>
<label class="field attribution-field">Attribution<input v-model="newSource.attribution" placeholder="&copy; OpenStreetMap contributors" /></label>
<label class="field zoom-field">最大缩放<input v-model.number="newSource.max_zoom" type="number" min="1" max="30" /></label>
<label class="switch-card"><input v-model="newSource.enabled" type="checkbox" /> <span>启用</span></label>
<label class="switch-card"><input v-model="newSource.is_default" type="checkbox" /> <span>设为默认</span></label>
<label class="switch-card"><input v-model="newSource.proxy_enabled" type="checkbox" /> <span>是否代理</span></label>
<div class="form-actions">
<button class="admin-button" type="submit" :disabled="loading">添加图源</button>
</div>
</form>
<p class="template-tip">示例<code>https://tile.openstreetmap.jp/{z}/{x}/{y}.png</code></p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="message" class="success">{{ message }}</p>
</div>
<div class="panel map-source-list-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Sources</p>
<h2>图源列表</h2>
</div>
<span class="badge">{{ items.length }} </span>
</div>
<div v-if="items.length === 0" class="empty-state">暂无地图图源先在上方添加一个配置</div>
<article v-for="item in items" :key="item.id" class="map-source-card" :class="{ default: item.is_default, disabled: !item.enabled }">
<header class="source-card-title">
<div>
<div class="source-title-row">
<h3>{{ item.name }}</h3>
<span v-if="item.is_default" class="status-pill ok">默认</span>
<span v-else-if="item.enabled" class="status-pill">启用</span>
<span v-else class="status-pill disabled">停用</span>
</div>
<p class="source-url">{{ item.url_template }}</p>
</div>
<button v-if="!item.is_default" class="admin-button ghost" :disabled="loading || !item.enabled" @click="setDefaultSource(item)">设为默认</button>
</header>
<div v-if="drafts[item.id]" class="source-edit-grid">
<label class="field">名称<input v-model="drafts[item.id].name" /></label>
<label class="field url-field">URL 模板<input v-model="drafts[item.id].url_template" /></label>
<label class="field attribution-field">Attribution<input v-model="drafts[item.id].attribution" /></label>
<label class="field zoom-field">最大缩放<input v-model.number="drafts[item.id].max_zoom" type="number" min="1" max="30" /></label>
<label class="switch-card"><input v-model="drafts[item.id].enabled" type="checkbox" :disabled="item.is_default" /> <span>启用图源</span></label>
<label class="switch-card"><input v-model="drafts[item.id].proxy_enabled" type="checkbox" /> <span>是否代理</span></label>
</div>
<div class="source-meta">
<div><span>ID</span><strong>{{ item.id }}</strong></div>
<div><span>最大缩放</span><strong>{{ item.max_zoom }}</strong></div>
<div><span>Attribution</span><strong>{{ item.attribution || '-' }}</strong></div>
</div>
<div class="actions">
<button class="admin-button" :disabled="loading" @click="saveSource(item)">保存</button>
<button class="admin-button danger" :disabled="loading || item.is_default" @click="removeSource(item)">删除</button>
</div>
</article>
<div class="pagination">
<button :disabled="loading || !canPrev()" @click="changePage(page - 1)">上一页</button>
<span> {{ page }} </span>
<span>每页 {{ pageSize }} </span>
<button :disabled="loading || !canNext()" @click="changePage(page + 1)">下一页</button>
</div>
</div>
</section>
</template>
<style scoped>
.map-source-page {
display: grid;
gap: 12px;
}
.map-source-page :deep(input) {
width: 100%;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 9px 11px;
color: var(--color-heading);
font: inherit;
background: var(--color-surface);
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.map-source-page :deep(input:focus) {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.map-source-page :deep(input[type='checkbox']) {
width: auto;
}
.map-source-hero,
.map-source-create-panel,
.map-source-list-panel {
padding: 18px;
}
.map-source-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
}
.hero-copy {
min-width: 260px;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 0.75rem;
}
.hero-stats div {
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 16px;
text-align: center;
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
}
.hero-stats strong {
display: block;
overflow: hidden;
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
font-size: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-stats span,
.source-meta span,
.template-tip,
.source-url {
color: var(--color-muted);
font-size: 13px;
}
.panel-heading,
.source-card-title,
.source-title-row,
.actions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.panel-heading,
.source-card-title {
justify-content: space-between;
}
.panel-heading.compact {
margin-bottom: 1rem;
}
.map-source-form,
.source-edit-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(320px, 2fr) minmax(220px, 1.4fr) minmax(100px, 0.5fr) auto auto;
gap: 0.75rem;
align-items: end;
}
.field {
display: grid;
gap: 6px;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
}
.url-field {
min-width: 320px;
}
.zoom-field {
min-width: 96px;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
.switch-card {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 39px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 9px 11px;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
background: var(--color-surface-soft);
}
.template-tip {
margin: 12px 0 0;
}
.map-source-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
margin-top: 1rem;
background: var(--color-surface);
box-shadow: inset 4px 0 0 var(--color-primary-soft);
}
.map-source-card.default {
box-shadow: inset 4px 0 0 var(--color-success);
}
.map-source-card.disabled {
background: var(--color-surface-soft);
box-shadow: inset 4px 0 0 var(--color-border-strong);
}
.source-title-row h3 {
margin: 0;
color: var(--color-heading);
font-size: 18px;
}
.source-url {
max-width: 860px;
margin: 6px 0 0;
overflow-wrap: anywhere;
font-family: var(--font-mono);
}
.status-pill {
border-radius: 999px;
padding: 7px 12px;
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
font-size: 13px;
font-weight: 800;
background: var(--color-primary-soft);
}
.status-pill.ok {
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: var(--color-success-soft);
}
.status-pill.disabled {
color: var(--color-muted);
background: var(--color-surface-muted);
}
.source-edit-grid {
grid-template-columns: minmax(180px, 1fr) minmax(320px, 2fr) minmax(220px, 1.4fr) minmax(100px, 0.5fr) auto;
margin-top: 1rem;
}
.source-meta {
display: grid;
grid-template-columns: minmax(70px, 0.4fr) minmax(100px, 0.5fr) minmax(220px, 2fr);
gap: 0.75rem;
margin: 1rem 0;
}
.source-meta div {
min-width: 0;
border-radius: var(--radius-md);
padding: 10px 12px;
background: var(--color-surface-soft);
}
.source-meta strong {
display: block;
margin-top: 3px;
overflow-wrap: anywhere;
color: var(--color-heading);
}
.actions {
justify-content: flex-end;
}
.empty-state {
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
padding: 24px;
color: var(--color-muted);
text-align: center;
background: var(--color-surface-soft);
}
@media (max-width: 1100px) {
.map-source-hero,
.panel-heading,
.source-card-title {
align-items: stretch;
flex-direction: column;
}
.hero-stats,
.map-source-form,
.source-edit-grid,
.source-meta {
grid-template-columns: 1fr 1fr;
}
.url-field,
.attribution-field {
grid-column: 1 / -1;
min-width: 0;
}
}
@media (max-width: 700px) {
.hero-stats,
.map-source-form,
.source-edit-grid,
.source-meta {
grid-template-columns: 1fr;
}
}
</style>
@@ -582,21 +582,20 @@ onBeforeUnmount(() => {
.mqtt-forward-page :deep(input),
.mqtt-forward-page :deep(select) {
width: 100%;
box-sizing: border-box;
border: 1px solid #cbd5e1;
border-radius: 10px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 9px 11px;
color: #0f172a;
color: var(--color-heading);
font: inherit;
background: #fff;
background: var(--color-surface);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.mqtt-forward-page :deep(input:focus),
.mqtt-forward-page :deep(select:focus) {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.mqtt-hero,
@@ -610,7 +609,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: space-between;
gap: 1rem;
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
}
.mqtt-hero h2 {
@@ -624,23 +623,23 @@ onBeforeUnmount(() => {
}
.hero-stats div {
border: 1px solid #dbeafe;
border-radius: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 16px;
text-align: center;
background: rgba(255, 255, 255, 0.78);
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
}
.hero-stats strong {
display: block;
color: #1d4ed8;
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
font-size: 24px;
}
.hero-stats span,
.endpoint-line,
.runtime-grid span {
color: #64748b;
color: var(--color-muted);
font-size: 13px;
}
@@ -674,7 +673,7 @@ onBeforeUnmount(() => {
.field {
display: grid;
gap: 6px;
color: #334155;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
}
@@ -687,9 +686,9 @@ onBeforeUnmount(() => {
.edit-section,
.forwarder-card,
.topics-box {
border: 1px solid #dbe4ef;
border-radius: 16px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.broker-card {
@@ -702,16 +701,16 @@ onBeforeUnmount(() => {
.broker-card legend {
padding: 0 8px;
color: #334155;
color: var(--color-text);
font-weight: 800;
}
.source-card {
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
background: linear-gradient(180deg, var(--color-surface-soft) 0%, var(--color-surface) 100%);
}
.target-card {
background: linear-gradient(180deg, #f8fffb 0%, #fff 100%);
background: linear-gradient(180deg, var(--color-success-soft) 0%, var(--color-surface) 100%);
}
.form-actions {
@@ -726,13 +725,13 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #dbe4ef;
border-radius: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 9px 11px;
color: #334155;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
background: #f8fafc;
background: var(--color-surface-soft);
}
.switch-card input,
@@ -743,34 +742,34 @@ onBeforeUnmount(() => {
.forwarder-card {
padding: 1rem;
margin-top: 1rem;
box-shadow: inset 4px 0 0 #dbeafe;
box-shadow: inset 4px 0 0 var(--color-primary-soft);
}
.forwarder-title h3 {
color: #0f172a;
color: var(--color-heading);
font-size: 18px;
}
.status-pill {
border-radius: 999px;
padding: 7px 12px;
color: #92400e;
background: #fffbeb;
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
background: var(--color-warning-soft);
}
.status-pill.ok {
color: #166534;
background: #dcfce7;
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: var(--color-success-soft);
}
.status-pill.warn {
color: #92400e;
background: #fef3c7;
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
background: var(--color-warning-soft);
}
.status-pill.disabled {
color: #475569;
background: #e2e8f0;
color: var(--color-muted);
background: var(--color-surface-muted);
}
.runtime-grid {
@@ -781,23 +780,23 @@ onBeforeUnmount(() => {
}
.runtime-grid div {
border-radius: 12px;
border-radius: var(--radius-md);
padding: 10px 12px;
background: #f8fafc;
background: var(--color-surface-soft);
}
.runtime-grid strong {
display: block;
margin-top: 3px;
color: #0f172a;
color: var(--color-heading);
}
.inline-error {
border: 1px solid #fecaca;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--color-danger) 36%, white);
border-radius: var(--radius-md);
padding: 10px 12px;
color: #b91c1c;
background: #fef2f2;
color: color-mix(in srgb, var(--color-danger) 74%, var(--color-heading));
background: var(--color-danger-soft);
word-break: break-word;
}
@@ -831,24 +830,10 @@ onBeforeUnmount(() => {
margin-top: 0.75rem;
}
.admin-button.ghost {
color: #1d4ed8;
border: 1px solid #bfdbfe;
background: #eff6ff;
}
.admin-button.secondary {
background: #475569;
}
.admin-button.danger {
background: #dc2626;
}
.topics-box {
margin-top: 1rem;
padding: 1rem;
background: #f8fafc;
background: var(--color-surface-soft);
}
.topic-row {
@@ -856,25 +841,25 @@ onBeforeUnmount(() => {
grid-template-columns: minmax(180px, 1.6fr) minmax(90px, 0.7fr) minmax(150px, 1fr) repeat(2, minmax(120px, 1fr)) minmax(90px, 0.7fr) minmax(90px, 0.7fr) auto auto;
gap: 0.5rem;
align-items: center;
border-top: 1px solid #e2e8f0;
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-top: 0.75rem;
}
.topic-row.new-topic {
border: 1px dashed #93c5fd;
border-radius: 14px;
border: 1px dashed color-mix(in srgb, var(--color-primary) 54%, white);
border-radius: var(--radius-md);
padding: 0.75rem;
background: #eff6ff;
background: var(--color-primary-soft);
}
.empty-state {
border: 1px dashed #cbd5e1;
border-radius: 16px;
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
padding: 24px;
color: #64748b;
color: var(--color-muted);
text-align: center;
background: #f8fafc;
background: var(--color-surface-soft);
}
.pagination {
+20 -8
View File
@@ -26,6 +26,7 @@ const menuX = ref(0)
const menuY = ref(0)
const topThreshold = 8
const bottomThreshold = 40
const scrollOverflowAllowance = 1
const groupedMessages = computed<GroupedTextMessage[]>(() => {
const groups = new Map<string, GroupedTextMessage>()
@@ -102,25 +103,29 @@ function handleKeydown(event: KeyboardEvent) {
}
}
function handleScroll() {
closeMessageMenu()
const el = panelRef.value
function loadOlderFromCurrentScroll(el: HTMLElement) {
if (
!el ||
props.loadingOlder ||
!props.hasMoreMessages ||
props.messages.length === 0 ||
groupedMessages.value.length === 0 ||
restoreScrollHeight != null
) {
return
}
if (el.scrollTop <= topThreshold) {
restoreScrollHeight = el.scrollHeight
restoreScrollTop = el.scrollTop
restoreMessageCount = props.messages.length
restoreMessageCount = groupedMessages.value.length
emit('load-older')
}
function handleScroll() {
closeMessageMenu()
const el = panelRef.value
if (!el || el.scrollTop > topThreshold) {
return
}
loadOlderFromCurrentScroll(el)
}
onBeforeUpdate(() => {
@@ -138,6 +143,9 @@ onMounted(async () => {
if (el) {
el.scrollTop = el.scrollHeight
didInitialScroll = true
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
loadOlderFromCurrentScroll(el)
}
}
})
@@ -153,7 +161,7 @@ onUpdated(() => {
}
if (restoreScrollHeight != null) {
if (props.messages.length > restoreMessageCount) {
if (groupedMessages.value.length > restoreMessageCount) {
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
clearRestoreState()
return
@@ -167,6 +175,10 @@ onUpdated(() => {
el.scrollTop = el.scrollHeight
didInitialScroll = true
}
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
loadOlderFromCurrentScroll(el)
}
})
</script>
+227 -18
View File
@@ -2,7 +2,8 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable } from '../types'
import { fallbackMapSource } from '../mapSource'
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable, PublicMapTileSource } from '../types'
const props = withDefaults(defineProps<{
items: MapRenderable[]
@@ -10,9 +11,13 @@ const props = withDefaults(defineProps<{
isAdmin: boolean
autoFit?: boolean
loading?: boolean
mapSource?: PublicMapTileSource
mapSources?: PublicMapTileSource[]
}>(), {
autoFit: true,
loading: false,
mapSource: () => fallbackMapSource,
mapSources: () => [fallbackMapSource],
})
const emit = defineEmits<{
@@ -21,6 +26,7 @@ const emit = defineEmits<{
'delete-node': [nodeId: string]
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
'bounds-change': [payload: MapBoundsChangePayload]
'map-source-change': [sourceId: number]
}>()
const mapEl = ref<HTMLElement | null>(null)
@@ -29,8 +35,11 @@ const menuX = ref(0)
const menuY = ref(0)
const lastRaisedNodeId = ref<string | null>(null)
let map: L.Map | null = null
let tileLayer: L.TileLayer | null = null
let markerLayer: L.LayerGroup | null = null
const markersByKey = new Map<string, L.Marker>()
const overlapShuffleOrders = new Map<string, string[]>()
const shuffledSelectedNodeIds = new Set<string>()
let hasFitBounds = false
const minMapZoom = 3
@@ -55,15 +64,12 @@ onMounted(async () => {
maxBoundsViscosity: 1.0,
worldCopyJump: false,
}).setView(defaultMapCenter, defaultMapZoom)
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
minZoom: minMapZoom,
maxZoom: 19,
noWrap: true,
bounds: worldBounds,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map)
map.attributionControl.setPrefix(false)
applyTileLayer()
map.on('click', () => {
closeNodeMenu()
overlapShuffleOrders.clear()
shuffledSelectedNodeIds.clear()
emit('clear-node')
})
map.on('moveend', emitBoundsChange)
@@ -77,8 +83,11 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
map?.remove()
map = null
tileLayer = null
markerLayer = null
markersByKey.clear()
overlapShuffleOrders.clear()
shuffledSelectedNodeIds.clear()
})
watch(
@@ -87,6 +96,32 @@ watch(
{ deep: true },
)
watch(
() => props.mapSource,
() => applyTileLayer(),
{ deep: true },
)
function selectMapSource(sourceId: number) {
emit('map-source-change', sourceId)
}
function applyTileLayer() {
if (!map) {
return
}
if (tileLayer) {
tileLayer.remove()
}
tileLayer = L.tileLayer(props.mapSource.url_template, {
minZoom: minMapZoom,
maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
noWrap: true,
bounds: worldBounds,
attribution: props.mapSource.attribution || fallbackMapSource.attribution,
}).addTo(map)
}
function closeNodeMenu() {
menuNode.value = null
}
@@ -149,6 +184,7 @@ function renderMarkers(forceFit: boolean) {
}
const bounds = L.latLngBounds([])
const visibleMarkerKeys = new Set<string>()
const overlapGroups = buildOverlapGroups(props.items)
for (const item of props.items) {
const markerKey = mapMarkerKey(item)
@@ -166,8 +202,14 @@ function renderMarkers(forceFit: boolean) {
}
const node = item
const selected = node.node_id === props.selectedNodeId
const rawSelected = node.node_id === props.selectedNodeId
const shuffledSelected = rawSelected && shuffledSelectedNodeIds.has(node.node_id)
const selected = rawSelected && !shuffledSelected
const overlapGroupKey = nodeOverlapGroupKey(node, overlapGroups)
const overlapGroup = overlapGroupKey ? overlapGroups.get(overlapGroupKey) : undefined
const overlapIndex = overlapGroup ? nodeOverlapIndex(node, overlapGroup) : 0
const raised = selected || node.node_id === lastRaisedNodeId.value
const zIndexOffset = raised ? 1000 : overlapIndex
const nodeIcon = L.divIcon({
className: `node-marker${selected ? ' selected' : ''}`,
html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`,
@@ -180,7 +222,7 @@ function renderMarkers(forceFit: boolean) {
marker = L.marker([node.latitude, node.longitude], {
icon: nodeIcon,
title: node.label,
zIndexOffset: raised ? 1000 : 0,
zIndexOffset,
})
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
marker.addTo(markerLayer)
@@ -188,7 +230,7 @@ function renderMarkers(forceFit: boolean) {
} else {
marker.setLatLng([node.latitude, node.longitude])
marker.setIcon(nodeIcon)
marker.setZIndexOffset(raised ? 1000 : 0)
marker.setZIndexOffset(zIndexOffset)
marker.options.title = node.label
marker.getElement()?.setAttribute('title', node.label)
const popup = marker.getPopup()
@@ -203,8 +245,18 @@ function renderMarkers(forceFit: boolean) {
marker.off('contextmenu')
marker.on('click', (event) => {
L.DomEvent.stopPropagation(event)
lastRaisedNodeId.value = node.node_id
closeNodeMenu()
if (node.node_id === props.selectedNodeId) {
if (moveSelectedNodeBehindOverlap(node, overlapGroups)) {
shuffledSelectedNodeIds.add(node.node_id)
marker?.closePopup()
emit('clear-node')
renderMarkers(false)
}
return
}
shuffledSelectedNodeIds.clear()
lastRaisedNodeId.value = node.node_id
emit('select-node', node.node_id)
})
marker.on('contextmenu', (event) => openNodeMenu(node, event))
@@ -235,6 +287,126 @@ function mapMarkerKey(item: MapRenderable): string {
return `node:${item.node_id}`
}
function buildOverlapGroups(items: MapRenderable[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
if (!map) {
return groups
}
const capsules = items
.filter((item): item is MapNode => item.type !== 'cluster')
.map((node) => ({ node, bounds: nodeCapsuleBounds(node) }))
const visited = new Set<string>()
for (const capsule of capsules) {
if (visited.has(capsule.node.node_id)) {
continue
}
const stack = [capsule]
const group: string[] = []
visited.add(capsule.node.node_id)
while (stack.length > 0) {
const current = stack.pop()
if (!current) {
continue
}
group.push(current.node.node_id)
for (const candidate of capsules) {
if (visited.has(candidate.node.node_id)) {
continue
}
if (capsuleBoundsOverlap(current.bounds, candidate.bounds)) {
visited.add(candidate.node.node_id)
stack.push(candidate)
}
}
}
if (group.length >= 2) {
const key = overlapGroupKey(group)
const existingOrder = overlapShuffleOrders.get(key) ?? []
const activeIds = new Set(group)
const ordered = existingOrder.filter((nodeId) => activeIds.has(nodeId))
for (const nodeId of group) {
if (!ordered.includes(nodeId)) {
ordered.push(nodeId)
}
}
overlapShuffleOrders.set(key, ordered)
groups.set(key, ordered)
}
}
for (const key of overlapShuffleOrders.keys()) {
if (!groups.has(key)) {
overlapShuffleOrders.delete(key)
}
}
return groups
}
function nodeOverlapGroupKey(node: MapNode, overlapGroups: Map<string, string[]>): string | null {
for (const [key, nodeIds] of overlapGroups) {
if (nodeIds.includes(node.node_id)) {
return key
}
}
return null
}
function nodeOverlapIndex(node: MapNode, group: string[]): number {
const index = group.indexOf(node.node_id)
return index === -1 ? 0 : index
}
function moveSelectedNodeBehindOverlap(node: MapNode, overlapGroups: Map<string, string[]>): boolean {
const groupKey = nodeOverlapGroupKey(node, overlapGroups)
if (!groupKey) {
return false
}
const group = overlapGroups.get(groupKey)
if (!group || group.length < 2) {
return false
}
const nextOrder = [node.node_id, ...group.filter((nodeId) => nodeId !== node.node_id)]
overlapShuffleOrders.set(groupKey, nextOrder)
lastRaisedNodeId.value = null
return true
}
function overlapGroupKey(nodeIds: string[]): string {
return [...nodeIds].sort().join('|')
}
function nodeCapsuleBounds(node: MapNode): { left: number; right: number; top: number; bottom: number } {
const point = map!.latLngToLayerPoint([node.latitude, node.longitude])
const width = nodeCapsuleWidth(node)
const height = 22
return {
left: point.x - width / 2,
right: point.x + width / 2,
top: point.y - height / 2,
bottom: point.y + height / 2,
}
}
function nodeCapsuleWidth(node: MapNode): number {
const label = node.label || 'N'
return Math.max(34, Math.ceil(label.length * 6 + 10))
}
function capsuleBoundsOverlap(
left: { left: number; right: number; top: number; bottom: number },
right: { left: number; right: number; top: number; bottom: number },
): boolean {
return left.left <= right.right && left.right >= right.left && left.top <= right.bottom && left.bottom >= right.top
}
function buildClusterMarker(cluster: MapClusterNode): L.Marker {
const size = clusterIconSize(cluster.count)
const marker = L.marker([cluster.latitude, cluster.longitude], {
@@ -342,15 +514,15 @@ function nodeColor(nodeId: string): string {
}
const hueRanges = [
[35, 75],
[95, 165],
[185, 250],
[265, 315],
[42, 68],
[92, 136],
[188, 218],
[330, 354],
]
const range = hueRanges[hash % hueRanges.length]
const hue = range[0] + (hash % (range[1] - range[0]))
const saturation = 68 + (hash % 18)
const lightness = 32 + (hash % 10)
const saturation = 24 + (hash % 14)
const lightness = 42 + (hash % 12)
return `hsl(${hue} ${saturation}% ${lightness}%)`
}
@@ -371,6 +543,43 @@ function escapeHTML(value: string): string {
<template>
<section class="map-panel panel">
<div ref="mapEl" class="map-container"></div>
<div
class="map-source-control"
@click.stop
@mousedown.stop
@dblclick.stop
@wheel.stop
>
<button class="map-source-icon" type="button" aria-label="切换地图图源">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
<path d="M9 4v13" />
<path d="M15 7v5.5" />
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
<path d="M19 18v.01" />
</svg>
</button>
<div class="map-source-popover">
<div class="map-source-drawer-header">
<span>地图图源</span>
</div>
<div v-if="mapSources.length > 1" class="map-source-options">
<button
v-for="source in mapSources"
:key="source.id"
class="map-source-option"
:class="{ active: source.id === mapSource.id }"
type="button"
@click="selectMapSource(source.id)"
>
<span class="map-source-option-name">{{ source.name }}</span>
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
</button>
</div>
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
</div>
</div>
<!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
<div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
<div
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types'
import type { MapReport, NodeInfo, PositionRecord, PublicMapTileSource, TelemetryRecord, TextMessage } from '../types'
import { fallbackMapSource, loadEnabledMapSources } from '../mapSource'
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
@@ -15,12 +16,25 @@ const mapReport = ref<MapReport | null>(null)
const messages = ref<TextMessage[]>([])
const positions = ref<PositionRecord[]>([])
const telemetry = ref<TelemetryRecord[]>([])
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
const loading = ref(true)
const chatLoadingOlder = ref(false)
const chatHasMore = ref(true)
const telemetryLoading = ref(false)
const trajectoryLoading = ref(false)
const trajectoryError = ref('')
const trajectoryTruncated = ref(false)
const error = ref('')
const chatPageSize = 20
const telemetryPageSize = 25
const trajectoryPageSize = 500
const maxTrajectoryPoints = 5000
const telemetryPage = ref(1)
const trajectoryStartDate = ref(toDateInputValue())
const trajectoryEndDate = ref(toDateInputValue())
const chatHistoryRef = ref<HTMLElement | null>(null)
const scrollOverflowAllowance = 1
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
type PendingDeleteAction =
| { kind: 'delete-message'; message: GroupedTextMessage }
@@ -94,6 +108,27 @@ function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function toDateInputValue(date = new Date()): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function localDateRange(startDate: string, endDate: string): { since: string; until: string } | null {
if (!startDate || !endDate) {
trajectoryError.value = '请选择开始日期和结束日期'
return null
}
const safeStartDate = startDate <= endDate ? startDate : endDate
const safeEndDate = startDate <= endDate ? endDate : startDate
trajectoryStartDate.value = safeStartDate
trajectoryEndDate.value = safeEndDate
const since = new Date(`${safeStartDate}T00:00:00.000`)
const until = new Date(`${safeEndDate}T23:59:59.999`)
return { since: since.toISOString(), until: until.toISOString() }
}
function metricEntries(value: string | null): Array<[string, unknown]> {
if (!value) {
return []
@@ -175,6 +210,79 @@ async function optional<T>(request: Promise<T>): Promise<T | null> {
}
}
function canTelemetryPrev(): boolean {
return telemetryPage.value > 1
}
function canTelemetryNext(): boolean {
return telemetry.value.length === telemetryPageSize
}
async function loadTelemetryPage() {
telemetryLoading.value = true
try {
const response = await getTelemetry(telemetryPageSize, (telemetryPage.value - 1) * telemetryPageSize, props.nodeId)
telemetry.value = response.items
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
telemetryLoading.value = false
}
}
function changeTelemetryPage(nextPage: number) {
telemetryPage.value = Math.max(1, nextPage)
loadTelemetryPage()
}
async function loadTrajectoryRange() {
const range = localDateRange(trajectoryStartDate.value, trajectoryEndDate.value)
if (!range) {
return
}
trajectoryLoading.value = true
trajectoryError.value = ''
trajectoryTruncated.value = false
positions.value = []
try {
const items: PositionRecord[] = []
for (let offset = 0; offset < maxTrajectoryPoints; offset += trajectoryPageSize) {
const response = await getPositions(trajectoryPageSize, offset, {
nodeId: props.nodeId,
since: range.since,
until: range.until,
})
items.push(...response.items)
if (response.items.length < trajectoryPageSize) {
break
}
if (items.length >= maxTrajectoryPoints) {
trajectoryTruncated.value = true
break
}
}
positions.value = items.slice(0, maxTrajectoryPoints)
} catch (err) {
trajectoryError.value = err instanceof Error ? err.message : String(err)
} finally {
trajectoryLoading.value = false
}
}
function applyRecentTrajectory(days: number) {
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - days + 1)
trajectoryStartDate.value = toDateInputValue(start)
trajectoryEndDate.value = toDateInputValue(end)
loadTrajectoryRange()
}
function applyTodayTrajectory() {
applyRecentTrajectory(1)
}
async function loadInitialMessages() {
const response = await getTextMessages(chatPageSize, 0, props.nodeId)
messages.value = toChronological(response.items)
@@ -183,24 +291,30 @@ async function loadInitialMessages() {
const el = chatHistoryRef.value
if (el) {
el.scrollTop = el.scrollHeight
await loadMoreUntilScrollable(el)
}
}
async function loadOlderMessages() {
const el = chatHistoryRef.value
await loadOlderMessagesFromCurrentScroll(el)
}
async function loadOlderMessagesFromCurrentScroll(el: HTMLElement | null) {
if (chatLoadingOlder.value || !chatHasMore.value) {
return
}
const el = chatHistoryRef.value
const previousScrollHeight = el?.scrollHeight ?? 0
const previousScrollTop = el?.scrollTop ?? 0
const previousGroupedMessageCount = groupedMessages.value.length
chatLoadingOlder.value = true
try {
const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId)
messages.value = mergeMessages(messages.value, toChronological(response.items))
chatHasMore.value = response.items.length === chatPageSize
await nextTick()
if (el) {
if (el && groupedMessages.value.length > previousGroupedMessageCount) {
el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop
}
} catch (err) {
@@ -210,6 +324,16 @@ async function loadOlderMessages() {
}
}
async function loadMoreUntilScrollable(el: HTMLElement) {
while (chatHasMore.value && el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
const previousGroupedMessageCount = groupedMessages.value.length
await loadOlderMessagesFromCurrentScroll(el)
if (groupedMessages.value.length <= previousGroupedMessageCount) {
break
}
}
}
function closeMessageMenu() {
menuMessage.value = null
}
@@ -367,21 +491,36 @@ function handleChatScroll() {
loadOlderMessages()
}
async function loadMapSource() {
const sources = await loadEnabledMapSources()
mapSources.value = sources
mapSource.value = sources[0] ?? fallbackMapSource
}
function selectMapSource(sourceId: number) {
const source = mapSources.value.find((item) => item.id === sourceId)
if (source) {
mapSource.value = source
}
}
async function loadDetails() {
loading.value = true
error.value = ''
trajectoryError.value = ''
telemetryPage.value = 1
try {
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([
const [nodeData, reportData] = await Promise.all([
optional(getNodeInfoById(props.nodeId)),
optional(getMapReportById(props.nodeId)),
getPositions(500, 0, props.nodeId),
getTelemetry(200, 0, props.nodeId),
])
nodeInfo.value = nodeData
mapReport.value = reportData
positions.value = positionData.items
telemetry.value = telemetryData.items
await loadInitialMessages()
await Promise.all([
loadTrajectoryRange(),
loadTelemetryPage(),
loadInitialMessages(),
])
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
@@ -392,6 +531,7 @@ async function loadDetails() {
onMounted(() => {
window.addEventListener('click', closeMessageMenu)
window.addEventListener('keydown', handleKeydown)
loadMapSource()
loadDetails()
})
@@ -492,7 +632,28 @@ onBeforeUnmount(() => {
</div>
<span class="badge">{{ positions.length }}</span>
</div>
<NodeTrajectoryMap :positions="positions" />
<div class="trajectory-toolbar">
<label class="trajectory-date-field">
<span>开始日期</span>
<input v-model="trajectoryStartDate" type="date" :disabled="trajectoryLoading" @change="loadTrajectoryRange" />
</label>
<label class="trajectory-date-field">
<span>结束日期</span>
<input v-model="trajectoryEndDate" type="date" :disabled="trajectoryLoading" @change="loadTrajectoryRange" />
</label>
<button type="button" :disabled="trajectoryLoading" @click="applyTodayTrajectory">今天</button>
<button type="button" :disabled="trajectoryLoading" @click="applyRecentTrajectory(3)">最近三天</button>
<button type="button" :disabled="trajectoryLoading" @click="applyRecentTrajectory(7)">最近七天</button>
</div>
<p v-if="trajectoryError" class="error trajectory-status">{{ trajectoryError }}</p>
<p v-else-if="trajectoryTruncated" class="trajectory-status">轨迹点较多仅显示前 {{ maxTrajectoryPoints }} 请缩小日期范围</p>
<p v-else-if="trajectoryLoading" class="trajectory-status">正在加载轨迹...</p>
<NodeTrajectoryMap
:positions="positions"
:map-source="mapSource"
:map-sources="mapSources"
@map-source-change="selectMapSource"
/>
</div>
</div>
@@ -502,8 +663,9 @@ onBeforeUnmount(() => {
<p class="eyebrow">Telemetry</p>
<h2>遥测数据{{ nodeTitle }}</h2>
</div>
<span class="badge">{{ telemetry.length }}</span>
<span class="badge">本页 {{ telemetry.length }}</span>
</div>
<div v-if="telemetryLoading" class="admin-loading">正在加载遥测数据...</div>
<div class="node-table-wrap">
<table class="node-table">
<thead>
@@ -530,6 +692,12 @@ onBeforeUnmount(() => {
</table>
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
</div>
<div class="pagination">
<button :disabled="telemetryLoading || !canTelemetryPrev()" @click="changeTelemetryPage(telemetryPage - 1)">上一页</button>
<span> {{ telemetryPage }} </span>
<span>每页 {{ telemetryPageSize }} </span>
<button :disabled="telemetryLoading || !canTelemetryNext()" @click="changeTelemetryPage(telemetryPage + 1)">下一页</button>
</div>
</div>
<ConfirmDeleteModal
@@ -2,16 +2,44 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { PositionRecord } from '../types'
import { fallbackMapSource } from '../mapSource'
import type { PositionRecord, PublicMapTileSource } from '../types'
const props = defineProps<{
const props = withDefaults(defineProps<{
positions: PositionRecord[]
mapSource?: PublicMapTileSource
mapSources?: PublicMapTileSource[]
}>(), {
mapSource: () => fallbackMapSource,
mapSources: () => [fallbackMapSource],
})
const emit = defineEmits<{
'map-source-change': [sourceId: number]
}>()
const mapEl = ref<HTMLElement | null>(null)
let map: L.Map | null = null
let tileLayer: L.TileLayer | null = null
let layer: L.LayerGroup | null = null
function selectMapSource(sourceId: number) {
emit('map-source-change', sourceId)
}
function applyTileLayer() {
if (!map) {
return
}
if (tileLayer) {
tileLayer.remove()
}
tileLayer = L.tileLayer(props.mapSource.url_template, {
maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
attribution: props.mapSource.attribution || fallbackMapSource.attribution,
}).addTo(map)
}
function renderTrajectory() {
if (!map || !layer) {
return
@@ -28,10 +56,10 @@ function renderTrajectory() {
}
if (points.length > 1) {
L.polyline(points, { color: '#2563eb', weight: 4, opacity: 0.8 }).addTo(layer)
L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(layer)
}
L.circleMarker(points[0], { radius: 6, color: '#16a34a', fillColor: '#22c55e', fillOpacity: 0.9 }).bindPopup('起点').addTo(layer)
L.circleMarker(points[points.length - 1], { radius: 6, color: '#dc2626', fillColor: '#ef4444', fillOpacity: 0.9 }).bindPopup('终点').addTo(layer)
L.circleMarker(points[0], { radius: 6, color: '#7f9183', fillColor: '#9aaa95', fillOpacity: 0.88 }).bindPopup('起点').addTo(layer)
L.circleMarker(points[points.length - 1], { radius: 6, color: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88 }).bindPopup('终点').addTo(layer)
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
}
@@ -49,10 +77,8 @@ onMounted(async () => {
maxBoundsViscosity: 1.0,
worldCopyJump: false,
}).setView([0, 0], 2)
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map)
map.attributionControl.setPrefix(false)
applyTileLayer()
layer = L.layerGroup().addTo(map)
renderTrajectory()
})
@@ -60,6 +86,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
map?.remove()
map = null
tileLayer = null
layer = null
})
@@ -68,8 +95,53 @@ watch(
() => renderTrajectory(),
{ deep: true },
)
watch(
() => props.mapSource,
() => applyTileLayer(),
{ deep: true },
)
</script>
<template>
<div class="trajectory-map-shell">
<div ref="mapEl" class="trajectory-map"></div>
<div
class="map-source-control"
@click.stop
@mousedown.stop
@dblclick.stop
@wheel.stop
>
<button class="map-source-icon" type="button" aria-label="切换地图图源">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
<path d="M9 4v13" />
<path d="M15 7v5.5" />
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
<path d="M19 18v.01" />
</svg>
</button>
<div class="map-source-popover">
<div class="map-source-drawer-header">
<span>地图图源</span>
</div>
<div v-if="mapSources.length > 1" class="map-source-options">
<button
v-for="source in mapSources"
:key="source.id"
class="map-source-option"
:class="{ active: source.id === mapSource.id }"
type="button"
@click="selectMapSource(source.id)"
>
<span class="map-source-option-name">{{ source.name }}</span>
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
</button>
</div>
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
</div>
</div>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
import { getDefaultMapSource, getEnabledMapSources } from './api'
import type { PublicMapTileSource } from './types'
export const fallbackMapSource: PublicMapTileSource = {
id: 0,
name: 'OpenStreetMap Japan',
url_template: 'https://tile.openstreetmap.jp/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap contributors',
max_zoom: 19,
}
export async function loadDefaultMapSource(): Promise<PublicMapTileSource> {
try {
const response = await getDefaultMapSource()
return response.item
} catch {
return fallbackMapSource
}
}
export async function loadEnabledMapSources(): Promise<PublicMapTileSource[]> {
try {
const response = await getEnabledMapSources()
return response.items.length > 0 ? response.items : [fallbackMapSource]
} catch {
return [fallbackMapSource]
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -76,6 +76,44 @@ export interface MapBoundsChangePayload {
zoom: number
}
export interface PublicMapTileSource {
id: number
name: string
url_template: string
attribution: string
max_zoom: number
}
export interface MapTileSource extends PublicMapTileSource {
enabled: boolean
is_default: boolean
proxy_enabled: boolean
created_at: string
updated_at: string
}
export interface MapTileSourcePayload {
name: string
url_template: string
attribution: string
max_zoom: number
enabled: boolean
is_default: boolean
proxy_enabled: boolean
}
export interface MapTileSourceResponse {
item: MapTileSource
}
export interface PublicMapTileSourceResponse {
item: PublicMapTileSource
}
export interface PublicMapTileSourcesResponse {
items: PublicMapTileSource[]
}
export interface MapViewportPoint extends MapReport {
type: 'point'
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [vue(), tailwindcss()],
build: {
outDir: "../dist",
assetsDir: "assets",
+5 -2
View File
@@ -51,13 +51,13 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api")
registerAPIRoutes(api, store)
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
registerStaticRoutes(r, cfg.StaticDir)
return r
}
func registerAPIRoutes(r gin.IRouter, store *store) {
func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
r.GET("/health", func(c *gin.Context) {
status := gin.H{"status": "ok", "database": "ok"}
if err := store.Ping(); err != nil {
@@ -72,6 +72,8 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
registerNodeInfoRoutes(r, store, "/nodeinfo")
registerNodeInfoRoutes(r, store, "/nodes")
registerMapReportRoutes(r, store)
registerMapSourceRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
registerHelpRoutes(r, store)
r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c)
@@ -186,6 +188,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
registerAdminBlockingRoutes(protected, store, blocking)
registerAdminMQTTForwardRoutes(protected, store, forwarder)
registerAdminRuntimeSettingsRoutes(protected, store, settings)
registerAdminMapSourceRoutes(protected, store)
registerAdminHelpRoutes(protected, store)
registerAdminBotRoutes(protected, store, botSender)
protected.GET("/me", func(c *gin.Context) {