Compare commits
48
Commits
16341972af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7642ecca5 | ||
|
|
457ea42417 | ||
|
|
7dc6089871 | ||
|
|
2a5a46a984 | ||
|
|
0691e85ccf | ||
|
|
d9a9918e21 | ||
|
|
ac5587d0c8 | ||
|
|
2524e40566 | ||
|
|
5167d799bc | ||
|
|
cb2df5076d | ||
|
|
2d5cf39151 | ||
|
|
05dcc3769d | ||
|
|
f36f17bd2a | ||
|
|
3877c6d128 | ||
|
|
3ce42b3cca | ||
|
|
057d7d584d | ||
|
|
90ca08066b | ||
|
|
d481f13d1d | ||
|
|
7ecf51eba5 | ||
|
|
59c0f5d5d8 | ||
|
|
16455027ec | ||
|
|
4be6d2add0 | ||
|
|
15144fb9d4 | ||
|
|
a89ad8143a | ||
|
|
fb5b46a548 | ||
|
|
f6092cd178 | ||
|
|
3cba5df5c7 | ||
|
|
b2566d45f8 | ||
|
|
8b88377d4f | ||
|
|
f6af57cf58 | ||
|
|
160918a93e | ||
|
|
f622baadbd | ||
|
|
78284ed816 | ||
|
|
d96b90b5bf | ||
|
|
c0eada9bcc | ||
|
|
378727ac57 | ||
|
|
8b43e8b2ea | ||
|
|
786f364743 | ||
|
|
a0e2df615c | ||
|
|
4eb9abff49 | ||
|
|
15f1ee38db | ||
|
|
ec1cf20347 | ||
|
|
6a0c2d1fc4 | ||
|
|
5bc85effa7 | ||
|
|
13a670eadd | ||
|
|
0cedb51e50 | ||
|
|
6c819f8c52 | ||
|
|
2a1f5e11f8 |
@@ -0,0 +1,9 @@
|
||||
* text=auto
|
||||
|
||||
# cmd.exe misparses a batch file whose lines end in a bare LF: it loses
|
||||
# characters at the start of later lines, so a working script silently turns
|
||||
# into "command not recognized" errors. Force CRLF on checkout.
|
||||
*.bat text eol=crlf
|
||||
|
||||
# The mirror image: a CR at the end of a shebang or line breaks these.
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same ref makes an in-flight build obsolete.
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
# Let both platforms finish, so a failure on one still reports the other.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The .sh scripts are run through `bash` because a checkout made on
|
||||
# Windows does not carry the executable bit, so ./build-*.sh would
|
||||
# come back as "Permission denied" on the Linux runner.
|
||||
- os: ubuntu-latest
|
||||
build_linux: bash build-linux-x86_64.sh
|
||||
- os: windows-latest
|
||||
build_windows: ./build-win-x86_64.bat
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# Each script builds the Vue console itself and then compiles it into the
|
||||
# binary (web/embed.go), so the uploaded executables are self-contained.
|
||||
- name: Build Linux (amd64)
|
||||
run: ${{ matrix.build_linux }}
|
||||
|
||||
- name: Build Windows (amd64)
|
||||
run: ${{ matrix.build_windows }}
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nukumizu-binaries-${{ matrix.os }}
|
||||
path: |
|
||||
nukumizu-linux-amd64
|
||||
nukumizu-windows-amd64.exe
|
||||
if-no-files-found: error
|
||||
+11
@@ -2,7 +2,18 @@
|
||||
agent.md
|
||||
config.json
|
||||
bot_user_config.json
|
||||
bot_node_config.json
|
||||
*.exe
|
||||
nukumizu-linux-amd64
|
||||
|
||||
# Scratch test files dropped in the repo root stay untracked; the real suite
|
||||
# lives next to the code it covers (config/, handler/, ...) and is tracked.
|
||||
/*test.go
|
||||
|
||||
# The built web console. web/embed.go compiles it into the binary, and the
|
||||
# build-*.sh / build-*.bat scripts rebuild it before every compile.
|
||||
/web/dist/
|
||||
/frontend/dist/
|
||||
|
||||
# SQLite database files. They live on a network share (Y:), and git/cloud
|
||||
# sync touching them while a WAL database is open corrupts the WAL index and
|
||||
|
||||
@@ -2,94 +2,180 @@
|
||||
|
||||
Remote server monitoring and command execution subsystem for [Komari](https://www.komari.wiki).
|
||||
|
||||
## Overview
|
||||
Nukumizu connects to a Komari Dashboard instance, keeps an in-memory view of every monitored server, and pushes alerts to several notification channels. It also runs interactive bots on QQ (via [NapCat](https://napneko.github.io/), OneBot 11) and Telegram so operators can control servers from chat.
|
||||
|
||||
Nukumizu connects to a Komari Dashboard instance to:
|
||||
- Monitor server status in real-time via WebSocket
|
||||
- Execute commands on remote servers via the Komari task API
|
||||
- Provide Bot interfaces on QQ (via NapCat) and Telegram for interactive server control
|
||||
- Send one-way status notifications via Email, Ntfy, and Webhook
|
||||
## Features
|
||||
|
||||
## Project Structure
|
||||
- **Komari integration** — logs into the Komari Dashboard, refreshes the node list on startup and every 5 minutes, and polls each node's latest status through the Komari WebSocket every 5 seconds.
|
||||
- **Status tracking** — a thread-safe node tracker keeps the latest report and static info per server and detects `Online` / `Offline` transitions.
|
||||
- **Remote command execution** — dispatches commands through the Komari task API and polls the result (1s interval, up to 60s timeout).
|
||||
- **Interactive bots** — QQ (NapCat / OneBot 11) and Telegram bots for `/list`, `/status`, `/info`, `/run`, `/shutdown`, `/reboot`, and more, protected by an admin / trusted-group permission model.
|
||||
- **Notification channels** — server status changes are pushed to every enabled channel: QQ, Telegram, Email (SMTP), [ntfy](https://ntfy.sh), and Webhook.
|
||||
- **Incoming webhook API** — external applications can push their own alerts in via `POST /api/webhook/post/<name>`, and Nukumizu relays them to the channels that endpoint lists. Each endpoint carries its own token and target channels, and the API is served on a **separate listener** so it can be exposed without exposing the admin API.
|
||||
- **Network proxy** — a global proxy URL can be enabled per controller (`networkUseProxy`) for HTTP, WebSocket, and even SMTP (HTTP CONNECT tunnel).
|
||||
- **Customizable message templates** — every bot/notification message is rendered from a template in `config.json`, with Markdown formatting switched on per channel.
|
||||
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
|
||||
- **Dashboard API** — token-authenticated REST API plus an admin-only live log-streaming WebSocket.
|
||||
- **Web console** — a Vue 3 admin UI for browsing nodes, editing `config.json`, managing bot trust and webhook endpoints, and tailing logs. The built bundle is embedded in the binary, so a single executable serves both the API and the console.
|
||||
|
||||
## How it works
|
||||
|
||||
1. On startup Nukumizu logs in to the Komari Dashboard. A failed login aborts the process.
|
||||
2. It fetches the node list (name → UUID, static info) and stores it in memory, then opens a WebSocket to poll live status every 5 seconds. If the connection drops it reconnects with exponential backoff; after 5 failed attempts it notifies and keeps retrying.
|
||||
3. The node tracker uses the first received snapshot as a baseline so a restart does not produce false "offline" alerts, then fires a status-change event for every real transition.
|
||||
4. Each status-change event is rendered through the `SERVER_STATUS_CHANGED` template and sent to all enabled controllers. QQ / Telegram additionally receive a startup welcome and the initial server list.
|
||||
5. Every 12 hours the Komari session is re-authenticated; the node list is refreshed every 5 minutes.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
nukumizu-backend/
|
||||
├── main.go # Entry point, startup sequence, graceful shutdown
|
||||
├── router.go # HTTP route registration
|
||||
├── main.go # Entry point, startup sequence, graceful shutdown
|
||||
├── router.go # HTTP route registration (main + webhook API)
|
||||
├── config/
|
||||
│ └── config.go # Configuration loading, defaults
|
||||
│ ├── config.go # Load config files, apply defaults
|
||||
│ └── variables.go # Config schema structs + globals
|
||||
├── global/
|
||||
│ └── variables.go # Software build metadata (name/version/developer)
|
||||
├── handler/
|
||||
│ ├── user.go # User login/register handlers
|
||||
│ ├── server.go # Server list/status/exec handlers
|
||||
│ └── health.go # Health check endpoint
|
||||
│ ├── user.go # /api/user/login, /api/user/register
|
||||
│ ├── server.go # /api/server/list, getInfo, getStatus, exec
|
||||
│ ├── settings.go # /api/settings/get, set
|
||||
│ ├── webhook.go # /api/webhook/post/{name} (incoming webhook API)
|
||||
│ ├── webhook_endpoints.go # /api/webhook/add, modify, delete, list
|
||||
│ └── health.go # /health
|
||||
├── database/
|
||||
│ └── user.go # SQLite user database
|
||||
│ └── user.go # user.db (SQLite) user store
|
||||
├── utils/
|
||||
│ ├── auth.go # Token management, authentication
|
||||
│ └── middleware.go # Rate limit, CORS, XSS protection
|
||||
├── postLog/ # Logging subsystem
|
||||
│ ├── auth.go # Token management, Auth middleware, JSON responses
|
||||
│ └── middleware.go # Rate limit, CORS, XSS headers, WebSocket auth
|
||||
├── postLog/ # Logging subsystem
|
||||
│ ├── postLog.go # Leveled logger (stdout + broadcast)
|
||||
│ ├── database.go # log.db (SQLite, one table per run)
|
||||
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
||||
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler (admin only)
|
||||
├── web/
|
||||
│ ├── embed.go # Embeds the built console (web/dist) in the binary
|
||||
│ ├── dist/ # Vite build output — generated, gitignored
|
||||
│ └── handler.go # Static file serving + SPA fallback
|
||||
├── internal/
|
||||
│ ├── komari/
|
||||
│ │ ├── client.go # Komari HTTP API client
|
||||
│ │ └── ws.go # Komari WebSocket client
|
||||
│ ├── qq/
|
||||
│ │ ├── qq.go # QQ (Napcat) Bot controller
|
||||
│ │ └── napcat.go # NapCat HTTP API + WebSocket client
|
||||
│ │ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
|
||||
│ │ └── ws.go # Komari status WebSocket (poll + reconnect)
|
||||
│ ├── node/
|
||||
│ │ └── tracker.go # Thread-safe node state tracking
|
||||
│ ├── controller/
|
||||
│ │ ├── controller.go # Controller interface & manager
|
||||
│ │ └── pipes/
|
||||
│ │ ├── telegram.go # Telegram Bot controller
|
||||
│ │ ├── email.go # Email notification controller
|
||||
│ │ ├── ntfy.go # Ntfy notification controller
|
||||
│ │ └── webhook.go # Webhook notification controller
|
||||
│ └── template/
|
||||
│ └── template.go # Message template engine
|
||||
│ │ └── tracker.go # Thread-safe server state, status-change detection
|
||||
│ ├── netproxy/
|
||||
│ │ └── netproxy.go # Unified network proxy for controllers
|
||||
│ ├── template/
|
||||
│ │ └── template.go # Message template renderer ({{ variables }})
|
||||
│ └── controller/
|
||||
│ ├── controller.go # Manager, Controller / BotController interfaces, alerts
|
||||
│ ├── trigger.go # Command parsing, authorization, routing
|
||||
│ ├── processor.go # Command handlers
|
||||
│ ├── utils.go
|
||||
│ └── pipes/
|
||||
│ ├── email.go # Email notification pipe
|
||||
│ ├── ntfy.go # ntfy notification pipe
|
||||
│ ├── webhook.go # Outgoing webhook notification pipe
|
||||
│ ├── qq_napcat/
|
||||
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||
│ │ └── napcat.go # NapCat WebSocket + HTTP API client
|
||||
│ └── telegram/
|
||||
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||
│ └── send.go # Message sending / splitting (Telegram Markdown)
|
||||
└── frontend/ # Vue 3 admin console (Vite)
|
||||
├── index.html
|
||||
├── vite.config.js # Dev server; proxies /api to the backend
|
||||
└── src/
|
||||
├── main.js # Bootstrap: theme + runtime flags
|
||||
├── App.vue # Root component + toast host
|
||||
├── api/index.js # Wrappers around the REST endpoints
|
||||
├── router/index.js # Routes and the login guard
|
||||
├── utils/ # http/auth/theme/toast/format/runtime helpers
|
||||
├── components/ # Modal, Toggle, editors, ConfigSection, top bar, side bar
|
||||
└── views/ # Login, Overview, Trusted, Settings, WebHooks, Logs
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go **1.25** or newer
|
||||
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
|
||||
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
|
||||
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint
|
||||
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
|
||||
|
||||
Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy and modify `config.json` at the project root:
|
||||
There are two configuration files, both read from the working directory unless overridden:
|
||||
|
||||
| File | CLI flag | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `config.json` | `-config` | `config.json` | Core settings: system, Komari, controllers, message templates |
|
||||
| `bot_user_config.json` | `-bot-user-config` | `bot_user_config.json` | Per-bot admins / trusted groups and their notification preferences |
|
||||
|
||||
> Both files are in `.gitignore` because they contain credentials (Komari password, bot tokens, proxy auth). Start from the samples below and never commit real secrets.
|
||||
|
||||
### `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"system": {
|
||||
"debugMode": true,
|
||||
"listenAddr": "0.0.0.0",
|
||||
"listenPort": "8080"
|
||||
"listenPort": "8080",
|
||||
"networkProxy": "http://127.0.0.1:7890"
|
||||
},
|
||||
"debug": {
|
||||
"showNapcatMsg": false,
|
||||
"showTelegramMsg": false
|
||||
"showNapcatAction": false,
|
||||
"showTelegramMsg": false,
|
||||
"showTriggerCmdEcho": true,
|
||||
"showKomariTaskEcho": false,
|
||||
"napcatIgnoreSelfMsg": false
|
||||
},
|
||||
"komari": {
|
||||
"dashboardURL": "http://127.0.0.1:25774",
|
||||
"dashboardURL": "https://status.example.com",
|
||||
"account": {
|
||||
"username": "admin",
|
||||
"password": "admin"
|
||||
"password": "CHANGE_ME"
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"listenAddr": "0.0.0.0",
|
||||
"listenPort": "8081",
|
||||
"endpoints": {
|
||||
"example": {
|
||||
"enabled": true,
|
||||
"token": "CHANGE_ME",
|
||||
"notifyPipes": ["qq(napcat)", "telegram", "email", "ntfy"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllerMethod": {
|
||||
"qq(napcat)": {
|
||||
"enabled": false,
|
||||
"markdown": false,
|
||||
"networkUseProxy": false,
|
||||
"napcatAddr": "127.0.0.1",
|
||||
"napcatPort": "3000",
|
||||
"napcatToken": "",
|
||||
"botQQID": 0,
|
||||
"listenMethod": "global",
|
||||
"admins": [],
|
||||
"trustedGroups": []
|
||||
"listenMethod": "global"
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"markdown": true,
|
||||
"networkUseProxy": false,
|
||||
"botToken": "",
|
||||
"listenMethod": "global",
|
||||
"admins": [],
|
||||
"trustedGroups": []
|
||||
"listenMethod": "global"
|
||||
},
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"markdown": false,
|
||||
"networkUseProxy": false,
|
||||
"smtpHost": "",
|
||||
"smtpPort": 587,
|
||||
"username": "",
|
||||
@@ -100,6 +186,8 @@ Copy and modify `config.json` at the project root:
|
||||
},
|
||||
"ntfy": {
|
||||
"enabled": false,
|
||||
"markdown": false,
|
||||
"networkUseProxy": false,
|
||||
"server": "https://ntfy.sh",
|
||||
"topic": "",
|
||||
"token": "",
|
||||
@@ -107,6 +195,8 @@ Copy and modify `config.json` at the project root:
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"markdown": false,
|
||||
"networkUseProxy": false,
|
||||
"url": "",
|
||||
"method": "POST",
|
||||
"headers": {},
|
||||
@@ -114,51 +204,305 @@ Copy and modify `config.json` at the project root:
|
||||
}
|
||||
},
|
||||
"controllerMessage": {
|
||||
"SERVER_STATUS_CHANGED": "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}",
|
||||
"SERVER_LIST": "All server list:\nOnline:\n{{ list.onlineServers }}\nOffline:\n{{ list.offlineServers }}",
|
||||
"SERVER_EXECUTE_RESULT": "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
|
||||
"BOT_STARTED": "Nukumizu Alert Bot Started\nTime: {{ time }}\n- Software Version: {{ softwareVersion }}\n- Build Version: {{ softwareBuildVer }}\n- Commit Hash: {{ softwareCommitHash }}\n- Build Type: {{ softwareBuildType }}\n- Build Time: {{ softwareBuildTime }}\n- Developer: {{ softwareDeveloper }}",
|
||||
"BOT_HELP": "Nukumizu Alert Bot Ver. {{ softwareVersion }}.{{ softwareBuildVer }}.{{ softwareCommitHash }}\nCommand Lists:\n- /help: Show this help message\n- /list: List all servers and show their status\n- /status <UUID>: Show specific server status\n- /info <UUID>: Show specific server info\n- /run <UUID> <command>: Execute command on specific server. If you type \"all\" in <UUID>, you will run the command on all servers.\n- /shutdown <UUID>: Shutdown specific server.\n- /reboot <UUID>: Reboot specific server.\n- /getip <UUID>: Get specific server IP address.",
|
||||
"TG_BOT_START": "Welcome to use Nukumizu Alert Bot!\nUse `/help` to get command list.",
|
||||
"SERVER_STATUS_CHANGED": "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\n- Event: {{ event }}\n- Server Name: {{ serverName }}\n- Message: {{ message }}\n- Time: {{ time }}",
|
||||
"SERVER_LIST": "All server list:\n- Online:\n{{ list.onlineServers }}\n- Offline:\n{{ list.offlineServers }}",
|
||||
"SERVER_EXECUTE_RESULT": "Command execute result:\n- Server ID: {{ serverName }}\n- Command: {{ command }}\n-----**Result**-----\n\n{{ result }}\n----------\n\n- Time: {{ time }}"
|
||||
},
|
||||
"dataPath": "./data",
|
||||
"dbPath": "./db"
|
||||
}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent.
|
||||
- `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown.
|
||||
- `controllerMethod.qq(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
|
||||
- `debug` toggles verbose per-channel message/action logging; these only matter in debug builds / `debugMode`.
|
||||
- `email.useTLS` is kept for configuration compatibility.
|
||||
- `dataPath` / `dbPath` default to `./data` and `./db`; `user.db` and `log.db` are created under `dbPath`.
|
||||
- Missing keys fall back to built-in defaults (host `0.0.0.0`, port `8080`, webhook API `0.0.0.0:8081`, no webhook endpoints, NapCat `127.0.0.1:3000`, ntfy server `https://ntfy.sh`, webhook method `POST`, etc.). Message templates have built-in fallbacks too. `markdown` defaults to `false`, so add it explicitly for Telegram (see the sample above) to keep its formatting.
|
||||
|
||||
### `bot_user_config.json`
|
||||
|
||||
Admins and trusted groups are defined **per bot channel** and map a member ID to that member's notification preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"qq(napcat)": {
|
||||
"admins": {
|
||||
"123456789": {
|
||||
"event_status_notify": true,
|
||||
"event_bot_started": true,
|
||||
"event_reply": true
|
||||
}
|
||||
},
|
||||
"trustedGroups": {
|
||||
"987654321": {
|
||||
"event_status_notify": true,
|
||||
"event_bot_started": false,
|
||||
"event_reply": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"admins": {
|
||||
"user_handle": {
|
||||
"event_status_notify": true,
|
||||
"event_bot_started": true,
|
||||
"event_reply": true
|
||||
}
|
||||
},
|
||||
"trustedGroups": {
|
||||
"-1001234567890": {
|
||||
"event_status_notify": true,
|
||||
"event_bot_started": false,
|
||||
"event_reply": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
- **QQ**: member IDs are QQ numbers; group IDs are the numeric group number.
|
||||
- **Telegram**: member IDs may be `@username` (resolved to the numeric user ID once that user has messaged the bot) or the numeric user ID; group IDs are the numeric chat ID (supergroups are negative).
|
||||
- Per-member options:
|
||||
- `event_status_notify` — receive server `Online`/`Offline` push notifications.
|
||||
- `event_bot_started` — receive the automatic startup message (welcome + initial server list).
|
||||
- `event_reply` — reserved for opting out of replies to that member's own commands.
|
||||
- Admins of a channel also appear in every trusted-group/private-chat context where the bot sends notifications.
|
||||
|
||||
All API responses follow the format: `{"success": bool, "message": "..."}`
|
||||
### Message templates
|
||||
|
||||
Authentication is via `X-Token` and `X-Timestamp` HTTP headers.
|
||||
`controllerMessage` templates are rendered before sending. Available variables (channels with `markdown: true` additionally wrap the verbatim values in Markdown — see the field notes above):
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `{{ time }}` | Current server time |
|
||||
| `{{ serverName }}` | Targeted server name |
|
||||
| `{{ serverUUID }}` | Targeted server UUID |
|
||||
| `{{ upStatus }}` | `Online` / `Offline` |
|
||||
| `{{ event }}` | Status change event (`Online` / `Offline`) |
|
||||
| `{{ message }}` | Message accompanying a status event |
|
||||
| `{{ command }}` | The command that was executed |
|
||||
| `{{ result }}` | Command execution result |
|
||||
| `{{ list.onlineServers }}` | Formatted list of online servers (`- Name (uuid)`) |
|
||||
| `{{ list.offlineServers }}` | Formatted list of offline servers |
|
||||
| `{{ softwareVersion }}`, `{{ softwareBuildVer }}`, `{{ softwareCommitHash }}`, `{{ softwareBuildType }}`, `{{ softwareBuildTime }}`, `{{ softwareDeveloper }}`, `{{ softwareDescription }}` | Build metadata (commit hash and build time are injected at compile time) |
|
||||
|
||||
### What applies without a restart
|
||||
|
||||
Saving settings applies most of them immediately. How each group takes effect:
|
||||
|
||||
| Settings | How it applies |
|
||||
|---|---|
|
||||
| `controllerMethod` (all five channels) | Every channel is **rebuilt**: the running controllers are stopped and a fresh set is built from the new settings. A channel that owns a connection reconnects — Telegram re-runs its `getMe` handshake, NapCat opens a new WebSocket — so notifications sent during the swap are lost. The rebuild only happens when this section actually changed; saving a message template does not disturb the channels. |
|
||||
| `controllerMessage`, `debug`, `bot_user_config.json`, `bot_node_config.json`, `webhook.endpoints` | Picked up as they are used; nothing is restarted. |
|
||||
| `system.debugMode` | Applies to both behavior and log filtering. |
|
||||
| `system.networkProxy` | Read on every request and every dial, so a new address reaches channels that are already running. Only the per-channel `networkUseProxy` opt-in is fixed when a channel is built, so toggling that still needs the rebuild above. |
|
||||
| `system.listenAddr` / `listenPort`, `webhook.enabled` / `listenAddr` / `listenPort`, `dataPath`, `dbPath`, `komari.dashboardURL` | **Applied at startup only.** Saving them changes the file and the in-memory configuration but not the running listener, database or Komari client — restart to apply. |
|
||||
|
||||
## API
|
||||
|
||||
Success responses follow the envelope `{"success": true, "message": "...", "data": {...}}`, with the payload nested under a single `data` key. Error responses use `{"success": false, "message": "..."}`. `message` may be omitted on success when there is nothing to report.
|
||||
|
||||
### Authentication
|
||||
|
||||
Requests are authenticated with HTTP headers:
|
||||
|
||||
| Header | Meaning |
|
||||
|---|---|
|
||||
| `X-Token` | Token returned by login/register. Held in memory only (lost on restart). |
|
||||
| `X-Timestamp` | Unix timestamp (seconds); rejected if more than ±30 minutes from server time. **Skipped entirely when `system.debugMode` is `true`.** |
|
||||
|
||||
Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any authenticated call refreshes the timer. Permission levels: `None`, `bot`, `admin`. Endpoints requiring `bot` accept both `bot` and `admin` tokens. Currently registration/login always issue `admin`-level tokens.
|
||||
|
||||
Browser WebSocket handshakes cannot carry custom headers, so `/api/system/getLogs` also accepts the same credentials as `?token=` and `?timestamp=` query parameters (headers still take precedence when both are present). Only `admin` tokens are accepted; the timestamp is checked once at handshake time, so an accepted connection stays open past its tolerance window. Because the query string can leak into proxy and access logs, a token-carrying WebSocket URL should be treated as a secret.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Endpoint | Method | Permission | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/user/login` | POST | None | User login |
|
||||
| `/api/user/register` | POST | None | First-time registration |
|
||||
| `/api/server/list` | GET | bot/admin | List all servers |
|
||||
| `/api/server/getStatus` | GET | bot/admin | Get server recent status |
|
||||
| `/api/server/exec` | POST | bot/admin | Execute command on server(s) |
|
||||
| `/health` | GET | None | Health check |
|
||||
| `/api/system/getLogs` | WS | None | Real-time log streaming |
|
||||
| `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `data: {token, userID, username, level, registerDate}`. |
|
||||
| `/api/user/register` | POST | None | Register the first user. Body `{username, password}`. Only allowed while no user exists; otherwise `403`. Returns `data: {token, userID, username, level}`. |
|
||||
| `/api/server/list` | GET | bot / admin | List all monitored servers. |
|
||||
| `/api/server/getInfo` | GET | admin | Static server info (mirrors the Bot's `/info`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, info}}` — one entry per requested server. `404` for an unknown single uuid. |
|
||||
| `/api/server/getStatus` | GET | admin | Live server status (mirrors the Bot's `/status`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. |
|
||||
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `data: {taskID, results}`. |
|
||||
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `data: {config}`, where `config` is the selected config file's content (same layout as the JSON file). |
|
||||
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. Returns `data: {type, restartRequired}`, where `restartRequired` lists the keys the update changed that are only read at startup (see [What applies without a restart](#what-applies-without-a-restart)) — the write succeeds regardless, this only says which edits are not live yet. Always an array, empty when everything took effect. |
|
||||
| `/api/webhook/add` | POST | admin | Add an incoming webhook endpoint. Body `{name, enabled?, token?, notifyPipes?}` — only the fields given are stored, the rest start at their defaults. `409` when the name is already configured. |
|
||||
| `/api/webhook/modify` | POST | admin | Change an existing endpoint. Body `{name, ...}` — the fields given are the fields that change (same partial-update rule as `/api/settings/set`, but scoped to one endpoint). `404` for an unknown name, `400` when no other field is given. |
|
||||
| `/api/webhook/delete` | POST | admin | Remove an endpoint. Body `{name}`. `404` for an unknown name. |
|
||||
| `/api/webhook/list` | GET | admin | Every configured incoming webhook endpoint, keyed by name, under `data.endpoints`. |
|
||||
| `/health` | GET | None | Health check. Returns `data: {status, database}`. |
|
||||
| `/api/system/getLogs` | WebSocket | admin | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. Credentials via `X-Token`/`X-Timestamp` headers or `?token=`/`?timestamp=` query parameters; a failed check answers with the JSON error and no upgrade. |
|
||||
|
||||
## Bot Commands
|
||||
Middleware applied to the whole server:
|
||||
|
||||
- **Rate limit** — token bucket, 100 requests/minute per client IP.
|
||||
- **CORS** — `Access-Control-Allow-Origin: *`, allows `Content-Type`, `X-Token`, `X-Timestamp`, `Authorization`.
|
||||
- **Security headers** — `X-XSS-Protection`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, a restrictive CSP.
|
||||
- **WebSocket auth** — `utils.WebSocketAuthMiddleware` is attached to `/api/system/getLogs` (route-level, not global): it authenticates the upgrade request and requires an `admin` token before the connection is handed to the log handler.
|
||||
|
||||
### Incoming webhook API
|
||||
|
||||
A listener of its own, so external applications can be pointed at it without being able to reach the admin API. It is switched on with `webhook.enabled` and binds `webhook.listenAddr:webhook.listenPort` (default `0.0.0.0:8081`); that half of the configuration is applied at startup, while `webhook.endpoints` is re-read whenever the config is reloaded. Only the rate limit and CORS middleware apply here — no session token is involved.
|
||||
|
||||
The console's **WebHooks** page manages the listener settings and the endpoints. The outgoing WebHook notification channel (`controllerMethod.webhook`) stays on the Settings page with the other notification channels, since it is one of them.
|
||||
|
||||
| Endpoint | Method | Permission | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/webhook/post/<name>` | POST | Endpoint token | Relay an alert to the channels the endpoint lists in `notifyPipes`. Body `{token, subject, content}`. Returns `data: {endpoint, channels}`. |
|
||||
|
||||
Every entry under `webhook.endpoints` is one endpoint, addressed by its key as the last path segment: the key `example` is served at `POST /api/webhook/post/example`. The `post/` segment keeps the endpoints' own namespace separate from the management routes (`/api/webhook/add` and friends), which live on the admin listener. Endpoints are managed over the admin API (`/api/webhook/add`, `modify`, `delete` and `list` — see [Endpoints](#endpoints)), which writes the same `webhook.endpoints` section of `config.json`; a newly added endpoint accepts requests as soon as the configuration is reloaded, without a restart. An endpoint holds:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `enabled` | Whether the endpoint accepts requests. A disabled endpoint answers `403`. |
|
||||
| `token` | Shared secret the caller sends as the `token` body field; compared in constant time. An endpoint with an empty token answers `500` instead of accepting requests from anyone. |
|
||||
| `notifyPipes` | The channels the alert is delivered to, named as in `controllerMethod`: `qq(napcat)`, `telegram`, `email`, `ntfy`, `webhook`. A channel that is unknown or disabled is skipped and reported. |
|
||||
|
||||
The management API accepts exactly these three fields. A request naming any other field, or giving one of them the wrong type (`enabled` must be a boolean, `token` a string, `notifyPipes` an array of strings), is refused with `400` instead of being written to `config.json` — a field the program does not understand must not end up in the file. A `name` must be non-empty and free of `/`, since it becomes the last segment of the endpoint URL.
|
||||
|
||||
The alert is rendered per channel as:
|
||||
|
||||
```
|
||||
{{ subject }}
|
||||
- Source: {{ source }}
|
||||
- Content:
|
||||
{{ content }}
|
||||
|
||||
- Time: {{ time }}
|
||||
Sent by Nukumizu Alert System
|
||||
```
|
||||
|
||||
`{{ source }}` is the endpoint name, so recipients can tell which application triggered the alert. On a channel with `markdown: true` the source is wrapped in inline code and the content in a fenced code block; `{{ subject }}` and `{{ time }}` stay plain.
|
||||
|
||||
Status codes: `200` delivered, `400` malformed body or empty `subject`/`content`, `401` wrong token, `403` endpoint disabled, `404` unknown endpoint name, `405` non-POST request, `500` endpoint has no token configured, `502` no channel accepted the alert.
|
||||
|
||||
## Bots
|
||||
|
||||
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 (WebSocket event stream + HTTP actions); Telegram uses `go-telegram/bot` long polling.
|
||||
|
||||
### Permission model
|
||||
|
||||
- **Trusted group**: any command issued *inside a group* is only answered if that group is listed in `trustedGroups`. Messages in other groups are ignored.
|
||||
- **Admin commands**: `/shutdown`, `/reboot`, and `/run` additionally require the *sender* to be listed in `admins`.
|
||||
- **Private chat**: non-admin commands (`/help`, `/list`, `/status`, `/info`, `/getip`) are answered for any private sender; admin commands still require admin.
|
||||
|
||||
### Bot recognition modes
|
||||
|
||||
- `global` — the bot watches all messages in trusted groups and reacts to recognized commands without being mentioned. Unknown `/`-commands are silently ignored.
|
||||
- `at` — the bot only reacts when it is mentioned (QQ `@`, Telegram `@botname`). In this mode an unknown command produces an `Unknown command: /…` reply.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Permission | Description |
|
||||
|---|---|---|
|
||||
| `/list` | None | List all server status |
|
||||
| `/shutdown <uuid>` | Admin | Shutdown specific server |
|
||||
| `/reboot <uuid>` | Admin | Reboot specific server |
|
||||
| `/status <uuid>` | None | Get server detailed status |
|
||||
| `/run <uuid\|all> <command>` | Admin | Run command on server(s) |
|
||||
| `/help` | All | Show the help message (`BOT_HELP` template). |
|
||||
| `/list` | All | List all servers with online/offline state (`SERVER_LIST` template). |
|
||||
| `/status <uuid>` | All | Live report for a server (CPU, RAM, disk, network, uptime, processes). |
|
||||
| `/info <uuid>` | All | Static info for a server (OS, kernel, CPU, RAM, swap, disk, billing, tags). |
|
||||
| `/getip <uuid>` | All | IPv4 / IPv6 address of a server. |
|
||||
| `/shutdown <uuid>` | Admin | Shut the server down via the Komari task API. |
|
||||
| `/reboot <uuid>` | Admin | Reboot the server via the Komari task API. |
|
||||
| `/run <uuid\|all> <command>` | Admin | Execute a command on one server or on all servers (`all`), then report the result (`SERVER_EXECUTE_RESULT` template). |
|
||||
| `/start` | All | Telegram only — sends the `TG_BOT_START` welcome message. |
|
||||
|
||||
## Notification channels
|
||||
|
||||
QQ and Telegram are *interactive* channels. Email, ntfy, and webhook are **status-only** channels — they receive server status-change alerts but cannot run commands. On startup, the welcome message and initial server list are delivered only to the bot channels (QQ / Telegram), honoring each member's `event_bot_started` preference.
|
||||
|
||||
All five channels can also carry an alert submitted by an external application through the [incoming webhook API](#incoming-webhook-api). A bot channel delivers it to the groups and admins configured for that channel; a status-only channel delivers it to its configured destination (mail recipients, ntfy topic, outgoing webhook URL). Markdown formatting is decided per channel by its `markdown` setting, never by the channel's name.
|
||||
|
||||
## Building
|
||||
|
||||
Requires Go 1.25+ and — to build the web console — Node.js 22+.
|
||||
|
||||
Helper scripts in the repo root build the Vue console first, then compile the backend with it embedded. They also bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
|
||||
|
||||
| Script | Output |
|
||||
|---|---|
|
||||
| `build-linux-x86_64.sh` / `build-linux-x86_64.bat` | `nukumizu-linux-amd64` |
|
||||
| `build-win-x86_64.sh` / `build-win-x86_64.bat` | `nukumizu-windows-amd64.exe` |
|
||||
|
||||
```bash
|
||||
go build -o nukumizu-backend .
|
||||
# Linux / macOS
|
||||
./build-linux-x86_64.sh
|
||||
./build-win-x86_64.sh
|
||||
|
||||
# Windows
|
||||
build-linux-x86_64.bat
|
||||
build-win-x86_64.bat
|
||||
```
|
||||
|
||||
The console is **embedded in the binary**. Vite writes it to `web/dist` and `web/embed.go` compiles that directory in with `go:embed`, so the executable serves the whole frontend on its own — copy it anywhere, with neither `frontend/` nor `web/dist` next to it, and `/` still returns the console. The build scripts run `npm ci` when `frontend/node_modules` is missing and `npm run build` on every run, so they need Node.js 22+ on the build machine (not on the machine that runs the binary).
|
||||
|
||||
Building the backend therefore requires the console to have been built at least once: `web/dist` is a generated, gitignored directory, and `go build` fails with `pattern all:dist: no matching files found` until it exists. Any `build-*` script handles that ordering for you.
|
||||
|
||||
Equivalent manual builds:
|
||||
|
||||
```bash
|
||||
# 1. Console (once per frontend change)
|
||||
cd frontend && npm ci && npm run build && cd ..
|
||||
|
||||
# 2. Backend
|
||||
# Linux / macOS
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-o nukumizu-linux-amd64 .
|
||||
|
||||
# Windows
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
||||
go build -ldflags "-X main.CommitHash=<commit> -X main.BuildTime=<utc-time>" \
|
||||
-o nukumizu-windows-amd64.exe .
|
||||
```
|
||||
|
||||
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
|
||||
|
||||
CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest`, `.bat` on `windows-latest` — and uploads the two self-contained binaries as artifacts.
|
||||
|
||||
## Running
|
||||
|
||||
Both `config.json` and `bot_user_config.json` must exist in the working directory (or be passed explicitly):
|
||||
|
||||
```bash
|
||||
./nukumizu-backend -config config.json
|
||||
# Development (uses go run, so config files must be in the CWD)
|
||||
run.bat
|
||||
|
||||
# Or build first, then run a binary
|
||||
./nukumizu-linux-amd64 -config config.json -bot-user-config bot_user_config.json
|
||||
```
|
||||
|
||||
On startup the program logs in to Komari, loads node state, connects the status WebSocket, then starts each enabled controller and the HTTP server on `listenAddr:listenPort`. Press `Ctrl+C` for a graceful shutdown.
|
||||
|
||||
### Frontend development
|
||||
|
||||
`run.bat` only runs the Go backend — it does not build the console, so `web/dist` must already exist (run any `build-*` script once, or `npm run build` in `frontend/`) or `go run` fails to compile. While working on the frontend, run the two sides separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1 — backend (API + WebSocket) on :8080
|
||||
run.bat
|
||||
|
||||
# Terminal 2 — Vite dev server with hot reload on :5173
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173. The dev server proxies `/api` — including the log WebSocket — to `http://127.0.0.1:8080`; point it elsewhere with `NUKUMIZU_API` if the backend listens on another address:
|
||||
|
||||
```bash
|
||||
NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
|
||||
```
|
||||
|
||||
For a production build the Go server serves the embedded console itself, on the normal listen address — see [Building](#building). Vite is not involved at runtime, so `npm run build` alone does not change what a running binary serves: rebuild the binary to pick up frontend changes.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
+66
-33
@@ -1,33 +1,66 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo Building for Linux (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Linux build
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-linux-amd64
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo Build succeeded: %OUTPUT%
|
||||
) else (
|
||||
echo Build failed.
|
||||
)
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: Build from the repository root, however the script was invoked.
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: The Vue console is built first and embedded into the binary (web\dist, see
|
||||
:: web\embed.go), so the executable serves the whole frontend on its own:
|
||||
:: neither frontend\ nor web\dist\ is needed where it runs.
|
||||
echo Building frontend...
|
||||
cd frontend
|
||||
|
||||
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
|
||||
:: the lockfile; a warm tree only rebuilds.
|
||||
if not exist "node_modules" (
|
||||
call npm ci
|
||||
if errorlevel 1 goto :frontend_failed
|
||||
)
|
||||
|
||||
call npm run build
|
||||
if errorlevel 1 goto :frontend_failed
|
||||
cd ..
|
||||
|
||||
:: go:embed on web\dist fails anyway, but this names the real problem.
|
||||
if exist "web\dist\index.html" goto :backend
|
||||
echo Frontend build produced no web\dist\index.html.
|
||||
goto :fail
|
||||
|
||||
:frontend_failed
|
||||
cd ..
|
||||
echo Frontend build failed.
|
||||
goto :fail
|
||||
|
||||
:backend
|
||||
echo Building for Linux (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Linux build
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-linux-amd64
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo Build succeeded: %OUTPUT%
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo Build failed.
|
||||
exit /b 1
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the Linux (amd64) binary.
|
||||
#
|
||||
# The Vue console is built first and embedded into the binary (web/dist, see
|
||||
# web/embed.go), so the executable serves the whole frontend on its own —
|
||||
# neither frontend/ nor web/dist/ is needed where it runs.
|
||||
set -euo pipefail
|
||||
|
||||
# Build from the repository root, however the script was invoked.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Building frontend..."
|
||||
(
|
||||
cd frontend
|
||||
# node_modules is gitignored, so a fresh checkout (CI included) installs
|
||||
# from the lockfile; a warm tree only rebuilds.
|
||||
if [ ! -d node_modules ]; then
|
||||
npm ci
|
||||
fi
|
||||
npm run build
|
||||
)
|
||||
|
||||
# go:embed on web/dist fails anyway, but this names the real problem.
|
||||
if [ ! -f web/dist/index.html ]; then
|
||||
echo "Frontend build produced no web/dist/index.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building for Linux (amd64)..."
|
||||
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export CGO_ENABLED=0
|
||||
|
||||
OUTPUT=nukumizu-linux-amd64
|
||||
|
||||
echo "Commit: $COMMIT"
|
||||
echo "BuildDate: $BUILD_DATE"
|
||||
echo "Output: $OUTPUT"
|
||||
|
||||
go build -ldflags "-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT" -o "$OUTPUT" .
|
||||
|
||||
echo "Build succeeded: $OUTPUT"
|
||||
+66
-33
@@ -1,33 +1,66 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo Building for Windows (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Windows build
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-windows-amd64.exe
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo Build succeeded: %OUTPUT%
|
||||
) else (
|
||||
echo Build failed.
|
||||
)
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: Build from the repository root, however the script was invoked.
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: The Vue console is built first and embedded into the binary (web\dist, see
|
||||
:: web\embed.go), so the executable serves the whole frontend on its own:
|
||||
:: neither frontend\ nor web\dist\ is needed where it runs.
|
||||
echo Building frontend...
|
||||
cd frontend
|
||||
|
||||
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
|
||||
:: the lockfile; a warm tree only rebuilds.
|
||||
if not exist "node_modules" (
|
||||
call npm ci
|
||||
if errorlevel 1 goto :frontend_failed
|
||||
)
|
||||
|
||||
call npm run build
|
||||
if errorlevel 1 goto :frontend_failed
|
||||
cd ..
|
||||
|
||||
:: go:embed on web\dist fails anyway, but this names the real problem.
|
||||
if exist "web\dist\index.html" goto :backend
|
||||
echo Frontend build produced no web\dist\index.html.
|
||||
goto :fail
|
||||
|
||||
:frontend_failed
|
||||
cd ..
|
||||
echo Frontend build failed.
|
||||
goto :fail
|
||||
|
||||
:backend
|
||||
echo Building for Windows (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Windows build
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-windows-amd64.exe
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo Build succeeded: %OUTPUT%
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo Build failed.
|
||||
exit /b 1
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the Windows (amd64) binary.
|
||||
#
|
||||
# The Vue console is built first and embedded into the binary (web/dist, see
|
||||
# web/embed.go), so the executable serves the whole frontend on its own —
|
||||
# neither frontend/ nor web/dist/ is needed where it runs.
|
||||
set -euo pipefail
|
||||
|
||||
# Build from the repository root, however the script was invoked.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Building frontend..."
|
||||
(
|
||||
cd frontend
|
||||
# node_modules is gitignored, so a fresh checkout (CI included) installs
|
||||
# from the lockfile; a warm tree only rebuilds.
|
||||
if [ ! -d node_modules ]; then
|
||||
npm ci
|
||||
fi
|
||||
npm run build
|
||||
)
|
||||
|
||||
# go:embed on web/dist fails anyway, but this names the real problem.
|
||||
if [ ! -f web/dist/index.html ]; then
|
||||
echo "Frontend build produced no web/dist/index.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building for Windows (amd64)..."
|
||||
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export CGO_ENABLED=0
|
||||
|
||||
OUTPUT=nukumizu-windows-amd64.exe
|
||||
|
||||
echo "Commit: $COMMIT"
|
||||
echo "BuildDate: $BUILD_DATE"
|
||||
echo "Output: $OUTPUT"
|
||||
|
||||
go build -ldflags "-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT" -o "$OUTPUT" .
|
||||
|
||||
echo "Build succeeded: $OUTPUT"
|
||||
@@ -0,0 +1,23 @@
|
||||
## Ver.0.2.0.5-661cf08.pre-release
|
||||
### Features
|
||||
- [Enhance security of websocket log system]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9")
|
||||
- [Implement incoming webhook API interface with configurable endpoints]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/48533404fac20c23134e3ec1d3305ebdf4423c80")
|
||||
- [Implement webhook API settings and configuration in frontend webpage]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6046fb5f882c108b7e94e9537bc15de288bd204b")
|
||||
|
||||
## Ver.0.1.2.4-a6f3107.pre-release
|
||||
### Bug Fixes
|
||||
- [Integrate frontend build into backend binary]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/cc4aebde6a5a4fba5eeb65ff2feccdd06dc6529d")
|
||||
|
||||
|
||||
## Ver.0.1.2.3-1c4ad61.pre-release
|
||||
### Features
|
||||
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
|
||||
- [Add scripts for building frontend and backend for Linux and Windows]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/b970d66bc091b44b29ee0781888eb5b253b78aa6")
|
||||
|
||||
### Bug Fixes
|
||||
- [Update API response structure to nest payloads under a single "data" key]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/bc7eb9dfdd5b98c0264aa6b5970cf0adb106aeb1")
|
||||
|
||||
|
||||
## Ver.0.1.1.2-15f1ee3.pre-release
|
||||
### Features
|
||||
- [Add per-node notify switch controller]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6a0c2d1fc4b0fefa7eebc0b5a25a888817a186ca")
|
||||
@@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// TestConcurrentReloadAndRead drives every configuration accessor from reader
|
||||
// goroutines while UpdateSettings and SaveBotNodeConfig replace the in-memory
|
||||
// configurations underneath them. Run with -race to check that the swap is
|
||||
// race-free: before the singletons were published through atomic pointers this
|
||||
// pattern was an unsynchronized read of a variable written by LoadGlobalConfig
|
||||
// and friends, which the race detector reports.
|
||||
//
|
||||
// The readers call the accessors the way production code does — take the value
|
||||
// and use it immediately, never store it — because that is what keeps a reader
|
||||
// pinned to one complete version of the configuration.
|
||||
func TestConcurrentReloadAndRead(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"system": {
|
||||
"debugMode": true,
|
||||
"listenPort": "8080"
|
||||
},
|
||||
"webhook": {
|
||||
"endpoints": {
|
||||
"example": { "enabled": true, "token": "t", "notifyPipes": ["ntfy"] }
|
||||
}
|
||||
},
|
||||
"controllerMessage": {
|
||||
"BOT_STARTED": "hello"
|
||||
}
|
||||
}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
|
||||
"qq(napcat)": {
|
||||
"admins": { "1": { "event_reply": true } },
|
||||
"trustedGroups": { "2": { "event_status_notify": true } }
|
||||
}
|
||||
}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
|
||||
"node-1": { "enableStatusNotify": true }
|
||||
}`)
|
||||
|
||||
// Seed every singleton so the readers start from a loaded configuration
|
||||
// rather than racing the first store.
|
||||
if _, err := LoadGlobalConfig(global.ConfigPath.Global); err != nil {
|
||||
t.Fatalf("LoadGlobalConfig: %v", err)
|
||||
}
|
||||
if _, err := LoadBotUserConfig(global.ConfigPath.BotUserConfig); err != nil {
|
||||
t.Fatalf("LoadBotUserConfig: %v", err)
|
||||
}
|
||||
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
|
||||
const readers = 4
|
||||
const rounds = 40
|
||||
|
||||
var readersWg, writersWg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < readers; i++ {
|
||||
readersWg.Add(1)
|
||||
go func() {
|
||||
defer readersWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if cfg := Current(); cfg != nil {
|
||||
_ = cfg.System.DebugMode
|
||||
_ = cfg.System.ListenPort
|
||||
_ = cfg.ControllerMessage.BotStarted
|
||||
_ = cfg.Webhook.Endpoints
|
||||
}
|
||||
if users := BotUsers(); users != nil {
|
||||
_ = users.QQ.Admins.IDs()
|
||||
_ = users.QQ.TrustedGroups.IDs()
|
||||
}
|
||||
_ = BotNodes()
|
||||
_ = IsDebugMode()
|
||||
_ = NodeStatusNotifyEnabled("node-1")
|
||||
_ = WebhookEndpoints()
|
||||
_, _ = GetWebhookEndpoint("example")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Writer: reloads the global and bot user configurations, and rewrites the
|
||||
// node registry through UpdateSettings so its reload runs too.
|
||||
writersWg.Add(1)
|
||||
go func() {
|
||||
defer writersWg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
enabled := i%2 == 0
|
||||
patch := map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": enabled},
|
||||
"controllerMessage": map[string]interface{}{
|
||||
"BOT_STARTED": "hello",
|
||||
},
|
||||
}
|
||||
if err := UpdateSettings(SettingGlobal, patch); err != nil {
|
||||
t.Errorf("UpdateSettings(global): %v", err)
|
||||
return
|
||||
}
|
||||
if err := UpdateSettings(SettingBotUserConfig, map[string]interface{}{
|
||||
"qq(napcat)": map[string]interface{}{
|
||||
"admins": map[string]interface{}{
|
||||
"1": map[string]interface{}{"event_reply": enabled},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Errorf("UpdateSettings(bot_user_config): %v", err)
|
||||
return
|
||||
}
|
||||
if err := UpdateSettings(SettingBotNodeConfig, map[string]interface{}{
|
||||
"node-2": map[string]interface{}{"enableStatusNotify": enabled},
|
||||
}); err != nil {
|
||||
t.Errorf("UpdateSettings(bot_node_config): %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Second writer: the node tracker's background save, which shares the same
|
||||
// read-modify-write lock as the admin edits above.
|
||||
writersWg.Add(1)
|
||||
go func() {
|
||||
defer writersWg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
if err := SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, []string{"node-1", "node-2"}); err != nil {
|
||||
t.Errorf("SaveBotNodeConfig: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Let the writers finish, then release the readers. Waiting on the readers
|
||||
// first would deadlock: they only return once stop is closed.
|
||||
writersWg.Wait()
|
||||
close(stop)
|
||||
readersWg.Wait()
|
||||
|
||||
// The last write must be visible: the accessors are not allowed to serve a
|
||||
// stale configuration once UpdateSettings has returned.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("final UpdateSettings(global): %v", err)
|
||||
}
|
||||
cfg := Current()
|
||||
if cfg == nil {
|
||||
t.Fatal("Current() is nil after a successful reload")
|
||||
}
|
||||
if !cfg.System.DebugMode {
|
||||
t.Error("Current() did not observe the reloaded debugMode")
|
||||
}
|
||||
if cfg.System.ListenPort != "8080" {
|
||||
t.Errorf("reload dropped an untouched sibling: listenPort = %q", cfg.System.ListenPort)
|
||||
}
|
||||
}
|
||||
+99
-4
@@ -1,11 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
||||
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
||||
// other config files this one is auto-generated and optional: a missing or
|
||||
// empty file yields an empty map, so every node falls back to its default
|
||||
// enableStatusNotify value (true).
|
||||
func LoadBotNodeConfig(configPath string) error {
|
||||
cfg := BotNodeMembers{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
botNodeConfig.Store(&cfg)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read bot node config file: %w", err)
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("failed to parse bot node config file: %w", err)
|
||||
}
|
||||
}
|
||||
botNodeConfig.Store(&cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeStatusNotifyEnabled reports whether the node identified by uuid should
|
||||
// broadcast status-change notifications, per bot_node_config.json.
|
||||
// enableStatusNotify defaults to true: a node notifies unless its entry
|
||||
// explicitly sets the flag to false. A missing configuration (nil map) yields
|
||||
// the same default.
|
||||
func NodeStatusNotifyEnabled(uuid string) bool {
|
||||
opts, ok := BotNodes()[uuid]
|
||||
if !ok || opts.EnableStatusNotify == nil {
|
||||
return true
|
||||
}
|
||||
return *opts.EnableStatusNotify
|
||||
}
|
||||
|
||||
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
||||
// and stores it as a global singleton.
|
||||
func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
@@ -67,6 +105,17 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
cfg.ControllerMethod.Webhook.Headers = map[string]string{}
|
||||
}
|
||||
|
||||
// Apply defaults for the incoming webhook API.
|
||||
if cfg.Webhook.ListenAddr == "" {
|
||||
cfg.Webhook.ListenAddr = "0.0.0.0"
|
||||
}
|
||||
if cfg.Webhook.ListenPort == "" {
|
||||
cfg.Webhook.ListenPort = "8081"
|
||||
}
|
||||
if cfg.Webhook.Endpoints == nil {
|
||||
cfg.Webhook.Endpoints = map[string]WebhookEndpointConfig{}
|
||||
}
|
||||
|
||||
// Apply defaults for paths.
|
||||
if cfg.DataPath == "" {
|
||||
cfg.DataPath = "./data"
|
||||
@@ -95,7 +144,7 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
cfg.ControllerMessage.ServerExecuteResult = "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
|
||||
}
|
||||
|
||||
C_globalConfig = &cfg
|
||||
globalConfig.Store(&cfg)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -111,14 +160,60 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse bot user config file: %w", err)
|
||||
}
|
||||
C_botUserConfig = &cfg
|
||||
botUserConfig.Store(&cfg)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveBotNodeConfig persists the given node UUIDs to the bot node config file,
|
||||
// which is a plain registry of the nodes Komari reports (read back at startup
|
||||
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
||||
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
||||
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
|
||||
// the first time are added as an empty object, i.e. no parameter is written, so
|
||||
// they inherit the enableStatusNotify default (true). The resulting JSON has
|
||||
// its object keys emitted in sorted order by encoding/json, keeping the file
|
||||
// deterministic across writes. The path is supplied by the caller (typically
|
||||
// global.ConfigPath.BotNodeConfig).
|
||||
func SaveBotNodeConfig(configPath string, uuids []string) error {
|
||||
// Serialize against admin edits of the same file via UpdateSettings so the
|
||||
// two read-modify-write paths cannot drop each other's changes.
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
// Start from whatever is already on disk so nothing is dropped. An empty or
|
||||
// missing file is treated as an empty map.
|
||||
members := make(BotNodeMembers)
|
||||
if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, &members); err != nil {
|
||||
return fmt.Errorf("failed to parse existing bot node config %s: %w", configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add every currently-fetched UUID, but never overwrite an entry that is
|
||||
// already configured.
|
||||
for _, uuid := range uuids {
|
||||
if _, ok := members[uuid]; !ok {
|
||||
members[uuid] = BotNodeOptions{}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(members, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal bot node config: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write bot node config %s: %w", configPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDebugMode returns whether debug mode is enabled.
|
||||
func IsDebugMode() bool {
|
||||
if C_globalConfig == nil {
|
||||
cfg := Current()
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
return C_globalConfig.System.DebugMode
|
||||
return cfg.System.DebugMode
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// reloadHooks are the callbacks run after the global configuration has been
|
||||
// reloaded, i.e. after every settings update that touches config.json.
|
||||
//
|
||||
// They exist so that packages which already depend on config — the controller
|
||||
// manager, the logger — can react to an update without config importing them,
|
||||
// which would be an import cycle. Everything a hook needs is passed in.
|
||||
var (
|
||||
reloadHooksMu sync.Mutex
|
||||
reloadHooks []func(*Config)
|
||||
|
||||
// reloadRunMu serializes hook execution. A hook mutates process-wide state
|
||||
// — the logger's debug flag, the controller registry — so two overlapping
|
||||
// settings updates must not run them at the same time, or both would decide
|
||||
// to rebuild the controllers from their own view of what was applied.
|
||||
reloadRunMu sync.Mutex
|
||||
)
|
||||
|
||||
// OnReload registers a hook to run after every reload of the global
|
||||
// configuration, receiving the configuration now in effect. Hooks run in
|
||||
// registration order.
|
||||
//
|
||||
// Register once, at startup, before the first settings update can arrive: a
|
||||
// hook registered later has already missed the updates that came before it, and
|
||||
// the configuration it would have seen is not replayed.
|
||||
//
|
||||
// A hook runs on the goroutine serving /api/settings/set, so it must not block
|
||||
// for long. It may be called concurrently by two overlapping updates.
|
||||
func OnReload(hook func(*Config)) {
|
||||
reloadHooksMu.Lock()
|
||||
defer reloadHooksMu.Unlock()
|
||||
reloadHooks = append(reloadHooks, hook)
|
||||
}
|
||||
|
||||
// notifyReload runs every registered hook with cfg. A nil cfg is the signal
|
||||
// that the update touched one of the other settings files, which have no
|
||||
// hook-visible reload, and it is ignored.
|
||||
//
|
||||
// A panicking hook is logged and skipped rather than allowed to unwind through
|
||||
// UpdateSettings: by the time hooks run the new configuration is already on
|
||||
// disk and published in memory, so reporting the write as failed would be a
|
||||
// lie, and the hooks registered after the broken one must still run.
|
||||
func notifyReload(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Copy under the registry lock, then release it before running anything: a
|
||||
// hook is free to register another hook without deadlocking.
|
||||
reloadHooksMu.Lock()
|
||||
hooks := make([]func(*Config), len(reloadHooks))
|
||||
copy(hooks, reloadHooks)
|
||||
reloadHooksMu.Unlock()
|
||||
|
||||
reloadRunMu.Lock()
|
||||
defer reloadRunMu.Unlock()
|
||||
for _, hook := range hooks {
|
||||
runReloadHook(hook, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// runReloadHook runs one hook, isolating a panic to that hook. Recovering in a
|
||||
// separate function rather than inline is deliberate: a deferred recover placed
|
||||
// in the loop body would not run until notifyReload itself returned, which
|
||||
// would abandon the remaining hooks.
|
||||
func runReloadHook(hook func(*Config), cfg *Config) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Configuration reload hook panicked: %v", r))
|
||||
}
|
||||
}()
|
||||
hook(cfg)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// swapReloadHooks replaces the registered reload hooks for the duration of a
|
||||
// test and restores the previous set afterwards, so the package-level registry
|
||||
// cannot leak into another test.
|
||||
func swapReloadHooks(t *testing.T, hooks ...func(*Config)) {
|
||||
t.Helper()
|
||||
|
||||
reloadHooksMu.Lock()
|
||||
original := reloadHooks
|
||||
reloadHooks = hooks
|
||||
reloadHooksMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
reloadHooksMu.Lock()
|
||||
reloadHooks = original
|
||||
reloadHooksMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestReloadHookRunsOnlyForGlobalSettings(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{}`)
|
||||
|
||||
var seen []bool
|
||||
swapReloadHooks(t, func(cfg *Config) {
|
||||
seen = append(seen, cfg.System.DebugMode)
|
||||
})
|
||||
|
||||
// A config.json update runs the hook, with the values just written.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings(global): %v", err)
|
||||
}
|
||||
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("hook ran %d times for a config.json update, want 1", len(seen))
|
||||
}
|
||||
if !seen[0] {
|
||||
t.Error("hook received a configuration without the updated debugMode")
|
||||
}
|
||||
|
||||
// The other two files reload a singleton that callers read at the point of
|
||||
// use, so they have nothing to notify.
|
||||
for _, settingsType := range []string{SettingBotUserConfig, SettingBotNodeConfig} {
|
||||
if err := UpdateSettings(settingsType, map[string]interface{}{
|
||||
"unused": map[string]interface{}{"event_reply": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings(%s): %v", settingsType, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(seen) != 1 {
|
||||
t.Errorf("hook ran %d times after updates to the other settings files, want 1", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadHookIsolatesPanic(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
|
||||
reached := false
|
||||
swapReloadHooks(t,
|
||||
func(*Config) { panic("hook under test") },
|
||||
func(*Config) { reached = true },
|
||||
)
|
||||
|
||||
// The configuration is already on disk and published by the time hooks run,
|
||||
// so a broken hook must not turn a successful write into a failed request.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("a panicking hook must not fail the settings write: %v", err)
|
||||
}
|
||||
if !reached {
|
||||
t.Error("a panicking hook stopped the hooks registered after it")
|
||||
}
|
||||
|
||||
// The write itself must still have landed.
|
||||
cfg := Current()
|
||||
if cfg == nil || !cfg.System.DebugMode {
|
||||
t.Error("the settings write did not take effect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnrelatedUpdateLeavesControllerMethodAlone guards the trigger for a
|
||||
// controller rebuild. Whether to rebuild is decided by comparing the whole
|
||||
// controllerMethod section with the one the running controllers were built
|
||||
// from, so reloading the file has to reproduce that section byte for byte. A
|
||||
// default applied inconsistently — a nil recipient slice turned into an empty
|
||||
// one on the second load, say — would make every settings edit look like a
|
||||
// controller change and tear down every channel on each save.
|
||||
func TestUnrelatedUpdateLeavesControllerMethodAlone(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"controllerMethod": {
|
||||
"qq(napcat)": { "enabled": false },
|
||||
"email": { "enabled": false, "to": [] },
|
||||
"webhook": { "enabled": false, "headers": {} }
|
||||
}
|
||||
}`)
|
||||
|
||||
first, err := LoadGlobalConfig(global.ConfigPath.Global)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadGlobalConfig: %v", err)
|
||||
}
|
||||
before := first.ControllerMethod
|
||||
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"controllerMessage": map[string]interface{}{"BOT_STARTED": "hello"},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
after := Current().ControllerMethod
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("an unrelated update changed the controllerMethod section:\nbefore: %+v\nafter: %+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadHookSeesEveryWriterPath pins the invariant that each writer of
|
||||
// config.json notifies, not just /api/settings/set: the webhook endpoint
|
||||
// helpers go through the same channel.
|
||||
func TestReloadHookSeesEveryWriterPath(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"webhook": { "endpoints": {} }
|
||||
}`)
|
||||
|
||||
var seen int
|
||||
swapReloadHooks(t, func(*Config) { seen++ })
|
||||
|
||||
if err := AddWebhookEndpoint("example", map[string]interface{}{
|
||||
"enabled": true,
|
||||
"token": "secret",
|
||||
"notifyPipes": []interface{}{"ntfy"},
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Errorf("hook ran %d times after adding an endpoint, want 1", seen)
|
||||
}
|
||||
|
||||
if err := ModifyWebhookEndpoint("example", map[string]interface{}{
|
||||
"enabled": false,
|
||||
}); err != nil {
|
||||
t.Fatalf("ModifyWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Errorf("hook ran %d times after modifying an endpoint, want 2", seen)
|
||||
}
|
||||
|
||||
if err := DeleteWebhookEndpoint("example"); err != nil {
|
||||
t.Fatalf("DeleteWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 3 {
|
||||
t.Errorf("hook ran %d times after deleting an endpoint, want 3", seen)
|
||||
}
|
||||
|
||||
// A rejected write changes nothing, so it must not notify either.
|
||||
if err := DeleteWebhookEndpoint("ghost"); err == nil {
|
||||
t.Error("deleting an unknown endpoint should fail")
|
||||
}
|
||||
if seen != 3 {
|
||||
t.Errorf("hook ran for a rejected write (%d notifications, want 3)", seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// Settings types accepted by the /api/settings/get and /api/settings/set
|
||||
// endpoints. Each maps 1:1 to a JSON configuration file on disk.
|
||||
const (
|
||||
SettingGlobal = "global"
|
||||
SettingBotUserConfig = "bot_user_config"
|
||||
SettingBotNodeConfig = "bot_node_config"
|
||||
)
|
||||
|
||||
// ErrUnsupportedSettingsType is returned when a settings type is not one of the
|
||||
// accepted constants above.
|
||||
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
|
||||
|
||||
// startupOnlySettings are the config.json keys that are read once before the
|
||||
// program starts serving and never again: the listener addresses, the paths the
|
||||
// databases are opened from, and the Komari dashboard its client is built
|
||||
// against. Editing one writes the file and replaces the in-memory
|
||||
// configuration, but the running program keeps the old value, so an update that
|
||||
// touches one is reported back to the caller instead of being silently
|
||||
// accepted.
|
||||
//
|
||||
// Keep this in step with main: these are exactly the settings main reads before
|
||||
// the HTTP server comes up. Everything else — controllerMethod, networkProxy,
|
||||
// the message templates, the debug switches — is picked up at runtime.
|
||||
var startupOnlySettings = []string{
|
||||
"system.listenAddr",
|
||||
"system.listenPort",
|
||||
"webhook.enabled",
|
||||
"webhook.listenAddr",
|
||||
"webhook.listenPort",
|
||||
"komari.dashboardURL",
|
||||
"dataPath",
|
||||
"dbPath",
|
||||
}
|
||||
|
||||
// RestartRequiredKeys lists the settings in patch that only take effect at
|
||||
// startup, as dot-separated paths, in the order startupOnlySettings declares
|
||||
// them. Only config.json carries such settings; an update to one of the other
|
||||
// files always reports nothing.
|
||||
//
|
||||
// The write itself succeeds either way — this is advice for the user, not a
|
||||
// rejection. The result is never nil, so a caller can put it straight into a
|
||||
// JSON response and get [] rather than null.
|
||||
func RestartRequiredKeys(settingsType string, patch map[string]interface{}) []string {
|
||||
keys := []string{}
|
||||
if settingsType != SettingGlobal {
|
||||
return keys
|
||||
}
|
||||
|
||||
patched := patchPaths(patch)
|
||||
for _, watched := range startupOnlySettings {
|
||||
for _, path := range patched {
|
||||
if pathsOverlap(path, watched) {
|
||||
keys = append(keys, watched)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// patchPaths expands a nested settings patch into the dot-separated paths of its
|
||||
// leaves. An object is descended into rather than reported, so a patch that only
|
||||
// names sections still resolves to the keys it changes, and a JSON null is a
|
||||
// leaf because it deletes the key it names.
|
||||
func patchPaths(patch map[string]interface{}) []string {
|
||||
paths := []string{}
|
||||
var walk func(prefix string, node map[string]interface{})
|
||||
walk = func(prefix string, node map[string]interface{}) {
|
||||
for key, value := range node {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if nested, ok := value.(map[string]interface{}); ok && nested != nil {
|
||||
walk(path, nested)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
walk("", patch)
|
||||
return paths
|
||||
}
|
||||
|
||||
// pathsOverlap reports whether a patched path and a watched setting can affect
|
||||
// each other: they are the same key, the patch names something inside the
|
||||
// watched setting, or the patch names a section the watched setting lives in.
|
||||
// The last case matters because a patch may replace a whole section, which
|
||||
// changes every key under it.
|
||||
func pathsOverlap(patched, watched string) bool {
|
||||
return patched == watched ||
|
||||
strings.HasPrefix(patched, watched+".") ||
|
||||
strings.HasPrefix(watched, patched+".")
|
||||
}
|
||||
|
||||
// settingsLock serializes read-modify-write access to the on-disk configuration
|
||||
// files so concurrent admin edits (UpdateSettings) and the node tracker's
|
||||
// background save (SaveBotNodeConfig) cannot lose each other's updates.
|
||||
var settingsLock sync.RWMutex
|
||||
|
||||
// settingsPath resolves a settings type to its JSON configuration file path.
|
||||
func settingsPath(settingsType string) (string, error) {
|
||||
switch settingsType {
|
||||
case SettingGlobal:
|
||||
return global.ConfigPath.Global, nil
|
||||
case SettingBotUserConfig:
|
||||
return global.ConfigPath.BotUserConfig, nil
|
||||
case SettingBotNodeConfig:
|
||||
return global.ConfigPath.BotNodeConfig, nil
|
||||
default:
|
||||
return "", ErrUnsupportedSettingsType
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidSettingsType reports whether the given string is one of the accepted
|
||||
// settings types.
|
||||
func IsValidSettingsType(settingsType string) bool {
|
||||
_, err := settingsPath(settingsType)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetSettings returns the raw JSON of the file backing the given settings type,
|
||||
// byte-for-byte the same content as the source file. bot_node_config.json is
|
||||
// optional and auto-generated, so a missing or empty file yields an empty
|
||||
// object.
|
||||
func GetSettings(settingsType string) ([]byte, error) {
|
||||
path, err := settingsPath(settingsType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && settingsType == SettingBotNodeConfig {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read %s settings file: %w", settingsType, err)
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UpdateSettings merges the given partial update into the JSON file backing the
|
||||
// given settings type and persists the result back to disk. Because the merge is
|
||||
// recursive, a payload such as {"system":{"debugMode":true}} only touches the
|
||||
// nested keys it names and leaves every sibling key untouched. After the file is
|
||||
// written the matching in-memory singleton is reloaded so runtime code observes
|
||||
// the new values.
|
||||
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
return updateSettingsLocked(settingsType, patch)
|
||||
})
|
||||
}
|
||||
|
||||
// runSettingsUpdate runs fn under settingsLock and then, once the lock is
|
||||
// released, runs the reload hooks with whatever configuration fn reports (nil
|
||||
// when the update did not touch config.json).
|
||||
//
|
||||
// The hooks deliberately run outside settingsLock. A hook rebuilds controllers,
|
||||
// which can wait on a network call, while settingsLock is also held by the node
|
||||
// tracker's background save (SaveBotNodeConfig); holding it across a hook would
|
||||
// stall node registration behind an unrelated settings edit.
|
||||
func runSettingsUpdate(fn func() (*Config, error)) error {
|
||||
cfg, err := func() (*Config, error) {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
return fn()
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyReload(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateSettingsLocked is UpdateSettings without the locking, for callers that
|
||||
// need to inspect the loaded configuration and write in one critical section
|
||||
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
|
||||
//
|
||||
// It returns the freshly loaded global configuration, or nil when the settings
|
||||
// type is one of the other files. The caller is responsible for handing that
|
||||
// value to notifyReload once settingsLock is released — which runSettingsUpdate
|
||||
// does for every writer.
|
||||
func updateSettingsLocked(settingsType string, patch map[string]interface{}) (*Config, error) {
|
||||
path, err := settingsPath(settingsType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Start from whatever is already on disk so nothing is dropped. A missing or
|
||||
// empty file is treated as an empty object.
|
||||
current := map[string]interface{}{}
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, ¤t); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
|
||||
deepMergeSettings(current, patch)
|
||||
|
||||
data, err = json.MarshalIndent(current, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
|
||||
return reloadSettings(settingsType, path)
|
||||
}
|
||||
|
||||
// deepMergeSettings recursively overlays src onto dst. Object values merge
|
||||
// key-by-key so partial updates keep sibling keys untouched; arrays and scalars
|
||||
// always replace the destination value. A JSON null in the payload removes that
|
||||
// key from dst, giving clients a way to delete entries (members, nodes, map
|
||||
// rows) through /api/settings/set.
|
||||
func deepMergeSettings(dst, src map[string]interface{}) {
|
||||
for key, srcVal := range src {
|
||||
if srcVal == nil {
|
||||
delete(dst, key)
|
||||
continue
|
||||
}
|
||||
srcObj, srcIsObj := srcVal.(map[string]interface{})
|
||||
if srcIsObj {
|
||||
if dstObj, ok := dst[key].(map[string]interface{}); ok {
|
||||
deepMergeSettings(dstObj, srcObj)
|
||||
} else {
|
||||
dst[key] = srcVal
|
||||
}
|
||||
continue
|
||||
}
|
||||
dst[key] = srcVal
|
||||
}
|
||||
}
|
||||
|
||||
// reloadSettings refreshes the in-memory singleton for the given settings type
|
||||
// so the running program observes the values just persisted to disk. Only
|
||||
// config.json has a hook-visible reload, so for SettingGlobal it returns the
|
||||
// configuration now in effect and for the other types it returns nil.
|
||||
func reloadSettings(settingsType, path string) (*Config, error) {
|
||||
switch settingsType {
|
||||
case SettingGlobal:
|
||||
return LoadGlobalConfig(path)
|
||||
case SettingBotUserConfig:
|
||||
_, err := LoadBotUserConfig(path)
|
||||
return nil, err
|
||||
case SettingBotNodeConfig:
|
||||
return nil, LoadBotNodeConfig(path)
|
||||
default:
|
||||
return nil, ErrUnsupportedSettingsType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// writeTempConfig writes content to a fresh temp file and points the matching
|
||||
// global.ConfigPath field at it, returning a cleanup that restores the original.
|
||||
func writeTempConfig(t *testing.T, field *string, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
original := *field
|
||||
*field = path
|
||||
t.Cleanup(func() { *field = original })
|
||||
}
|
||||
|
||||
func TestUpdateSettingsDeepMerge(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
|
||||
"qq(napcat)": {
|
||||
"admins": {
|
||||
"100000001": {
|
||||
"event_status_notify": true,
|
||||
"event_bot_started": true
|
||||
}
|
||||
},
|
||||
"trustedGroups": {
|
||||
"200000002": {
|
||||
"event_status_notify": true,
|
||||
"event_bot_started": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
patch := map[string]interface{}{
|
||||
"qq(napcat)": map[string]interface{}{
|
||||
"admins": map[string]interface{}{
|
||||
"100000001": map[string]interface{}{
|
||||
"event_status_notify": false, // toggle an existing nested flag
|
||||
"event_reply": true, // add a key that is not in the file
|
||||
},
|
||||
},
|
||||
"trustedGroups": map[string]interface{}{
|
||||
"12345": map[string]interface{}{ // add a whole new member
|
||||
"event_bot_started": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := UpdateSettings(SettingBotUserConfig, patch); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
data, err := GetSettings(SettingBotUserConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
`"event_status_notify": false`,
|
||||
`"event_reply": true`,
|
||||
`"event_bot_started": true`,
|
||||
`"event_status_notify": true`, // sibling under trustedGroups 200000002 kept
|
||||
`"12345"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("merged config missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The in-memory singleton must reflect the merged file too.
|
||||
users := BotUsers()
|
||||
if users == nil {
|
||||
t.Fatal("bot user config not reloaded")
|
||||
}
|
||||
if users.QQ.Admins["100000001"].EventStatusNotify {
|
||||
t.Error("expected reloaded admin event_status_notify = false")
|
||||
}
|
||||
if !users.QQ.Admins["100000001"].EventReply {
|
||||
t.Error("expected reloaded admin event_reply = true")
|
||||
}
|
||||
if !users.QQ.TrustedGroups["12345"].EventBotStarted {
|
||||
t.Error("expected new trusted group event_bot_started = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsReplacesArraysAndKeepsNumbers(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"system": {
|
||||
"debugMode": true,
|
||||
"listenPort": "8080"
|
||||
},
|
||||
"controllerMethod": {
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"smtpHost": "smtp.example.com",
|
||||
"smtpPort": 587,
|
||||
"to": ["old@example.com"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
patch := map[string]interface{}{
|
||||
"system": map[string]interface{}{
|
||||
"debugMode": false, // partial: listenPort must survive
|
||||
},
|
||||
"controllerMethod": map[string]interface{}{
|
||||
"email": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"smtpPort": json.Number("465"),
|
||||
"to": []interface{}{"new@example.com"}, // arrays replace, not merge
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := UpdateSettings(SettingGlobal, patch); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
data, err := GetSettings(SettingGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
`"debugMode": false`,
|
||||
`"listenPort": "8080"`, // sibling untouched
|
||||
`"smtpHost": "smtp.example.com"`, // sibling untouched
|
||||
`"smtpPort": 465`, // number kept verbatim, not 465.0
|
||||
`"new@example.com"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("merged config missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "old@example.com") {
|
||||
t.Errorf("array was merged instead of replaced:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartRequiredKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
settingsType string
|
||||
patch map[string]interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "a runtime switch needs no restart",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"system": map[string]interface{}{"debugMode": true}},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "the listen port does",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"system": map[string]interface{}{"listenPort": "9090"}},
|
||||
want: []string{"system.listenPort"},
|
||||
},
|
||||
{
|
||||
name: "only the startup key of a mixed patch is reported",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{
|
||||
"system": map[string]interface{}{"listenPort": "9090", "debugMode": true},
|
||||
},
|
||||
want: []string{"system.listenPort"},
|
||||
},
|
||||
{
|
||||
name: "a top-level path is reported",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"dataPath": "/srv/data"},
|
||||
want: []string{"dataPath"},
|
||||
},
|
||||
{
|
||||
name: "results follow the declared order, not the patch order",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"dbPath": "/srv/db", "dataPath": "/srv/data"},
|
||||
want: []string{"dataPath", "dbPath"},
|
||||
},
|
||||
{
|
||||
name: "deleting a startup key with null is reported",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"system": map[string]interface{}{"listenPort": nil}},
|
||||
want: []string{"system.listenPort"},
|
||||
},
|
||||
{
|
||||
name: "replacing a whole section reports the startup keys inside it",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"webhook": map[string]interface{}{"listenAddr": "127.0.0.1"}},
|
||||
want: []string{"webhook.listenAddr"},
|
||||
},
|
||||
{
|
||||
// Deleting the section resets the URL to its built-in default.
|
||||
name: "deleting a section the startup key lives in reports it",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"komari": nil},
|
||||
want: []string{"komari.dashboardURL"},
|
||||
},
|
||||
{
|
||||
name: "deleting a section reports every startup key inside it",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"webhook": nil},
|
||||
want: []string{"webhook.enabled", "webhook.listenAddr", "webhook.listenPort"},
|
||||
},
|
||||
{
|
||||
// An empty object merges nothing, so it changes no key and needs no
|
||||
// restart — surprising enough to pin.
|
||||
name: "an empty object changes nothing",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{"komari": map[string]interface{}{}},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
// webhook.endpoints must not be mistaken for webhook.enabled.
|
||||
name: "a sibling subtree is not mistaken for the startup key",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{
|
||||
"webhook": map[string]interface{}{
|
||||
"endpoints": map[string]interface{}{"example": map[string]interface{}{"enabled": true}},
|
||||
},
|
||||
},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
// The Komari credentials are re-read on the next login, so only the
|
||||
// dashboard URL is startup-only.
|
||||
name: "komari credentials are not startup-only",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{
|
||||
"komari": map[string]interface{}{
|
||||
"account": map[string]interface{}{"username": "admin", "password": "x"},
|
||||
},
|
||||
},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "controller settings are not startup-only",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{
|
||||
"controllerMethod": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{"enabled": true, "botToken": "t"},
|
||||
},
|
||||
},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "an empty patch reports nothing",
|
||||
settingsType: SettingGlobal,
|
||||
patch: map[string]interface{}{},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
// Only config.json has settings that are read once at startup.
|
||||
name: "the other settings files never need a restart",
|
||||
settingsType: SettingBotUserConfig,
|
||||
patch: map[string]interface{}{"dataPath": "/srv/data"},
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := RestartRequiredKeys(tc.settingsType, tc.patch)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("RestartRequiredKeys() = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got == nil {
|
||||
t.Error("the result must never be nil, so it serializes as [] rather than null")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestartRequiredKeysIsAdvisory pins that reporting a startup-only key does
|
||||
// not stop the write: the caller is told, the file is still updated.
|
||||
func TestRestartRequiredKeysIsAdvisory(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"listenPort":"8080"}}`)
|
||||
|
||||
keys := RestartRequiredKeys(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"listenPort": "9090"},
|
||||
})
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected the listen port to be reported, got %v", keys)
|
||||
}
|
||||
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"listenPort": "9090"},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
if got := Current().System.ListenPort; got != "9090" {
|
||||
t.Errorf("the update was not applied: listenPort = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsTypeValidation(t *testing.T) {
|
||||
for _, valid := range []string{SettingGlobal, SettingBotUserConfig, SettingBotNodeConfig} {
|
||||
if !IsValidSettingsType(valid) {
|
||||
t.Errorf("expected %q to be a valid settings type", valid)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"", "system", "node", "bot"} {
|
||||
if IsValidSettingsType(invalid) {
|
||||
t.Errorf("expected %q to be an invalid settings type", invalid)
|
||||
}
|
||||
if _, err := GetSettings(invalid); err != ErrUnsupportedSettingsType {
|
||||
t.Errorf("GetSettings(%q) error = %v, want ErrUnsupportedSettingsType", invalid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettingsBotNodeMissingFile(t *testing.T) {
|
||||
// Point at a temp path that does not exist yet.
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, "")
|
||||
os.Remove(global.ConfigPath.BotNodeConfig)
|
||||
|
||||
data, err := GetSettings(SettingBotNodeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings on missing bot_node_config: %v", err)
|
||||
}
|
||||
if string(data) != "{}" {
|
||||
t.Errorf("expected empty object for missing bot_node_config, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRemovesKeysWithNull(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
|
||||
"qq(napcat)": {
|
||||
"admins": {
|
||||
"100000001": { "event_status_notify": true },
|
||||
"200000002": { "event_status_notify": false }
|
||||
},
|
||||
"trustedGroups": {
|
||||
"999": { "event_bot_started": true }
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"admins": {}
|
||||
}
|
||||
}`)
|
||||
|
||||
// null removes a nested member and keeps its siblings; an empty section stays.
|
||||
patch := map[string]interface{}{
|
||||
"qq(napcat)": map[string]interface{}{
|
||||
"admins": map[string]interface{}{
|
||||
"100000001": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := UpdateSettings(SettingBotUserConfig, patch); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
data, err := GetSettings(SettingBotUserConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
if strings.Contains(got, "100000001") {
|
||||
t.Errorf("deleted member still present:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"200000002", `"trustedGroups"`, `"telegram"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("unrelated content missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
-4
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// SystemConfig holds system-level configuration.
|
||||
type SystemConfig struct {
|
||||
@@ -34,6 +37,7 @@ type KomariConfig struct {
|
||||
|
||||
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
||||
type QQConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
NapcatAddr string `json:"napcatAddr"`
|
||||
@@ -45,6 +49,7 @@ type QQConfig struct {
|
||||
|
||||
// TelegramConfig holds Telegram Bot controller configuration.
|
||||
type TelegramConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
BotToken string `json:"botToken"`
|
||||
@@ -53,6 +58,7 @@ type TelegramConfig struct {
|
||||
|
||||
// EmailConfig holds Email notification controller configuration.
|
||||
type EmailConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
@@ -66,6 +72,7 @@ type EmailConfig struct {
|
||||
|
||||
// NtfyConfig holds Ntfy notification controller configuration.
|
||||
type NtfyConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
Server string `json:"server"`
|
||||
@@ -74,8 +81,11 @@ type NtfyConfig struct {
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// WebhookConfig holds Webhook notification controller configuration.
|
||||
// WebhookConfig holds the outgoing Webhook notification controller
|
||||
// configuration. It is the counterpart of WebhookReceiverConfig, which serves
|
||||
// the incoming webhook API.
|
||||
type WebhookConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
URL string `json:"url"`
|
||||
@@ -93,6 +103,50 @@ type ControllerMethodConfig struct {
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
}
|
||||
|
||||
// WebhookEndpointConfig holds a single incoming webhook endpoint. Endpoints are
|
||||
// keyed by name under webhook.endpoints; the name is the last path segment of
|
||||
// the endpoint's URL, so an endpoint named "example" is served at
|
||||
// POST /api/webhook/example. One endpoint per external application and target
|
||||
// channel group keeps their tokens and recipients apart.
|
||||
type WebhookEndpointConfig struct {
|
||||
// Enabled controls whether the endpoint accepts requests. A disabled
|
||||
// endpoint answers with 403.
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Token is the shared secret the caller must send in the request body. An
|
||||
// endpoint without a token is rejected: an empty token would make the
|
||||
// endpoint an open relay, so it is treated as a configuration error.
|
||||
Token string `json:"token"`
|
||||
|
||||
// NotifyPipes lists the notification channels the alert is delivered to, by
|
||||
// controller name (e.g. "qq(napcat)", "telegram", "email", "ntfy",
|
||||
// "webhook").
|
||||
NotifyPipes []string `json:"notifyPipes"`
|
||||
}
|
||||
|
||||
// WebhookReceiverConfig holds the incoming webhook API settings. The API is
|
||||
// served on its own listener instead of the main one, so external applications
|
||||
// can be given access to the webhook port without exposing the admin API. Only
|
||||
// the endpoints map is re-read on a settings update; enabled, listenAddr and
|
||||
// listenPort are applied at startup.
|
||||
type WebhookReceiverConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ListenAddr string `json:"listenAddr"`
|
||||
ListenPort string `json:"listenPort"`
|
||||
Endpoints map[string]WebhookEndpointConfig `json:"endpoints"`
|
||||
}
|
||||
|
||||
// GetWebhookEndpoint returns the incoming webhook endpoint registered under the
|
||||
// given name, and whether such an endpoint exists.
|
||||
func GetWebhookEndpoint(name string) (WebhookEndpointConfig, bool) {
|
||||
cfg := Current()
|
||||
if cfg == nil {
|
||||
return WebhookEndpointConfig{}, false
|
||||
}
|
||||
endpoint, ok := cfg.Webhook.Endpoints[name]
|
||||
return endpoint, ok
|
||||
}
|
||||
|
||||
// ControllerMessageConfig holds message templates for controller responses.
|
||||
type ControllerMessageConfig struct {
|
||||
BotStarted string `json:"BOT_STARTED"`
|
||||
@@ -108,19 +162,42 @@ type Config struct {
|
||||
System SystemConfig `json:"system"`
|
||||
Debug DebugConfig `json:"debug"`
|
||||
Komari KomariConfig `json:"komari"`
|
||||
Webhook WebhookReceiverConfig `json:"webhook"`
|
||||
ControllerMethod ControllerMethodConfig `json:"controllerMethod"`
|
||||
ControllerMessage ControllerMessageConfig `json:"controllerMessage"`
|
||||
DataPath string `json:"dataPath"`
|
||||
DBPath string `json:"dbPath"`
|
||||
}
|
||||
|
||||
var C_globalConfig *Config
|
||||
// globalConfig holds the configuration currently in effect. It is replaced as a
|
||||
// whole by LoadGlobalConfig — and therefore by every settings update — and never
|
||||
// mutated in place, so a reader that loads the pointer always observes a fully
|
||||
// initialized Config. Read it through Current rather than caching the result: a
|
||||
// cached pointer stops tracking reloads.
|
||||
var globalConfig atomic.Pointer[Config]
|
||||
|
||||
// Current returns the configuration currently in effect, or nil before the
|
||||
// first successful LoadGlobalConfig. It is safe to call from any goroutine, and
|
||||
// must be called on every use rather than stored, so the caller sees reloads.
|
||||
func Current() *Config {
|
||||
return globalConfig.Load()
|
||||
}
|
||||
|
||||
// BotUserOptions holds per-member options stored in bot_user_config.json.
|
||||
type BotUserOptions struct {
|
||||
// EventStatusNotify indicates whether this member is subscribed to node
|
||||
// status change notifications.
|
||||
EventStatusNotify bool `json:"event_status_notify"`
|
||||
|
||||
// EventBotStarted indicates whether this member receives the automatic
|
||||
// messages the bot pushes on startup (welcome message and startup server
|
||||
// list).
|
||||
EventBotStarted bool `json:"event_bot_started"`
|
||||
|
||||
// EventReply indicates whether this member receives automatic replies to
|
||||
// their commands (e.g. /status, /list). If false, the bot will not send any
|
||||
// reply to this member's commands.
|
||||
EventReply bool `json:"event_reply"`
|
||||
}
|
||||
|
||||
// BotUserMembers maps a member ID (QQ number, Telegram @username or numeric
|
||||
@@ -159,4 +236,45 @@ type BotUserConfig struct {
|
||||
Telegram BotUser_TelegramConfig `json:"telegram"`
|
||||
}
|
||||
|
||||
var C_botUserConfig *BotUserConfig
|
||||
// botUserConfig mirrors bot_user_config.json the same way globalConfig mirrors
|
||||
// config.json: replaced wholesale on reload and read through BotUsers.
|
||||
var botUserConfig atomic.Pointer[BotUserConfig]
|
||||
|
||||
// BotUsers returns the bot user configuration currently in effect, or nil
|
||||
// before the first successful LoadBotUserConfig. Like Current it must be called
|
||||
// on every use rather than stored.
|
||||
func BotUsers() *BotUserConfig {
|
||||
return botUserConfig.Load()
|
||||
}
|
||||
|
||||
// BotNodeOptions holds per-node options stored in bot_node_config.json. The
|
||||
// file is auto-populated by the node tracker for every node Komari reports;
|
||||
// per-node options are edited by hand in the JSON file.
|
||||
type BotNodeOptions struct {
|
||||
// EnableStatusNotify controls whether this node broadcasts status-change
|
||||
// notifications. It defaults to true: only an explicit false in the JSON
|
||||
// file disables a node's notifications. The pointer (rather than a plain
|
||||
// bool) lets an absent field be told apart from an explicit false, and
|
||||
// omitempty keeps untouched nodes stored as {}.
|
||||
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
|
||||
}
|
||||
|
||||
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
|
||||
// options.
|
||||
type BotNodeMembers map[string]BotNodeOptions
|
||||
|
||||
// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig.
|
||||
// The map is rebuilt rather than mutated on every load, so the pointer can be
|
||||
// swapped atomically; read it through BotNodes.
|
||||
var botNodeConfig atomic.Pointer[BotNodeMembers]
|
||||
|
||||
// BotNodes returns the per-node options currently in effect, or nil before the
|
||||
// first LoadBotNodeConfig. Like Current it must be called on every use rather
|
||||
// than stored.
|
||||
func BotNodes() BotNodeMembers {
|
||||
nodes := botNodeConfig.Load()
|
||||
if nodes == nil {
|
||||
return nil
|
||||
}
|
||||
return *nodes
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Errors reported by the incoming webhook endpoint helpers. The HTTP layer maps
|
||||
// them onto statuses: exists -> 409, not found -> 404, invalid -> 400.
|
||||
var (
|
||||
// ErrWebhookEndpointExists is returned by AddWebhookEndpoint when the name
|
||||
// is already configured.
|
||||
ErrWebhookEndpointExists = errors.New("webhook endpoint already exists")
|
||||
|
||||
// ErrWebhookEndpointNotFound is returned when the named endpoint is not
|
||||
// configured.
|
||||
ErrWebhookEndpointNotFound = errors.New("webhook endpoint not found")
|
||||
|
||||
// ErrWebhookEndpointInvalid is returned when a name or field supplied for an
|
||||
// endpoint cannot be stored.
|
||||
ErrWebhookEndpointInvalid = errors.New("invalid webhook endpoint")
|
||||
)
|
||||
|
||||
// webhookEndpointFields are the endpoint keys a client may set. A field that is
|
||||
// absent from an update is left untouched; a field that is present but not
|
||||
// listed here is rejected rather than written, so a typo cannot leave an
|
||||
// endpoint silently ignoring a setting.
|
||||
var webhookEndpointFields = map[string]func(interface{}) bool{
|
||||
"enabled": isJSONBool,
|
||||
"token": isJSONString,
|
||||
"notifyPipes": isJSONStringArray,
|
||||
}
|
||||
|
||||
// WebhookEndpoints returns the configured incoming webhook endpoints keyed by
|
||||
// name, as a copy: changing the result does not change the loaded
|
||||
// configuration.
|
||||
func WebhookEndpoints() map[string]WebhookEndpointConfig {
|
||||
endpoints := map[string]WebhookEndpointConfig{}
|
||||
cfg := Current()
|
||||
if cfg == nil {
|
||||
return endpoints
|
||||
}
|
||||
for name, endpoint := range cfg.Webhook.Endpoints {
|
||||
endpoints[name] = endpoint
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
// AddWebhookEndpoint registers a new incoming webhook endpoint under name. Only
|
||||
// the fields present in fields are set, so an endpoint can be created with
|
||||
// default values and completed later by ModifyWebhookEndpoint. Unlike
|
||||
// ModifyWebhookEndpoint it refuses to touch an endpoint that already exists.
|
||||
func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||
if err := validateWebhookEndpointName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
patch, err := webhookEndpointPatch(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
if _, exists := webhookEndpoint(name); exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
})
|
||||
}
|
||||
|
||||
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
||||
// fields present in fields are changed; every other field keeps its configured
|
||||
// value.
|
||||
func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||
if err := validateWebhookEndpointName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
patch, err := webhookEndpointPatch(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
|
||||
}
|
||||
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
|
||||
// name. The endpoint stops accepting requests as soon as the configuration is
|
||||
// reloaded.
|
||||
func DeleteWebhookEndpoint(name string) error {
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
||||
})
|
||||
}
|
||||
|
||||
// webhookEndpoint returns the named endpoint held by the loaded configuration.
|
||||
// No extra lock is needed to read it: a reload replaces the whole configuration
|
||||
// rather than mutating it in place, and Current publishes the replacement
|
||||
// atomically, so the value read is always from one complete version.
|
||||
func webhookEndpoint(name string) (WebhookEndpointConfig, bool) {
|
||||
cfg := Current()
|
||||
if cfg == nil {
|
||||
return WebhookEndpointConfig{}, false
|
||||
}
|
||||
endpoint, exists := cfg.Webhook.Endpoints[name]
|
||||
return endpoint, exists
|
||||
}
|
||||
|
||||
// webhookEndpointsPatch wraps the fields of one endpoint into the nested patch
|
||||
// the settings merge expects for webhook.endpoints.<name>.
|
||||
func webhookEndpointsPatch(name string, fields map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"webhook": map[string]interface{}{
|
||||
"endpoints": map[string]interface{}{name: fields},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// webhookEndpointDeletePatch is the patch that removes an endpoint. The value is
|
||||
// a null, which the settings merge reads as "delete this key". It must be an
|
||||
// untyped nil: a nil map of type map[string]interface{} would be merged as an
|
||||
// empty object instead, leaving the endpoint in the configuration.
|
||||
func webhookEndpointDeletePatch(name string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"webhook": map[string]interface{}{
|
||||
"endpoints": map[string]interface{}{name: nil},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// webhookEndpointPatch validates the fields of one endpoint and returns them as
|
||||
// the value to merge. Fields not accepted for an endpoint are rejected instead
|
||||
// of being written to the configuration file.
|
||||
func webhookEndpointPatch(fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
patch := make(map[string]interface{}, len(fields))
|
||||
for key, value := range fields {
|
||||
accepts, known := webhookEndpointFields[key]
|
||||
if !known {
|
||||
return nil, fmt.Errorf("%w: unknown field %q", ErrWebhookEndpointInvalid, key)
|
||||
}
|
||||
if !accepts(value) {
|
||||
return nil, fmt.Errorf("%w: field %q has the wrong type", ErrWebhookEndpointInvalid, key)
|
||||
}
|
||||
patch[key] = value
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
// validateWebhookEndpointName checks that a name can address an endpoint. The
|
||||
// name is the last segment of the endpoint URL, so a name containing a slash
|
||||
// could never be reached.
|
||||
func validateWebhookEndpointName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: name must not be empty", ErrWebhookEndpointInvalid)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return fmt.Errorf("%w: name must not contain %q", ErrWebhookEndpointInvalid, "/")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The predicates below accept the decoded JSON types a field may carry. Numbers
|
||||
// decoded with UseNumber stay json.Number, so a JSON true/false is the only
|
||||
// value accepted for a boolean field.
|
||||
func isJSONBool(value interface{}) bool {
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isJSONString(value interface{}) bool {
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isJSONStringArray(value interface{}) bool {
|
||||
items, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, ok := item.(string); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+31
-3
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -81,12 +82,36 @@ func HashPassword(password string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user into the database.
|
||||
func CreateUser(username, password, level string) (int64, error) {
|
||||
// ErrUsersExist is returned by RegisterFirstUser when the users table is not
|
||||
// empty. Registration is only ever allowed for the very first user.
|
||||
var ErrUsersExist = errors.New("registration rejected: only the first user can be registered via this endpoint")
|
||||
|
||||
// RegisterFirstUser atomically creates the first user, but only while the users
|
||||
// table is empty. The emptiness check and the insert run inside a single
|
||||
// transaction. Because InitUserDB caps the pool at one connection, a concurrent
|
||||
// registration blocks at Begin until the in-flight transaction commits, so it
|
||||
// cannot observe the table as empty between the check and the insert. This
|
||||
// closes the check-then-insert race two simultaneous first-run registrations
|
||||
// would otherwise hit.
|
||||
func RegisterFirstUser(username, password, level string) (int64, error) {
|
||||
hashedPassword := HashPassword(password)
|
||||
registerDate := time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
result, err := UserDB.Exec(
|
||||
tx, err := UserDB.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("failed to check existing users: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return 0, ErrUsersExist
|
||||
}
|
||||
|
||||
result, err := tx.Exec(
|
||||
"INSERT INTO users (username, password, level, register_date) VALUES (?, ?, ?, ?)",
|
||||
username, hashedPassword, level, registerDate,
|
||||
)
|
||||
@@ -94,6 +119,9 @@ func CreateUser(username, password, level string) (int64, error) {
|
||||
return 0, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("failed to commit user creation: %w", err)
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// initTempDB opens a fresh user database in a temp directory and registers a
|
||||
// cleanup that closes it when the test finishes.
|
||||
func initTempDB(t *testing.T) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "user.db")
|
||||
if err := InitUserDB(path); err != nil {
|
||||
t.Fatalf("InitUserDB: %v", err)
|
||||
}
|
||||
t.Cleanup(CloseUserDB)
|
||||
}
|
||||
|
||||
func TestRegisterFirstUserClosedAfterFirst(t *testing.T) {
|
||||
initTempDB(t)
|
||||
|
||||
if _, err := RegisterFirstUser("alice", "password1", "admin"); err != nil {
|
||||
t.Fatalf("first registration should succeed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := RegisterFirstUser("bob", "password2", "admin"); !errors.Is(err, ErrUsersExist) {
|
||||
t.Fatalf("second registration error = %v, want ErrUsersExist", err)
|
||||
}
|
||||
|
||||
count, err := GetUserCount()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserCount: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("user count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterFirstUserConcurrentOnlyOneWins(t *testing.T) {
|
||||
initTempDB(t)
|
||||
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := RegisterFirstUser("user"+string(rune('a'+i)), "password", "admin")
|
||||
results <- err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
successes := 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrUsersExist):
|
||||
// Expected for every registration that lost the race.
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("concurrent registrations: %d succeeded, want exactly 1", successes)
|
||||
}
|
||||
|
||||
count, err := GetUserCount()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserCount: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("user count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,62 @@
|
||||
# Nukumizu Console
|
||||
|
||||
Web console for the Nukumizu backend — a remote-server monitor and alert bot. Built with Vue 3 + Vite, no UI framework; styling is hand-rolled on CSS custom properties with a light/dark theme.
|
||||
|
||||
## Features
|
||||
|
||||
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
|
||||
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes.
|
||||
- **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`.
|
||||
- **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section.
|
||||
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
The dev server proxies `/api` (and the log websocket) to the backend. By default it targets `http://127.0.0.1:8080`; override with:
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:NUKUMIZU_API = "http://192.168.20.4:8080"; npm run dev
|
||||
```
|
||||
|
||||
Production build:
|
||||
|
||||
```bash
|
||||
npm run build # outputs ../web/dist
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Vite writes to `../web/dist` rather than `frontend/dist` (see `build.outDir` in `vite.config.js`) because the Go backend embeds that directory into the binary — `go:embed` cannot reach outside the package it sits in, so the output has to live under `web/`. The repo's `build-*` scripts run this build for you and compile the backend afterwards; `npm run build` alone does not change what an already-built binary serves.
|
||||
|
||||
## API contract notes
|
||||
|
||||
- Auth uses two headers on every request: `X-Token` (from login) and `X-Timestamp` — a **Unix timestamp in seconds** (not milliseconds) with a ±30 min tolerance (skipped when `system.debugMode` is on).
|
||||
- Token & user are kept in `localStorage`. On a `401` the session is cleared and you are returned to the login page.
|
||||
- The panel talks to `/api/server/getStatus`, `/api/server/getInfo`, `/api/settings/get`, `/api/settings/set` and `/api/user/login|register`. All management endpoints require an `admin` token.
|
||||
- `/api/settings/set` is a deep merge: sending a JSON `null` for a key removes it (used when deleting a trusted member).
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── index.html
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── main.js / App.vue
|
||||
├── router/ # routes + auth guard
|
||||
├── api/index.js # authApi, serverApi, settingsApi
|
||||
├── utils/ # auth, http client, theme, toasts, formatting
|
||||
├── styles/ # theme.css (tokens) + ui.css (primitives)
|
||||
├── components/ # TopBar, SideBar, Modal, Toggle, TagsEditor, HeadersEditor
|
||||
└── views/ # Login, Layout, Overview, Trusted, Settings, Logs
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- The log websocket needs an `admin` token, and a browser cannot set headers on a WebSocket handshake, so the token rides in the query string (`/api/system/getLogs?token=…×tamp=…`). That URL is a credential: it can end up in proxy and access logs, so don't paste it into third-party tools. The view reconnects with a fresh token from `localStorage` on every attempt.
|
||||
- Registering more than one user is intentionally impossible; the backend only accepts the very first registration.
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Nukumizu Console</title>
|
||||
<script>
|
||||
(function () {
|
||||
var saved = localStorage.getItem('nukumizu_theme');
|
||||
var dark = saved === 'dark' || (saved !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nukumizu-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Web console for Nukumizu — remote server monitor & alert bot",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"axios": "^1.7.9",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { toasts } from './utils/toast.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const glyph = { success: '✓', error: '✕', info: 'i', warn: '!' };
|
||||
|
||||
function onAuthExpired() {
|
||||
if (router.currentRoute.value.name !== 'Login') {
|
||||
router.push({ name: 'Login' });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('auth:expired', onAuthExpired));
|
||||
onBeforeUnmount(() => window.removeEventListener('auth:expired', onAuthExpired));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<div class="toasts" aria-live="polite">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast"
|
||||
:class="[t.type, { leaving: t.leaving }]"
|
||||
role="status"
|
||||
>
|
||||
<span class="t-icon">{{ glyph[t.type] || 'i' }}</span>
|
||||
<span class="t-msg">{{ t.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import http from '../utils/http.js';
|
||||
|
||||
export const authApi = {
|
||||
login: (username, password) => http.post('/user/login', { username, password }),
|
||||
register: (username, password) => http.post('/user/register', { username, password })
|
||||
};
|
||||
|
||||
// /api/server/getStatus?uuid=all → { success, message, data: { "<uuid>": { uuid, name, online, report } } }
|
||||
export const serverApi = {
|
||||
statusAll: () => http.get('/server/getStatus?uuid=all'),
|
||||
infoAll: () => http.get('/server/getInfo?uuid=all')
|
||||
};
|
||||
|
||||
// /api/settings/get?type=… / /api/settings/set?type=…
|
||||
// get → { success, message, data: { config } }.
|
||||
// set → { success, message, data: { type, restartRequired } }, where
|
||||
// restartRequired lists the keys the update changed that are only read at
|
||||
// startup, so the caller can say which edits are not live yet. It is
|
||||
// always an array, empty when the whole update took effect.
|
||||
// `type` is one of global | bot_user_config | bot_node_config.
|
||||
// For set, pass a partial object; a JSON null value removes that key.
|
||||
export const settingsApi = {
|
||||
get: (type) => http.get(`/settings/get?type=${encodeURIComponent(type)}`),
|
||||
set: (type, patch) => http.post(`/settings/set?type=${encodeURIComponent(type)}`, patch)
|
||||
};
|
||||
|
||||
// Incoming webhook endpoints (admin). They live in the `webhook.endpoints`
|
||||
// section of config.json, but are managed here rather than through the settings
|
||||
// API because they are a keyed collection: add/modify take one endpoint object
|
||||
// and change only the fields they carry, and a new endpoint is rejected with
|
||||
// 409 when its name is taken.
|
||||
// list → { success, message, data: { endpoints: { "<name>": { enabled, token, notifyPipes } } } }.
|
||||
export const webhookApi = {
|
||||
list: () => http.get('/webhook/list'),
|
||||
add: (endpoint) => http.post('/webhook/add', endpoint),
|
||||
modify: (endpoint) => http.post('/webhook/modify', endpoint),
|
||||
remove: (name) => http.post('/webhook/delete', { name })
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
import { debugMode } from '../utils/runtime.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import Toggle from './Toggle.vue';
|
||||
import TagsEditor from './TagsEditor.vue';
|
||||
import HeadersEditor from './HeadersEditor.vue';
|
||||
|
||||
// One card of the global config.json: it renders the fields a section
|
||||
// descriptor declares and saves exactly those fields, leaving every other key
|
||||
// of the file untouched. Settings.vue and WebHooks.vue both compose this
|
||||
// component, which is why the descriptor (not the config layout) is what a view
|
||||
// supplies here.
|
||||
//
|
||||
// A descriptor is:
|
||||
// id unique key of the section, used for logging
|
||||
// title card heading
|
||||
// hint optional line under the heading
|
||||
// root path in config.json the fields live under, e.g. ['controllerMethod', 'ntfy']
|
||||
// fields [{ key, type, label, ... }], where type is one of
|
||||
// bool | text | password | number | select | textarea | tags | headers
|
||||
// - `lp` overrides the field key with an explicit path inside root
|
||||
// - `options` lists the choices of a select, `placeholder`/`help` are
|
||||
// passed through to the input
|
||||
const props = defineProps({
|
||||
section: { type: Object, required: true },
|
||||
config: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['saved']);
|
||||
|
||||
const vals = ref({});
|
||||
const saving = ref(false);
|
||||
|
||||
function fieldPath(f) {
|
||||
return f.lp || [f.key];
|
||||
}
|
||||
|
||||
function getVal(obj, path, fb) {
|
||||
let cur = obj;
|
||||
for (const k of path) {
|
||||
if (cur === null || cur === undefined || typeof cur !== 'object') return fb;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur === undefined || cur === null ? fb : cur;
|
||||
}
|
||||
|
||||
// hasVal reports whether a path actually resolves in the loaded config. A
|
||||
// missing key and a key whose value equals the fallback are indistinguishable
|
||||
// from getVal's return value alone, so misses are detected separately.
|
||||
function hasVal(obj, path) {
|
||||
let cur = obj;
|
||||
for (const k of path) {
|
||||
if (cur === null || cur === undefined || typeof cur !== 'object') return false;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur !== undefined && cur !== null;
|
||||
}
|
||||
|
||||
function defaults(f) {
|
||||
switch (f.type) {
|
||||
case 'bool': return false;
|
||||
case 'number': return 0;
|
||||
case 'tags': return [];
|
||||
case 'headers': return {};
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror wrapRoot() on save: prepend the section's root path so a value is read
|
||||
// from the same place it is written to.
|
||||
function read() {
|
||||
const obj = {};
|
||||
for (const f of props.section.fields) {
|
||||
const path = [...props.section.root, ...fieldPath(f)];
|
||||
obj[f.key] = getVal(props.config, path, defaults(f));
|
||||
if (debugMode.value) {
|
||||
console.log(`[ConfigSection] Loaded ${props.section.id}.${f.key}:`, obj[f.key]);
|
||||
if (!hasVal(props.config, path)) {
|
||||
console.warn(`[ConfigSection] ${props.section.id}.${f.key} missing at "${path.join('.')}" — using default`);
|
||||
}
|
||||
}
|
||||
}
|
||||
vals.value = obj;
|
||||
}
|
||||
|
||||
function normalize(f, v) {
|
||||
switch (f.type) {
|
||||
case 'number': {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
case 'tags': return Array.isArray(v) ? v : [];
|
||||
case 'headers': return v && typeof v === 'object' ? v : {};
|
||||
default: return v === null || v === undefined ? '' : v;
|
||||
}
|
||||
}
|
||||
|
||||
function nest(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = k.split('.');
|
||||
let o = out;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const seg = path[i];
|
||||
if (!o[seg]) o[seg] = {};
|
||||
o = o[seg];
|
||||
}
|
||||
o[path[path.length - 1]] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wrapRoot(section, obj) {
|
||||
const root = section.root;
|
||||
if (!root.length) return obj;
|
||||
const out = {};
|
||||
let o = out;
|
||||
for (let i = 0; i < root.length - 1; i += 1) {
|
||||
o[root[i]] = {};
|
||||
o = o[root[i]];
|
||||
}
|
||||
o[root[root.length - 1]] = obj;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const obj = {};
|
||||
for (const f of props.section.fields) {
|
||||
obj[f.key] = normalize(f, vals.value[f.key]);
|
||||
}
|
||||
const patch = wrapRoot(props.section, nest(obj));
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await settingsApi.set('global', patch);
|
||||
// The backend reports the keys it wrote that are only read at startup.
|
||||
// A plain "saved" would suggest those are live too.
|
||||
const pending = (res && res.data && res.data.restartRequired) || [];
|
||||
if (pending.length) {
|
||||
toast.warn(`${props.section.title} saved — restart to apply: ${pending.join(', ')}`, 7000);
|
||||
} else {
|
||||
toast.success(`${props.section.title} saved`);
|
||||
}
|
||||
emit('saved');
|
||||
} catch (e) {
|
||||
toast.error('Failed to save: ' + e.message);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read whenever the caller reloads the configuration, so the card always
|
||||
// shows what the file holds.
|
||||
watch(() => props.config, read, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p v-if="section.hint" class="hint">{{ section.hint }}</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
|
||||
<span v-if="saving" class="spinner" style="width:12px;height:12px" />
|
||||
<i v-else class="fas fa-check" /> Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="form-grid">
|
||||
<template v-for="f in section.fields" :key="f.key">
|
||||
<div v-if="f.type === 'bool'" class="bool-cell">
|
||||
<Toggle :model-value="vals[f.key]" :label="f.label" @update:model-value="vals[f.key] = $event" />
|
||||
<p v-if="f.help" class="field-help">{{ f.help }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'tags'" class="field span-2">
|
||||
<label>{{ f.label }}</label>
|
||||
<TagsEditor v-model="vals[f.key]" :placeholder="f.placeholder" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'headers'" class="field span-2">
|
||||
<label>{{ f.label }}</label>
|
||||
<HeadersEditor v-model="vals[f.key]" />
|
||||
</div>
|
||||
|
||||
<div v-else class="field span-2">
|
||||
<label>{{ f.label }}</label>
|
||||
<textarea
|
||||
v-if="f.type === 'textarea'"
|
||||
v-model="vals[f.key]"
|
||||
class="textarea"
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<select
|
||||
v-else-if="f.type === 'select'"
|
||||
v-model="vals[f.key]"
|
||||
class="select"
|
||||
>
|
||||
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model="vals[f.key]"
|
||||
class="input"
|
||||
:type="f.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="f.placeholder || ''"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bool-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
padding-left: 50px;
|
||||
max-width: 340px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const rows = reactive([]);
|
||||
|
||||
let suppress = false;
|
||||
|
||||
function sync() {
|
||||
rows.length = 0;
|
||||
for (const [k, v] of Object.entries(props.modelValue || {})) {
|
||||
if (k !== '') rows.push({ k, v: v === null || v === undefined ? '' : String(v) });
|
||||
}
|
||||
if (!rows.length) rows.push({ k: '', v: '' });
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const obj = {};
|
||||
for (const r of rows) {
|
||||
const key = r.k.trim();
|
||||
if (key) obj[key] = r.v;
|
||||
}
|
||||
// Emitting updates the parent model, which flows back through the prop and
|
||||
// would otherwise rebuild the rows mid-typing (and drop focus). Suppress
|
||||
// that self-echo for this tick.
|
||||
suppress = true;
|
||||
emit('update:modelValue', obj);
|
||||
setTimeout(() => { suppress = false; }, 0);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (rows.length && !rows[rows.length - 1].k.trim()) return;
|
||||
rows.push({ k: '', v: '' });
|
||||
}
|
||||
|
||||
function removeRow(i) {
|
||||
rows.splice(i, 1);
|
||||
if (!rows.length) rows.push({ k: '', v: '' });
|
||||
commit();
|
||||
}
|
||||
|
||||
// Re-sync only when the value is replaced externally (e.g. after reload).
|
||||
watch(() => props.modelValue, () => {
|
||||
if (!suppress) sync();
|
||||
}, { deep: false });
|
||||
|
||||
sync();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kv-editor">
|
||||
<div v-for="(r, i) in rows" :key="i" class="kv-row">
|
||||
<input v-model="r.k" class="input kv-key mono" placeholder="Header name" @input="commit" />
|
||||
<input v-model="r.v" class="input kv-val mono" placeholder="Value" @input="commit" />
|
||||
<button type="button" class="icon-btn danger" title="Remove header" @click="removeRow(i)">
|
||||
<i class="fas fa-minus" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm kv-add" @click="addRow">
|
||||
<i class="fas fa-plus" /> Add header
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kv-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr 34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-key {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.kv-add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
wide: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade-switch">
|
||||
<div v-if="open" class="modal-mask" @click.self="emit('close')">
|
||||
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true" @keydown.esc="emit('close')">
|
||||
<div class="modal-head">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="icon-btn" title="Close" @click="emit('close')">
|
||||
<i class="fas fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.foot" class="modal-foot">
|
||||
<slot name="foot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
const groups = [
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ name: 'Overview', to: '/overview', title: 'Nodes', icon: 'fa-server' },
|
||||
{ name: 'Trusted', to: '/trusted', title: 'Trust', icon: 'fa-shield-halved' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ name: 'Settings', to: '/settings', title: 'Settings', icon: 'fa-sliders' },
|
||||
{ name: 'WebHooks', to: '/webhooks', title: 'WebHooks', icon: 'fa-satellite-dish' },
|
||||
{ name: 'Logs', to: '/logs', title: 'Logs', icon: 'fa-terminal' }
|
||||
]
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<nav class="nav">
|
||||
<div v-for="group in groups" :key="group.label" class="group">
|
||||
<p class="group-label">{{ group.label }}</p>
|
||||
<router-link
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<i class="fas" :class="item.icon" aria-hidden="true" />
|
||||
<span>{{ item.title }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="side-foot">
|
||||
<span class="foot-dot" />
|
||||
<span>Nukumizu Console</span>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 232px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px 12px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 4px 10px 6px;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--r-m);
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 17px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-3);
|
||||
transition: color 0.14s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item:hover i {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.active i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 6px 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.foot-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 3px var(--ok-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Type and press Enter to add' }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const text = ref('');
|
||||
|
||||
function add() {
|
||||
const items = text.value
|
||||
.split(/[,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!items.length) return;
|
||||
const next = [...props.modelValue];
|
||||
for (const it of items) {
|
||||
if (!next.includes(it)) next.push(it);
|
||||
}
|
||||
emit('update:modelValue', next);
|
||||
text.value = '';
|
||||
}
|
||||
|
||||
function removeAt(i) {
|
||||
const next = props.modelValue.slice();
|
||||
next.splice(i, 1);
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add();
|
||||
} else if (e.key === 'Backspace' && !text.value && props.modelValue.length) {
|
||||
removeAt(props.modelValue.length - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags-editor">
|
||||
<div v-if="modelValue.length" class="tags">
|
||||
<span v-for="(t, i) in modelValue" :key="i" class="tag">
|
||||
{{ t }}
|
||||
<button type="button" class="tag-x" @click="removeAt(i)"><i class="fas fa-xmark" /></button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="text"
|
||||
class="input tags-input"
|
||||
:placeholder="modelValue.length ? placeholder : placeholder"
|
||||
@keydown="onKeydown"
|
||||
@blur="add"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-s);
|
||||
padding: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tags-editor:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px 3px 10px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-x {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tag-x:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
|
||||
.tags-input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 2px 4px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.tags-input:focus {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="switch" :class="{ disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<span class="track" />
|
||||
<span v-if="label" class="switch-label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.switch.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { auth, clearSession } from '../utils/auth.js';
|
||||
import { currentTheme, toggleTheme } from '../utils/theme.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isDark = ref(currentTheme() === 'dark');
|
||||
const user = computed(() => auth.user || {});
|
||||
const initial = computed(() => ((user.value.username || '?').slice(0, 1) || '?').toUpperCase());
|
||||
|
||||
function onToggleTheme() {
|
||||
isDark.value = toggleTheme() === 'dark';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearSession();
|
||||
toast.info('Signed out');
|
||||
router.push({ name: 'Login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="mark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6.5v3.1C3 15.2 6.9 20 12 21c5.1-1 9-5.8 9-11.4V6.5L12 2Zm0 7.2V19c-3.7-.7-6.6-4.4-6.6-8.2V7.6L12 4.4l6.6 3.2v3.2c0 1.9-.5 3.7-1.4 5.1H12Z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<span class="wordmark">
|
||||
<strong>Nukumizu</strong>
|
||||
<small>Console</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" :title="isDark ? 'Switch to light' : 'Switch to dark'" @click="onToggleTheme">
|
||||
<i class="fas" :class="isDark ? 'fa-sun' : 'fa-moon'" />
|
||||
</button>
|
||||
|
||||
<div class="user-chip">
|
||||
<span class="avatar">{{ initial }}</span>
|
||||
<div class="who">
|
||||
<span class="name">{{ user.username || '—' }}</span>
|
||||
<span class="level">{{ user.level || 'admin' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="icon-btn" title="Sign out" @click="logout">
|
||||
<i class="fas fa-arrow-right-from-bracket" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 52px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(150deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 10px -3px color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
}
|
||||
|
||||
.mark svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wordmark strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.wordmark small {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 6px 5px 5px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.who .name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.who .level {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router/index.js';
|
||||
import { initTheme } from './utils/theme.js';
|
||||
import { isLoggedIn } from './utils/auth.js';
|
||||
import { loadDebugMode } from './utils/runtime.js';
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import './styles/theme.css';
|
||||
import './styles/ui.css';
|
||||
|
||||
initTheme();
|
||||
|
||||
// A persisted session can fetch the runtime flags right away; a fresh visitor
|
||||
// has no token yet and picks them up after signing in.
|
||||
if (isLoggedIn()) {
|
||||
loadDebugMode();
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { isLoggedIn } from '../utils/auth.js';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/Layout.vue'),
|
||||
redirect: { name: 'Overview' },
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
name: 'Overview',
|
||||
component: () => import('../views/Overview.vue'),
|
||||
meta: { title: 'Nodes' }
|
||||
},
|
||||
{
|
||||
path: 'trusted',
|
||||
name: 'Trusted',
|
||||
component: () => import('../views/Trusted.vue'),
|
||||
meta: { title: 'Bot trust' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
meta: { title: 'Settings' }
|
||||
},
|
||||
{
|
||||
path: 'webhooks',
|
||||
name: 'WebHooks',
|
||||
component: () => import('../views/WebHooks.vue'),
|
||||
meta: { title: 'WebHooks' }
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
meta: { title: 'Logs' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta.public && !isLoggedIn()) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (to.name === 'Login' && isLoggedIn()) {
|
||||
return { name: 'Overview' };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Nukumizu Console — design tokens. Light is the default, dark is applied by
|
||||
setting data-theme="dark" on <html>. The look is a restrained, Apple-school
|
||||
console: neutral warm-gray fields, one accent reserved for interaction, and
|
||||
color used only where it carries meaning (health, log severity). */
|
||||
|
||||
:root {
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
|
||||
--bg: #eef0f3;
|
||||
--bg-grad: linear-gradient(180deg, #f4f5f7 0%, #eaedf1 100%);
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fafbfc;
|
||||
--surface-3: #eef0f3;
|
||||
--surface-hover: #e7eaee;
|
||||
|
||||
--text: #1b1e24;
|
||||
--text-2: rgba(27, 30, 36, 0.68);
|
||||
--text-3: rgba(27, 30, 36, 0.46);
|
||||
|
||||
--line: rgba(20, 24, 32, 0.09);
|
||||
--line-strong: rgba(20, 24, 32, 0.16);
|
||||
|
||||
--accent: #006ee0;
|
||||
--accent-strong: #0059b8;
|
||||
--accent-soft: rgba(0, 110, 224, 0.09);
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
--ok: #1f9d58;
|
||||
--ok-soft: rgba(31, 157, 88, 0.12);
|
||||
--warn: #c07b06;
|
||||
--warn-soft: rgba(192, 123, 6, 0.13);
|
||||
--bad: #d73a49;
|
||||
--bad-soft: rgba(215, 58, 73, 0.1);
|
||||
--muted: #8a919c;
|
||||
|
||||
--log-debug: #188a4b;
|
||||
--log-info: #0a62d0;
|
||||
--log-warn: #b5720a;
|
||||
--log-error: #cc3340;
|
||||
--log-fatal: #a31b28;
|
||||
|
||||
--r-s: 7px;
|
||||
--r-m: 11px;
|
||||
--r-l: 16px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(16, 22, 30, 0.05);
|
||||
--shadow-2: 0 1px 1px rgba(16, 22, 30, 0.04), 0 6px 24px -8px rgba(16, 22, 30, 0.14);
|
||||
--shadow-3: 0 2px 4px rgba(16, 22, 30, 0.06), 0 18px 48px -12px rgba(16, 22, 30, 0.22);
|
||||
|
||||
--nav-bg: rgba(15, 17, 21, 0.86);
|
||||
--nav-text: rgba(255, 255, 255, 0.72);
|
||||
--nav-text-hover: #ffffff;
|
||||
--nav-line: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0b0c10;
|
||||
--bg-grad: linear-gradient(180deg, #0e1014 0%, #090a0e 100%);
|
||||
--surface: #15171d;
|
||||
--surface-2: #1b1e25;
|
||||
--surface-3: #24272f;
|
||||
--surface-hover: #2b2f38;
|
||||
|
||||
--text: #eceef2;
|
||||
--text-2: rgba(236, 238, 242, 0.66);
|
||||
--text-3: rgba(236, 238, 242, 0.44);
|
||||
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--line-strong: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--accent: #2f8dff;
|
||||
--accent-strong: #57a3ff;
|
||||
--accent-soft: rgba(47, 141, 255, 0.16);
|
||||
|
||||
--ok: #3ecf83;
|
||||
--ok-soft: rgba(62, 207, 131, 0.16);
|
||||
--warn: #e3a23a;
|
||||
--warn-soft: rgba(227, 162, 58, 0.16);
|
||||
--bad: #ff6b76;
|
||||
--bad-soft: rgba(255, 107, 118, 0.15);
|
||||
--muted: #6b7280;
|
||||
|
||||
--log-debug: #43cf86;
|
||||
--log-info: #4b9dff;
|
||||
--log-warn: #efb458;
|
||||
--log-error: #ff7b84;
|
||||
--log-fatal: #ff525e;
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-2: 0 1px 1px rgba(0, 0, 0, 0.4), 0 8px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-3: 0 2px 4px rgba(0, 0, 0, 0.4), 0 22px 56px -16px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
/* Nukumizu Console — shared UI primitives. Components may add scoped styles,
|
||||
but everything reusable lives here. */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
background: var(--bg-grad) fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: 999px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* --- Page scaffolding --------------------------------------------------- */
|
||||
|
||||
.page {
|
||||
padding: 34px 40px 60px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.page-head .eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.page-head h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.page-head .lead {
|
||||
margin-top: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-2);
|
||||
margin: 26px 0 12px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* --- Buttons ------------------------------------------------------------ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 15px;
|
||||
border-radius: var(--r-m);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--bad-soft);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: color-mix(in srgb, var(--bad) 16%, transparent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 11px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--r-s);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--bad-soft);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
/* --- Cards -------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-l);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Cards inside a grid are spaced by `gap`. Without this the stacked-card rule
|
||||
above would inset every card but the first, leaving the first one sticking
|
||||
up by 18px. */
|
||||
.grid-2 > .card,
|
||||
.grid-3 > .card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card-head h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-head .hint {
|
||||
color: var(--text-3);
|
||||
font-size: 12.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* --- Forms -------------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field > label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.field > label .required {
|
||||
color: var(--bad);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.field .help {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-s);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.input:hover, .select:hover, .textarea:hover {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 84px;
|
||||
line-height: 1.55;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-2) 50%),
|
||||
linear-gradient(135deg, var(--text-2) 50%, transparent 50%);
|
||||
background-position: calc(100% - 17px) 55%, calc(100% - 12px) 55%;
|
||||
background-size: 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field input[type="color"] {
|
||||
padding: 2px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
}
|
||||
|
||||
.form-grid .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* --- Switch ------------------------------------------------------------- */
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .track {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
position: relative;
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.switch .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + .track {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.switch input:focus-visible + .track {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.switch .switch-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.switch input:checked ~ .switch-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- Badges / dots ------------------------------------------------------- */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.badge-online, .b-online .dot { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge-online .dot { background: var(--ok); }
|
||||
.badge-offline, .b-offline .dot { background: var(--surface-3); color: var(--text-3); }
|
||||
.badge-offline .dot { background: var(--muted); }
|
||||
.badge-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge-danger { background: var(--bad-soft); color: var(--bad); }
|
||||
.badge-accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* --- Stat / metric ------------------------------------------------------- */
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric .m-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric .m-value {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metric .m-value small {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* --- Meter (soft progress) ----------------------------------------------- */
|
||||
|
||||
.meter {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.meter > i.warn { background: var(--warn); }
|
||||
.meter > i.bad { background: var(--bad); }
|
||||
.meter > i.ok { background: var(--ok); }
|
||||
|
||||
/* --- Server list --------------------------------------------------------- */
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.server-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 16px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.server-card .row-1 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-card .row-2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-card .sv-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-card .sv-uuid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-card .row-3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Members / options list ---------------------------------------------- */
|
||||
|
||||
.opt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.opt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.opt-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.opt-row .who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.opt-row .who .id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opt-row .who .sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.opt-row .toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* --- Empty / skeleton ----------------------------------------------------- */
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 20px;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty .e-icon {
|
||||
font-size: 30px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* --- Toolbar --------------------------------------------------------------- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.search .icon {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* --- Modal ------------------------------------------------------------------ */
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 10vh 20px 40px;
|
||||
background: rgba(12, 14, 18, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: fade-in 0.18s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-l);
|
||||
box-shadow: var(--shadow-3);
|
||||
animation: rise 0.22s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal.wide { max-width: 640px; }
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 14px;
|
||||
}
|
||||
|
||||
.modal-head h3 { font-size: 17px; }
|
||||
|
||||
.modal-body {
|
||||
padding: 4px 20px 8px;
|
||||
}
|
||||
|
||||
.modal-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
|
||||
/* --- Toasts ------------------------------------------------------------------ */
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border-radius: var(--r-m);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-3);
|
||||
font-size: 13.5px;
|
||||
animation: toast-in 0.28s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
.toast .t-icon {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.toast.success .t-icon { background: var(--ok); }
|
||||
.toast.error .t-icon { background: var(--bad); }
|
||||
.toast.info .t-icon { background: var(--accent); }
|
||||
.toast.warn .t-icon { background: var(--warn); }
|
||||
|
||||
.toast .t-msg {
|
||||
flex: 1;
|
||||
line-height: 1.45;
|
||||
padding-top: 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast.leaving { animation: toast-out 0.25s ease forwards; }
|
||||
|
||||
/* --- Animations -------------------------------------------------------------- */
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(-16px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
to { opacity: 0; transform: translateY(-14px) scale(0.97); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* route transition */
|
||||
.fade-switch-enter-active, .fade-switch-leave-active {
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.fade-switch-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.fade-switch-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const TOKEN_KEY = 'nukumizu_token';
|
||||
const USER_KEY = 'nukumizu_user';
|
||||
|
||||
function readUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = reactive({
|
||||
token: localStorage.getItem(TOKEN_KEY) || '',
|
||||
user: readUser()
|
||||
});
|
||||
|
||||
export function setSession(token, user) {
|
||||
auth.token = token;
|
||||
auth.user = user;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
auth.token = '';
|
||||
auth.user = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return auth.token;
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!auth.token;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Severity levels as sent by the backend log websocket (see postLog package):
|
||||
// 0=DEBUG 1=INFO 2=WARN 3=ERROR 4=FATAL
|
||||
export const LOG_LEVELS = [
|
||||
{ key: 'DEBUG', value: 0 },
|
||||
{ key: 'INFO', value: 1 },
|
||||
{ key: 'WARN', value: 2 },
|
||||
{ key: 'ERROR', value: 3 },
|
||||
{ key: 'FATAL', value: 4 }
|
||||
];
|
||||
|
||||
export const levelOf = (value) => LOG_LEVELS.find((l) => l.value === value) || { key: 'LOG', value };
|
||||
|
||||
export function formatBytes(n, digits = 1) {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '-';
|
||||
if (n < 1024) return `${Math.round(n)} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let v = n;
|
||||
let u = -1;
|
||||
do {
|
||||
v /= 1024;
|
||||
u += 1;
|
||||
} while (v >= 1024 && u < units.length - 1);
|
||||
return `${v.toFixed(digits)} ${units[u]}`;
|
||||
}
|
||||
|
||||
export function formatPercent(used, total) {
|
||||
if (!total) return '—';
|
||||
return `${Math.min(100, Math.max(0, Math.round((used / total) * 100)))}%`;
|
||||
}
|
||||
|
||||
export function shortUUID(uuid = '') {
|
||||
if (!uuid) return '';
|
||||
if (uuid.length <= 13) return uuid;
|
||||
return `${uuid.slice(0, 8)}…${uuid.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Online/offline counts + aggregate online ratio. Report arrays carry the
|
||||
// report; when absent the server has no live telemetry yet.
|
||||
export function sumUp(entries) {
|
||||
const total = entries.length;
|
||||
const online = entries.filter((e) => e.online).length;
|
||||
return { total, online, offline: total - online };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
import { getToken, clearSession } from './auth.js';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers['X-Token'] = token;
|
||||
}
|
||||
// Backend expects a Unix timestamp in *seconds* with a ±30 min tolerance.
|
||||
config.headers['X-Timestamp'] = Math.floor(Date.now() / 1000);
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
const status = error.response ? error.response.status : 0;
|
||||
const payload = error.response && error.response.data;
|
||||
const message = (payload && payload.message) || error.message || 'Network error';
|
||||
|
||||
if (status === 401) {
|
||||
clearSession();
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
}
|
||||
|
||||
const err = new Error(message);
|
||||
err.status = status;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default http;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
|
||||
// Runtime flags mirrored from the backend config so any view can read them
|
||||
// without refetching the whole config.
|
||||
|
||||
// debugMode mirrors system.debugMode from the global config (config.json).
|
||||
export const debugMode = ref(false);
|
||||
|
||||
// loadDebugMode reads the global config and mirrors system.debugMode into the
|
||||
// `debugMode` global. The endpoint needs an admin session, so callers only
|
||||
// invoke it once logged in; a failed request keeps the current value.
|
||||
export async function loadDebugMode() {
|
||||
try {
|
||||
const res = await settingsApi.get('global');
|
||||
const config = (res && res.data && res.data.config) || {};
|
||||
const system = config.system || {};
|
||||
debugMode.value = !!system.debugMode;
|
||||
if (debugMode.value) {
|
||||
console.log('Debug mode enabled');
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: the flag simply keeps its current value.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const KEY = 'nukumizu_theme';
|
||||
|
||||
function systemPrefersDark() {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
const dark = theme === 'dark' || (theme !== 'light' && systemPrefersDark());
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
return dark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function currentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
applyTheme(saved || 'system');
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
localStorage.setItem(KEY, theme);
|
||||
return applyTheme(theme);
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem(KEY, next);
|
||||
applyTheme(next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
let seq = 0;
|
||||
|
||||
export const toasts = reactive([]);
|
||||
|
||||
function dismiss(id) {
|
||||
const idx = toasts.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) toasts[idx].leaving = true;
|
||||
setTimeout(() => {
|
||||
const i = toasts.findIndex((t) => t.id === id);
|
||||
if (i !== -1) toasts.splice(i, 1);
|
||||
}, 240);
|
||||
}
|
||||
|
||||
function push(type, message, timeout) {
|
||||
const id = ++seq;
|
||||
toasts.push({ id, type, message, leaving: false });
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => dismiss(id), timeout);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success(message, timeout = 3600) {
|
||||
return push('success', message, timeout);
|
||||
},
|
||||
error(message, timeout = 5200) {
|
||||
return push('error', message, timeout);
|
||||
},
|
||||
info(message, timeout = 3200) {
|
||||
return push('info', message, timeout);
|
||||
},
|
||||
warn(message, timeout = 4200) {
|
||||
return push('warn', message, timeout);
|
||||
},
|
||||
dismiss
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import TopBar from '../components/TopBar.vue';
|
||||
import SideBar from '../components/SideBar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<TopBar />
|
||||
<div class="app-body">
|
||||
<SideBar />
|
||||
<main class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-switch" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 232px;
|
||||
padding-top: 52px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { authApi } from '../api/index.js';
|
||||
import { setSession } from '../utils/auth.js';
|
||||
import { loadDebugMode } from '../utils/runtime.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const mode = ref('login');
|
||||
const loading = ref(false);
|
||||
const showPw = ref(false);
|
||||
const form = reactive({ username: '', password: '' });
|
||||
const errMsg = ref('');
|
||||
|
||||
const canSubmit = computed(() => form.username.trim().length > 0 && form.password.length >= 6);
|
||||
|
||||
function goTo(target) {
|
||||
mode.value = target;
|
||||
errMsg.value = '';
|
||||
}
|
||||
|
||||
function afterLogin(token, user) {
|
||||
setSession(token, user);
|
||||
// The settings endpoint requires the token we just stored.
|
||||
loadDebugMode();
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview';
|
||||
router.push(redirect);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value) return;
|
||||
loading.value = true;
|
||||
errMsg.value = '';
|
||||
try {
|
||||
if (mode.value === 'login') {
|
||||
const res = await authApi.login(form.username.trim(), form.password);
|
||||
const d = res.data;
|
||||
afterLogin(d.token, {
|
||||
userID: d.userID,
|
||||
username: d.username,
|
||||
level: d.level,
|
||||
registerDate: d.registerDate
|
||||
});
|
||||
} else {
|
||||
const res = await authApi.register(form.username.trim(), form.password);
|
||||
const d = res.data;
|
||||
afterLogin(d.token, {
|
||||
userID: d.userID,
|
||||
username: d.username,
|
||||
level: d.level
|
||||
});
|
||||
toast.success('Admin created — welcome to Nukumizu');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mode.value === 'register' && e.status === 403) {
|
||||
errMsg.value = 'An admin already exists — registration is closed. Please sign in.';
|
||||
mode.value = 'login';
|
||||
} else {
|
||||
errMsg.value = e.message || 'Operation failed';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<div class="auth-inner">
|
||||
<div class="brand">
|
||||
<span class="mark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6.5v3.1C3 15.2 6.9 20 12 21c5.1-1 9-5.8 9-11.4V6.5L12 2Zm0 7.2V19c-3.7-.7-6.6-4.4-6.6-8.2V7.6L12 4.4l6.6 3.2v3.2c0 1.9-.5 3.7-1.4 5.1H12Z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<h1>Nukumizu</h1>
|
||||
<p>Remote server monitoring & alert bot — admin console</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="tabs">
|
||||
<button :class="{ on: mode === 'login' }" @click="goTo('login')">Sign in</button>
|
||||
<button :class="{ on: mode === 'register' }" @click="goTo('register')">Create admin</button>
|
||||
</div>
|
||||
|
||||
<form class="fields" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" v-model="form.username" class="input" autocomplete="username" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<div class="pw">
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="input"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
placeholder="At least 6 characters"
|
||||
/>
|
||||
<button type="button" class="icon-btn eye" tabindex="-1" @click="showPw = !showPw">
|
||||
<i class="fas" :class="showPw ? 'fa-eye-slash' : 'fa-eye'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errMsg" class="form-err">{{ errMsg }}</p>
|
||||
|
||||
<button class="btn btn-primary submit" type="submit" :disabled="!canSubmit || loading">
|
||||
<span v-if="loading" class="spinner" style="width:14px;height:14px;border-width:2px" />
|
||||
<span>{{ mode === 'login' ? 'Sign in' : 'Create & enter' }}</span>
|
||||
</button>
|
||||
|
||||
<p v-if="mode === 'register'" class="hint">
|
||||
Only available while no user exists yet. The first account becomes the admin.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 20px;
|
||||
background:
|
||||
radial-gradient(900px 480px at 15% -10%, var(--accent-soft), transparent 60%),
|
||||
radial-gradient(700px 420px at 110% 110%, color-mix(in srgb, var(--ok) 8%, transparent), transparent 60%),
|
||||
var(--bg-grad) fixed;
|
||||
}
|
||||
|
||||
.auth-inner {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 15px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(150deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px -8px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mark svg { width: 32px; height: 32px; }
|
||||
|
||||
.brand h1 {
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-l);
|
||||
box-shadow: var(--shadow-2);
|
||||
padding: 8px 24px 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0 -24px 20px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
padding: 14px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.tabs button.on { color: var(--accent); }
|
||||
|
||||
.tabs button.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pw { position: relative; }
|
||||
|
||||
.pw .input { padding-right: 40px; }
|
||||
|
||||
.pw .eye {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.form-err {
|
||||
font-size: 13px;
|
||||
color: var(--bad);
|
||||
background: var(--bad-soft);
|
||||
border-radius: var(--r-s);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.submit { width: 100%; padding: 11px; }
|
||||
|
||||
.hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { getToken } from '../utils/auth.js';
|
||||
import { LOG_LEVELS } from '../utils/fmt.js';
|
||||
|
||||
const MAX_LOGS = 1200;
|
||||
|
||||
const logs = ref([]);
|
||||
const pending = ref([]);
|
||||
const status = ref('connecting');
|
||||
const paused = ref(false);
|
||||
const query = ref('');
|
||||
const enabled = reactive(Object.fromEntries(LOG_LEVELS.map((l) => [l.key, true])));
|
||||
let ws = null;
|
||||
let closedManually = false;
|
||||
let reconnectTimer = null;
|
||||
|
||||
const levelOf = (value) => LOG_LEVELS.find((l) => l.value === value) || { key: 'LOG', value };
|
||||
|
||||
const visible = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
return logs.value.filter((m) => {
|
||||
if (!enabled[levelOf(m.level).key]) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
String(m.content || '').toLowerCase().includes(q) ||
|
||||
levelOf(m.level).key.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (status.value) {
|
||||
case 'open': return { label: 'Connected', cls: 'ok' };
|
||||
case 'connecting': return { label: 'Reconnecting', cls: 'warn' };
|
||||
default: return { label: 'Disconnected', cls: 'muted' };
|
||||
}
|
||||
});
|
||||
|
||||
function wsUrl() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// The backend only upgrades the request for an admin token. A browser cannot
|
||||
// set headers on a WebSocket handshake, so the credentials travel in the
|
||||
// query string — the URL itself is therefore a secret.
|
||||
const params = new URLSearchParams({
|
||||
token: getToken(),
|
||||
timestamp: String(Math.floor(Date.now() / 1000))
|
||||
});
|
||||
return `${proto}//${window.location.host}/api/system/getLogs?${params}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
}
|
||||
// Signed out: the handshake would be rejected, so don't spin on reconnects.
|
||||
if (!getToken()) {
|
||||
status.value = 'closed';
|
||||
return;
|
||||
}
|
||||
status.value = 'connecting';
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl());
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
status.value = 'open';
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof msg.level !== 'number') return;
|
||||
push(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.value = 'closed';
|
||||
if (!closedManually) scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (closedManually) return;
|
||||
reconnectTimer = setTimeout(connect, 1800);
|
||||
}
|
||||
|
||||
function push(msg) {
|
||||
if (paused.value) {
|
||||
pending.value.push(msg);
|
||||
if (pending.value.length > MAX_LOGS) pending.value.shift();
|
||||
return;
|
||||
}
|
||||
logs.value.unshift(msg);
|
||||
if (logs.value.length > MAX_LOGS) logs.value.pop();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
paused.value = !paused.value;
|
||||
if (!paused.value) {
|
||||
const buffered = pending.value.splice(0);
|
||||
logs.value.unshift(...buffered);
|
||||
if (logs.value.length > MAX_LOGS) logs.value.length = MAX_LOGS;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = [];
|
||||
pending.value = [];
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
const lines = visible.value
|
||||
.map((m) => `[${m.timestamp}] [${levelOf(m.level).key.padEnd(5)}] ${m.content}`)
|
||||
.join('\n');
|
||||
const blob = new Blob([lines], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nukumizu-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function toggleLevel(key) {
|
||||
enabled[key] = !enabled[key];
|
||||
}
|
||||
|
||||
onMounted(connect);
|
||||
onBeforeUnmount(() => {
|
||||
closedManually = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) ws.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Telemetry</p>
|
||||
<h1>System logs</h1>
|
||||
<p class="lead">Live log stream from the server. Newest entries appear first.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<span class="conn" :class="'conn-' + statusText.cls">
|
||||
<span class="dot" />{{ statusText.label }}
|
||||
</span>
|
||||
<button class="btn btn-ghost" title="Reconnect" @click="connect"><i class="fas fa-rotate-right" /></button>
|
||||
<button class="btn btn-ghost" title="Export visible logs" @click="exportLogs"><i class="fas fa-download" /></button>
|
||||
<button class="btn btn-ghost" title="Clear view" @click="clearLogs"><i class="fas fa-eraser" /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<i class="fas fa-magnifying-glass icon" />
|
||||
<input v-model="query" type="text" placeholder="Filter logs…" />
|
||||
</div>
|
||||
|
||||
<div class="level-chips">
|
||||
<button
|
||||
v-for="l in LOG_LEVELS"
|
||||
:key="l.key"
|
||||
class="chip"
|
||||
:class="['lv-' + l.key.toLowerCase(), { off: !enabled[l.key] }]"
|
||||
@click="toggleLevel(l.key)"
|
||||
>
|
||||
{{ l.key }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="spacer" />
|
||||
|
||||
<button class="btn" :class="paused ? 'btn-primary' : 'btn-ghost'" @click="togglePause">
|
||||
<i class="fas" :class="paused ? 'fa-play' : 'fa-pause'" />
|
||||
{{ paused ? `Resume${pending.length ? ` (${pending.length})` : ''}` : 'Pause' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card log-card">
|
||||
<div v-if="visible.length === 0" class="empty">
|
||||
<i class="fas fa-terminal e-icon" />
|
||||
<p>No matching log entries.</p>
|
||||
</div>
|
||||
<div v-else class="log-list">
|
||||
<div v-for="(m, i) in visible" :key="m.timestamp + '-' + i" class="log-line">
|
||||
<span class="t mono">{{ m.timestamp }}</span>
|
||||
<span class="lv mono" :class="'lv-' + levelOf(m.level).key.toLowerCase()">
|
||||
{{ levelOf(m.level).key }}
|
||||
</span>
|
||||
<span class="msg">{{ m.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.conn-ok { color: var(--ok); }
|
||||
.conn-ok .dot { background: var(--ok); }
|
||||
.conn-warn { color: var(--warn); }
|
||||
.conn-warn .dot { background: var(--warn); }
|
||||
.conn-muted { color: var(--text-3); }
|
||||
.conn-muted .dot { background: var(--muted); }
|
||||
|
||||
.level-chips { display: flex; gap: 6px; }
|
||||
|
||||
.chip { cursor: pointer; transition: opacity 0.15s ease; }
|
||||
.chip.off { opacity: 0.38; text-decoration: line-through; }
|
||||
|
||||
.lv-debug { color: var(--log-debug); }
|
||||
.lv-info { color: var(--log-info); }
|
||||
.lv-warn { color: var(--log-warn); }
|
||||
.lv-error { color: var(--log-error); }
|
||||
.lv-fatal { color: var(--log-fatal); }
|
||||
|
||||
.log-card { overflow: hidden; }
|
||||
|
||||
.log-list {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 170px 72px 1fr;
|
||||
gap: 14px;
|
||||
padding: 7px 16px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
border-bottom: 1px solid var(--line);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.log-line:hover { background: var(--surface-2); }
|
||||
|
||||
.log-line .t { color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
.log-line .lv {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-line .lv.lv-debug { background: var(--ok-soft); }
|
||||
.log-line .lv.lv-info { background: var(--accent-soft); }
|
||||
.log-line .lv.lv-warn { background: var(--warn-soft); }
|
||||
.log-line .lv.lv-error { background: var(--bad-soft); }
|
||||
.log-line .lv.lv-fatal { background: var(--bad-soft); }
|
||||
|
||||
.log-line .msg { color: var(--text); word-break: break-word; white-space: pre-wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { serverApi, settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import { formatBytes, shortUUID } from '../utils/fmt.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
|
||||
function percent(m) {
|
||||
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
|
||||
}
|
||||
|
||||
const query = ref('');
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const entries = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
let timer = null;
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return entries.value;
|
||||
return entries.value.filter((e) =>
|
||||
(e.name || '').toLowerCase().includes(q) || (e.uuid || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const stats = computed(() => {
|
||||
let online = 0;
|
||||
for (const e of entries.value) if (e.online) online += 1;
|
||||
return { total: entries.value.length, online, offline: entries.value.length - online };
|
||||
});
|
||||
|
||||
function meterClass(pct) {
|
||||
if (pct >= 90) return 'bad';
|
||||
if (pct >= 75) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [status, info, nodes] = await Promise.all([
|
||||
serverApi.statusAll(),
|
||||
serverApi.infoAll(),
|
||||
settingsApi.get('bot_node_config')
|
||||
]);
|
||||
const statusData = (status && status.data) || {};
|
||||
const infoData = (info && info.data) || {};
|
||||
const nodeConf = (nodes && nodes.data && nodes.data.config) || {};
|
||||
const list = [];
|
||||
for (const key of Object.keys(statusData)) {
|
||||
const s = statusData[key] || {};
|
||||
const infoEntry = infoData[key] || {};
|
||||
const conf = nodeConf[key] || {};
|
||||
list.push({
|
||||
uuid: s.uuid || key,
|
||||
name: s.name || shortUUID(s.uuid || key),
|
||||
online: !!s.online,
|
||||
report: s.report || null,
|
||||
info: infoEntry.info || null,
|
||||
notify: conf.enableStatusNotify !== false
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
|
||||
entries.value = list;
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status !== 0) toast.error(e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotify(entry) {
|
||||
const next = !entry.notify;
|
||||
const prev = entry.notify;
|
||||
entry.notify = next;
|
||||
saving.add(entry.uuid);
|
||||
try {
|
||||
const patch = {};
|
||||
patch[entry.uuid] = { enableStatusNotify: next };
|
||||
await settingsApi.set('bot_node_config', patch);
|
||||
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`);
|
||||
} catch (e) {
|
||||
entry.notify = prev;
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
saving.delete(entry.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
function copyUUID(uuid) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(uuid).then(() => toast.success('UUID copied'), () => {});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
timer = setInterval(load, 15000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>Nodes</h1>
|
||||
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
|
||||
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<i class="fas fa-magnifying-glass icon" />
|
||||
<input v-model="query" type="text" placeholder="Search name or UUID…" />
|
||||
</div>
|
||||
<div class="badge badge-accent"><span class="dot" /> {{ stats.total }} total</div>
|
||||
<div class="badge badge-online"><span class="dot" /> {{ stats.online }} online</div>
|
||||
<div class="badge badge-offline"><span class="dot" /> {{ stats.offline }} offline</div>
|
||||
<span class="spacer" />
|
||||
<span class="refresh-hint"><i class="fas fa-circle-info" /> Auto-refreshes every 15s</span>
|
||||
</div>
|
||||
|
||||
<div v-if="failed && !loading" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Cannot reach the backend service</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="card empty">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="entries.length === 0" class="card empty">
|
||||
<i class="fas fa-server e-icon" />
|
||||
<p>No monitored nodes yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filtered.length === 0" class="card empty">
|
||||
<i class="fas fa-magnifying-glass e-icon" />
|
||||
<p>No nodes match “{{ query }}”</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid-2">
|
||||
<article
|
||||
v-for="e in filtered"
|
||||
:key="e.uuid"
|
||||
class="card server-card"
|
||||
:class="{ dim: !e.online }"
|
||||
>
|
||||
<div class="row-1">
|
||||
<span class="sv-icon"><i class="fas fa-server" /></span>
|
||||
<div class="sv-main">
|
||||
<p class="sv-name" :title="e.name">{{ e.name }}</p>
|
||||
<button class="sv-uuid" type="button" :title="'Copy: ' + e.uuid" @click="copyUUID(e.uuid)">
|
||||
{{ shortUUID(e.uuid) }}
|
||||
<i class="fas fa-copy" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="badge" :class="e.online ? 'badge-online' : 'badge-offline'">
|
||||
<span class="dot" />{{ e.online ? 'Online' : 'Offline' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="e.online && e.report">
|
||||
<div class="meter-row">
|
||||
<span class="mr-label">CPU</span>
|
||||
<div class="meter"><i :class="meterClass(e.report.cpu.usage)" :style="{ width: Math.min(100, e.report.cpu.usage) + '%' }" /></div>
|
||||
<span class="mr-val">{{ e.report.cpu.usage.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="meter-row">
|
||||
<span class="mr-label">RAM</span>
|
||||
<div class="meter"><i :class="meterClass(percent(e.report.ram))" :style="{ width: percent(e.report.ram) + '%' }" /></div>
|
||||
<span class="mr-val">{{ formatBytes(e.report.ram.used) }} / {{ formatBytes(e.report.ram.total) }}</span>
|
||||
</div>
|
||||
<div class="meter-row">
|
||||
<span class="mr-label">Disk</span>
|
||||
<div class="meter"><i :class="meterClass(percent(e.report.disk))" :style="{ width: percent(e.report.disk) + '%' }" /></div>
|
||||
<span class="mr-val">{{ formatBytes(e.report.disk.used) }} / {{ formatBytes(e.report.disk.total) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="e.online">
|
||||
<p class="no-report">Online, waiting for the first status report…</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="no-report">{{ e.report && e.report.message ? e.report.message : 'Currently offline' }}</p>
|
||||
</template>
|
||||
|
||||
<footer class="row-3">
|
||||
<div class="spec" v-if="e.info && e.info.cpu">
|
||||
<span>{{ e.info.os ? e.info.os.os || '—' : '—' }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ e.info.cpu.cpu_cores ? e.info.cpu.cpu_cores + ' cores' : '' }}</span>
|
||||
<span>·</span>
|
||||
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
|
||||
</div>
|
||||
<div class="notify">
|
||||
<Toggle
|
||||
:model-value="e.notify"
|
||||
label="Status notify"
|
||||
:disabled="saving.has(e.uuid)"
|
||||
@update:model-value="toggleNotify(e)"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-card { transition: opacity 0.25s ease; }
|
||||
.server-card.dim .sv-icon,
|
||||
.server-card.dim .sv-name { opacity: 0.75; }
|
||||
|
||||
.sv-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.server-card.dim .sv-icon {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.sv-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sv-uuid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sv-uuid:hover { color: var(--accent); }
|
||||
|
||||
.meter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mr-label { font-size: 12px; color: var(--text-3); font-weight: 600; }
|
||||
|
||||
.mr-val {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.no-report {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.row-3 {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.spec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notify { display: flex; align-items: center; }
|
||||
|
||||
.refresh-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import ConfigSection from '../components/ConfigSection.vue';
|
||||
|
||||
// Every section is rendered and saved by ConfigSection, which owns the
|
||||
// field-descriptor format. The incoming webhook API has its own page (see
|
||||
// WebHooks.vue) and is not listed here.
|
||||
const sections = [
|
||||
{
|
||||
id: 'system',
|
||||
title: 'System',
|
||||
hint: 'HTTP listener and global runtime switches. A changed listen address or port applies on restart.',
|
||||
root: ['system'],
|
||||
fields: [
|
||||
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
|
||||
{ key: 'listenAddr', type: 'text', label: 'Listen address' },
|
||||
{ key: 'listenPort', type: 'text', label: 'Listen port' },
|
||||
{ key: 'networkProxy', type: 'text', label: 'Network proxy', placeholder: 'http://host:port' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
title: 'Debug logging',
|
||||
hint: 'Per-module verbosity for the bot pipes.',
|
||||
root: ['debug'],
|
||||
fields: [
|
||||
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
|
||||
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
|
||||
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
|
||||
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
|
||||
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
|
||||
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'komari',
|
||||
title: 'Komari dashboard',
|
||||
hint: 'Connection the monitor reads node data from. The URL applies on restart; the account is re-read on the next login.',
|
||||
root: ['komari'],
|
||||
fields: [
|
||||
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
|
||||
{ key: 'account.username', lp: ['account', 'username'], type: 'text', label: 'Account' },
|
||||
{ key: 'account.password', lp: ['account', 'password'], type: 'password', label: 'Password' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'controllerMessage',
|
||||
title: 'Message templates',
|
||||
hint: 'Templates rendered for bot pushes. Placeholders like {{ serverName }} stay as-is.',
|
||||
root: ['controllerMessage'],
|
||||
fields: [
|
||||
{ key: 'BOT_STARTED', type: 'textarea', label: 'BOT_STARTED' },
|
||||
{ key: 'BOT_HELP', type: 'textarea', label: 'BOT_HELP' },
|
||||
{ key: 'TG_BOT_START', type: 'textarea', label: 'TG_BOT_START' },
|
||||
{ key: 'SERVER_STATUS_CHANGED', type: 'textarea', label: 'SERVER_STATUS_CHANGED' },
|
||||
{ key: 'SERVER_LIST', type: 'textarea', label: 'SERVER_LIST' },
|
||||
{ key: 'SERVER_EXECUTE_RESULT', type: 'textarea', label: 'SERVER_EXECUTE_RESULT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
title: 'QQ controller (NapCat)',
|
||||
root: ['controllerMethod', 'qq(napcat)'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
|
||||
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
||||
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
|
||||
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'telegram',
|
||||
title: 'Telegram controller',
|
||||
root: ['controllerMethod', 'telegram'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Sends messages with parse_mode=Markdown; turn off to have text delivered verbatim.' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'botToken', type: 'password', label: 'Bot token' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email notifications',
|
||||
root: ['controllerMethod', 'email'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
|
||||
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
|
||||
{ key: 'username', type: 'text', label: 'Username' },
|
||||
{ key: 'password', type: 'password', label: 'Password' },
|
||||
{ key: 'from', type: 'text', label: 'From address' },
|
||||
{ key: 'to', type: 'tags', label: 'Recipients', placeholder: 'Type an address and press Enter' },
|
||||
{ key: 'useTLS', type: 'bool', label: 'Use TLS' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ntfy',
|
||||
title: 'Ntfy notifications',
|
||||
root: ['controllerMethod', 'ntfy'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'server', type: 'text', label: 'Server' },
|
||||
{ key: 'topic', type: 'text', label: 'Topic' },
|
||||
{ key: 'token', type: 'password', label: 'Token' },
|
||||
{ key: 'priority', type: 'select', label: 'Priority', options: ['min', 'low', 'default', 'high', 'urgent', 'max'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
title: 'Webhook notifications',
|
||||
hint: 'Where this program posts its own alerts. The incoming webhook API has its own page (see WebHooks).',
|
||||
root: ['controllerMethod', 'webhook'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Format the alert body with code blocks and inline code in the posted payload.' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'url', type: 'text', label: 'URL' },
|
||||
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
||||
{ key: 'headers', type: 'headers', label: 'Headers' },
|
||||
{ key: 'template', type: 'textarea', label: 'Payload template' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'paths',
|
||||
title: 'Storage paths',
|
||||
hint: 'Where runtime data lives. Takes effect on restart.',
|
||||
root: [],
|
||||
fields: [
|
||||
{ key: 'dataPath', type: 'text', label: 'Data path' },
|
||||
{ key: 'dbPath', type: 'text', label: 'Database path' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const cfg = ref({});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await settingsApi.get('global');
|
||||
cfg.value = (res && res.data && res.data.config) || {};
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status) toast.error('Failed to load settings: ' + e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>System settings</h1>
|
||||
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="failed" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Failed to load settings</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
|
||||
|
||||
<div v-else>
|
||||
<ConfigSection v-for="s in sections" :key="s.id" :section="s" :config="cfg" @saved="load" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
const CHANNELS = [
|
||||
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' },
|
||||
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
|
||||
];
|
||||
|
||||
const KINDS = [
|
||||
{ key: 'admins', label: 'Admins' },
|
||||
{ key: 'trustedGroups', label: 'Trusted groups' }
|
||||
];
|
||||
|
||||
const OPTIONS = [
|
||||
{ key: 'event_status_notify', label: 'Status notify' },
|
||||
{ key: 'event_bot_started', label: 'Startup notify' },
|
||||
{ key: 'event_reply', label: 'Command replies' }
|
||||
];
|
||||
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const rows = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
|
||||
const addModal = reactive({ open: false, ch: CHANNELS[0].key, kind: 'admins', id: '' });
|
||||
const removing = reactive({ open: false, ch: '', kind: '', id: '' });
|
||||
|
||||
const rowsOf = (ch, kind) => rows.value
|
||||
.filter((r) => r.ch === ch && r.kind === kind)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const summary = computed(() => {
|
||||
const out = {};
|
||||
for (const c of CHANNELS) {
|
||||
out[c.key] = { admins: rowsOf(c.key, 'admins').length, trustedGroups: rowsOf(c.key, 'trustedGroups').length };
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const defaultOpts = () => ({
|
||||
event_status_notify: true,
|
||||
event_bot_started: true,
|
||||
event_reply: true
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await settingsApi.get('bot_user_config');
|
||||
const cfg = (res && res.data && res.data.config) || {};
|
||||
const flat = [];
|
||||
for (const c of CHANNELS) {
|
||||
const chData = cfg[c.key] || {};
|
||||
for (const k of KINDS) {
|
||||
const map = chData[k.key] || {};
|
||||
for (const [id, opts] of Object.entries(map)) {
|
||||
flat.push({
|
||||
ch: c.key,
|
||||
kind: k.key,
|
||||
id,
|
||||
opts: {
|
||||
event_status_notify: opts.event_status_notify !== false,
|
||||
event_bot_started: opts.event_bot_started !== false,
|
||||
event_reply: opts.event_reply !== false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.value = flat;
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status) toast.error('Failed to load: ' + e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function patchRow(row) {
|
||||
const p = {};
|
||||
p[row.ch] = { [row.kind]: { [row.id]: { ...row.opts } } };
|
||||
return p;
|
||||
}
|
||||
|
||||
async function applyPatch(patch, okMsg, rollbackFn) {
|
||||
try {
|
||||
await settingsApi.set('bot_user_config', patch);
|
||||
if (okMsg) toast.success(okMsg);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (rollbackFn) rollbackFn();
|
||||
toast.error('Save failed: ' + e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpt(row, key, value) {
|
||||
const prev = row.opts[key];
|
||||
row.opts[key] = value;
|
||||
applyPatch(patchRow(row), undefined, () => { row.opts[key] = prev; });
|
||||
}
|
||||
|
||||
function openAdd(ch, kind) {
|
||||
addModal.ch = ch;
|
||||
addModal.kind = kind;
|
||||
addModal.id = '';
|
||||
addModal.open = true;
|
||||
}
|
||||
|
||||
async function confirmAdd() {
|
||||
const id = addModal.id.trim();
|
||||
if (!id) return toast.warn('Enter a member ID');
|
||||
const exists = rows.value.some((r) => r.ch === addModal.ch && r.kind === addModal.kind && r.id === id);
|
||||
if (exists) {
|
||||
toast.warn('That member already exists');
|
||||
return;
|
||||
}
|
||||
const row = { ch: addModal.ch, kind: addModal.kind, id, opts: defaultOpts() };
|
||||
if (await applyPatch(patchRow(row), 'Member added')) {
|
||||
rows.value.push(row);
|
||||
addModal.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askRemove(row) {
|
||||
removing.ch = row.ch;
|
||||
removing.kind = row.kind;
|
||||
removing.id = row.id;
|
||||
removing.open = true;
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
const { ch, kind, id } = removing;
|
||||
const p = {};
|
||||
p[ch] = { [kind]: { [id]: null } };
|
||||
if (await applyPatch(p, 'Member removed')) {
|
||||
rows.value = rows.value.filter((r) => !(r.ch === ch && r.kind === kind && r.id === id));
|
||||
removing.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Bot trust</p>
|
||||
<h1>Trust management</h1>
|
||||
<p class="lead">Which admins and groups each bot channel answers to, plus per-member notification and reply switches.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load">
|
||||
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="failed" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Failed to load, please check the backend connection</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-for="c in CHANNELS" :key="c.key" class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>{{ c.label }}</h3>
|
||||
<p class="hint">{{ c.hint }}</p>
|
||||
</div>
|
||||
<span class="chip">
|
||||
{{ summary[c.key].admins }} admins · {{ summary[c.key].trustedGroups }} groups
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div v-for="k in KINDS" :key="k.key" class="kind-block">
|
||||
<div class="kind-head">
|
||||
<span class="kind-title">{{ k.label }}</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="openAdd(c.key, k.key)">
|
||||
<i class="fas fa-plus" /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty" style="padding:24px"><span class="spinner" /></div>
|
||||
<div v-else-if="rowsOf(c.key, k.key).length === 0" class="empty" style="padding:22px">
|
||||
No {{ k.label.toLowerCase() }} yet
|
||||
</div>
|
||||
<div v-else class="opt-list">
|
||||
<div v-for="r in rowsOf(c.key, k.key)" :key="r.id" class="opt-row">
|
||||
<div class="who">
|
||||
<span class="id mono">{{ r.id }}</span>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<div v-for="o in OPTIONS" :key="o.key" class="oc">
|
||||
<span class="oc-label">{{ o.label }}</span>
|
||||
<Toggle
|
||||
:model-value="r.opts[o.key]"
|
||||
@update:model-value="toggleOpt(r, o.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
<button class="icon-btn danger" title="Remove" @click="askRemove(r)">
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :open="addModal.open" title="Add member" @close="addModal.open = false">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>Channel</label>
|
||||
<select v-model="addModal.ch" class="select">
|
||||
<option v-for="c in CHANNELS" :key="c.key" :value="c.key">{{ c.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<select v-model="addModal.kind" class="select">
|
||||
<option v-for="k in KINDS" :key="k.key" :value="k.key">{{ k.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>Member ID</label>
|
||||
<input
|
||||
v-model="addModal.id"
|
||||
class="input mono"
|
||||
placeholder="QQ / group number, or Telegram @username / numeric ID"
|
||||
@keydown.enter="confirmAdd"
|
||||
/>
|
||||
<p class="help">New members start with every notification enabled; adjust them in the list.</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="addModal.open = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmAdd">Add</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal :open="removing.open" title="Remove member" @close="removing.open = false">
|
||||
<p style="line-height:1.6">
|
||||
Remove <strong class="mono">{{ removing.id }}</strong> from
|
||||
<strong>{{ KINDS.find((k) => k.key === removing.kind)?.label }}</strong>?
|
||||
</p>
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="removing.open = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="confirmRemove">Remove</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind-block + .kind-block { margin-top: 20px; }
|
||||
|
||||
.kind-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kind-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.oc-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { settingsApi, webhookApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import ConfigSection from '../components/ConfigSection.vue';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
// The notification channels an endpoint may relay to. These mirror the
|
||||
// controller names in config.json (controllerMethod.*), which is exactly what
|
||||
// the backend matches notifyPipes against.
|
||||
const CHANNELS = [
|
||||
{ key: 'qq(napcat)', label: 'QQ' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'ntfy', label: 'ntfy' },
|
||||
{ key: 'webhook', label: 'WebHook' }
|
||||
];
|
||||
|
||||
// The listener half of the incoming webhook API. The endpoints it serves are
|
||||
// managed through /api/webhook/* instead of the settings API, because they are
|
||||
// a keyed collection rather than a fixed set of fields. The outgoing WebHook
|
||||
// notification channel stays on the Settings page with the other channels.
|
||||
const apiSection = {
|
||||
id: 'webhookApi',
|
||||
title: 'Incoming API',
|
||||
hint: 'Listener external applications post to. The address and port are read at startup — changing them needs a restart.',
|
||||
root: ['webhook'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled', help: 'Disabled means no listener is started at all.' },
|
||||
{ key: 'listenAddr', type: 'text', label: 'Listen address' },
|
||||
{ key: 'listenPort', type: 'text', label: 'Listen port' }
|
||||
]
|
||||
};
|
||||
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const cfg = ref({});
|
||||
const endpoints = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
const revealed = reactive(new Set());
|
||||
|
||||
const editor = reactive({ open: false, mode: 'add', name: '', token: '', pipes: [] });
|
||||
const removing = reactive({ open: false, name: '' });
|
||||
|
||||
const channelLabel = (key) => (CHANNELS.find((c) => c.key === key) || { label: key }).label;
|
||||
|
||||
// Endpoint URLs are on the webhook listener, not the one serving this console,
|
||||
// so the host is taken from the browser and the port from the configuration.
|
||||
const listenPort = computed(() => (cfg.value.webhook && cfg.value.webhook.listenPort) || '8081');
|
||||
const endpointURL = (name) => `http://${window.location.hostname}:${listenPort.value}/api/webhook/post/${name}`;
|
||||
|
||||
function normalizeEndpoint(name, e) {
|
||||
return {
|
||||
name,
|
||||
enabled: e.enabled === true,
|
||||
token: typeof e.token === 'string' ? e.token : '',
|
||||
notifyPipes: Array.isArray(e.notifyPipes) ? e.notifyPipes : []
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [settings, list] = await Promise.all([settingsApi.get('global'), webhookApi.list()]);
|
||||
cfg.value = (settings && settings.data && settings.data.config) || {};
|
||||
const map = (list && list.data && list.data.endpoints) || {};
|
||||
endpoints.value = Object.entries(map)
|
||||
.map(([name, e]) => normalizeEndpoint(name, e))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status) toast.error('Failed to load WebHooks: ' + e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEndpoint(name, fields, okMsg) {
|
||||
saving.add(name);
|
||||
try {
|
||||
await webhookApi.modify({ name, ...fields });
|
||||
if (okMsg) toast.success(okMsg);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error('Save failed: ' + e.message);
|
||||
return false;
|
||||
} finally {
|
||||
saving.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(row, value) {
|
||||
const prev = row.enabled;
|
||||
row.enabled = value;
|
||||
if (!(await saveEndpoint(row.name, { enabled: value }))) {
|
||||
row.enabled = prev;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editor.mode = 'add';
|
||||
editor.name = '';
|
||||
editor.token = generateToken();
|
||||
editor.pipes = [];
|
||||
editor.open = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editor.mode = 'edit';
|
||||
editor.name = row.name;
|
||||
editor.token = row.token;
|
||||
editor.pipes = [...row.notifyPipes];
|
||||
editor.open = true;
|
||||
}
|
||||
|
||||
function togglePipe(key) {
|
||||
const at = editor.pipes.indexOf(key);
|
||||
if (at === -1) editor.pipes.push(key);
|
||||
else editor.pipes.splice(at, 1);
|
||||
}
|
||||
|
||||
// A 30-character hex token, so a new endpoint never starts out with a guessable
|
||||
// shared secret.
|
||||
function generateToken() {
|
||||
const bytes = new Uint8Array(15);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function confirmEditor() {
|
||||
const name = editor.name.trim();
|
||||
if (!name) return toast.warn('Enter a name');
|
||||
if (name.includes('/')) return toast.warn('The name cannot contain "/"');
|
||||
// The backend refuses requests to an endpoint that has no token, so an
|
||||
// empty one would only ever answer 500.
|
||||
if (!editor.token) return toast.warn('Enter or generate a token');
|
||||
|
||||
const body = { name, token: editor.token, notifyPipes: [...editor.pipes] };
|
||||
try {
|
||||
if (editor.mode === 'add') {
|
||||
// A new endpoint starts enabled; the switch in the list turns it off.
|
||||
await webhookApi.add({ ...body, enabled: true });
|
||||
toast.success('Endpoint added');
|
||||
} else {
|
||||
// enabled is deliberately left out: the list switch owns it, and a
|
||||
// value read when the editor opened could undo a toggle made since.
|
||||
await webhookApi.modify(body);
|
||||
toast.success('Endpoint updated');
|
||||
}
|
||||
editor.open = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error('Save failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function askRemove(row) {
|
||||
removing.name = row.name;
|
||||
removing.open = true;
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
try {
|
||||
await webhookApi.remove(removing.name);
|
||||
toast.success('Endpoint removed');
|
||||
removing.open = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error('Remove failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function copy(value, okMsg) {
|
||||
if (!navigator.clipboard) return toast.warn('Clipboard unavailable — copy it manually');
|
||||
navigator.clipboard.writeText(value).then(() => toast.success(okMsg), () => {});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Integrations</p>
|
||||
<h1>WebHooks</h1>
|
||||
<p class="lead">
|
||||
Let external applications push alerts through this program and relay them to the notification channels.
|
||||
Where this program posts its own alerts is configured on the Settings page.
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load">
|
||||
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="failed" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Failed to load WebHook settings</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
|
||||
|
||||
<template v-else>
|
||||
<ConfigSection :section="apiSection" :config="cfg" @saved="load" />
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>Endpoints</h3>
|
||||
<p class="hint">One endpoint per external application. Each carries its own token and relays to its own channels.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="openAdd">
|
||||
<i class="fas fa-plus" /> Add endpoint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div v-if="endpoints.length === 0" class="empty" style="padding:28px">
|
||||
No endpoints yet — add one to get an URL to post to
|
||||
</div>
|
||||
<div v-else class="opt-list">
|
||||
<div v-for="e in endpoints" :key="e.name" class="ep-row">
|
||||
<div class="ep-main">
|
||||
<div class="ep-title">
|
||||
<span class="id mono">{{ e.name }}</span>
|
||||
<span class="badge" :class="e.enabled ? 'badge-online' : 'badge-offline'">
|
||||
<span class="dot" />{{ e.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ep-line">
|
||||
<span class="method mono">POST</span>
|
||||
<span class="url mono">{{ endpointURL(e.name) }}</span>
|
||||
<button class="icon-btn" title="Copy URL" @click="copy(endpointURL(e.name), 'Endpoint URL copied')">
|
||||
<i class="fas fa-link" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ep-line">
|
||||
<span class="lbl">Token</span>
|
||||
<span class="url mono">{{ revealed.has(e.name) ? e.token : '••••••••••••' }}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
:title="revealed.has(e.name) ? 'Hide token' : 'Show token'"
|
||||
@click="revealed.has(e.name) ? revealed.delete(e.name) : revealed.add(e.name)"
|
||||
>
|
||||
<i class="fas" :class="revealed.has(e.name) ? 'fa-eye-slash' : 'fa-eye'" />
|
||||
</button>
|
||||
<button class="icon-btn" title="Copy token" @click="copy(e.token, 'Token copied')">
|
||||
<i class="fas fa-copy" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ep-channels">
|
||||
<span v-if="e.notifyPipes.length === 0" class="badge badge-danger">
|
||||
<span class="dot" />No channels
|
||||
</span>
|
||||
<span v-for="p in e.notifyPipes" :key="p" class="badge badge-accent">{{ channelLabel(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ep-actions">
|
||||
<Toggle
|
||||
:model-value="e.enabled"
|
||||
:disabled="saving.has(e.name)"
|
||||
@update:model-value="toggleEnabled(e, $event)"
|
||||
/>
|
||||
<button class="icon-btn" title="Edit" @click="openEdit(e)"><i class="fas fa-pen" /></button>
|
||||
<button class="icon-btn danger" title="Remove" @click="askRemove(e)"><i class="fas fa-trash" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Modal
|
||||
:open="editor.open"
|
||||
:title="editor.mode === 'add' ? 'Add endpoint' : 'Edit endpoint'"
|
||||
@close="editor.open = false"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<div class="field span-2">
|
||||
<label>Name</label>
|
||||
<input
|
||||
v-model="editor.name"
|
||||
class="input mono"
|
||||
:disabled="editor.mode === 'edit'"
|
||||
placeholder="example"
|
||||
spellcheck="false"
|
||||
@keydown.enter="confirmEditor"
|
||||
/>
|
||||
<p class="help">
|
||||
Posted to <span class="mono">/api/webhook/post/<name></span>. The name cannot be changed afterwards.
|
||||
</p>
|
||||
<p v-if="editor.mode === 'add'" class="help">
|
||||
The endpoint starts enabled — use the switch in the list to disable it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label>Token</label>
|
||||
<div class="token-row">
|
||||
<input v-model="editor.token" class="input mono" spellcheck="false" autocomplete="off" />
|
||||
<button class="btn btn-ghost btn-sm" @click="editor.token = generateToken()">
|
||||
<i class="fas fa-dice" /> Generate
|
||||
</button>
|
||||
</div>
|
||||
<p class="help">The caller sends this in the request body. An endpoint without a token rejects every request.</p>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label>Relay to</label>
|
||||
<div class="pipe-picker">
|
||||
<button
|
||||
v-for="c in CHANNELS"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
class="pipe"
|
||||
:class="{ on: editor.pipes.includes(c.key) }"
|
||||
@click="togglePipe(c.key)"
|
||||
>
|
||||
<i class="fas" :class="editor.pipes.includes(c.key) ? 'fa-square-check' : 'fa-square'" />
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="help">
|
||||
The alert is relayed to every channel selected here; a channel that is disabled is skipped.
|
||||
The WebHook channel's own destination is configured on the Settings page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="editor.open = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmEditor">
|
||||
{{ editor.mode === 'add' ? 'Add' : 'Save' }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal :open="removing.open" title="Remove endpoint" @close="removing.open = false">
|
||||
<p style="line-height:1.6">
|
||||
Remove <strong class="mono">{{ removing.name }}</strong>? Requests to its URL will stop being accepted.
|
||||
</p>
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="removing.open = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="confirmRemove">Remove</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ep-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ep-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ep-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ep-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ep-title .id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ep-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ep-line .lbl {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ep-line .url {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-s);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ep-channels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ep-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-row .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pipe-picker {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pipe {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease;
|
||||
}
|
||||
|
||||
.pipe:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pipe.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
// Backend the dev server proxies /api (and the log websocket) to.
|
||||
const BACKEND = process.env.NUKUMIZU_API || 'http://127.0.0.1:8080';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
// web/embed.go embeds this directory into the Go binary. It has to live
|
||||
// inside the web package: go:embed cannot reach outside its own
|
||||
// directory, so ../web/dist is as close as it gets.
|
||||
outDir: '../web/dist',
|
||||
// The directory is outside the project root, so Vite refuses to empty
|
||||
// it unless told to. Without this, stale hashed assets from earlier
|
||||
// builds pile up in the binary.
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: BACKEND,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
+15
-3
@@ -14,11 +14,23 @@ type SoftwareInfoStr struct {
|
||||
|
||||
var SoftwareInfo = SoftwareInfoStr{
|
||||
Name: "Nukumizu",
|
||||
Version: "0.1.0",
|
||||
Version: "0.2.0",
|
||||
Developer: "Madobi Nanami",
|
||||
BuildVer: 1,
|
||||
BuildVer: 5,
|
||||
CommitHash: "unknown",
|
||||
Description: "Remote server monitoring and command execution subsystem for Komari",
|
||||
BuildType: "Debug",
|
||||
BuildType: "pre-release",
|
||||
BuildTime: "unknown",
|
||||
}
|
||||
|
||||
type ConfigPathStr struct {
|
||||
Global string
|
||||
BotUserConfig string
|
||||
BotNodeConfig string
|
||||
}
|
||||
|
||||
var ConfigPath = ConfigPathStr{
|
||||
Global: "config.json",
|
||||
BotUserConfig: "bot_user_config.json",
|
||||
BotNodeConfig: "bot_node_config.json",
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
module nukumizu-backend
|
||||
|
||||
go 1.25.0
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/go-telegram/bot v1.25.0
|
||||
|
||||
+85
-14
@@ -18,6 +18,38 @@ type ServerExecRequest struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ServerInfoValue is the per-server payload returned by GET /api/server/getInfo,
|
||||
// mirroring the static server info the Bot prints for /info.
|
||||
type ServerInfoValue struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Info *node.Info `json:"info"`
|
||||
}
|
||||
|
||||
// ServerStatusValue is the per-server payload returned by GET /api/server/getStatus,
|
||||
// mirroring the live status the Bot prints for /status. Report is null when the
|
||||
// node is known but has not delivered a status report yet.
|
||||
type ServerStatusValue struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
Report *node.Report `json:"report"`
|
||||
}
|
||||
|
||||
// collectTargetNodes resolves the uuid query parameter into the list of nodes
|
||||
// the caller asked for. uuid "all" selects every tracked node; any other uuid
|
||||
// selects that single node. A boolean reports whether the uuid was found.
|
||||
func collectTargetNodes(tracker *node.Tracker, uuid string) ([]*node.Node, bool) {
|
||||
if uuid == "all" {
|
||||
return tracker.GetAllNodes(), true
|
||||
}
|
||||
n, exists := tracker.GetNode(uuid)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
return []*node.Node{n}, true
|
||||
}
|
||||
|
||||
// ServerListHandler handles GET /api/server/list.
|
||||
func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "bot") {
|
||||
@@ -31,16 +63,18 @@ func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
params := template.BuildParamsFromServerList()
|
||||
result := template.Render("", params)
|
||||
result := template.Render("", params, false)
|
||||
|
||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||
"list": result,
|
||||
})
|
||||
}
|
||||
|
||||
// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx.
|
||||
func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "bot") {
|
||||
// ServerGetInfoHandler handles GET /api/server/getInfo?uuid=xxx|all.
|
||||
// Returns the static info of the requested server(s) as a uuid-keyed object,
|
||||
// mirroring the data the Bot uses for /info.
|
||||
func ServerGetInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,19 +84,56 @@ func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// First try to get data from the local tracker.
|
||||
tracker := node.GetTracker()
|
||||
if tracker != nil {
|
||||
if n, exists := tracker.GetNode(uuid); exists && n.LatestReport != nil {
|
||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||
"uuid": uuid,
|
||||
"report": n.LatestReport,
|
||||
})
|
||||
return
|
||||
}
|
||||
if tracker == nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("no recent data for uuid: %s", uuid))
|
||||
nodes, found := collectTargetNodes(tracker, uuid)
|
||||
if !found {
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid))
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]interface{}, len(nodes))
|
||||
for _, n := range nodes {
|
||||
result[n.UUID] = ServerInfoValue{UUID: n.UUID, Name: n.Name, Info: n.Info}
|
||||
}
|
||||
utils.SendSuccessResponse(w, "", result)
|
||||
}
|
||||
|
||||
// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx|all.
|
||||
// Returns the live status of the requested server(s) as a uuid-keyed object,
|
||||
// mirroring the data the Bot uses for /status.
|
||||
func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
uuid := r.URL.Query().Get("uuid")
|
||||
if uuid == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing uuid parameter")
|
||||
return
|
||||
}
|
||||
|
||||
tracker := node.GetTracker()
|
||||
if tracker == nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
nodes, found := collectTargetNodes(tracker, uuid)
|
||||
if !found {
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid))
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]interface{}, len(nodes))
|
||||
for _, n := range nodes {
|
||||
result[n.UUID] = ServerStatusValue{UUID: n.UUID, Name: n.Name, Online: n.Online, Report: n.LatestReport}
|
||||
}
|
||||
utils.SendSuccessResponse(w, "", result)
|
||||
}
|
||||
|
||||
// ServerExecHandler handles POST /api/server/exec.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/utils"
|
||||
)
|
||||
|
||||
// adminGet builds an authenticated GET request for the given path using an
|
||||
// admin token and a fresh X-Timestamp.
|
||||
func adminGet(t *testing.T, path string) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Token", "test-admin-token")
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return req
|
||||
}
|
||||
|
||||
// seedTracker initializes the global node tracker with two nodes, one online
|
||||
// with a status report and one offline that has not reported yet.
|
||||
func seedTracker(t *testing.T) {
|
||||
t.Helper()
|
||||
node.InitTracker()
|
||||
|
||||
tracker := node.GetTracker()
|
||||
tracker.UpdateNodeList(map[string]node.NodeListEntry{
|
||||
"u1": {
|
||||
Name: "alpha",
|
||||
Info: &node.Info{},
|
||||
},
|
||||
"u2": {
|
||||
Name: "beta",
|
||||
Info: &node.Info{},
|
||||
},
|
||||
})
|
||||
|
||||
report := node.Report{}
|
||||
report.CPU.Usage = 12.5
|
||||
report.RAM.Total = 1024
|
||||
report.RAM.Used = 512
|
||||
tracker.UpdateStatus([]string{"u1"}, map[string]node.Report{"u1": report})
|
||||
}
|
||||
|
||||
func setupAdminToken() {
|
||||
utils.AddToken("test-admin-token", 1, "admin", "tester")
|
||||
}
|
||||
|
||||
// decodeResponse decodes a success envelope and returns its data object, which
|
||||
// the getInfo and getStatus handlers key by uuid. Responses are wrapped by
|
||||
// utils.SendSuccessResponse as {success, message?, data:{...}}, the shape the
|
||||
// console reads as `response.data[uuid]` (see frontend/src/api/index.js), so
|
||||
// the tests index the returned map by uuid rather than by envelope key.
|
||||
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if !body.Success {
|
||||
t.Fatalf("response is not a success envelope: %s", w.Body.String())
|
||||
}
|
||||
if body.Data == nil {
|
||||
t.Fatalf("response has no data object: %s", w.Body.String())
|
||||
}
|
||||
return body.Data
|
||||
}
|
||||
|
||||
func TestServerGetInfoAll(t *testing.T) {
|
||||
setupAdminToken()
|
||||
seedTracker(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ServerGetInfoHandler(w, adminGet(t, "/api/server/getInfo?uuid=all"))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := decodeResponse(t, w)
|
||||
for _, uuid := range []string{"u1", "u2"} {
|
||||
if _, ok := body[uuid]; !ok {
|
||||
t.Errorf("response missing uuid %q: %s", uuid, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var one struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Info *node.Info `json:"info"`
|
||||
}
|
||||
if err := json.Unmarshal(body["u1"], &one); err != nil {
|
||||
t.Fatalf("decode u1: %v", err)
|
||||
}
|
||||
if one.UUID != "u1" || one.Name != "alpha" {
|
||||
t.Errorf("u1 = %+v", one)
|
||||
}
|
||||
if one.Info == nil {
|
||||
t.Error("expected static info present for u1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerGetInfoSingleAndMissing(t *testing.T) {
|
||||
setupAdminToken()
|
||||
seedTracker(t)
|
||||
|
||||
// Single existing uuid: response is keyed by that uuid (uniform shape).
|
||||
w := httptest.NewRecorder()
|
||||
ServerGetInfoHandler(w, adminGet(t, "/api/server/getInfo?uuid=u1"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeResponse(t, w)
|
||||
if _, ok := body["u1"]; !ok {
|
||||
t.Errorf("single-uuid response missing key u1: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// Unknown uuid yields 404.
|
||||
w2 := httptest.NewRecorder()
|
||||
ServerGetInfoHandler(w2, adminGet(t, "/api/server/getInfo?uuid=ghost"))
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Errorf("missing uuid status = %d, want 404", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerGetStatusAll(t *testing.T) {
|
||||
setupAdminToken()
|
||||
seedTracker(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ServerGetStatusHandler(w, adminGet(t, "/api/server/getStatus?uuid=all"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := decodeResponse(t, w)
|
||||
|
||||
var online struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
Report *node.Report `json:"report"`
|
||||
}
|
||||
if err := json.Unmarshal(body["u1"], &online); err != nil {
|
||||
t.Fatalf("decode u1: %v", err)
|
||||
}
|
||||
if !online.Online || online.Report == nil {
|
||||
t.Errorf("u1 should be online with a report: %+v", online)
|
||||
}
|
||||
|
||||
var offline struct {
|
||||
Online bool `json:"online"`
|
||||
Report *node.Report `json:"report"`
|
||||
}
|
||||
if err := json.Unmarshal(body["u2"], &offline); err != nil {
|
||||
t.Fatalf("decode u2: %v", err)
|
||||
}
|
||||
if offline.Online {
|
||||
t.Error("u2 should be offline")
|
||||
}
|
||||
if offline.Report != nil {
|
||||
t.Errorf("u2 report should be null, got %+v", offline.Report)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/postLog"
|
||||
"nukumizu-backend/utils"
|
||||
)
|
||||
|
||||
// SettingsGetHandler handles GET /api/settings/get?type=xxx.
|
||||
// type selects which config file to return and may be one of
|
||||
// "global", "bot_user_config" or "bot_node_config"; the returned data.config
|
||||
// object has the same layout as the source JSON file.
|
||||
func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
settingsType := r.URL.Query().Get("type")
|
||||
if settingsType == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing type parameter")
|
||||
return
|
||||
}
|
||||
if !config.IsValidSettingsType(settingsType) {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid type parameter: "+settingsType)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := config.GetSettings(settingsType)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||
"config": json.RawMessage(data),
|
||||
})
|
||||
}
|
||||
|
||||
// SettingsSetHandler handles POST /api/settings/set?type=xxx.
|
||||
// type selects which config file to update and may be one of "global",
|
||||
// "bot_user_config" or "bot_node_config". The JSON body is a partial config
|
||||
// object whose keys map directly to config entries, e.g.
|
||||
//
|
||||
// {"system": {"debugMode": true}}
|
||||
//
|
||||
// Multiple entries may be given at once; only the provided keys are changed.
|
||||
//
|
||||
// The response carries data.restartRequired: the keys the update changed that
|
||||
// are only read at startup, so the caller can say which edits are not live yet.
|
||||
// It is empty for an update that took effect in full.
|
||||
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "POST", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
settingsType := r.URL.Query().Get("type")
|
||||
if settingsType == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing type parameter")
|
||||
return
|
||||
}
|
||||
if !config.IsValidSettingsType(settingsType) {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid type parameter: "+settingsType)
|
||||
return
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.UseNumber() // Keep numeric values verbatim (e.g. QQ IDs) instead of float64.
|
||||
var patch map[string]interface{}
|
||||
if err := dec.Decode(&patch); err != nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object")
|
||||
return
|
||||
}
|
||||
if patch == nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "request body must be a JSON object")
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.UpdateSettings(settingsType, patch); err != nil {
|
||||
postLog.Error("Failed to update " + settingsType + " settings: " + err.Error())
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// The write landed either way. This only tells the caller which of the keys
|
||||
// it changed will not be live until the program is restarted.
|
||||
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
|
||||
"type": settingsType,
|
||||
"restartRequired": config.RestartRequiredKeys(settingsType, patch),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// tempConfigFile points one of the settings paths at a throwaway file, so a
|
||||
// test can drive the settings API without touching the config files in the
|
||||
// working directory.
|
||||
func tempConfigFile(t *testing.T, field *string, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
original := *field
|
||||
*field = path
|
||||
t.Cleanup(func() { *field = original })
|
||||
}
|
||||
|
||||
// settingsSetRequest builds an authenticated POST for the settings endpoint.
|
||||
func settingsSetRequest(t *testing.T, settingsType, body string) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/settings/set?type="+settingsType,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("X-Token", "test-admin-token")
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return req
|
||||
}
|
||||
|
||||
// settingsSetResponse is the envelope /api/settings/set answers with.
|
||||
type settingsSetResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Type string `json:"type"`
|
||||
RestartRequired []string `json:"restartRequired"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeSettingsSetResponse(t *testing.T, w *httptest.ResponseRecorder) settingsSetResponse {
|
||||
t.Helper()
|
||||
var body settingsSetResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestSettingsSetReportsStartupOnlyKeys covers the field the console reads to
|
||||
// tell the user which of their edits are not live yet.
|
||||
func TestSettingsSetReportsStartupOnlyKeys(t *testing.T) {
|
||||
setupAdminToken()
|
||||
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false,"listenPort":"8080"}}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SettingsSetHandler(w, settingsSetRequest(t, "global",
|
||||
`{"system":{"listenPort":"9090","debugMode":true}}`))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeSettingsSetResponse(t, w)
|
||||
if !body.Success {
|
||||
t.Fatalf("not a success envelope: %s", w.Body.String())
|
||||
}
|
||||
if body.Data.Type != "global" {
|
||||
t.Errorf("data.type = %q, want global", body.Data.Type)
|
||||
}
|
||||
if len(body.Data.RestartRequired) != 1 || body.Data.RestartRequired[0] != "system.listenPort" {
|
||||
t.Errorf("restartRequired = %v, want [system.listenPort]", body.Data.RestartRequired)
|
||||
}
|
||||
|
||||
// Being reported as startup-only must not stop the write.
|
||||
data, err := config.GetSettings(config.SettingGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"listenPort": "9090"`, `"debugMode": true`} {
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Errorf("the patch was not applied, missing %s:\n%s", want, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSetRestartRequiredIsAlwaysAnArray pins the shape a client
|
||||
// iterates over: an update with nothing to report must answer [] and not null.
|
||||
func TestSettingsSetRestartRequiredIsAlwaysAnArray(t *testing.T) {
|
||||
setupAdminToken()
|
||||
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SettingsSetHandler(w, settingsSetRequest(t, "global", `{"system":{"debugMode":true}}`))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"restartRequired":[]`) {
|
||||
t.Errorf("restartRequired should serialize as an empty array: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSetRejectsUnknownType keeps the failure path intact now that the
|
||||
// success path computes an extra field.
|
||||
func TestSettingsSetRejectsUnknownType(t *testing.T) {
|
||||
setupAdminToken()
|
||||
tempConfigFile(t, &global.ConfigPath.Global, `{}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SettingsSetHandler(w, settingsSetRequest(t, "nonsense", `{}`))
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
db "nukumizu-backend/database"
|
||||
@@ -98,10 +99,16 @@ func UserRegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := db.CreateUser(req.Username, req.Password, "admin")
|
||||
userID, err := db.RegisterFirstUser(req.Username, req.Password, "admin")
|
||||
if err != nil {
|
||||
// A concurrent registration may have won between the count check above
|
||||
// and this insert; both map to the same "registration is closed" answer.
|
||||
if errors.Is(err, db.ErrUsersExist) {
|
||||
utils.SendErrorResponse(w, http.StatusForbidden, "registration is closed: users already exist")
|
||||
return
|
||||
}
|
||||
postLog.Error("Failed to register user: " + err.Error())
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to register user, username may already exist")
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to register user")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "nukumizu-backend/database"
|
||||
)
|
||||
|
||||
// initHandlerUserDB opens a fresh user database in a temp directory for the
|
||||
// register handler tests.
|
||||
func initHandlerUserDB(t *testing.T) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "user.db")
|
||||
if err := db.InitUserDB(path); err != nil {
|
||||
t.Fatalf("InitUserDB: %v", err)
|
||||
}
|
||||
t.Cleanup(db.CloseUserDB)
|
||||
}
|
||||
|
||||
func registerRequest(t *testing.T, username, password string) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/register", bytes.NewReader(body))
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return req
|
||||
}
|
||||
|
||||
// TestRegisterOnlyFirstUser exercises the API rule: the database accepts
|
||||
// exactly the first registration and rejects every later one.
|
||||
func TestRegisterOnlyFirstUser(t *testing.T) {
|
||||
initHandlerUserDB(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
UserRegisterHandler(w, registerRequest(t, "alice", "password1"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("first register status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
UserRegisterHandler(w2, registerRequest(t, "bob", "password2"))
|
||||
if w2.Code != http.StatusForbidden {
|
||||
t.Fatalf("second register status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/controller"
|
||||
"nukumizu-backend/postLog"
|
||||
"nukumizu-backend/utils"
|
||||
)
|
||||
|
||||
// maxWebhookBodyBytes caps the size of an incoming webhook request body. The
|
||||
// endpoint is reachable without a session token, so the body is bounded before
|
||||
// it is read.
|
||||
const maxWebhookBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// webhookRequest is the JSON body accepted by the incoming webhook API.
|
||||
type webhookRequest struct {
|
||||
Token string `json:"token"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WebhookHandler handles POST /api/webhook/{name}, the incoming webhook API
|
||||
// served on its own listener (see webhook in config.json). The path segment
|
||||
// selects the endpoint, which carries the token to present and the notification
|
||||
// channels to deliver to:
|
||||
//
|
||||
// POST /api/webhook/example
|
||||
// {"token": "...", "subject": "...", "content": "..."}
|
||||
//
|
||||
// The alert is rendered per channel and sent through every channel the endpoint
|
||||
// lists in notifyPipes. This route is not part of the token-authenticated API:
|
||||
// it authenticates with the endpoint's own shared token.
|
||||
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
utils.SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.PathValue("name")
|
||||
endpoint, exists := config.GetWebhookEndpoint(name)
|
||||
if !exists {
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, "unknown webhook endpoint: "+name)
|
||||
return
|
||||
}
|
||||
if !endpoint.Enabled {
|
||||
utils.SendErrorResponse(w, http.StatusForbidden, "webhook endpoint is disabled: "+name)
|
||||
return
|
||||
}
|
||||
// An endpoint without a token would accept requests from anyone, so it is
|
||||
// treated as a misconfiguration rather than as an open endpoint.
|
||||
if endpoint.Token == "" {
|
||||
postLog.Error("Webhook endpoint " + name + " has no token configured, rejecting request")
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "webhook endpoint is not configured with a token: "+name)
|
||||
return
|
||||
}
|
||||
|
||||
var req webhookRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)).Decode(&req); err != nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object with token, subject and content")
|
||||
return
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(req.Token), []byte(endpoint.Token)) != 1 {
|
||||
postLog.Warning("Webhook request rejected for endpoint " + name + ": invalid token")
|
||||
utils.SendErrorResponse(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Subject == "" || req.Content == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing required parameter: subject and content must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
manager := controller.GetManager()
|
||||
if manager == nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "controller manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
alert := controller.Alert{
|
||||
Subject: req.Subject,
|
||||
Source: name,
|
||||
Content: req.Content,
|
||||
Time: time.Now().Format("2006-01-02T15:04:05.000000000-07:00"),
|
||||
}
|
||||
|
||||
delivered, err := manager.NotifyAlert(endpoint.NotifyPipes, alert)
|
||||
if err != nil {
|
||||
postLog.Error("Failed to deliver webhook alert for endpoint " + name + ": " + err.Error())
|
||||
utils.SendErrorResponse(w, http.StatusBadGateway, "failed to send alert: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
postLog.Info("Webhook alert delivered for endpoint " + name)
|
||||
utils.SendSuccessResponse(w, "alert sent", map[string]interface{}{
|
||||
"endpoint": name,
|
||||
"channels": delivered,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/utils"
|
||||
)
|
||||
|
||||
// The incoming webhook endpoints are managed from the admin API below. They
|
||||
// live in the same listener as the rest of the admin API — unlike the endpoints
|
||||
// they configure, which are served on the webhook listener (see webhook.go).
|
||||
//
|
||||
// Every handler takes a JSON object naming the endpoint, arranged the same way
|
||||
// as /api/settings/set: whatever fields the request carries are the fields that
|
||||
// change, and everything else keeps its configured value. Only the fields an
|
||||
// endpoint actually has are accepted, so a misspelled field is reported instead
|
||||
// of being written to the configuration file.
|
||||
|
||||
// decodeWebhookEndpointRequest authenticates an admin request, decodes its JSON
|
||||
// object body, and splits it into the endpoint name and the remaining fields.
|
||||
// It answers the request itself and reports ok == false when anything is wrong.
|
||||
func decodeWebhookEndpointRequest(w http.ResponseWriter, r *http.Request) (name string, fields map[string]interface{}, ok bool) {
|
||||
if !utils.Auth(w, r, "POST", "admin") {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.UseNumber() // Keep values verbatim, as /api/settings/set does.
|
||||
var body map[string]interface{}
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object")
|
||||
return "", nil, false
|
||||
}
|
||||
if body == nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "request body must be a JSON object")
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
rawName, present := body["name"]
|
||||
if !present {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing required parameter: name")
|
||||
return "", nil, false
|
||||
}
|
||||
name, isString := rawName.(string)
|
||||
if !isString {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid parameter: name must be a string")
|
||||
return "", nil, false
|
||||
}
|
||||
delete(body, "name")
|
||||
|
||||
return name, body, true
|
||||
}
|
||||
|
||||
// sendWebhookEndpointError maps the errors of the endpoint helpers onto the
|
||||
// matching HTTP responses.
|
||||
func sendWebhookEndpointError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, config.ErrWebhookEndpointExists):
|
||||
utils.SendErrorResponse(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, config.ErrWebhookEndpointNotFound):
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, config.ErrWebhookEndpointInvalid):
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to update webhook endpoints: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookAddHandler handles POST /api/webhook/add.
|
||||
// Body: {name, ...fields}. The endpoint must not exist yet; the fields given are
|
||||
// stored and any field left out starts at its default (disabled, no token, no
|
||||
// notify pipes).
|
||||
func WebhookAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name, fields, ok := decodeWebhookEndpointRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.AddWebhookEndpoint(name, fields); err != nil {
|
||||
sendWebhookEndpointError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "webhook endpoint added", map[string]interface{}{"name": name})
|
||||
}
|
||||
|
||||
// WebhookModifyHandler handles POST /api/webhook/modify.
|
||||
// Body: {name, ...fields}. Only the fields given are changed.
|
||||
func WebhookModifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name, fields, ok := decodeWebhookEndpointRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.ModifyWebhookEndpoint(name, fields); err != nil {
|
||||
sendWebhookEndpointError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "webhook endpoint updated", map[string]interface{}{"name": name})
|
||||
}
|
||||
|
||||
// WebhookDeleteHandler handles POST /api/webhook/delete.
|
||||
// Body: {name}.
|
||||
func WebhookDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name, _, ok := decodeWebhookEndpointRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.DeleteWebhookEndpoint(name); err != nil {
|
||||
sendWebhookEndpointError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "webhook endpoint deleted", map[string]interface{}{"name": name})
|
||||
}
|
||||
|
||||
// WebhookListHandler handles GET /api/webhook/list.
|
||||
// Returns every configured incoming webhook endpoint, keyed by name.
|
||||
func WebhookListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||
"endpoints": config.WebhookEndpoints(),
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/node"
|
||||
@@ -12,7 +16,7 @@ import (
|
||||
|
||||
// Command represents a parsed bot command.
|
||||
type Command struct {
|
||||
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
||||
Source string // Name of the pipe the command arrived on (see Controller.Name)
|
||||
RawText string // The raw text of the command message
|
||||
Command string // The command word (e.g., "list", "status")
|
||||
Args []string // Command arguments
|
||||
@@ -21,15 +25,77 @@ type Command struct {
|
||||
SenderID int64 // User ID of the sender
|
||||
}
|
||||
|
||||
// Message represents a message to be sent by a controller.
|
||||
type Message struct {
|
||||
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
||||
Content string // The message content
|
||||
ChatID int64 // Chat/group ID where the message should be sent
|
||||
Type string // Message type (see MessageType*), used for per-member opt-outs
|
||||
}
|
||||
|
||||
// Message type labels. They let bot pipes apply per-member opt-out options from
|
||||
// bot_user_config.json (see MemberReceives) to automatic messages.
|
||||
const (
|
||||
// MessageTypeBotStarted marks the automatic welcome/server-list messages the
|
||||
// bot pushes on startup. Gated by BotUserOptions.EventBotStarted.
|
||||
MessageTypeBotStarted = "event_bot_started"
|
||||
// MessageTypeReply marks a direct reply to a user command. Reserved for the
|
||||
// BotUserOptions.EventReply opt-out.
|
||||
MessageTypeReply = "event_reply"
|
||||
// MessageTypeAlert marks an alert submitted by an external application
|
||||
// through the incoming webhook API. Not member-controllable: an alert is
|
||||
// always delivered to the channel's recipients.
|
||||
MessageTypeAlert = "alert"
|
||||
)
|
||||
|
||||
// Alert is a free-form notification submitted by an external application
|
||||
// through the incoming webhook API. Its target channels are chosen per webhook
|
||||
// endpoint in config.json, not per alert.
|
||||
type Alert struct {
|
||||
Subject string // Short one-line title of the alert
|
||||
Source string // Name of the webhook endpoint the alert was submitted to
|
||||
Content string // Free-form alert body
|
||||
Time string // Submission time
|
||||
}
|
||||
|
||||
// Render renders the alert body for a channel, wrapping the source and content
|
||||
// in Markdown when that channel has markdown enabled (see template.RenderAlert).
|
||||
func (a Alert) Render(markdown bool) string {
|
||||
return template.RenderAlert(template.AlertParams{
|
||||
Subject: a.Subject,
|
||||
Source: a.Source,
|
||||
Content: a.Content,
|
||||
Time: a.Time,
|
||||
}, markdown)
|
||||
}
|
||||
|
||||
// MemberReceives reports whether a member whose bot_user_config.json options are
|
||||
// opts receives an automatic message of the given type. Only member-controllable
|
||||
// types are gated; anything else is always delivered.
|
||||
func MemberReceives(opts config.BotUserOptions, messageType string) bool {
|
||||
switch messageType {
|
||||
case MessageTypeBotStarted:
|
||||
return opts.EventBotStarted
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Controller defines the interface for all notification/bot controllers.
|
||||
type Controller interface {
|
||||
Name() string
|
||||
Start() error
|
||||
Stop()
|
||||
IsEnabled() bool
|
||||
// IsMarkdown reports whether the channel renders Markdown, per its own
|
||||
// "markdown" setting in config.json.
|
||||
IsMarkdown() bool
|
||||
SendStatusChange(change node.StatusChange) error
|
||||
SendServerList(onlineServers, offlineServers string) error
|
||||
SendExecuteResult(serverName, serverUUID, command, result string) error
|
||||
// SendAlert delivers a free-form alert submitted through the incoming
|
||||
// webhook API to the channel's own recipients.
|
||||
SendAlert(alert Alert) error
|
||||
}
|
||||
|
||||
// BotController is implemented by controllers that act as chat bots and can
|
||||
@@ -38,13 +104,19 @@ type Controller interface {
|
||||
// pipes (email, ntfy, webhook) do not.
|
||||
type BotController interface {
|
||||
Controller
|
||||
SendMessage(message string) error
|
||||
SendMessage(message Message) error
|
||||
}
|
||||
|
||||
// Manager manages all controller instances and routes events.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
controllers map[string]Controller
|
||||
|
||||
// builtFrom records the controllerMethod section the registered controllers
|
||||
// were built from, so a settings update that concerns them can be told apart
|
||||
// from one that does not. It is read on the settings-update goroutine and
|
||||
// written when the set is replaced.
|
||||
builtFrom atomic.Pointer[config.ControllerMethodConfig]
|
||||
}
|
||||
|
||||
var globalManager *Manager
|
||||
@@ -62,24 +134,84 @@ func GetManager() *Manager {
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// Register adds a controller to the manager.
|
||||
func (m *Manager) Register(c Controller) {
|
||||
// NeedsRebuild reports whether next differs from the controllerMethod section
|
||||
// the registered controllers were built from. A manager with no controllers yet
|
||||
// always reports true, so the first call installs the initial set.
|
||||
//
|
||||
// Controllers are rebuilt wholesale rather than reconfigured in place: each one
|
||||
// reads its settings into fields at construction, and two of them own
|
||||
// connections that cannot be re-pointed (the NapCat WebSocket listener is
|
||||
// stopped through a sync.Once, the Telegram polling context is created with the
|
||||
// controller). Replacing the set keeps every channel on the same footing.
|
||||
func (m *Manager) NeedsRebuild(next config.ControllerMethodConfig) bool {
|
||||
built := m.builtFrom.Load()
|
||||
if built == nil {
|
||||
return true
|
||||
}
|
||||
// The section carries a header map and a recipient slice, so it is not
|
||||
// comparable with ==.
|
||||
return !reflect.DeepEqual(*built, next)
|
||||
}
|
||||
|
||||
// ReplaceAll stops every registered controller and swaps in next, which the
|
||||
// caller built from method. It is the only way controllers are installed, at
|
||||
// startup and after a settings change alike.
|
||||
//
|
||||
// The swap happens under the registry lock so routing flips to the new set
|
||||
// atomically; stopping and starting happen outside it. Both can block — Stop
|
||||
// closes sockets, Start performs a handshake — and holding m.mu across them
|
||||
// would stall every notification for the duration.
|
||||
func (m *Manager) ReplaceAll(next []Controller, method config.ControllerMethodConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.controllers[c.Name()] = c
|
||||
postLog.Info("Controller registered: " + c.Name())
|
||||
previous := m.controllers
|
||||
m.controllers = make(map[string]Controller, len(next))
|
||||
for _, ctrl := range next {
|
||||
m.controllers[ctrl.Name()] = ctrl
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.builtFrom.Store(&method)
|
||||
|
||||
names := make([]string, 0, len(next))
|
||||
for _, ctrl := range next {
|
||||
names = append(names, ctrl.Name())
|
||||
}
|
||||
postLog.Info("Controller set installed: " + strings.Join(names, ", "))
|
||||
|
||||
for _, ctrl := range previous {
|
||||
ctrl.Stop()
|
||||
}
|
||||
|
||||
// Start off the calling goroutine, the way startup does: Telegram's getMe
|
||||
// and the NapCat WebSocket handshake would otherwise hold the settings
|
||||
// request open for as long as they take. The new controllers are already
|
||||
// routable, and each one can send before Start returns.
|
||||
for _, ctrl := range next {
|
||||
go func(ctrl Controller) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Controller %s panicked on start: %v", ctrl.Name(), r))
|
||||
}
|
||||
}()
|
||||
if err := ctrl.Start(); err != nil {
|
||||
postLog.Error(fmt.Sprintf("Failed to start controller %s: %v", ctrl.Name(), err))
|
||||
}
|
||||
}(ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
// ShowBotInitMessage sends the bot initialization message to all enabled
|
||||
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
||||
// not implement BotController are skipped.
|
||||
// not implement BotController are skipped. The message is typed
|
||||
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
||||
// EventBotStarted opt-out. It is rendered once per controller because the
|
||||
// Markdown formatting depends on each channel's own markdown setting.
|
||||
func (m *Manager) ShowBotInitMessage() {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildBotInitializationMsgParams()
|
||||
message := template.Render(cfg.ControllerMessage.BotStarted, params)
|
||||
|
||||
for _, ctrl := range m.controllers {
|
||||
if !ctrl.IsEnabled() {
|
||||
@@ -89,22 +221,29 @@ func (m *Manager) ShowBotInitMessage() {
|
||||
if !ok {
|
||||
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
|
||||
}
|
||||
message := Message{
|
||||
Source: bot.Name(),
|
||||
Content: template.Render(cfg.ControllerMessage.BotStarted, params, ctrl.IsMarkdown()),
|
||||
Type: MessageTypeBotStarted,
|
||||
}
|
||||
if err := bot.SendMessage(message); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Controller %s failed to send init message: %v", bot.Name(), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShowBotServerList sends the server list to all enabled bot controllers. The
|
||||
// message content is identical to the /list command (same template and
|
||||
// parameters).
|
||||
// ShowBotServerList sends the startup server list to all enabled bot
|
||||
// controllers. The message content is identical to the /list command (same
|
||||
// template and parameters). Like the init message it is typed
|
||||
// MessageTypeBotStarted so members who opted out of bot-started pushes do not
|
||||
// receive it, and rendered once per controller so each channel's markdown
|
||||
// setting is honored.
|
||||
func (m *Manager) ShowBotServerList() {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||
|
||||
for _, ctrl := range m.controllers {
|
||||
if !ctrl.IsEnabled() {
|
||||
@@ -114,35 +253,93 @@ func (m *Manager) ShowBotServerList() {
|
||||
if !ok {
|
||||
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
|
||||
}
|
||||
message := Message{
|
||||
Source: bot.Name(),
|
||||
Content: template.Render(cfg.ControllerMessage.ServerList, params, ctrl.IsMarkdown()),
|
||||
Type: MessageTypeBotStarted,
|
||||
}
|
||||
if err := bot.SendMessage(message); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Controller %s failed to send server list: %v", bot.Name(), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyStatusChange sends a status change notification to all enabled controllers.
|
||||
// NotifyStatusChange sends a status change notification to all enabled
|
||||
// controllers. It honors the per-node allow-list in bot_node_config.json: a
|
||||
// node whose enableStatusNotify is not true is skipped entirely, so no
|
||||
// controller (chat bots or notification pipes) broadcasts its change.
|
||||
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
||||
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
||||
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
templateStr := cfg.ControllerMessage.ServerStatusChanged
|
||||
params := template.BuildParamsFromStatusChange(change)
|
||||
|
||||
for _, ctrl := range m.controllers {
|
||||
if !ctrl.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the names of controllers that support commands for the message.
|
||||
if err := ctrl.SendStatusChange(change); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Controller %s failed to send status change: %v", ctrl.Name(), err))
|
||||
}
|
||||
_ = templateStr
|
||||
_ = params
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyAlert delivers an alert to the named pipes only, and returns the names
|
||||
// of the pipes it was handed to. A pipe that is unknown, disabled or fails to
|
||||
// send is reported through the returned error instead of stopping the delivery
|
||||
// to the remaining pipes; if no pipe accepted the alert, the error describes
|
||||
// every failure.
|
||||
func (m *Manager) NotifyAlert(pipes []string, alert Alert) ([]string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var delivered, failures []string
|
||||
for _, name := range pipes {
|
||||
ctrl, ok := m.controllers[name]
|
||||
if !ok {
|
||||
failures = append(failures, fmt.Sprintf("%s: no such channel", name))
|
||||
continue
|
||||
}
|
||||
if !ctrl.IsEnabled() {
|
||||
failures = append(failures, fmt.Sprintf("%s: channel is disabled", name))
|
||||
continue
|
||||
}
|
||||
if err := ctrl.SendAlert(alert); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
delivered = append(delivered, name)
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
postLog.Warning(fmt.Sprintf("Alert %q from %s not delivered by: %s", alert.Subject, alert.Source, strings.Join(failures, "; ")))
|
||||
}
|
||||
if len(delivered) == 0 {
|
||||
if len(failures) == 0 {
|
||||
return nil, errors.New("no notify channel configured")
|
||||
}
|
||||
return nil, errors.New(strings.Join(failures, "; "))
|
||||
}
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
// IsMarkdown reports whether the pipe with the given name renders Markdown, per
|
||||
// its channel's "markdown" setting in config.json. An unknown pipe renders
|
||||
// plain text.
|
||||
func (m *Manager) IsMarkdown(pipeName string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
ctrl, ok := m.controllers[pipeName]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return ctrl.IsMarkdown()
|
||||
}
|
||||
|
||||
// StopAll stops all registered controllers.
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.RLock()
|
||||
@@ -163,4 +360,4 @@ func (m *Manager) NotifyAllAdmins(message string) {
|
||||
}
|
||||
postLog.Info(fmt.Sprintf("Notifying via %s: %s", ctrl.Name(), message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/node"
|
||||
)
|
||||
|
||||
// fakeController records the lifecycle calls it receives so a test can assert
|
||||
// what ReplaceAll did to it. Start signals on started, because ReplaceAll starts
|
||||
// controllers off the calling goroutine.
|
||||
type fakeController struct {
|
||||
name string
|
||||
started chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
starts int
|
||||
stops int
|
||||
}
|
||||
|
||||
func newFake(name string) *fakeController {
|
||||
return &fakeController{name: name, started: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (f *fakeController) Name() string { return f.name }
|
||||
func (f *fakeController) IsEnabled() bool { return true }
|
||||
func (f *fakeController) IsMarkdown() bool { return false }
|
||||
|
||||
func (f *fakeController) Start() error {
|
||||
f.mu.Lock()
|
||||
f.starts++
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case f.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeController) Stop() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stops++
|
||||
}
|
||||
|
||||
func (f *fakeController) lifecycle() (starts, stops int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.starts, f.stops
|
||||
}
|
||||
|
||||
func (f *fakeController) SendStatusChange(node.StatusChange) error { return nil }
|
||||
func (f *fakeController) SendServerList(string, string) error { return nil }
|
||||
func (f *fakeController) SendExecuteResult(string, string, string, string) error { return nil }
|
||||
func (f *fakeController) SendAlert(Alert) error { return nil }
|
||||
|
||||
// waitStarted blocks until the controller's Start has run.
|
||||
func (f *fakeController) waitStarted(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("controller %s was never started", f.name)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestManager() *Manager {
|
||||
return &Manager{controllers: make(map[string]Controller)}
|
||||
}
|
||||
|
||||
// routedTo returns the controller the manager currently routes the given name
|
||||
// to.
|
||||
func (m *Manager) routedTo(name string) Controller {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.controllers[name]
|
||||
}
|
||||
|
||||
func TestReplaceAllInstallsAndStarts(t *testing.T) {
|
||||
m := newTestManager()
|
||||
alpha, beta := newFake("alpha"), newFake("beta")
|
||||
|
||||
m.ReplaceAll([]Controller{alpha, beta}, config.ControllerMethodConfig{})
|
||||
|
||||
alpha.waitStarted(t)
|
||||
beta.waitStarted(t)
|
||||
|
||||
if got := m.routedTo("alpha"); got != Controller(alpha) {
|
||||
t.Errorf("alpha is not routable after ReplaceAll: %v", got)
|
||||
}
|
||||
if got := m.routedTo("beta"); got != Controller(beta) {
|
||||
t.Errorf("beta is not routable after ReplaceAll: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceAllStopsTheOutgoingSet is the invariant the rebuild relies on: the
|
||||
// old controllers must be shut down, or a rebuilt NapCat or Telegram controller
|
||||
// would leave its previous connection running.
|
||||
func TestReplaceAllStopsTheOutgoingSet(t *testing.T) {
|
||||
m := newTestManager()
|
||||
outgoing := newFake("alpha")
|
||||
m.ReplaceAll([]Controller{outgoing}, config.ControllerMethodConfig{})
|
||||
outgoing.waitStarted(t)
|
||||
|
||||
incoming := newFake("alpha")
|
||||
m.ReplaceAll([]Controller{incoming}, config.ControllerMethodConfig{})
|
||||
incoming.waitStarted(t)
|
||||
|
||||
if starts, stops := outgoing.lifecycle(); starts != 1 || stops != 1 {
|
||||
t.Errorf("outgoing controller lifecycle = %d starts / %d stops, want 1/1", starts, stops)
|
||||
}
|
||||
if _, stops := incoming.lifecycle(); stops != 0 {
|
||||
t.Errorf("incoming controller was stopped %d times", stops)
|
||||
}
|
||||
if got := m.routedTo("alpha"); got != Controller(incoming) {
|
||||
t.Error("routing still points at the outgoing controller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAllDropsChannelsLeftOut(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
|
||||
|
||||
m.ReplaceAll([]Controller{newFake("beta")}, config.ControllerMethodConfig{})
|
||||
|
||||
if got := m.routedTo("alpha"); got != nil {
|
||||
t.Errorf("a channel missing from the new set is still routable: %v", got)
|
||||
}
|
||||
if got := m.routedTo("beta"); got == nil {
|
||||
t.Error("the surviving channel is not routable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRebuild(t *testing.T) {
|
||||
m := newTestManager()
|
||||
base := config.ControllerMethodConfig{
|
||||
Email: config.EmailConfig{Enabled: true, SMTPPort: 587, To: []string{"a@example.com"}},
|
||||
}
|
||||
|
||||
if !m.NeedsRebuild(base) {
|
||||
t.Error("a manager with nothing installed must report that a rebuild is needed")
|
||||
}
|
||||
|
||||
m.ReplaceAll(nil, base)
|
||||
if m.NeedsRebuild(base) {
|
||||
t.Error("the settings the set was built from must not ask for another rebuild")
|
||||
}
|
||||
|
||||
changed := base
|
||||
changed.Email.Enabled = false
|
||||
if !m.NeedsRebuild(changed) {
|
||||
t.Error("a changed email setting must ask for a rebuild")
|
||||
}
|
||||
|
||||
// The section carries maps and slices, so it is compared by value rather
|
||||
// than by identity: equal content must not trigger a rebuild.
|
||||
withHeaders := config.ControllerMethodConfig{
|
||||
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
|
||||
}
|
||||
m.ReplaceAll(nil, withHeaders)
|
||||
equalHeaders := config.ControllerMethodConfig{
|
||||
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
|
||||
}
|
||||
if m.NeedsRebuild(equalHeaders) {
|
||||
t.Error("equal header maps must not ask for a rebuild")
|
||||
}
|
||||
|
||||
differentHeaders := config.ControllerMethodConfig{
|
||||
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "other"}},
|
||||
}
|
||||
if !m.NeedsRebuild(differentHeaders) {
|
||||
t.Error("a changed header must ask for a rebuild")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceAllDuringNotification drives ReplaceAll while notifications are
|
||||
// being routed. Run with -race: the swap replaces the map the routing path
|
||||
// reads, which is what the registry lock exists to make safe.
|
||||
func TestReplaceAllDuringNotification(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.ReplaceAll([]Controller{newFake("alpha")}, config.ControllerMethodConfig{})
|
||||
|
||||
var readers, writers sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
m.NotifyStatusChange(node.StatusChange{UUID: "u1", Name: "alpha", Event: "Online"})
|
||||
_ = m.IsMarkdown("alpha")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
|
||||
}
|
||||
}()
|
||||
|
||||
// Let the writer finish, then release the readers: they only return once
|
||||
// stop is closed.
|
||||
writers.Wait()
|
||||
close(stop)
|
||||
readers.Wait()
|
||||
|
||||
if got := m.routedTo("beta"); got == nil {
|
||||
t.Error("the last installed set is not routable")
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package pipes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
gomail "gopkg.in/mail.v2"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/controller"
|
||||
"nukumizu-backend/internal/netproxy"
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/internal/template"
|
||||
@@ -13,22 +15,34 @@ import (
|
||||
)
|
||||
|
||||
// EmailController handles email notifications via SMTP.
|
||||
//
|
||||
// cfg is written once, by the constructor, and never again: a controller that
|
||||
// needs different settings is replaced wholesale by the manager rather than
|
||||
// reconfigured in place, so the send methods can read it without locking.
|
||||
type EmailController struct {
|
||||
cfg config.EmailConfig
|
||||
}
|
||||
|
||||
// NewEmailController creates a new Email controller.
|
||||
func NewEmailController(cfg config.EmailConfig) *EmailController {
|
||||
if cfg.NetworkUseProxy {
|
||||
// Route SMTP through the HTTP CONNECT proxy. NetDialTimeout is
|
||||
// gomail's documented hook for overriding how the SMTP connection is
|
||||
// dialed. There is a single global email channel, so overriding it
|
||||
// unconditionally when the flag is set is safe.
|
||||
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
|
||||
}
|
||||
applyEmailProxy(cfg.NetworkUseProxy)
|
||||
return &EmailController{cfg: cfg}
|
||||
}
|
||||
|
||||
// applyEmailProxy routes SMTP through the HTTP CONNECT proxy, or restores a
|
||||
// direct dial. gomail exposes the dial path as the package-level
|
||||
// NetDialTimeout, whose own default is net.DialTimeout, so turning the proxy
|
||||
// off has to put that back rather than leave the hook in place. There is a
|
||||
// single global email channel, so setting a package-level hook here is
|
||||
// unambiguous.
|
||||
func applyEmailProxy(useProxy bool) {
|
||||
if useProxy {
|
||||
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
|
||||
return
|
||||
}
|
||||
gomail.NetDialTimeout = net.DialTimeout
|
||||
}
|
||||
|
||||
// Name returns the controller name.
|
||||
func (e *EmailController) Name() string {
|
||||
return "email"
|
||||
@@ -54,6 +68,12 @@ func (e *EmailController) IsEnabled() bool {
|
||||
return e.cfg.Enabled
|
||||
}
|
||||
|
||||
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||
// setting in config.json.
|
||||
func (e *EmailController) IsMarkdown() bool {
|
||||
return e.cfg.Markdown
|
||||
}
|
||||
|
||||
// SendStatusChange sends a status change notification via Email.
|
||||
func (e *EmailController) SendStatusChange(change node.StatusChange) error {
|
||||
if !e.cfg.Enabled {
|
||||
@@ -64,9 +84,9 @@ func (e *EmailController) SendStatusChange(change node.StatusChange) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromStatusChange(change)
|
||||
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, e.cfg.Markdown)
|
||||
|
||||
subject := fmt.Sprintf("Server Status Change: %s - %s", change.Name, change.Event)
|
||||
return e.sendEmail(subject, body)
|
||||
@@ -78,9 +98,9 @@ func (e *EmailController) SendServerList(onlineServers, offlineServers string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
body := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||
body := template.Render(cfg.ControllerMessage.ServerList, params, e.cfg.Markdown)
|
||||
|
||||
return e.sendEmail("Server List", body)
|
||||
}
|
||||
@@ -91,14 +111,28 @@ func (e *EmailController) SendExecuteResult(serverName, serverUUID, command, res
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
||||
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, e.cfg.Markdown)
|
||||
|
||||
subject := fmt.Sprintf("Command Result: %s on %s", command, serverName)
|
||||
return e.sendEmail(subject, body)
|
||||
}
|
||||
|
||||
// SendAlert sends an alert submitted through the incoming webhook API to the
|
||||
// configured recipients.
|
||||
func (e *EmailController) SendAlert(alert controller.Alert) error {
|
||||
if !e.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if len(e.cfg.To) == 0 {
|
||||
postLog.Debug("Email controller has no recipients configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.sendEmail(alert.Subject, alert.Render(e.cfg.Markdown))
|
||||
}
|
||||
|
||||
func (e *EmailController) sendEmail(subject, body string) error {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", e.cfg.From)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package pipes
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
gomail "gopkg.in/mail.v2"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
)
|
||||
|
||||
// dialerIsDefault reports whether gomail still dials directly. Function values
|
||||
// are not comparable in Go, so the code pointers are compared instead.
|
||||
func dialerIsDefault() bool {
|
||||
return reflect.ValueOf(gomail.NetDialTimeout).Pointer() ==
|
||||
reflect.ValueOf(net.DialTimeout).Pointer()
|
||||
}
|
||||
|
||||
// TestApplyEmailProxyRestoresDefaultDialer covers the case the constructor used
|
||||
// to get wrong. gomail exposes its dial hook as a package-level variable with no
|
||||
// unset, and the code only installed the proxy dialer when the flag was set, so
|
||||
// turning networkUseProxy back off left SMTP tunnelled through a proxy nobody
|
||||
// had asked for — with no way to undo it short of a restart.
|
||||
func TestApplyEmailProxyRestoresDefaultDialer(t *testing.T) {
|
||||
t.Cleanup(func() { gomail.NetDialTimeout = net.DialTimeout })
|
||||
|
||||
applyEmailProxy(true)
|
||||
if dialerIsDefault() {
|
||||
t.Fatal("applyEmailProxy(true) did not install the proxy dialer")
|
||||
}
|
||||
|
||||
applyEmailProxy(false)
|
||||
if !dialerIsDefault() {
|
||||
t.Error("applyEmailProxy(false) left the proxy dialer in place")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEmailControllerAppliesProxySetting pins that the dialer follows the
|
||||
// settings a controller is built with, which is what makes a rebuilt controller
|
||||
// pick up a changed proxy flag.
|
||||
func TestNewEmailControllerAppliesProxySetting(t *testing.T) {
|
||||
t.Cleanup(func() { gomail.NetDialTimeout = net.DialTimeout })
|
||||
|
||||
NewEmailController(config.EmailConfig{NetworkUseProxy: true})
|
||||
if dialerIsDefault() {
|
||||
t.Error("a controller built with networkUseProxy: true did not install the proxy dialer")
|
||||
}
|
||||
|
||||
NewEmailController(config.EmailConfig{NetworkUseProxy: false})
|
||||
if !dialerIsDefault() {
|
||||
t.Error("a controller rebuilt with networkUseProxy: false left the proxy dialer in place")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/controller"
|
||||
"nukumizu-backend/internal/netproxy"
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/internal/template"
|
||||
@@ -14,6 +15,11 @@ import (
|
||||
)
|
||||
|
||||
// NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server.
|
||||
//
|
||||
// cfg and httpClient are written once, by the constructor, and never again: a
|
||||
// controller that needs different settings is replaced wholesale by the manager
|
||||
// rather than reconfigured in place, so the send methods can read them without
|
||||
// locking.
|
||||
type NtfyController struct {
|
||||
cfg config.NtfyConfig
|
||||
httpClient *http.Client
|
||||
@@ -52,15 +58,21 @@ func (n *NtfyController) IsEnabled() bool {
|
||||
return n.cfg.Enabled
|
||||
}
|
||||
|
||||
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||
// setting in config.json.
|
||||
func (n *NtfyController) IsMarkdown() bool {
|
||||
return n.cfg.Markdown
|
||||
}
|
||||
|
||||
// SendStatusChange sends a status change notification via Ntfy.
|
||||
func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
|
||||
if !n.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromStatusChange(change)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, n.cfg.Markdown)
|
||||
|
||||
title := fmt.Sprintf("Server %s: %s", change.Name, change.Event)
|
||||
return n.publish(title, message)
|
||||
@@ -72,9 +84,9 @@ func (n *NtfyController) SendServerList(onlineServers, offlineServers string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params, n.cfg.Markdown)
|
||||
|
||||
return n.publish("Server List", message)
|
||||
}
|
||||
@@ -85,14 +97,24 @@ func (n *NtfyController) SendExecuteResult(serverName, serverUUID, command, resu
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, n.cfg.Markdown)
|
||||
|
||||
title := fmt.Sprintf("Command Result: %s on %s", command, serverName)
|
||||
return n.publish(title, message)
|
||||
}
|
||||
|
||||
// SendAlert sends an alert submitted through the incoming webhook API to the
|
||||
// configured topic.
|
||||
func (n *NtfyController) SendAlert(alert controller.Alert) error {
|
||||
if !n.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return n.publish(alert.Subject, alert.Render(n.cfg.Markdown))
|
||||
}
|
||||
|
||||
func (n *NtfyController) publish(title, message string) error {
|
||||
serverURL := n.cfg.Server
|
||||
if serverURL == "" {
|
||||
|
||||
@@ -17,6 +17,15 @@ import (
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// actionLogEnabled reports whether the NapCat HTTP API methods echo each action
|
||||
// they send, per the showNapcatAction toggle. Unlike the WebSocket message path
|
||||
// in qq.go it deliberately does not also require debugMode: these methods have
|
||||
// always logged on this toggle alone, and preserving that is intentional.
|
||||
func actionLogEnabled() bool {
|
||||
cfg := config.Current()
|
||||
return cfg != nil && cfg.Debug.ShowNapcatAction
|
||||
}
|
||||
|
||||
// APIResponse mirrors NapCat's HTTP API response envelope.
|
||||
type APIResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -155,7 +164,7 @@ func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bo
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if actionLogEnabled() {
|
||||
postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message))
|
||||
}
|
||||
|
||||
@@ -176,7 +185,7 @@ func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if actionLogEnabled() {
|
||||
postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID))
|
||||
}
|
||||
|
||||
@@ -190,7 +199,7 @@ func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
|
||||
|
||||
// GetGroupList retrieves the list of joined groups from NapCat.
|
||||
func (c *Client) GetGroupList() (*APIResponse, error) {
|
||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if actionLogEnabled() {
|
||||
postLog.Debug("[Napcat] GetGroupList -> /get_group_list")
|
||||
}
|
||||
|
||||
@@ -211,7 +220,7 @@ func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if actionLogEnabled() {
|
||||
postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID))
|
||||
}
|
||||
|
||||
@@ -225,7 +234,7 @@ func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
|
||||
|
||||
// GetFriendsList retrieves the friends list from NapCat.
|
||||
func (c *Client) GetFriendsList() (*APIResponse, error) {
|
||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if actionLogEnabled() {
|
||||
postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list")
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ func (q *QQController) IsEnabled() bool {
|
||||
return q.cfg.Enabled
|
||||
}
|
||||
|
||||
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||
// setting in config.json.
|
||||
func (q *QQController) IsMarkdown() bool {
|
||||
return q.cfg.Markdown
|
||||
}
|
||||
|
||||
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
|
||||
func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
var ev oneBotEvent
|
||||
@@ -94,24 +100,34 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatMsg {
|
||||
// The configuration is read once per event: the debug guards below are
|
||||
// evaluated several times, and taking them all from one version keeps a
|
||||
// concurrent reload from splitting them mid-event.
|
||||
cfg := config.Current()
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
debugMode := cfg.System.DebugMode
|
||||
showAction := debugMode && cfg.Debug.ShowNapcatAction
|
||||
|
||||
if debugMode && cfg.Debug.ShowNapcatMsg {
|
||||
postLog.Debug("Napcat WS event received: " + string(raw))
|
||||
}
|
||||
|
||||
// Only handle message events; ignore notice/request/meta_event.
|
||||
if ev.PostType != "message" {
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if showAction {
|
||||
postLog.Debug("Ignoring Napcat WS event: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore messages the bot itself sent (echo prevention).
|
||||
if q.isSelfMessage(ev) && !config.C_globalConfig.System.DebugMode {
|
||||
if q.isSelfMessage(ev) && !debugMode {
|
||||
return
|
||||
}
|
||||
if q.isSelfMessage(ev) && config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.NapcatIgnoreSelfMsg {
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg {
|
||||
if showAction {
|
||||
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
|
||||
}
|
||||
return
|
||||
@@ -132,7 +148,7 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
|
||||
response := q.processCommand(cmd)
|
||||
if response == "" {
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if showAction {
|
||||
postLog.Debug("Napcat WS command discarded: " + string(raw))
|
||||
}
|
||||
return
|
||||
@@ -154,6 +170,7 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
// complete command to the unified processor. It returns the response text to
|
||||
// reply with; an empty response means the message was discarded.
|
||||
func (q *QQController) processCommand(cmd controller.Command) string {
|
||||
cfg := config.Current()
|
||||
text := cmd.RawText
|
||||
|
||||
// In "at" listen mode, require an @mention of the bot and strip it before
|
||||
@@ -162,7 +179,7 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
||||
if q.cfg.ListenMethod == "at" {
|
||||
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
|
||||
if !strings.Contains(text, atMention) {
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction {
|
||||
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
|
||||
}
|
||||
return "" // Not mentioned, ignore.
|
||||
@@ -179,12 +196,12 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
||||
parsed.ChatID = cmd.ChatID
|
||||
parsed.ChatType = cmd.ChatType
|
||||
parsed.SenderID = cmd.SenderID
|
||||
parsed.Source = "qq_napcat"
|
||||
parsed.Source = q.Name()
|
||||
|
||||
// Hand the complete command to the unified processor, which checks group
|
||||
// vs private, trusted groups, admin permissions, and executes it.
|
||||
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho {
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
|
||||
postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
|
||||
}
|
||||
if err != nil {
|
||||
@@ -207,7 +224,7 @@ func (q *QQController) isSelfMessage(ev oneBotEvent) bool {
|
||||
|
||||
// adminIDs returns the QQ admin IDs from bot_user_config.json.
|
||||
func (q *QQController) adminIDs() []string {
|
||||
if c := config.C_botUserConfig; c != nil {
|
||||
if c := config.BotUsers(); c != nil {
|
||||
return c.QQ.Admins.IDs()
|
||||
}
|
||||
return nil
|
||||
@@ -215,24 +232,35 @@ func (q *QQController) adminIDs() []string {
|
||||
|
||||
// trustedGroupIDs returns the QQ trusted group IDs from bot_user_config.json.
|
||||
func (q *QQController) trustedGroupIDs() []string {
|
||||
if c := config.C_botUserConfig; c != nil {
|
||||
if c := config.BotUsers(); c != nil {
|
||||
return c.QQ.TrustedGroups.IDs()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
|
||||
// to all QQ trusted groups and admins.
|
||||
func (q *QQController) SendMessage(message string) error {
|
||||
// SendMessage sends an automatic message (e.g. the bot initialization message
|
||||
// and the startup server list) to all QQ trusted groups and admins. Each
|
||||
// member's opt-out options in bot_user_config.json (e.g. EventBotStarted for
|
||||
// event_bot_started messages) are honored per recipient.
|
||||
func (q *QQController) SendMessage(message controller.Message) error {
|
||||
if !q.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, groupID := range q.trustedGroupIDs() {
|
||||
q.sendGroupMessage(groupID, message)
|
||||
}
|
||||
for _, adminID := range q.adminIDs() {
|
||||
q.sendPrivateMessage(adminID, message)
|
||||
// Only notify trusted groups and admins whose options allow this message type.
|
||||
if uc := config.BotUsers(); uc != nil {
|
||||
for groupID, opts := range uc.QQ.TrustedGroups {
|
||||
if !controller.MemberReceives(opts, message.Type) {
|
||||
continue
|
||||
}
|
||||
q.sendGroupMessage(groupID, message.Content)
|
||||
}
|
||||
for adminID, opts := range uc.QQ.Admins {
|
||||
if !controller.MemberReceives(opts, message.Type) {
|
||||
continue
|
||||
}
|
||||
q.sendPrivateMessage(adminID, message.Content)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -243,18 +271,24 @@ func (q *QQController) SendStatusChange(change node.StatusChange) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromStatusChange(change)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, q.cfg.Markdown)
|
||||
|
||||
// Send to trusted groups.
|
||||
for _, groupID := range q.trustedGroupIDs() {
|
||||
q.sendGroupMessage(groupID, message)
|
||||
}
|
||||
|
||||
// Send to admins via private message.
|
||||
for _, adminID := range q.adminIDs() {
|
||||
q.sendPrivateMessage(adminID, message)
|
||||
// Only notify trusted groups and admins whose event_status_notify is true.
|
||||
if uc := config.BotUsers(); uc != nil {
|
||||
for groupID, opts := range uc.QQ.TrustedGroups {
|
||||
if !opts.EventStatusNotify {
|
||||
continue
|
||||
}
|
||||
q.sendGroupMessage(groupID, message)
|
||||
}
|
||||
for adminID, opts := range uc.QQ.Admins {
|
||||
if !opts.EventStatusNotify {
|
||||
continue
|
||||
}
|
||||
q.sendPrivateMessage(adminID, message)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -266,9 +300,9 @@ func (q *QQController) SendServerList(onlineServers, offlineServers string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params, q.cfg.Markdown)
|
||||
|
||||
for _, groupID := range q.trustedGroupIDs() {
|
||||
q.sendGroupMessage(groupID, message)
|
||||
@@ -282,9 +316,9 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, q.cfg.Markdown)
|
||||
|
||||
for _, groupID := range q.trustedGroupIDs() {
|
||||
q.sendGroupMessage(groupID, message)
|
||||
@@ -292,6 +326,20 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAlert sends an alert submitted through the incoming webhook API to all QQ
|
||||
// trusted groups and admins.
|
||||
func (q *QQController) SendAlert(alert controller.Alert) error {
|
||||
if !q.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return q.SendMessage(controller.Message{
|
||||
Source: q.Name(),
|
||||
Content: alert.Render(q.cfg.Markdown),
|
||||
Type: controller.MessageTypeAlert,
|
||||
})
|
||||
}
|
||||
|
||||
func (q *QQController) sendGroupMessage(groupID string, message string) {
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"nukumizu-backend/internal/controller"
|
||||
)
|
||||
|
||||
// maxMessageLen is the safe chunk size for outbound messages. Telegram's hard
|
||||
@@ -13,20 +14,23 @@ import (
|
||||
const maxMessageLen = 4000
|
||||
|
||||
// sendMessage sends a text message to a chat, splitting it into chunks that fit
|
||||
// Telegram's 4096-character limit. All messages are sent with
|
||||
// parse_mode=Markdown so fenced code blocks and inline formatting render as
|
||||
// rich text. Templates must stay valid under Telegram's legacy Markdown:
|
||||
// unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the
|
||||
// API reject the whole message.
|
||||
func (t *TelegramController) sendMessage(chatID int64, text string) error {
|
||||
// Telegram's 4096-character limit. When the channel has markdown enabled the
|
||||
// message is sent with parse_mode=Markdown so fenced code blocks and inline
|
||||
// formatting render as rich text; templates must then stay valid under
|
||||
// Telegram's legacy Markdown, because unpaired '*' or '_' characters (e.g. a
|
||||
// lone '*Event: ...' label) make the API reject the whole message. With
|
||||
// markdown disabled the message is sent without a parse mode, so it is
|
||||
// delivered verbatim whatever it contains.
|
||||
func (t *TelegramController) sendMessage(message controller.Message) error {
|
||||
if t.client == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
if strings.TrimSpace(message.Content) == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitMessage(text, maxMessageLen) {
|
||||
if err := t.sendMessageChunk(chatID, chunk); err != nil {
|
||||
|
||||
for _, chunk := range splitMessage(message.Content, maxMessageLen) {
|
||||
if err := t.sendMessageChunk(message.ChatID, chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -38,11 +42,15 @@ func (t *TelegramController) sendMessageChunk(chatID int64, text string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := t.client.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: models.ParseModeMarkdownV1, // Telegram legacy Markdown
|
||||
})
|
||||
params := &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
}
|
||||
if t.cfg.Markdown {
|
||||
params.ParseMode = models.ParseModeMarkdownV1 // Telegram legacy Markdown
|
||||
}
|
||||
|
||||
_, err := t.client.SendMessage(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,12 @@ func (t *TelegramController) IsEnabled() bool {
|
||||
return t.cfg.Enabled
|
||||
}
|
||||
|
||||
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||
// setting in config.json.
|
||||
func (t *TelegramController) IsMarkdown() bool {
|
||||
return t.cfg.Markdown
|
||||
}
|
||||
|
||||
// handleUpdate processes a single Telegram update received via long polling. It
|
||||
// is installed as the framework's default handler (every update with a Message
|
||||
// reaches it). Updates are processed sequentially because the bot is created
|
||||
@@ -142,7 +148,7 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update
|
||||
}
|
||||
msg := update.Message
|
||||
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTelegramMsg {
|
||||
if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTelegramMsg {
|
||||
raw, _ := json.Marshal(update)
|
||||
postLog.Debug("Telegram update received: " + string(raw))
|
||||
}
|
||||
@@ -180,7 +186,14 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update
|
||||
return
|
||||
}
|
||||
|
||||
if err := t.sendMessage(msg.Chat.ID, response); err != nil {
|
||||
message := controller.Message{
|
||||
Source: "telegram",
|
||||
Content: response,
|
||||
ChatID: msg.Chat.ID,
|
||||
Type: controller.MessageTypeReply,
|
||||
}
|
||||
|
||||
if err := t.sendMessage(message); err != nil {
|
||||
postLog.Warning("Failed to send Telegram reply: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -219,7 +232,7 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
|
||||
// Hand the complete command to the unified processor, which checks group
|
||||
// vs private, trusted groups, admin permissions, and executes it.
|
||||
response, err := controller.GetManager().Trigger(parsed, t.trustedGroupIDs(), t.resolvedAdminList(), t.cfg.ListenMethod)
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho {
|
||||
if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
|
||||
postLog.Debug(fmt.Sprintf("[telegram] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
|
||||
}
|
||||
if err != nil {
|
||||
@@ -229,13 +242,30 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
|
||||
return response
|
||||
}
|
||||
|
||||
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
|
||||
// to all Telegram trusted groups and admins.
|
||||
func (t *TelegramController) SendMessage(message string) error {
|
||||
// SendMessage sends an automatic message (e.g. the bot initialization message
|
||||
// and the startup server list) to all Telegram trusted groups and admins. Each
|
||||
// member's opt-out options in bot_user_config.json (e.g. EventBotStarted for
|
||||
// event_bot_started messages) are honored per recipient.
|
||||
func (t *TelegramController) SendMessage(message controller.Message) error {
|
||||
if !t.cfg.Enabled || t.client == nil {
|
||||
return nil
|
||||
}
|
||||
t.sendToGroupsAndAdmins(message)
|
||||
|
||||
// Only notify trusted groups and admins whose options allow this message type.
|
||||
if uc := config.BotUsers(); uc != nil {
|
||||
for groupID, opts := range uc.Telegram.TrustedGroups {
|
||||
if !controller.MemberReceives(opts, message.Type) {
|
||||
continue
|
||||
}
|
||||
t.sendGroupMessage(groupID, message.Content)
|
||||
}
|
||||
for admin, opts := range uc.Telegram.Admins {
|
||||
if !controller.MemberReceives(opts, message.Type) {
|
||||
continue
|
||||
}
|
||||
t.sendAdminMessage(admin, message.Content)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -245,11 +275,25 @@ func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromStatusChange(change)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, t.cfg.Markdown)
|
||||
|
||||
t.sendToGroupsAndAdmins(message)
|
||||
// Only notify trusted groups and admins whose event_status_notify is true.
|
||||
if uc := config.BotUsers(); uc != nil {
|
||||
for groupID, opts := range uc.Telegram.TrustedGroups {
|
||||
if !opts.EventStatusNotify {
|
||||
continue
|
||||
}
|
||||
t.sendGroupMessage(groupID, message)
|
||||
}
|
||||
for admin, opts := range uc.Telegram.Admins {
|
||||
if !opts.EventStatusNotify {
|
||||
continue
|
||||
}
|
||||
t.sendAdminMessage(admin, message)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -259,9 +303,9 @@ func (t *TelegramController) SendServerList(onlineServers, offlineServers string
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params, t.cfg.Markdown)
|
||||
|
||||
t.sendToGroups(message)
|
||||
return nil
|
||||
@@ -273,14 +317,28 @@ func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command,
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, t.cfg.Markdown)
|
||||
|
||||
t.sendToGroups(message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAlert sends an alert submitted through the incoming webhook API to all
|
||||
// Telegram trusted groups and admins.
|
||||
func (t *TelegramController) SendAlert(alert controller.Alert) error {
|
||||
if !t.cfg.Enabled || t.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return t.SendMessage(controller.Message{
|
||||
Source: t.Name(),
|
||||
Content: alert.Render(t.cfg.Markdown),
|
||||
Type: controller.MessageTypeAlert,
|
||||
})
|
||||
}
|
||||
|
||||
// telegramChatType maps a Telegram chat type to the unified ChatType value used
|
||||
// by the controller package. Empty means the chat type is unsupported.
|
||||
func telegramChatType(chatType string) string {
|
||||
@@ -337,7 +395,7 @@ func (t *TelegramController) resolveUsername(username string) (int64, bool) {
|
||||
// adminIDs returns the Telegram admin entries (numeric user ID or @username)
|
||||
// from bot_user_config.json.
|
||||
func (t *TelegramController) adminIDs() []string {
|
||||
if c := config.C_botUserConfig; c != nil {
|
||||
if c := config.BotUsers(); c != nil {
|
||||
return c.Telegram.Admins.IDs()
|
||||
}
|
||||
return nil
|
||||
@@ -345,7 +403,7 @@ func (t *TelegramController) adminIDs() []string {
|
||||
|
||||
// trustedGroupIDs returns the Telegram trusted group IDs from bot_user_config.json.
|
||||
func (t *TelegramController) trustedGroupIDs() []string {
|
||||
if c := config.C_botUserConfig; c != nil {
|
||||
if c := config.BotUsers(); c != nil {
|
||||
return c.Telegram.TrustedGroups.IDs()
|
||||
}
|
||||
return nil
|
||||
@@ -368,14 +426,6 @@ func (t *TelegramController) resolvedAdminList() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// sendToGroupsAndAdmins sends a message to all trusted groups and admins.
|
||||
func (t *TelegramController) sendToGroupsAndAdmins(message string) {
|
||||
t.sendToGroups(message)
|
||||
for _, admin := range t.adminIDs() {
|
||||
t.sendAdminMessage(admin, message)
|
||||
}
|
||||
}
|
||||
|
||||
// sendToGroups sends a message to all trusted groups.
|
||||
func (t *TelegramController) sendToGroups(message string) {
|
||||
for _, groupID := range t.trustedGroupIDs() {
|
||||
@@ -412,7 +462,13 @@ func (t *TelegramController) sendAdminMessage(admin string, message string) {
|
||||
|
||||
// sendToChat sends a message to a chat ID, logging failures.
|
||||
func (t *TelegramController) sendToChat(chatID int64, message string) {
|
||||
if err := t.sendMessage(chatID, message); err != nil {
|
||||
messaged := controller.Message{
|
||||
Source: "telegram",
|
||||
Content: message,
|
||||
ChatID: chatID,
|
||||
Type: controller.MessageTypeReply,
|
||||
}
|
||||
if err := t.sendMessage(messaged); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Failed to send Telegram message to %d: %v", chatID, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/controller"
|
||||
"nukumizu-backend/internal/netproxy"
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/internal/template"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
)
|
||||
|
||||
// WebhookController handles notifications via generic HTTP webhooks.
|
||||
//
|
||||
// cfg and httpClient are written once, by the constructor, and never again: a
|
||||
// controller that needs different settings is replaced wholesale by the manager
|
||||
// rather than reconfigured in place, so the send methods can read them without
|
||||
// locking.
|
||||
type WebhookController struct {
|
||||
cfg config.WebhookConfig
|
||||
httpClient *http.Client
|
||||
@@ -53,15 +59,21 @@ func (w *WebhookController) IsEnabled() bool {
|
||||
return w.cfg.Enabled
|
||||
}
|
||||
|
||||
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||
// setting in config.json.
|
||||
func (w *WebhookController) IsMarkdown() bool {
|
||||
return w.cfg.Markdown
|
||||
}
|
||||
|
||||
// SendStatusChange sends a status change notification via Webhook.
|
||||
func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
|
||||
if !w.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromStatusChange(change)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, w.cfg.Markdown)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"event": change.Event,
|
||||
@@ -80,16 +92,16 @@ func (w *WebhookController) SendServerList(onlineServers, offlineServers string)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerList, params, w.cfg.Markdown)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"type": "serverList",
|
||||
"onlineServers": params.OnlineServers,
|
||||
"offlineServers": params.OfflineServers,
|
||||
"message": message,
|
||||
"time": params.Time,
|
||||
"type": "serverList",
|
||||
"onlineServers": params.OnlineServers,
|
||||
"offlineServers": params.OfflineServers,
|
||||
"message": message,
|
||||
"time": params.Time,
|
||||
}
|
||||
|
||||
return w.send(payload)
|
||||
@@ -101,9 +113,9 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, w.cfg.Markdown)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"type": "executeResult",
|
||||
@@ -118,6 +130,25 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
|
||||
return w.send(payload)
|
||||
}
|
||||
|
||||
// SendAlert sends an alert submitted through the incoming webhook API to the
|
||||
// configured URL.
|
||||
func (w *WebhookController) SendAlert(alert controller.Alert) error {
|
||||
if !w.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"type": "alert",
|
||||
"subject": alert.Subject,
|
||||
"source": alert.Source,
|
||||
"content": alert.Content,
|
||||
"message": alert.Render(w.cfg.Markdown),
|
||||
"time": alert.Time,
|
||||
}
|
||||
|
||||
return w.send(payload)
|
||||
}
|
||||
|
||||
func (w *WebhookController) send(payload map[string]interface{}) error {
|
||||
method := w.cfg.Method
|
||||
if method == "" {
|
||||
|
||||
@@ -10,16 +10,27 @@ import (
|
||||
"nukumizu-backend/internal/template"
|
||||
)
|
||||
|
||||
// commandMarkdown reports whether responses to the given command are rendered
|
||||
// with Markdown, per the markdown setting of the pipe the command came from
|
||||
// (see Command.Source).
|
||||
func commandMarkdown(cmd Command) bool {
|
||||
mgr := GetManager()
|
||||
if mgr == nil {
|
||||
return false
|
||||
}
|
||||
return mgr.IsMarkdown(cmd.Source)
|
||||
}
|
||||
|
||||
func handleHelp(cmd Command) (string, error) {
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildBotInitializationMsgParams()
|
||||
return template.Render(cfg.ControllerMessage.BotHelp, params, cmd.Source), nil
|
||||
return template.Render(cfg.ControllerMessage.BotHelp, params, commandMarkdown(cmd)), nil
|
||||
}
|
||||
|
||||
func handleList(cmd Command) (string, error) {
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromServerList()
|
||||
return template.Render(cfg.ControllerMessage.ServerList, params, cmd.Source), nil
|
||||
return template.Render(cfg.ControllerMessage.ServerList, params, commandMarkdown(cmd)), nil
|
||||
}
|
||||
|
||||
func handleStatus(cmd Command) (string, error) {
|
||||
@@ -128,9 +139,9 @@ func handleRun(cmd Command) (string, error) {
|
||||
return fmt.Sprintf("Error getting results: %v", err), nil
|
||||
}
|
||||
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results))
|
||||
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, cmd.Source), nil
|
||||
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, commandMarkdown(cmd)), nil
|
||||
}
|
||||
|
||||
func handleInfo(cmd Command) (string, error) {
|
||||
@@ -177,10 +188,10 @@ func handleInfo(cmd Command) (string, error) {
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func telegram_handleStart() (string, error) {
|
||||
cfg := config.C_globalConfig
|
||||
func telegram_handleStart(cmd Command) (string, error) {
|
||||
cfg := config.Current()
|
||||
params := template.BuildBotInitializationMsgParams()
|
||||
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, "telegram"), nil
|
||||
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, commandMarkdown(cmd)), nil
|
||||
}
|
||||
|
||||
func handleGetIP(cmd Command) (string, error) {
|
||||
|
||||
@@ -43,10 +43,10 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
|
||||
// RouteCommand processes a parsed bot command and returns the response text.
|
||||
// The actual command execution for every pipe is unified here.
|
||||
func (m *Manager) RouteCommand(cmd Command) (string, error) {
|
||||
if cmd.Source == "telegram"{
|
||||
if cmd.Source == "telegram" {
|
||||
switch cmd.Command {
|
||||
case "start":
|
||||
return telegram_handleStart()
|
||||
return telegram_handleStart(cmd)
|
||||
}
|
||||
}
|
||||
switch cmd.Command {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package controller
|
||||
|
||||
func Contains(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -13,10 +13,19 @@ import (
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/global"
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// taskEchoEnabled reports whether Komari task progress should be echoed to the
|
||||
// log: debug mode plus the showKomariTaskEcho toggle. The configuration is read
|
||||
// once per call so both flags come from the same reload.
|
||||
func taskEchoEnabled() bool {
|
||||
cfg := config.Current()
|
||||
return cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowKomariTaskEcho
|
||||
}
|
||||
|
||||
// NodeInfo represents a single node as returned by Komari's
|
||||
// common:getNodes RPC2 method.
|
||||
type NodeInfo struct {
|
||||
@@ -145,7 +154,7 @@ func (c *Client) Login(username, password string) error {
|
||||
|
||||
var kr KomariResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||
if config.C_globalConfig.System.DebugMode {
|
||||
if config.IsDebugMode() {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to parse komari login response: %w.\nResponse: %s", err, respBody)
|
||||
}
|
||||
@@ -225,6 +234,20 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
// Persist the fetched node UUIDs to bot_node_config.json on every fetch
|
||||
// (startup login, WebSocket reconnect and periodic refresh all funnel
|
||||
// through FetchNodes) so the file always reflects the nodes Komari
|
||||
// currently manages. A persistence failure is only logged: the node data
|
||||
// itself was fetched successfully and must not be discarded over a disk
|
||||
// write problem.
|
||||
uuids := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
uuids[i] = n.UUID
|
||||
}
|
||||
if err := config.SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, uuids); err != nil {
|
||||
postLog.Warning("Failed to save bot node config: " + err.Error())
|
||||
}
|
||||
|
||||
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
|
||||
return nodes, nil
|
||||
}
|
||||
@@ -317,7 +340,7 @@ func (c *Client) ExecTask(uuids []string, command string) (string, error) {
|
||||
return "", fmt.Errorf("failed to parse komari task exec data: %w", err)
|
||||
}
|
||||
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
|
||||
if taskEchoEnabled() {
|
||||
postLog.Debug(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids)))
|
||||
}
|
||||
return result.TaskID, nil
|
||||
@@ -360,7 +383,7 @@ func (c *Client) GetTaskResult(taskID string) ([]TaskResult, bool, error) {
|
||||
// PollTaskResult polls for task results every 1 second until all results are
|
||||
// available or 60 seconds have elapsed.
|
||||
func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
|
||||
if taskEchoEnabled() {
|
||||
postLog.Debug(fmt.Sprintf("Polling for Komari task %s results...", taskID))
|
||||
}
|
||||
|
||||
@@ -378,7 +401,7 @@ func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
if done {
|
||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
|
||||
if taskEchoEnabled() {
|
||||
postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results)))
|
||||
}
|
||||
return results, nil
|
||||
|
||||
@@ -409,7 +409,10 @@ func GetWSClient() *WSClient {
|
||||
|
||||
// LoginAndStart performs the Komari login and returns an error if it fails.
|
||||
func LoginAndStart() error {
|
||||
cfg := config.C_globalConfig
|
||||
cfg := config.Current()
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration not loaded")
|
||||
}
|
||||
client := GetClient()
|
||||
if client == nil {
|
||||
return fmt.Errorf("komari client not initialized")
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// network proxy configured in the system config. Each caller decides whether
|
||||
// to use the proxy by passing its own useProxy flag (the per-channel
|
||||
// networkUseProxy setting), so proxying is opt-in per channel.
|
||||
//
|
||||
// The opt-in is captured when a client is built, but the proxy address is not:
|
||||
// it is read again on every request and every dial, so editing
|
||||
// system.networkProxy takes effect on clients that already exist.
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
@@ -20,7 +24,11 @@ import (
|
||||
// proxyURL returns the system-wide network proxy URL, or nil when none is
|
||||
// configured. A missing scheme is normalized to http:// for convenience.
|
||||
func proxyURL() *url.URL {
|
||||
raw := config.C_globalConfig.System.NetworkProxy
|
||||
cfg := config.Current()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
raw := cfg.System.NetworkProxy
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -35,19 +43,23 @@ func proxyURL() *url.URL {
|
||||
}
|
||||
|
||||
// ProxyFunc returns a transport proxy function that routes requests through
|
||||
// the configured network proxy when enabled. It returns nil when the caller
|
||||
// opts out or no proxy is configured, meaning direct connection. The returned
|
||||
// function is compatible with both http.Transport.Proxy and
|
||||
// websocket.Dialer.Proxy.
|
||||
// the configured network proxy when enabled, and nil when the caller opts out
|
||||
// of proxying entirely. The returned function is compatible with both
|
||||
// http.Transport.Proxy and websocket.Dialer.Proxy.
|
||||
//
|
||||
// The proxy address is resolved on every call rather than once here, so a
|
||||
// settings update that changes system.networkProxy reaches a client that was
|
||||
// already built. That is also why opting out is the only case that returns nil:
|
||||
// a function resolved to nothing at construction time would pin its client to
|
||||
// whatever was configured then. A nil URL from the returned function means no
|
||||
// proxy is configured and the request goes direct.
|
||||
func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) {
|
||||
if !useProxy {
|
||||
return nil
|
||||
}
|
||||
u := proxyURL()
|
||||
if u == nil {
|
||||
return nil
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
return proxyURL(), nil
|
||||
}
|
||||
return http.ProxyURL(u)
|
||||
}
|
||||
|
||||
// HTTPClient builds an http.Client that sends traffic through the configured
|
||||
@@ -67,10 +79,16 @@ func HTTPClient(useProxy bool, timeout time.Duration) *http.Client {
|
||||
// through the configured HTTP CONNECT proxy when enabled. Its signature
|
||||
// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout
|
||||
// to send SMTP over the proxy.
|
||||
//
|
||||
// Like ProxyFunc it reads the proxy address per dial, so clearing or changing
|
||||
// system.networkProxy reaches a dialer that already exists.
|
||||
func DialWithTimeout(useProxy bool) func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
u := proxyURL()
|
||||
return func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
if !useProxy || u == nil {
|
||||
if !useProxy {
|
||||
return net.DialTimeout(network, addr, timeout)
|
||||
}
|
||||
u := proxyURL()
|
||||
if u == nil {
|
||||
return net.DialTimeout(network, addr, timeout)
|
||||
}
|
||||
return dialViaProxy(u, addr, timeout)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
)
|
||||
|
||||
// publishConfig writes a config.json and makes it the configuration in effect,
|
||||
// which is what a settings update does.
|
||||
func publishConfig(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
if _, err := config.LoadGlobalConfig(path); err != nil {
|
||||
t.Fatalf("LoadGlobalConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve runs a proxy function and returns the URL it chose, or "" when it
|
||||
// chose a direct connection.
|
||||
func resolve(t *testing.T, proxy func(*http.Request) (*url.URL, error)) string {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
u, err := proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy function: %v", err)
|
||||
}
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// TestProxyFuncResolvesPerCall is the property that makes system.networkProxy
|
||||
// hot-reloadable: the function handed to a transport keeps reading the live
|
||||
// configuration instead of the address that was configured when it was built.
|
||||
func TestProxyFuncResolvesPerCall(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
||||
|
||||
proxy := ProxyFunc(true)
|
||||
if proxy == nil {
|
||||
t.Fatal("ProxyFunc(true) returned nil, so the channel would never proxy")
|
||||
}
|
||||
if got := resolve(t, proxy); got != "http://127.0.0.1:7890" {
|
||||
t.Errorf("first resolution = %q", got)
|
||||
}
|
||||
|
||||
// The same function must follow a settings update.
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:8888"}}`)
|
||||
if got := resolve(t, proxy); got != "http://127.0.0.1:8888" {
|
||||
t.Errorf("after a settings update the same function resolved %q", got)
|
||||
}
|
||||
|
||||
// Clearing the proxy falls back to a direct connection.
|
||||
publishConfig(t, `{"system":{"networkProxy":""}}`)
|
||||
if got := resolve(t, proxy); got != "" {
|
||||
t.Errorf("a cleared proxy still resolved %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFuncOptOutReturnsNil(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
||||
|
||||
// A channel with networkUseProxy off must not be handed a function at all,
|
||||
// so its transport keeps the default direct dialing.
|
||||
if ProxyFunc(false) != nil {
|
||||
t.Error("ProxyFunc(false) must return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFuncNormalizesMissingScheme(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"127.0.0.1:7890"}}`)
|
||||
|
||||
if got := resolve(t, ProxyFunc(true)); got != "http://127.0.0.1:7890" {
|
||||
t.Errorf("resolved %q, want the http:// prefix added", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFuncIgnoresUnusableProxy(t *testing.T) {
|
||||
// A value that cannot be parsed must leave the client dialing directly
|
||||
// rather than failing every request.
|
||||
publishConfig(t, `{"system":{"networkProxy":"://missing-scheme"}}`)
|
||||
|
||||
if got := resolve(t, ProxyFunc(true)); got != "" {
|
||||
t.Errorf("an unparseable proxy resolved %q, want a direct connection", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialWithTimeoutDialsDirectlyWithoutProxy covers the path a cleared
|
||||
// system.networkProxy takes: the dialer was built while a proxy was configured,
|
||||
// and must fall back to a direct dial once there is none.
|
||||
func TestDialWithTimeoutDialsDirectlyWithoutProxy(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
||||
|
||||
dial := DialWithTimeout(true)
|
||||
if dial == nil {
|
||||
t.Fatal("DialWithTimeout(true) returned nil")
|
||||
}
|
||||
|
||||
// No proxy is listening on that address, so a dial attempted now would
|
||||
// fail; clearing the setting is what makes the direct path reachable.
|
||||
publishConfig(t, `{"system":{"networkProxy":""}}`)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := dial("tcp", ln.Addr().String(), 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial through a cleared proxy: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
@@ -19,6 +19,9 @@ type Params struct {
|
||||
Message string
|
||||
Command string
|
||||
Result string
|
||||
Subject string // Alert subject (see AlertParams)
|
||||
Source string // Alert source (see AlertParams)
|
||||
Content string // Alert content (see AlertParams)
|
||||
OnlineServers string // Pre-formatted multi-line list
|
||||
OfflineServers string // Pre-formatted multi-line list
|
||||
SoftwareVersion string
|
||||
@@ -30,6 +33,37 @@ type Params struct {
|
||||
SoftwareDescription string
|
||||
}
|
||||
|
||||
// AlertTemplate is the body format of an alert submitted by an external
|
||||
// application through the incoming webhook API.
|
||||
const AlertTemplate = "{{ subject }}\n- Source: {{ source }}\n- Content:\n{{ content }}\n\n- Time: {{ time }}\nSent by Nukumizu Alert System"
|
||||
|
||||
// AlertParams holds the parameters of an alert submitted through the incoming
|
||||
// webhook API.
|
||||
type AlertParams struct {
|
||||
Subject string // Short one-line title of the alert
|
||||
Source string // Name of the webhook endpoint the alert was submitted to
|
||||
Content string // Free-form alert body
|
||||
Time string // Submission time
|
||||
}
|
||||
|
||||
// RenderAlert renders the body of an alert for a channel. The alert source and
|
||||
// content may be wrapped in Markdown — the source in inline code, the content
|
||||
// in a fenced code block — when the target channel has markdown enabled
|
||||
// (markdown); everything else, the timestamp included, stays plain text.
|
||||
func RenderAlert(alert AlertParams, markdown bool) string {
|
||||
params := Params{
|
||||
Time: alert.Time,
|
||||
Subject: alert.Subject,
|
||||
Source: alert.Source,
|
||||
Content: alert.Content,
|
||||
}
|
||||
if markdown {
|
||||
params.Source = "`" + params.Source + "`"
|
||||
params.Content = "```\n" + params.Content + "\n```"
|
||||
}
|
||||
return Render(AlertTemplate, params, false)
|
||||
}
|
||||
|
||||
// BuildBotInitializationMsgParams creates template parameters for the bot initialization message.
|
||||
func BuildBotInitializationMsgParams() Params {
|
||||
return Params{
|
||||
@@ -80,7 +114,13 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
|
||||
}
|
||||
}
|
||||
|
||||
// Render substitutes {{ paramName }} placeholders in a template string.
|
||||
// Render substitutes {{ paramName }} placeholders in a template string. The
|
||||
// markdown argument is the target channel's markdown setting: when true the
|
||||
// values that are meant to be read verbatim (UUIDs, messages, commands, command
|
||||
// results) are wrapped in Markdown code spans and blocks, otherwise every value
|
||||
// is inserted as plain text. Whether a channel renders Markdown comes from the
|
||||
// configuration alone — the renderer never infers it from the channel name.
|
||||
//
|
||||
// Supported placeholders:
|
||||
// - {{ time }} — current server time
|
||||
// - {{ serverName }} — server name
|
||||
@@ -90,6 +130,9 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
|
||||
// - {{ message }} — event descriptive message
|
||||
// - {{ command }} — executed command
|
||||
// - {{ result }} — command execution result
|
||||
// - {{ subject }} — alert subject
|
||||
// - {{ source }} — alert source
|
||||
// - {{ content }} — alert content
|
||||
// - {{ list.onlineServers }} — multi-line online server list
|
||||
// - {{ list.offlineServers }} — multi-line offline server list
|
||||
// - {{ softwareVersion }} — software version
|
||||
@@ -99,19 +142,20 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
|
||||
// - {{ softwareBuildTime }} — software build time
|
||||
// - {{ softwareDeveloper }} — software developer
|
||||
// - {{ softwareDescription }} — software description
|
||||
func Render(tmpl string, params Params, source ...string) string {
|
||||
func Render(tmpl string, params Params, markdown bool) string {
|
||||
result := tmpl
|
||||
|
||||
if len(source) > 0 && source[0] == "telegram" {
|
||||
// Telegram requires special formatting for code blocks and inline code.
|
||||
result = strings.ReplaceAll(result, "{{ time }}", "**" + params.Time + "**")
|
||||
result = strings.ReplaceAll(result, "{{ serverName }}", "**" + params.ServerName + "**")
|
||||
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`" + params.ServerUUID + "`")
|
||||
result = strings.ReplaceAll(result, "{{ upStatus }}", "**" + params.UpStatus + "**")
|
||||
result = strings.ReplaceAll(result, "{{ event }}", "**" + params.Event + "**")
|
||||
result = strings.ReplaceAll(result, "{{ message }}", "`" + params.Message + "`")
|
||||
result = strings.ReplaceAll(result, "{{ command }}", "`" + params.Command + "`")
|
||||
result = strings.ReplaceAll(result, "{{ result }}", "```bash\n" + params.Result + "\n```")
|
||||
if markdown {
|
||||
// Channels that render Markdown get code blocks and inline code for the
|
||||
// values that are read verbatim.
|
||||
result = strings.ReplaceAll(result, "{{ time }}", "**"+params.Time+"**")
|
||||
result = strings.ReplaceAll(result, "{{ serverName }}", "**"+params.ServerName+"**")
|
||||
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`"+params.ServerUUID+"`")
|
||||
result = strings.ReplaceAll(result, "{{ upStatus }}", "**"+params.UpStatus+"**")
|
||||
result = strings.ReplaceAll(result, "{{ event }}", "**"+params.Event+"**")
|
||||
result = strings.ReplaceAll(result, "{{ message }}", "`"+params.Message+"`")
|
||||
result = strings.ReplaceAll(result, "{{ command }}", "`"+params.Command+"`")
|
||||
result = strings.ReplaceAll(result, "{{ result }}", "```bash\n"+params.Result+"\n```")
|
||||
result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers)
|
||||
result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers)
|
||||
result = strings.ReplaceAll(result, "{{ softwareVersion }}", params.SoftwareVersion)
|
||||
@@ -140,6 +184,13 @@ func Render(tmpl string, params Params, source ...string) string {
|
||||
result = strings.ReplaceAll(result, "{{ softwareDeveloper }}", params.SoftwareDeveloper)
|
||||
result = strings.ReplaceAll(result, "{{ softwareDescription }}", params.SoftwareDescription)
|
||||
}
|
||||
|
||||
// Alert values carry their own formatting (see RenderAlert), so they are
|
||||
// substituted identically in both branches.
|
||||
result = strings.ReplaceAll(result, "{{ subject }}", params.Subject)
|
||||
result = strings.ReplaceAll(result, "{{ source }}", params.Source)
|
||||
result = strings.ReplaceAll(result, "{{ content }}", params.Content)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -34,29 +33,52 @@ func main() {
|
||||
global.SoftwareInfo.CommitHash = CommitHash
|
||||
global.SoftwareInfo.BuildTime = BuildTime
|
||||
|
||||
// Parse CLI flags.
|
||||
configPath_global := flag.String("config", "config.json", "Path to configuration file")
|
||||
configPath_bot_user := flag.String("bot-user-config", "bot_user_config.json", "Path to bot user configuration file")
|
||||
flag.Parse()
|
||||
|
||||
// Log startup banner.
|
||||
postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s.%s Developed by %s at %s", global.SoftwareInfo.Name, global.SoftwareInfo.Version, global.SoftwareInfo.BuildVer, global.SoftwareInfo.BuildType, global.SoftwareInfo.CommitHash, global.SoftwareInfo.Developer, global.SoftwareInfo.BuildTime))
|
||||
|
||||
// Load configuration.
|
||||
cfg, err := config.LoadGlobalConfig(*configPath_global)
|
||||
cfg, err := config.LoadGlobalConfig(global.ConfigPath.Global)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load global config: %v", err)
|
||||
}
|
||||
|
||||
_, err = config.LoadBotUserConfig(*configPath_bot_user)
|
||||
_, err = config.LoadBotUserConfig(global.ConfigPath.BotUserConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load bot user config: %v", err)
|
||||
}
|
||||
|
||||
// Load the node registry config. Unlike the other files it is optional:
|
||||
// missing or empty bot_node_config.json simply means every node keeps its
|
||||
// default enableStatusNotify (true).
|
||||
if err := config.LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
log.Fatalf("Failed to load bot node config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize logging.
|
||||
postLog.SetDebugMode(cfg.System.DebugMode)
|
||||
postLog.InitLogBroadcaster()
|
||||
|
||||
// A settings update replaces the configuration in memory; these hooks push
|
||||
// the new values into the state that was derived from the old one. The
|
||||
// logger's debug flag is process-wide rather than read at every log call,
|
||||
// and each controller holds its own copy of its channel settings plus the
|
||||
// clients built from them.
|
||||
config.OnReload(func(updated *config.Config) {
|
||||
postLog.SetDebugMode(updated.System.DebugMode)
|
||||
|
||||
mgr := controller.GetManager()
|
||||
if mgr == nil || !mgr.NeedsRebuild(updated.ControllerMethod) {
|
||||
return
|
||||
}
|
||||
|
||||
// Controller settings changed. Each controller reads its settings into
|
||||
// fields when it is built, and the NapCat and Telegram ones own
|
||||
// connections that cannot be re-pointed, so the change is applied by
|
||||
// replacing the whole set rather than reconfiguring it in place.
|
||||
postLog.Info("Controller settings changed; rebuilding every channel")
|
||||
mgr.ReplaceAll(buildControllers(updated), updated.ControllerMethod)
|
||||
})
|
||||
|
||||
dbPath := cfg.DBPath
|
||||
|
||||
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
|
||||
@@ -145,6 +167,9 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// --- Start the incoming webhook listener ---
|
||||
startWebhookServer(cfg)
|
||||
|
||||
// --- Graceful shutdown ---
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -166,83 +191,57 @@ func main() {
|
||||
postLog.Info("Server stopped")
|
||||
}
|
||||
|
||||
// initControllers initializes and starts all configured controllers.
|
||||
func initControllers() {
|
||||
cfg := config.C_globalConfig
|
||||
mgr := controller.GetManager()
|
||||
if mgr == nil {
|
||||
// startWebhookServer serves the incoming webhook API on its own listener. The
|
||||
// API is not exposed on the main listener: external applications post alerts to
|
||||
// this port only, so its rate limiter and CORS policy are configured
|
||||
// independently. A failure to bind it is logged rather than fatal — the rest of
|
||||
// the program (bots, status monitoring) keeps running without it.
|
||||
func startWebhookServer(cfg *config.Config) {
|
||||
if !cfg.Webhook.Enabled {
|
||||
postLog.Warning("Incoming webhook API is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// QQ (Napcat) controller.
|
||||
qqCtrl := qq_napcat.NewQQController(cfg.ControllerMethod.QQ)
|
||||
mgr.Register(qqCtrl)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("QQ controller panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if err := qqCtrl.Start(); err != nil {
|
||||
postLog.Error("Failed to start QQ controller: " + err.Error())
|
||||
}
|
||||
}()
|
||||
handler := utils.RateLimitMiddleware(SetupWebhookRouter())
|
||||
handler = utils.CORSMiddleware(handler)
|
||||
|
||||
// Telegram controller.
|
||||
tgCtrl := telegram.NewTelegramController(cfg.ControllerMethod.Telegram)
|
||||
mgr.Register(tgCtrl)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Telegram controller panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if err := tgCtrl.Start(); err != nil {
|
||||
postLog.Error("Failed to start Telegram controller: " + err.Error())
|
||||
}
|
||||
}()
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Webhook.ListenAddr, cfg.Webhook.ListenPort)
|
||||
postLog.Info(fmt.Sprintf("Webhook API listening on %s", addr))
|
||||
|
||||
// Email controller (status-only).
|
||||
emailCtrl := pipes.NewEmailController(cfg.ControllerMethod.Email)
|
||||
mgr.Register(emailCtrl)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Email controller panic: %v", r))
|
||||
postLog.Error(fmt.Sprintf("Webhook server panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if err := emailCtrl.Start(); err != nil {
|
||||
postLog.Error("Failed to start Email controller: " + err.Error())
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
postLog.Error("Webhook server error: " + err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Ntfy controller (status-only).
|
||||
ntfyCtrl := pipes.NewNtfyController(cfg.ControllerMethod.Ntfy)
|
||||
mgr.Register(ntfyCtrl)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if err := ntfyCtrl.Start(); err != nil {
|
||||
postLog.Error("Failed to start Ntfy controller: " + err.Error())
|
||||
}
|
||||
}()
|
||||
// buildControllers constructs one controller per channel from the given
|
||||
// configuration. The set is built fresh whenever controller settings change,
|
||||
// because a controller reads its settings once at construction and two of them
|
||||
// own connections that cannot be re-pointed.
|
||||
func buildControllers(cfg *config.Config) []controller.Controller {
|
||||
return []controller.Controller{
|
||||
qq_napcat.NewQQController(cfg.ControllerMethod.QQ),
|
||||
telegram.NewTelegramController(cfg.ControllerMethod.Telegram),
|
||||
pipes.NewEmailController(cfg.ControllerMethod.Email),
|
||||
pipes.NewNtfyController(cfg.ControllerMethod.Ntfy),
|
||||
pipes.NewWebhookController(cfg.ControllerMethod.Webhook),
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook controller (status-only).
|
||||
webhookCtrl := pipes.NewWebhookController(cfg.ControllerMethod.Webhook)
|
||||
mgr.Register(webhookCtrl)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if err := webhookCtrl.Start(); err != nil {
|
||||
postLog.Error("Failed to start Webhook controller: " + err.Error())
|
||||
}
|
||||
}()
|
||||
// initControllers installs the initial controller set.
|
||||
func initControllers() {
|
||||
cfg := config.Current()
|
||||
mgr := controller.GetManager()
|
||||
if mgr == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
mgr.ReplaceAll(buildControllers(cfg), cfg.ControllerMethod)
|
||||
}
|
||||
|
||||
// startBackgroundTasks starts periodic background goroutines.
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"nukumizu-backend/handler"
|
||||
"nukumizu-backend/postLog"
|
||||
"nukumizu-backend/utils"
|
||||
"nukumizu-backend/web"
|
||||
)
|
||||
|
||||
// SetupRouter registers all HTTP routes and returns a configured ServeMux.
|
||||
@@ -20,26 +22,60 @@ func SetupRouter() *http.ServeMux {
|
||||
|
||||
// Server endpoints (authenticated).
|
||||
mux.HandleFunc("/api/server/list", handler.ServerListHandler)
|
||||
mux.HandleFunc("/api/server/getInfo", handler.ServerGetInfoHandler)
|
||||
mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler)
|
||||
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
|
||||
|
||||
// Settings endpoints (admin only).
|
||||
mux.HandleFunc("/api/settings/get", handler.SettingsGetHandler)
|
||||
mux.HandleFunc("/api/settings/set", handler.SettingsSetHandler)
|
||||
|
||||
// Incoming webhook endpoint management (admin only). These configure the
|
||||
// endpoints served by SetupWebhookRouter, which runs on its own listener.
|
||||
mux.HandleFunc("/api/webhook/add", handler.WebhookAddHandler)
|
||||
mux.HandleFunc("/api/webhook/modify", handler.WebhookModifyHandler)
|
||||
mux.HandleFunc("/api/webhook/delete", handler.WebhookDeleteHandler)
|
||||
mux.HandleFunc("/api/webhook/list", handler.WebhookListHandler)
|
||||
|
||||
// Health check endpoint.
|
||||
mux.HandleFunc("/health", handler.HealthHandler)
|
||||
|
||||
// WebSocket log streaming endpoint.
|
||||
// WebSocket log streaming endpoint (admin only). The middleware authenticates
|
||||
// the upgrade request, so an anonymous or non-admin client is rejected before
|
||||
// any log entry leaves the server.
|
||||
logBroadcaster := postLog.GetLogBroadcaster()
|
||||
if logBroadcaster != nil {
|
||||
logSocketHandler := postLog.NewLogSocketHandler(logBroadcaster)
|
||||
mux.HandleFunc("/api/system/getLogs", logSocketHandler.Handle)
|
||||
adminOnly := utils.AuthWS("admin")
|
||||
mux.Handle("/api/system/getLogs", adminOnly(http.HandlerFunc(logSocketHandler.Handle)))
|
||||
}
|
||||
|
||||
// Catch-all 404 handler.
|
||||
mux.HandleFunc("/", NotFoundHandler)
|
||||
// Static file serving for the web frontend.
|
||||
mux.HandleFunc("/", web.ServeStatic)
|
||||
|
||||
postLog.Info("Router setup completed")
|
||||
return mux
|
||||
}
|
||||
|
||||
// SetupWebhookRouter registers the routes of the incoming webhook API. Unlike
|
||||
// SetupRouter it is served on its own listener (webhook.listenAddr/listenPort),
|
||||
// so external applications can be given access to the webhook port without
|
||||
// reaching the admin API. Every endpoint configured under webhook.endpoints is
|
||||
// reachable as /api/webhook/<name>.
|
||||
func SetupWebhookRouter() *http.ServeMux {
|
||||
postLog.Info("Setting up webhook routers...")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// The wildcard segment selects the endpoint; requests for a name that is not
|
||||
// configured fall through to the handler, which answers with a JSON 404.
|
||||
mux.HandleFunc("/api/webhook/post/{name}", handler.WebhookHandler)
|
||||
mux.HandleFunc("/", NotFoundHandler)
|
||||
|
||||
postLog.Info("Webhook router setup completed")
|
||||
return mux
|
||||
}
|
||||
|
||||
// NotFoundHandler returns a 404 JSON response for unknown routes.
|
||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
postLog.Debug(fmt.Sprintf("Unknown request: %s %s", r.Method, r.URL.Path))
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: The console is embedded in the binary (web\embed.go), so compiling without
|
||||
:: web\dist fails. Say that plainly rather than leaving go:embed's error.
|
||||
if not exist "web\dist\index.html" (
|
||||
echo The web console is not built: web\dist is missing.
|
||||
echo Run build-win-x86_64.bat once, or "npm run build" in frontend\.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
go run -ldflags "%LDFLAGS%" .
|
||||
+130
-50
@@ -100,62 +100,58 @@ func GetUserLevelFromRequest(r *http.Request) string {
|
||||
return tokenInfo.Level
|
||||
}
|
||||
|
||||
// Auth is the central authentication and authorization function.
|
||||
// It validates the request method, X-Timestamp header (30min tolerance),
|
||||
// X-Token header, and permission level. Returns true if the request is authorized.
|
||||
// checkTimestamp validates a Unix timestamp in seconds against the server clock
|
||||
// (30 minute tolerance per agent.md). The check is skipped entirely in debug
|
||||
// mode. It returns 0 when the timestamp is acceptable, otherwise the HTTP status
|
||||
// and message to reject the request with.
|
||||
func checkTimestamp(timestamp string) (int, string) {
|
||||
if config.IsDebugMode() {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
if timestamp == "" {
|
||||
return http.StatusUnauthorized, "missing timestamp"
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, "invalid timestamp"
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
diff := now - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 1800 {
|
||||
return http.StatusUnauthorized, "request expired"
|
||||
}
|
||||
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// checkPermission validates a session token against the required permission
|
||||
// level and refreshes the token's idle timer on success.
|
||||
//
|
||||
// Permission levels: "None" (public, no token required), "bot", "admin".
|
||||
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||
// When level is "admin", only "admin" tokens are accepted.
|
||||
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
|
||||
// Validate HTTP method.
|
||||
if r.Method != targetMethod {
|
||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate X-Timestamp.
|
||||
timestamp := r.Header.Get("X-Timestamp")
|
||||
if !config.IsDebugMode() {
|
||||
if timestamp == "" {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "missing timestamp")
|
||||
return false
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid timestamp")
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
diff := now - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// 30 minute tolerance per agent.md.
|
||||
if diff > 1800 {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "request expired")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// It returns 0 when the token is authorized, otherwise the HTTP status and
|
||||
// message to reject the request with.
|
||||
func checkPermission(token string, targetLevel string) (int, string) {
|
||||
// Public endpoints require no token.
|
||||
if targetLevel == "None" {
|
||||
return true
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Validate X-Token.
|
||||
token := r.Header.Get("X-Token")
|
||||
if token == "" {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "missing token")
|
||||
return false
|
||||
return http.StatusUnauthorized, "missing token"
|
||||
}
|
||||
|
||||
tokenInfo, exists := GetTokenInfo(token)
|
||||
if !exists {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid token")
|
||||
return false
|
||||
return http.StatusUnauthorized, "invalid token"
|
||||
}
|
||||
|
||||
// Check permission level.
|
||||
@@ -164,21 +160,102 @@ func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLev
|
||||
switch targetLevel {
|
||||
case "admin":
|
||||
if tokenInfo.Level != "admin" {
|
||||
SendErrorResponse(w, http.StatusForbidden, "permission denied")
|
||||
return false
|
||||
return http.StatusForbidden, "permission denied"
|
||||
}
|
||||
case "bot":
|
||||
if tokenInfo.Level != "bot" && tokenInfo.Level != "admin" {
|
||||
SendErrorResponse(w, http.StatusForbidden, "permission denied")
|
||||
return false
|
||||
return http.StatusForbidden, "permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token last access time.
|
||||
RefreshToken(token)
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Auth is the central authentication and authorization function.
|
||||
// It validates the request method, X-Timestamp header (30min tolerance),
|
||||
// X-Token header, and permission level. Returns true if the request is authorized.
|
||||
//
|
||||
// Permission levels: "None" (public, no token required), "bot", "admin".
|
||||
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||
// When level is "admin", only "admin" tokens are accepted.
|
||||
//
|
||||
// WebSocket upgrades cannot carry custom headers from a browser; those endpoints
|
||||
// use WebSocketAuthMiddleware instead, which also accepts the credentials as
|
||||
// query parameters.
|
||||
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
|
||||
// Validate HTTP method.
|
||||
if r.Method != targetMethod {
|
||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate X-Timestamp.
|
||||
if status, message := checkTimestamp(r.Header.Get("X-Timestamp")); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate X-Token and its permission level.
|
||||
if status, message := checkPermission(r.Header.Get("X-Token"), targetLevel); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// AuthWS gates a WebSocket endpoint behind the given permission
|
||||
// level ("bot" or "admin"), authenticating the upgrade request before the
|
||||
// connection is handed to the handler. Unauthorized requests are answered with
|
||||
// the standard JSON error response and are never upgraded.
|
||||
//
|
||||
// A browser cannot set custom headers on a WebSocket handshake, so the session
|
||||
// token and timestamp are read from the X-Token / X-Timestamp headers when
|
||||
// present and otherwise from the "token" and "timestamp" query parameters:
|
||||
//
|
||||
// ws://host/api/system/getLogs?token=<token>×tamp=<unix seconds>
|
||||
//
|
||||
// The timestamp is only checked at handshake time, so a long-lived connection
|
||||
// stays open past its tolerance window. Because a query string commonly ends up
|
||||
// in proxy and access logs, a token-carrying URL should be treated as a secret.
|
||||
func AuthWS(targetLevel string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// An upgrade request is always a GET.
|
||||
if r.Method != http.MethodGet {
|
||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// Headers win over query parameters so programmatic clients can keep
|
||||
// the credentials out of the URL.
|
||||
token := r.Header.Get("X-Token")
|
||||
timestamp := r.Header.Get("X-Timestamp")
|
||||
query := r.URL.Query()
|
||||
if token == "" {
|
||||
token = query.Get("token")
|
||||
}
|
||||
if timestamp == "" {
|
||||
timestamp = query.Get("timestamp")
|
||||
}
|
||||
|
||||
if status, message := checkTimestamp(timestamp); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return
|
||||
}
|
||||
if status, message := checkPermission(token, targetLevel); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CleanExpiredTokens removes tokens that have been idle for over 1 hour.
|
||||
func CleanExpiredTokens() {
|
||||
tokenStoreLock.Lock()
|
||||
@@ -203,7 +280,9 @@ func StartTokenCleaner() {
|
||||
}()
|
||||
}
|
||||
|
||||
// SendSuccessResponse sends a standardized JSON success response.
|
||||
// SendSuccessResponse sends a standardized JSON success response. Every payload
|
||||
// is nested under a single "data" key, so success responses use the envelope
|
||||
// {"success": true, "message": "...", "data": {...}}.
|
||||
func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]interface{}{
|
||||
@@ -212,9 +291,10 @@ func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]
|
||||
if message != "" {
|
||||
resp["message"] = message
|
||||
}
|
||||
for k, v := range data {
|
||||
resp[k] = v
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
resp["data"] = data
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// distFS holds the built console. Vite writes it to web/dist (see the outDir
|
||||
// in frontend/vite.config.js) and it is compiled into the binary here, so a
|
||||
// running executable serves the whole frontend on its own: neither the
|
||||
// frontend sources nor web/dist need to exist on the machine that runs it.
|
||||
//
|
||||
// The all: prefix also picks up files whose names start with "_" or ".", which
|
||||
// the default pattern skips.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// StaticFiles is the built frontend, rooted at the directory Vite writes to,
|
||||
// so paths inside it are relative to that directory, e.g. "index.html" or
|
||||
// "assets/app.js".
|
||||
//
|
||||
// web/dist is a build artifact and is not in a fresh checkout, so the console
|
||||
// has to be built before the backend compiles — any build-*.sh / build-*.bat
|
||||
// does it first, or run `npm run build` in frontend/ yourself. Compiling
|
||||
// without it fails with "pattern all:dist: no matching files found".
|
||||
var StaticFiles fs.FS
|
||||
|
||||
func init() {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic("web: embedded frontend is unreadable: " + err.Error())
|
||||
}
|
||||
StaticFiles = sub
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// staticFS wraps the embedded frontend for net/http. StaticFiles is already
|
||||
// rooted at the directory Vite writes to, so it is served as-is — no further
|
||||
// fs.Sub is needed. http.FS copies a file that is not an io.Seeker into memory
|
||||
// before serving it, which keeps this working whatever fs.FS StaticFiles is.
|
||||
func staticFS() http.FileSystem {
|
||||
return http.FS(StaticFiles)
|
||||
}
|
||||
|
||||
func ServeStatic(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
|
||||
if urlPath == "/" || urlPath == "/index.html" || strings.HasPrefix(urlPath, "/assets/") {
|
||||
if urlPath == "/" {
|
||||
urlPath = "/index.html"
|
||||
}
|
||||
|
||||
filePath := strings.TrimPrefix(urlPath, "/")
|
||||
f, err := staticFS().Open(filePath)
|
||||
if err != nil {
|
||||
serveIndexHTML(w)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
serveIndexHTML(w)
|
||||
return
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
serveIndexHTML(w)
|
||||
return
|
||||
}
|
||||
|
||||
setContentType(w, path.Ext(filePath))
|
||||
http.ServeContent(w, r, filePath, stat.ModTime(), f)
|
||||
return
|
||||
}
|
||||
|
||||
serveIndexHTML(w)
|
||||
}
|
||||
|
||||
func serveIndexHTML(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
data, err := fs.ReadFile(StaticFiles, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "index.html not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func setContentType(w http.ResponseWriter, ext string) {
|
||||
switch ext {
|
||||
case ".html":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
case ".json":
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
case ".svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
case ".ico":
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
case ".woff":
|
||||
w.Header().Set("Content-Type", "font/woff")
|
||||
case ".woff2":
|
||||
w.Header().Set("Content-Type", "font/woff2")
|
||||
case ".ttf":
|
||||
w.Header().Set("Content-Type", "font/ttf")
|
||||
case ".eot":
|
||||
w.Header().Set("Content-Type", "application/vnd.ms-fontobject")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user