diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b13aa6 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index ae32a60..b8c090e 100644 --- a/README.md +++ b/README.md @@ -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 PSK,Base64 格式 | `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 开源。详见项目许可证文件。 \ No newline at end of file diff --git a/admin_map_source_routes.go b/admin_map_source_routes.go new file mode 100644 index 0000000..43172ff --- /dev/null +++ b/admin_map_source_routes.go @@ -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} +} diff --git a/config.go b/config.go index 4e3b72a..98f4a57 100644 --- a/config.go +++ b/config.go @@ -51,12 +51,13 @@ type mysqlConfig struct { } type webConfig struct { - Enabled bool `yaml:"enabled"` - Host string `yaml:"host"` - Port int `yaml:"port"` - SocketPath string `yaml:"socket_path"` - StaticDir string `yaml:"static_dir"` - Admin webAdminConfig `yaml:"admin"` + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + 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"` } type webAdminConfig struct { @@ -104,12 +105,13 @@ type rawMySQLConfig struct { } type rawWebConfig struct { - Enabled *bool `yaml:"enabled"` - Host *string `yaml:"host"` - Port *int `yaml:"port"` - SocketPath *string `yaml:"socket_path"` - StaticDir *string `yaml:"static_dir"` - Admin *rawWebAdminConfig `yaml:"admin"` + Enabled *bool `yaml:"enabled"` + Host *string `yaml:"host"` + 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"` } type rawWebAdminConfig struct { @@ -140,11 +142,12 @@ func defaultConfig() *config { MySQL: mysqlConfig{DSN: ""}, }, Web: webConfig{ - Enabled: true, - Host: "0.0.0.0", - Port: 8080, - SocketPath: defaultWebSocketPath(), - StaticDir: "./dist", + Enabled: true, + Host: "0.0.0.0", + 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") } diff --git a/config_test.go b/config_test.go index 6a480ff..fa86a6e 100644 --- a/config_test.go +++ b/config_test.go @@ -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 diff --git a/db.go b/db.go index 890f4b4..6d63647 100644 --- a/db.go +++ b/db.go @@ -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) { diff --git a/db_test.go b/db_test.go index 531925e..49e24de 100644 --- a/db_test.go +++ b/db_test.go @@ -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) diff --git a/doc/main_page.png b/doc/main_page.png new file mode 100644 index 0000000..157047c Binary files /dev/null and b/doc/main_page.png differ diff --git a/go.mod b/go.mod index 6e26832..bc34b44 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/main.go b/main.go index 3ff7752..47be655 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/map_source_store.go b/map_source_store.go new file mode 100644 index 0000000..fce0252 --- /dev/null +++ b/map_source_store.go @@ -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 = "© 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 +} diff --git a/map_source_store_test.go b/map_source_store_test.go new file mode 100644 index 0000000..d8350ce --- /dev/null +++ b/map_source_store_test.go @@ -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") + } +} diff --git a/map_tile_proxy_routes.go b/map_tile_proxy_routes.go new file mode 100644 index 0000000..d8fee9d --- /dev/null +++ b/map_tile_proxy_routes.go @@ -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) +} diff --git a/map_tile_proxy_routes_test.go b/map_tile_proxy_routes_test.go new file mode 100644 index 0000000..f046130 --- /dev/null +++ b/map_tile_proxy_routes_test.go @@ -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()) + } + } +} diff --git a/meshmap_frontend/package-lock.json b/meshmap_frontend/package-lock.json index ef68fbf..15603b9 100644 --- a/meshmap_frontend/package-lock.json +++ b/meshmap_frontend/package-lock.json @@ -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", diff --git a/meshmap_frontend/package.json b/meshmap_frontend/package.json index 172021f..7dd0b58 100644 --- a/meshmap_frontend/package.json +++ b/meshmap_frontend/package.json @@ -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" diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 35aab7c..b636a1c 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -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(null) const currentMapZoom = ref(2) const mapReportsLoading = ref(false) const mapReportTotal = ref(0) +const mapSources = ref([fallbackMapSource]) +const mapSource = ref(fallbackMapSource) const pendingDeleteAction = ref(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(() => { 屏蔽管理 MQTT转发 机器人 + 地图图源 帮助编辑 登录日志 丢弃数据 @@ -536,6 +555,7 @@ onBeforeUnmount(() => { + @@ -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" diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 5f48c57..a7d816f 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { return response.json() as Promise } -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(`/api/map-reports/viewport?${params.toString()}`) } +export function getDefaultMapSource(): Promise { + return getJSON('/api/map-source/default') +} + +export function getEnabledMapSources(): Promise { + return getJSON('/api/map-source/enabled') +} + export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise> { return getJSON>(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> { - return getJSON>(listPath('/api/positions', limit, offset, nodeId)) +export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise> { + return getJSON>(listPath('/api/positions', limit, offset, nodeIdOrOptions)) } export function getDiscardDetails(limit = 100, offset = 0): Promise> { @@ -207,6 +233,26 @@ export function getAdminLoginLogs(limit = 100, offset = 0): Promise(`/api/admin/log/login?limit=${limit}&offset=${offset}`) } +export function getAdminMapSources(limit = 100, offset = 0): Promise> { + return getJSON>(listPath('/api/admin/map-source', limit, offset)) +} + +export function createAdminMapSource(payload: MapTileSourcePayload): Promise { + return postJSON('/api/admin/map-source', payload) +} + +export function updateAdminMapSource(id: number, payload: MapTileSourcePayload): Promise { + return putJSON(`/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 { + return postJSON(`/api/admin/map-source/${id}/default`) +} + export function getNodeBlockingRules(limit = 100, offset = 0): Promise> { return getJSON>(listPath('/api/admin/blocking/nodes', limit, offset)) } diff --git a/meshmap_frontend/src/components/AdminBlockingManagement.vue b/meshmap_frontend/src/components/AdminBlockingManagement.vue index 9968a00..26fc269 100644 --- a/meshmap_frontend/src/components/AdminBlockingManagement.vue +++ b/meshmap_frontend/src/components/AdminBlockingManagement.vue @@ -418,7 +418,7 @@ onMounted(() => {

屏蔽管理

-

管理节点、IP/CIDR、违禁词三类屏蔽规则。当前页面只维护规则,不改变 MQTT 转发行为。

+

管理节点、IP/CIDR、违禁词三类屏蔽规则。

diff --git a/meshmap_frontend/src/components/AdminDashboard.vue b/meshmap_frontend/src/components/AdminDashboard.vue index 2053404..182b093 100644 --- a/meshmap_frontend/src/components/AdminDashboard.vue +++ b/meshmap_frontend/src/components/AdminDashboard.vue @@ -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 { diff --git a/meshmap_frontend/src/components/AdminMapSource.vue b/meshmap_frontend/src/components/AdminMapSource.vue new file mode 100644 index 0000000..bcb4ee0 --- /dev/null +++ b/meshmap_frontend/src/components/AdminMapSource.vue @@ -0,0 +1,549 @@ + + + + + diff --git a/meshmap_frontend/src/components/AdminMqttForward.vue b/meshmap_frontend/src/components/AdminMqttForward.vue index 6b86acf..558a7e6 100644 --- a/meshmap_frontend/src/components/AdminMqttForward.vue +++ b/meshmap_frontend/src/components/AdminMqttForward.vue @@ -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 { diff --git a/meshmap_frontend/src/components/ChatPanel.vue b/meshmap_frontend/src/components/ChatPanel.vue index 1169712..4db96d3 100644 --- a/meshmap_frontend/src/components/ChatPanel.vue +++ b/meshmap_frontend/src/components/ChatPanel.vue @@ -26,6 +26,7 @@ const menuX = ref(0) const menuY = ref(0) const topThreshold = 8 const bottomThreshold = 40 +const scrollOverflowAllowance = 1 const groupedMessages = computed(() => { const groups = new Map() @@ -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 - emit('load-older') + restoreScrollHeight = el.scrollHeight + restoreScrollTop = el.scrollTop + 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) + } }) diff --git a/meshmap_frontend/src/components/MeshMap.vue b/meshmap_frontend/src/components/MeshMap.vue index fa43bf9..f271343 100644 --- a/meshmap_frontend/src/components/MeshMap.vue +++ b/meshmap_frontend/src/components/MeshMap.vue @@ -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(null) @@ -29,8 +35,11 @@ const menuX = ref(0) const menuY = ref(0) const lastRaisedNodeId = ref(null) let map: L.Map | null = null +let tileLayer: L.TileLayer | null = null let markerLayer: L.LayerGroup | null = null const markersByKey = new Map() +const overlapShuffleOrders = new Map() +const shuffledSelectedNodeIds = new Set() 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: '© 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() + 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: `${escapeHTML(node.label || 'N')}`, @@ -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 { + const groups = new Map() + 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() + + 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 | 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): 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 {