feat: add shared PostgreSQL fnOS service and refresh UI

This commit is contained in:
2026-08-02 20:14:39 +08:00
parent de9f5ae110
commit e5b50ea85c
52 changed files with 3577 additions and 338 deletions
+2
View File
@@ -5,6 +5,8 @@
**/*.suo
frontend/node_modules/
frontend/dist/
frontend/dist-postgres/
**/.dotnet-cli-home/
.codex-temp/
.tools/
build.log
+23 -6
View File
@@ -2,7 +2,7 @@
Live Recorder 是一个多平台直播录制系统,支持自动检测开播、实时录制、弹幕采集、通知推送与事件脚本编排。
- 后端:`.NET 8 Web API + EF Core + SQLite`
- 后端:`.NET 8 Web API + EF Core + PostgreSQL`
- 前端:`Vue 3 + TypeScript + Element Plus`
当前已适配 **抖音**Douyin)与 **Bilibili**(部分),架构将平台特有逻辑隔离在 `Platforms` 目录下,新增虎牙、斗鱼、快手等平台无需改动应用层服务。
@@ -255,17 +255,34 @@ docker compose up -d
### fnOS 原生 FPK
```bash
./scripts/build-postgresql-fnos-package.sh
./scripts/build-fnos-package.sh
./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.0.1-x86_64.fpk
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-package.sh \
artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \
artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-migration.sh \
artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \
artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
```
- 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径
- x86_64 原生,不依赖 Docker;离线内置 .NET、PostgreSQL、官方 Node.js 22 与 curl
- 两个 FPK 都是 x86_64 原生应用,不依赖 Docker
- 先安装 `nxsir.postgresql`,再安装 Live RecorderfnOS 会通过 `install_dep_apps` 检查依赖
- PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433`
- 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector
- PostgreSQL 管理界面有独立管理员登录、客户端/数据库/角色/会话管理、只读 SQL 和手动备份恢复
- Live Recorder 安装向导会要求设置自身 `admin` 密码,并填写 PostgreSQL 服务的应用接入令牌
- 从旧 fnOS 包升级时会自动迁移原内置 PostgreSQL;只有自定义转储、SHA-256 和十张业务表行数校验全部成功后才切换
- 迁移失败会继续使用旧数据库并在下次启动重试;旧数据库和迁移转储不会自动删除
- Live Recorder 1.1.0 暂时仍携带旧 PG15 运行时,仅用于升级迁移和安全回退,不会在新安装上启动第二个数据库进程
- 依赖 fnOS 系统环境同时提供 `ffmpeg``ffprobe`,安装前请先确认二者可执行
- 安装向导会要求设置 `admin` 管理员密码
- Web 管理界面默认使用端口 `18080`
- 数据库与日志保存在 fnOS 应用持久化目录
- 录制文件保存在 fnOS 共享目录 `liverecorder/records`
- PostgreSQL 数据位于其应用持久化目录,手动备份位于共享目录 `postgresql/backups`
- 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改
更完整的安装、端口、凭据与迁移说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。
其他 fnOS 应用接入共享数据库时,请直接参考 [docs/fnos-postgresql-client-integration.md](docs/fnos-postgresql-client-integration.md)。
## 验证
+237
View File
@@ -0,0 +1,237 @@
# fnOS PostgreSQL 共享服务接入指南
本文面向需要接入 `nxsir.postgresql` 的 fnOS 原生应用。共享服务不依赖 Docker,多个应用共用一个 PostgreSQL 15 进程,但每个应用获得独立数据库、独立登录角色和独立随机密码。
## 1. 服务约定
| 项目 | 默认值 |
|---|---|
| fnOS 应用名 | `nxsir.postgresql` |
| PostgreSQL 地址 | `127.0.0.1:15432` |
| 管理/API 地址 | `http://127.0.0.1:15433` |
| 传输加密 | 不启用 TLS,仅允许本机回环连接 |
| 密码认证 | PostgreSQL SCRAM-SHA-256 |
| 可申请扩展 | `vector`pgvector |
不要连接 Unix Socket、不要使用服务管理员角色,也不要假设数据库名或角色名。客户端必须通过接入 API 获取完整凭据。
## 2. 声明 fnOS 依赖
在应用 FPK 的 `manifest` 中声明:
```ini
install_dep_apps=nxsir.postgresql
```
用户应先安装并启动 PostgreSQL 共享服务,再安装你的应用。你的安装/升级向导需要提供一个密码字段,让用户填写安装共享服务时设置的“应用接入令牌”。令牌长度为 20~256 个字符,不允许换行。
接入令牌与 PostgreSQL 管理员密码是两套独立凭据:
- 管理员密码只登录 PostgreSQL 管理面板。
- 接入令牌只用于本机应用首次签发或重新签发数据库凭据。
## 3. 注册客户端
注册接口只接受来自回环地址的请求:
```http
POST http://127.0.0.1:15433/internal/v1/enroll
Authorization: Bearer <>
Content-Type: application/json
```
普通 PostgreSQL 客户端:
```json
{
"appId": "myapp",
"displayName": "My fnOS App",
"requestedExtensions": []
}
```
需要 pgvector 的应用:
```json
{
"appId": "imagefind",
"displayName": "ImageFind",
"requestedExtensions": ["vector"]
}
```
字段限制:
- `appId`:稳定且全局唯一,3~64 个字符;以小写字母开头,只允许小写字母、数字、点、下划线和连字符。发布后不要更改。
- `displayName`:1~100 个字符,用于管理面板展示。
- `requestedExtensions`:目前只能是空数组或包含 `vector`
curl 示例:
```bash
curl --fail --silent --show-error \
-H "Authorization: Bearer $APP_ENROLLMENT_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"appId":"myapp","displayName":"My fnOS App","requestedExtensions":[]}' \
http://127.0.0.1:15433/internal/v1/enroll
```
成功响应:
```json
{
"host": "127.0.0.1",
"port": 15432,
"database": "appdb_myapp_...",
"username": "app_myapp_...",
"password": "一次性返回的随机密码",
"sslMode": "Disable",
"serviceVersion": "15"
}
```
每次对同一 `appId` 重新注册都会复用其数据库和角色,但会立即轮换密码,使旧密码失效。因此正常启动时应先读取本地凭据,只有首次安装、凭据丢失或明确执行密码轮换时才重新注册。
常见 HTTP 状态:
| 状态 | 含义 |
|---:|---|
| `200` | 注册成功;响应中包含新密码 |
| `400` | `appId`、显示名或扩展参数无效 |
| `401` | 接入令牌错误或已被管理员轮换 |
| `403` | 请求不是从本机回环地址发起 |
| `500` | 共享服务内部错误;查看共享服务日志 |
## 4. fnOS 生命周期脚本建议
安装回调只负责以 `0600` 保存令牌种子,不要在向导校验阶段依赖网络。应用启动时执行注册,并采用临时文件加原子重命名保存响应。
建议的持久化文件:
```text
${TRIM_PKGVAR}/postgres-enrollment-token.seed # 首次注册前,0600
${TRIM_PKGVAR}/postgres-client.conf # 注册成功后,0600
```
推荐配置格式:
```ini
host=127.0.0.1
port=15432
database=接口返回值
username=接口返回值
password=接口返回值
```
注册成功并安全落盘后应删除令牌种子,避免长期保存高权限接入令牌。不要把密码写入日志、命令行参数、进程标题或 Web 前端。应用卸载时是否保留数据库由用户决定;不要自行执行 `DROP DATABASE`
服务可能在 NAS 启动时稍晚就绪。建议:
- 请求超时 10 秒左右。
- 每 2 秒重试一次,最多等待 1~2 分钟。
- 先探测 `GET /health/ready`,或直接重试注册。
- 已有本地凭据时不要因为管理 API 暂时不可用而重新注册;直接尝试 PostgreSQL 连接。
## 5. 连接字符串
.NET / Npgsql
```text
Host=127.0.0.1;Port=15432;Database=<database>;Username=<username>;Password=<password>;SSL Mode=Disable;Timeout=15;Command Timeout=120;Keepalive=30
```
Python / psycopg
```python
import psycopg
connection = psycopg.connect(
host="127.0.0.1",
port=15432,
dbname=database,
user=username,
password=password,
sslmode="disable",
connect_timeout=15,
)
```
JDBC
```text
jdbc:postgresql://127.0.0.1:15432/<database>?sslmode=disable&connectTimeout=15
```
应用角色是目标数据库及 `public` schema 的所有者,可以执行自身 migrations、建表和创建索引,但不能创建数据库、创建角色、成为超级用户或连接其他托管应用的数据库。
## 6. pgvector
注册时申请 `"requestedExtensions":["vector"]` 后,共享服务会在应用数据库内执行:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
应用可直接在 migration 中使用:
```sql
CREATE TABLE embeddings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
embedding vector(768) NOT NULL
);
CREATE INDEX embeddings_cosine_idx
ON embeddings USING hnsw (embedding vector_cosine_ops);
```
不要由应用尝试安装系统扩展文件。未在允许列表中的扩展会在注册阶段被拒绝。
## 7. schema 与迁移
- 新数据库默认撤销 `PUBLIC` 的数据库连接权和 schema 权限。
- 应用角色拥有自己的数据库和 `public` schema,可正常运行 Flyway、EF Core、Alembic、Liquibase 等迁移。
- 用 custom-format `pg_dump` 迁移旧库时建议使用 `--no-owner --no-acl`
- 恢复前确认目标库为空;不要覆盖其他 `appId` 的数据库。
- 切换前至少校验关键表行数,保留旧数据和带 SHA-256 的转储,确认稳定后再人工清理。
## 8. 密码轮换、吊销与恢复
管理员可以在 PostgreSQL 管理面板中:
- 为某个客户端轮换密码:应用必须立即更新本地凭据。
- 吊销客户端:对应角色变为 `NOLOGIN`,数据库不会被删除。
- 轮换全局接入令牌:旧令牌立即失效,已经签发的数据库密码不受影响。
应用本地凭据丢失时,让用户在升级/修复向导重新输入当前接入令牌,再用相同 `appId` 注册。数据库内容会保留,但旧数据库密码会失效。
不要静默回退到陈旧数据库副本。若应用已经完成共享数据库迁移,但凭据不可用,应停止启动并提示修复凭据,避免产生两套分叉数据。
## 9. 健康检查与排错
```bash
curl -fsS http://127.0.0.1:15433/health
curl -fsS http://127.0.0.1:15433/health/ready
```
排查顺序:
1. 确认 `nxsir.postgresql` 已安装且状态为运行中。
2. 确认应用使用 `127.0.0.1`,而不是 NAS 局域网 IP。
3. 检查接入 API 端口 `15433` 与数据库端口 `15432` 是否混用。
4. 检查凭据文件权限是否为 `0600`,字段是否完整。
5. `password authentication failed` 通常表示密码已轮换;用相同 `appId` 重新注册并原子更新凭据。
6. `permission denied for database` 通常表示连接了其他应用的数据库;必须使用响应中的 database。
7. 共享服务升级或重启不会要求客户端重新注册,应用应使用已有凭据自动重连。
## 10. 接入验收清单
- manifest 已声明 `install_dep_apps=nxsir.postgresql`
- 接入 API 只从回环地址调用。
- `appId` 固定且不会随版本变化。
- 令牌和数据库密码从不写日志。
- 凭据以 `0600` 原子落盘,成功后删除令牌种子。
- 普通启动不重复注册、不意外轮换密码。
- 应用 migrations 能在自己的数据库执行。
- 已验证无法连接另一个测试应用的数据库。
- 共享服务重启后应用能用原凭据恢复。
- 已准备凭据丢失、密码轮换和迁移失败的明确恢复流程。
+47 -1
View File
@@ -1,4 +1,50 @@
# PostgreSQL 切换与 SQLite 历史数据迁移
# PostgreSQL 共享服务与历史数据迁移
## fnOS 原生部署
fnOS 方案由两个独立 FPK 组成:
| 应用 | 默认端口 | 持久化内容 |
|---|---:|---|
| `nxsir.postgresql` | 管理界面 `15433`、数据库 `127.0.0.1:15432` | PostgreSQL 数据、凭据散列、审计日志 |
| `liverecorder` | Web 管理界面 `18080` | 应用日志、签发后的数据库客户端凭据、迁移回退数据 |
安装顺序:
1. 安装 PostgreSQL 共享服务,设置独立管理密码与长度至少 20 位的应用接入令牌。
2. 安装 Live Recorder,设置应用管理员密码,并填写同一个接入令牌。
3. Live Recorder 只通过 `127.0.0.1` 注册。共享服务为它创建独立数据库和 SCRAM 角色,随机密码只在注册响应中返回一次。
4. 注册成功后,接入令牌会从 Live Recorder 持久化目录删除;签发凭据保存在权限为 `0600``postgres-client.conf`
新安装不会启动 Live Recorder 包内的旧 PostgreSQL。录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套。
### 从旧 fnOS 版本自动迁移
升级包检测到旧 `PG_VERSION` 且尚无迁移标记时会:
1. 启动旧的私有 PG15,只读导出 custom-format 转储并生成 SHA-256。
2. 清空新签发的目标 schema,以 `--no-owner --no-acl` 恢复。
3. 精确比较十张业务表在源库和目标库中的行数。
4. 全部成功后写入 `shared-database.active` 标记并停止旧 PostgreSQL。
任一步失败都会继续使用旧数据库,下次启动再重试。系统不会自动删除旧数据、转储、校验文件;确认新版本稳定并另行备份后再手工清理。迁移标记一旦存在,凭据损坏时应用会拒绝回退到已经过期的旧库,防止录制数据分叉。
### 管理与备份
- PostgreSQL 管理面板使用独立 `admin` 会话,不复用 Live Recorder 登录。
- SQL 工作台只接受单条 `SELECT``WITH``EXPLAIN``SHOW``VALUES``TABLE`,并在只读事务、30 秒超时和 1000 行上限下执行。
- 自动签发的应用数据库与角色不能在普通数据库/角色页面直接删除,应从客户端页面吊销。
- 备份仅手动触发,使用 custom-format `pg_dump` 并保存 SHA-256;恢复需要明确输入目标数据库名称确认。
### fnOS 验证
```bash
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-migration.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
```
下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。
这份说明对应当前主线版本:应用正式运行数据库已经切换为 PostgreSQL,SQLite 仅用于一次性历史数据导入。
+13
View File
@@ -0,0 +1,13 @@
{
".url": {
"nxsir.postgresql.Application": {
"title": "PostgreSQL 共享服务",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "",
"port": "15433",
"url": "/",
"allUsers": false
}
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -eu
ADMIN_PASSWORD="${wizard_postgres_admin_password:-}"
ADMIN_PASSWORD_CONFIRM="${wizard_postgres_admin_password_confirm:-}"
ENROLLMENT_TOKEN="${wizard_postgres_enrollment_token:-}"
ENROLLMENT_TOKEN_CONFIRM="${wizard_postgres_enrollment_token_confirm:-}"
unset wizard_postgres_admin_password wizard_postgres_admin_password_confirm
unset wizard_postgres_enrollment_token wizard_postgres_enrollment_token_confirm
fail() {
printf '%s\n' "$1" >&2
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
printf '%s\n' "$1" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
fi
exit 1
}
[ "$ADMIN_PASSWORD" = "$ADMIN_PASSWORD_CONFIRM" ] || fail "管理密码两次输入不一致。"
[ "${#ADMIN_PASSWORD}" -ge 12 ] || fail "管理密码至少需要 12 个字符。"
[ "${#ADMIN_PASSWORD}" -le 256 ] || fail "管理密码不能超过 256 个字符。"
[ "$ENROLLMENT_TOKEN" = "$ENROLLMENT_TOKEN_CONFIRM" ] || fail "接入令牌两次输入不一致。"
[ "${#ENROLLMENT_TOKEN}" -ge 20 ] || fail "接入令牌至少需要 20 个字符。"
[ "${#ENROLLMENT_TOKEN}" -le 256 ] || fail "接入令牌不能超过 256 个字符。"
case "$ADMIN_PASSWORD$ENROLLMENT_TOKEN" in
*$'\n'*|*$'\r'*) fail "密码和令牌不能包含换行符。" ;;
esac
mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log"
chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true
umask 077
printf '%s\n' "$ADMIN_PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed"
printf '%s\n' "$ENROLLMENT_TOKEN" >"${TRIM_PKGVAR}/enrollment-token.seed"
unset ADMIN_PASSWORD ADMIN_PASSWORD_CONFIRM ENROLLMENT_TOKEN ENROLLMENT_TOKEN_CONFIRM
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+175
View File
@@ -0,0 +1,175 @@
#!/bin/bash
set -u
PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}"
DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}"
VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}"
BACKUP_ROOT="${POSTGRES_SERVICE_BACKUP_ROOT:-$VOLUME_ROOT/@appshare/postgresql/backups}"
RUNTIME_ROOT="$APP_ROOT/runtime"
SERVER="$APP_ROOT/server/PostgresService.WebApi"
PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin"
PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15"
PG_LIB="$RUNTIME_ROOT/usr/lib/postgresql/15/lib"
PG_DATA="$DATA_ROOT/postgres"
RUN_ROOT="$DATA_ROOT/run"
LOG_ROOT="$DATA_ROOT/log"
APP_PID_FILE="$RUN_ROOT/postgres-service.pid"
APP_LOG="$LOG_ROOT/postgres-service.log"
PG_LOG="$LOG_ROOT/postgresql.log"
PG_PORT="${POSTGRES_SERVICE_PORT:-15432}"
SERVICE_PORT="${TRIM_SERVICE_PORT:-15433}"
RUNTIME_PATH="$PG_BIN:$RUNTIME_ROOT/usr/bin:${PATH:-/usr/local/bin:/usr/bin:/bin}"
RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu:$RUNTIME_ROOT/lib/x86_64-linux-gnu:$PG_LIB"
log_message() {
mkdir -p "$LOG_ROOT"
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG"
}
run_pg() {
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" "$@"
}
app_pid() {
if [ -f "$APP_PID_FILE" ]; then
pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]')
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
printf '%s' "$pid"
return 0
fi
fi
return 1
}
postgres_running() {
[ -x "$PG_BIN/pg_ctl" ] || return 1
[ -f "$PG_DATA/PG_VERSION" ] || return 1
run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1
}
initialize_postgres() {
[ ! -f "$PG_DATA/PG_VERSION" ] || return 0
mkdir -p "$PG_DATA" "$RUN_ROOT" "$LOG_ROOT"
chmod 0700 "$PG_DATA" "$RUN_ROOT"
run_pg "$PG_BIN/initdb" \
-D "$PG_DATA" \
-L "$PG_SHARE" \
--username=postgres_service \
--auth-local=trust \
--auth-host=scram-sha-256 \
--encoding=UTF8 \
--no-locale >>"$PG_LOG" 2>&1 || return 1
{
printf "listen_addresses = '127.0.0.1'\n"
printf "port = %s\n" "$PG_PORT"
printf "unix_socket_directories = '%s'\n" "$RUN_ROOT"
printf "password_encryption = 'scram-sha-256'\n"
printf "max_connections = 100\n"
printf "shared_buffers = '128MB'\n"
printf "timezone = 'UTC'\n"
printf "log_timezone = 'UTC'\n"
printf "log_min_duration_statement = 5000\n"
} >>"$PG_DATA/postgresql.conf"
{
printf 'local all postgres_service trust\n'
printf 'local all all reject\n'
printf 'host all all 127.0.0.1/32 scram-sha-256\n'
printf 'host all all ::1/128 scram-sha-256\n'
} >"$PG_DATA/pg_hba.conf"
}
start_postgres() {
postgres_running && return 0
initialize_postgres || { log_message "PostgreSQL 初始化失败。"; return 1; }
run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start || {
log_message "PostgreSQL 启动失败。"
return 1
}
}
stop_postgres() {
if postgres_running; then
run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -t 180 -w stop >>"$PG_LOG" 2>&1 || {
log_message "PostgreSQL 未能在 180 秒内安全停止。"
return 1
}
fi
}
start_app() {
mkdir -p "$DATA_ROOT" "$RUN_ROOT" "$LOG_ROOT" "$BACKUP_ROOT" "$DATA_ROOT/tmp" "$DATA_ROOT/cache"
chmod 0700 "$DATA_ROOT" "$RUN_ROOT" "$DATA_ROOT/tmp" "$DATA_ROOT/cache" 2>/dev/null || true
[ -x "$SERVER" ] || { log_message "管理服务不存在或不可执行:$SERVER"; return 1; }
[ -x "$PG_BIN/postgres" ] || { log_message "PostgreSQL 原生运行环境不完整。"; return 1; }
if app_pid >/dev/null; then return 0; fi
start_postgres || return 1
(
export ASPNETCORE_ENVIRONMENT=Production
export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT"
export POSTGRES_SERVICE_DATA_ROOT="$DATA_ROOT"
export POSTGRES_SERVICE_SOCKET_ROOT="$RUN_ROOT"
export POSTGRES_SERVICE_PORT="$PG_PORT"
export POSTGRES_SERVICE_ADMIN_USER=postgres_service
export POSTGRES_SERVICE_PG_BIN="$PG_BIN"
export POSTGRES_SERVICE_BACKUP_ROOT="$BACKUP_ROOT"
export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$DATA_ROOT/dotnet-bundle"
export XDG_CACHE_HOME="$DATA_ROOT/cache"
export TMPDIR="$DATA_ROOT/tmp"
export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH"
export PATH="$RUNTIME_PATH"
mkdir -p "$DOTNET_BUNDLE_EXTRACT_BASE_DIR"
cd "$APP_ROOT/server" || exit 1
exec "$SERVER"
) >>"$APP_LOG" 2>&1 &
pid=$!
printf '%s\n' "$pid" >"$APP_PID_FILE"
attempt=0
while [ "$attempt" -lt 90 ]; do
if ! kill -0 "$pid" 2>/dev/null; then
log_message "管理服务进程提前退出。"
stop_postgres
return 1
fi
if (exec 3<>"/dev/tcp/127.0.0.1/$SERVICE_PORT") 2>/dev/null; then
exec 3>&-
log_message "PostgreSQL 共享服务启动成功,管理端口 $SERVICE_PORT,数据库端口 $PG_PORT。"
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
log_message "管理服务未能在 90 秒内就绪。"
stop_app
return 1
}
stop_app() {
if pid=$(app_pid); then
kill "$pid" 2>/dev/null || true
attempt=0
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do
sleep 1
attempt=$((attempt + 1))
done
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$APP_PID_FILE"
stop_postgres
}
case "${1:-status}" in
start) start_app ;;
stop) stop_app ;;
restart) stop_app && start_app ;;
status)
if app_pid >/dev/null && postgres_running; then exit 0; fi
exit 3
;;
*) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;;
esac
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
# 数据目录与手动备份默认保留,避免误删多个应用的共享数据。
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -eu
"$(dirname -- "$0")/main" stop || true
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -eu
# 当前服务固定在 PostgreSQL 15 主版本;数据目录与管理凭据位于
# TRIM_PKGVARfnOS 替换不可变应用文件时无需复制。
exit 0
+5
View File
@@ -0,0 +1,5 @@
{
"defaults": {
"run-as": "package"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"data-share": {
"shares": [
{
"name": "postgresql",
"permission": {
"rw": ["nxsir.postgresql"]
}
},
{
"name": "postgresql/backups",
"permission": {
"rw": ["nxsir.postgresql"]
}
}
]
}
}
+12
View File
@@ -0,0 +1,12 @@
appname=nxsir.postgresql
version=15.1.0
display_name=PostgreSQL 共享服务
desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复
platform=x86
source=thirdparty
maintainer=Live Recorder Contributors
os_min_version=1.2.0
desktop_uidir=ui
desktop_applaunchname=nxsir.postgresql.Application
checkport=true
ctl_stop=true
+47
View File
@@ -0,0 +1,47 @@
[
{
"stepTitle": "设置 PostgreSQL 服务管理凭据",
"items": [
{
"type": "tips",
"helpText": "管理密码用于登录数据库面板;接入令牌用于其他 fnOS 应用首次申请独立数据库。两者均不会设置默认值。"
},
{
"type": "password",
"field": "wizard_postgres_admin_password",
"label": "管理面板密码",
"rules": [
{ "required": true, "message": "请输入管理面板密码" },
{ "min": 12, "message": "管理密码至少需要 12 个字符" },
{ "max": 256, "message": "管理密码不能超过 256 个字符" }
]
},
{
"type": "password",
"field": "wizard_postgres_admin_password_confirm",
"label": "再次输入管理密码",
"rules": [
{ "required": true, "message": "请再次输入管理密码" }
]
},
{
"type": "password",
"field": "wizard_postgres_enrollment_token",
"label": "应用接入令牌",
"rules": [
{ "required": true, "message": "请输入应用接入令牌" },
{ "min": 20, "message": "接入令牌至少需要 20 个字符" },
{ "max": 256, "message": "接入令牌不能超过 256 个字符" }
]
},
{
"type": "password",
"field": "wizard_postgres_enrollment_token_confirm",
"label": "再次输入接入令牌",
"rules": [
{ "required": true, "message": "请再次输入接入令牌" }
]
}
]
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"stepTitle": "升级 PostgreSQL 共享服务",
"items": [
{
"type": "tips",
"helpText": "升级期间所有依赖应用会暂时失去数据库连接。当前版本仅执行 PostgreSQL 15 同主版本升级,并保留数据、凭据、审计记录和备份。"
}
]
}
]
+9 -2
View File
@@ -3,7 +3,8 @@ set -eu
PASSWORD="${wizard_admin_password:-}"
PASSWORD_CONFIRM="${wizard_admin_password_confirm:-}"
unset wizard_admin_password wizard_admin_password_confirm
POSTGRES_ENROLLMENT_TOKEN="${wizard_postgres_enrollment_token:-}"
unset wizard_admin_password wizard_admin_password_confirm wizard_postgres_enrollment_token
fail() {
printf '%s\n' "$1" >&2
@@ -19,10 +20,16 @@ fail() {
case "$PASSWORD" in
*$'\n'*|*$'\r'*) fail "管理员密码不能包含换行符。" ;;
esac
[ "${#POSTGRES_ENROLLMENT_TOKEN}" -ge 20 ] || fail "PostgreSQL 应用接入令牌至少需要 20 个字符。"
[ "${#POSTGRES_ENROLLMENT_TOKEN}" -le 256 ] || fail "PostgreSQL 应用接入令牌不能超过 256 个字符。"
case "$POSTGRES_ENROLLMENT_TOKEN" in
*$'\n'*|*$'\r'*) fail "PostgreSQL 应用接入令牌不能包含换行符。" ;;
esac
mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log"
chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true
umask 077
printf '%s\n' "$PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed"
unset PASSWORD PASSWORD_CONFIRM
printf '%s\n' "$POSTGRES_ENROLLMENT_TOKEN" >"${TRIM_PKGVAR}/postgres-enrollment-token.seed"
unset PASSWORD PASSWORD_CONFIRM POSTGRES_ENROLLMENT_TOKEN
exit 0
+185 -3
View File
@@ -21,11 +21,22 @@ APP_PID_FILE="$RUN_ROOT/liverecorder.pid"
APP_LOG="$LOG_ROOT/liverecorder.log"
PG_LOG="$LOG_ROOT/postgresql.log"
ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed"
POSTGRES_ENROLLMENT_TOKEN_FILE="$DATA_ROOT/postgres-enrollment-token.seed"
POSTGRES_CREDENTIALS_FILE="$DATA_ROOT/postgres-client.conf"
POSTGRES_MIGRATION_ROOT="$DATA_ROOT/postgres-migration"
POSTGRES_MIGRATION_MARKER="$POSTGRES_MIGRATION_ROOT/shared-database.active"
PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}"
SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}"
POSTGRES_SERVICE_API="${POSTGRES_SERVICE_API:-http://127.0.0.1:15433}"
SYSTEM_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
RUNTIME_PATH="$RUNTIME_ROOT/bin:$SYSTEM_PATH"
RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib:$PG_LIB"
DATABASE_CONNECTION_STRING=""
SHARED_DB_HOST=""
SHARED_DB_PORT=""
SHARED_DB_NAME=""
SHARED_DB_USER=""
SHARED_DB_PASSWORD=""
log_message() {
mkdir -p "$LOG_ROOT"
@@ -52,6 +63,177 @@ run_native() {
SSL_CERT_FILE="$CA_BUNDLE" CURL_CA_BUNDLE="$CA_BUNDLE" "$@"
}
read_credential_field() {
key=$1
sed -n "s/^${key}=//p" "$POSTGRES_CREDENTIALS_FILE" | sed -n '1p'
}
configure_shared_credentials() {
[ -s "$POSTGRES_CREDENTIALS_FILE" ] || return 1
SHARED_DB_HOST=$(read_credential_field host)
SHARED_DB_PORT=$(read_credential_field port)
SHARED_DB_NAME=$(read_credential_field database)
SHARED_DB_USER=$(read_credential_field username)
SHARED_DB_PASSWORD=$(read_credential_field password)
[ "$SHARED_DB_HOST" = "127.0.0.1" ] || return 1
case "$SHARED_DB_PORT" in ''|*[!0-9]*) return 1 ;; esac
case "$SHARED_DB_NAME$SHARED_DB_USER" in *[!a-z0-9_]*) return 1 ;; esac
[ -n "$SHARED_DB_PASSWORD" ] || return 1
DATABASE_CONNECTION_STRING="Host=$SHARED_DB_HOST;Port=$SHARED_DB_PORT;Database=$SHARED_DB_NAME;Username=$SHARED_DB_USER;Password=$SHARED_DB_PASSWORD;SSL Mode=Disable;Timeout=15;Command Timeout=120;Keepalive=30"
}
enroll_shared_database() {
[ -s "$POSTGRES_ENROLLMENT_TOKEN_FILE" ] || {
log_message "缺少 PostgreSQL 共享服务接入令牌。请在升级或安装向导中重新填写。"
return 1
}
token=$(sed -n '1p' "$POSTGRES_ENROLLMENT_TOKEN_FILE")
[ -n "$token" ] || return 1
response_file="$DATA_ROOT/postgres-enrollment-response.tmp"
rm -f "$response_file"
attempt=0
while [ "$attempt" -lt 60 ]; do
if run_native "$CURL_BIN" \
--fail --silent --show-error \
--connect-timeout 3 --max-time 10 \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
--data '{"appId":"liverecorder","displayName":"Live Recorder","requestedExtensions":[]}' \
"$POSTGRES_SERVICE_API/internal/v1/enroll" >"$response_file" 2>>"$APP_LOG"; then
break
fi
attempt=$((attempt + 1))
sleep 2
done
unset token
[ -s "$response_file" ] || {
log_message "无法从 PostgreSQL 共享服务取得数据库凭据。"
rm -f "$response_file"
return 1
}
host=$(sed -n 's/.*"host":"\([^"]*\)".*/\1/p' "$response_file")
port=$(sed -n 's/.*"port":\([0-9][0-9]*\).*/\1/p' "$response_file")
database=$(sed -n 's/.*"database":"\([^"]*\)".*/\1/p' "$response_file")
username=$(sed -n 's/.*"username":"\([^"]*\)".*/\1/p' "$response_file")
password=$(sed -n 's/.*"password":"\([^"]*\)".*/\1/p' "$response_file")
[ "$host" = "127.0.0.1" ] && [ -n "$port" ] && [ -n "$database" ] && [ -n "$username" ] && [ -n "$password" ] || {
log_message "PostgreSQL 共享服务返回了无效凭据。"
rm -f "$response_file"
return 1
}
umask 077
{
printf 'host=%s\n' "$host"
printf 'port=%s\n' "$port"
printf 'database=%s\n' "$database"
printf 'username=%s\n' "$username"
printf 'password=%s\n' "$password"
} >"$POSTGRES_CREDENTIALS_FILE.tmp"
mv "$POSTGRES_CREDENTIALS_FILE.tmp" "$POSTGRES_CREDENTIALS_FILE"
chmod 0600 "$POSTGRES_CREDENTIALS_FILE"
rm -f "$POSTGRES_ENROLLMENT_TOKEN_FILE" "$response_file"
configure_shared_credentials
}
ensure_shared_credentials() {
if configure_shared_credentials; then
return 0
fi
rm -f "$POSTGRES_CREDENTIALS_FILE"
enroll_shared_database
}
run_shared_pg() {
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" \
PGPASSWORD="$SHARED_DB_PASSWORD" "$@"
}
collect_database_counts() {
mode=$1
destination=$2
: >"$destination"
for table_name in AppSettings CleanupOperations LiveRooms RecordResults RecordSessions RecordTasks RecordUploadJobs SystemLogEntries UserAccounts UserSessions; do
if [ "$mode" = "private" ]; then
count=$(run_pg "$PG_BIN/psql" -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder -Atqc \
"SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1
else
count=$(run_shared_pg "$PG_BIN/psql" -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" -Atqc \
"SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1
fi
printf '%s=%s\n' "$table_name" "$count" >>"$destination"
done
}
migrate_private_postgres() {
mkdir -p "$POSTGRES_MIGRATION_ROOT"
chmod 0700 "$POSTGRES_MIGRATION_ROOT"
dump_file="$POSTGRES_MIGRATION_ROOT/private-postgres-15.dump"
source_counts="$POSTGRES_MIGRATION_ROOT/source-counts.txt"
target_counts="$POSTGRES_MIGRATION_ROOT/target-counts.txt"
start_postgres || return 1
log_message "开始导出原内置 PostgreSQL 数据库。"
run_pg "$PG_BIN/pg_dump" \
-h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder \
--format=custom --no-owner --no-acl --file "$dump_file.tmp" >>"$PG_LOG" 2>&1 || return 1
mv "$dump_file.tmp" "$dump_file"
sha256sum "$dump_file" >"$dump_file.sha256"
collect_database_counts private "$source_counts" || return 1
log_message "开始恢复数据到 PostgreSQL 共享服务。"
run_shared_pg "$PG_BIN/psql" \
-h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \
-v ON_ERROR_STOP=1 -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION \"$SHARED_DB_USER\"" >>"$PG_LOG" 2>&1 || return 1
run_shared_pg "$PG_BIN/pg_restore" \
-h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \
--no-owner --no-acl --exit-on-error "$dump_file" >>"$PG_LOG" 2>&1 || return 1
collect_database_counts shared "$target_counts" || return 1
if ! cmp -s "$source_counts" "$target_counts"; then
log_message "共享数据库行数校验失败,继续使用原数据库。"
return 1
fi
umask 077
{
printf 'migrated_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf 'database=%s\n' "$SHARED_DB_NAME"
printf 'dump=%s\n' "$dump_file"
} >"$POSTGRES_MIGRATION_MARKER"
stop_postgres || return 1
log_message "内置 PostgreSQL 已迁移到共享服务,旧数据和迁移转储已保留。"
}
select_database_connection() {
if ! ensure_shared_credentials; then
if [ -f "$POSTGRES_MIGRATION_MARKER" ]; then
log_message "已完成共享数据库迁移,但当前凭据不可用;为避免使用过期旧库,应用不会启动。"
return 1
fi
if [ -f "$PG_DATA/PG_VERSION" ]; then
log_message "共享服务暂不可用,本次启动继续使用原内置 PostgreSQL。"
start_postgres || return 1
DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30"
return 0
fi
return 1
fi
if [ -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then
if ! migrate_private_postgres; then
log_message "共享数据库迁移失败,本次启动继续使用原内置 PostgreSQL;下次启动会重试迁移。"
start_postgres || return 1
DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30"
return 0
fi
elif [ ! -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then
mkdir -p "$POSTGRES_MIGRATION_ROOT"
umask 077
printf 'fresh_install_at=%s\ndatabase=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$SHARED_DB_NAME" >"$POSTGRES_MIGRATION_MARKER"
fi
return 0
}
system_media_tools_available() {
missing_tools=""
for tool_name in ffmpeg ffprobe; do
@@ -142,7 +324,7 @@ launch_app_process() {
(
export ASPNETCORE_ENVIRONMENT=Production
export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT"
export ConnectionStrings__DefaultConnection="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30"
export ConnectionStrings__DefaultConnection="$DATABASE_CONNECTION_STRING"
export LIVE_RECORDER_DEFAULT_OUTPUT_ROOT="$RECORD_ROOT"
if [ -f "$ADMIN_PASSWORD_FILE" ]; then
LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD=$(sed -n '1p' "$ADMIN_PASSWORD_FILE")
@@ -183,7 +365,7 @@ start_app() {
fi
rm -f "$APP_PID_FILE"
start_postgres || return 1
select_database_connection || return 1
launch_attempt=1
launch_app_process
pid=$APP_PROCESS_PID
@@ -235,7 +417,7 @@ case "${1:-status}" in
stop) stop_app ;;
restart) stop_app && start_app ;;
status)
if app_pid >/dev/null && postgres_running; then
if app_pid >/dev/null; then
exit 0
fi
exit 3
+13
View File
@@ -1,2 +1,15 @@
#!/bin/bash
set -eu
TOKEN="${wizard_postgres_enrollment_token:-}"
unset wizard_postgres_enrollment_token
if [ -n "$TOKEN" ]; then
[ "${#TOKEN}" -ge 20 ] || { printf '%s\n' "PostgreSQL 应用接入令牌至少需要 20 个字符。" >&2; exit 1; }
case "$TOKEN" in
*$'\n'*|*$'\r'*) printf '%s\n' "PostgreSQL 应用接入令牌不能包含换行符。" >&2; exit 1 ;;
esac
umask 077
printf '%s\n' "$TOKEN" >"${TRIM_PKGVAR}/postgres-enrollment-token.seed"
fi
unset TOKEN
exit 0
+3 -2
View File
@@ -1,7 +1,7 @@
appname=liverecorder
version=1.0.1
version=1.1.0
display_name=Live Recorder
desc=原生直播录制系统,离线内置 PostgreSQL、Node.js 和 Web 管理界面,支持分片录制、弹幕采集与 OpenList 自动上传
desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传
platform=x86
source=thirdparty
maintainer=Live Recorder Contributors
@@ -10,3 +10,4 @@ desktop_uidir=ui
desktop_applaunchname=liverecorder.Application
checkport=true
ctl_stop=true
install_dep_apps=nxsir.postgresql
+20
View File
@@ -43,6 +43,26 @@
"message": "管理员密码不能超过 256 个字符"
}
]
},
{
"type": "password",
"field": "wizard_postgres_enrollment_token",
"label": "PostgreSQL 应用接入令牌",
"helpText": "填写安装 PostgreSQL 共享服务时设置的应用接入令牌。令牌只用于首次签发独立数据库凭据。",
"rules": [
{
"required": true,
"message": "请输入 PostgreSQL 应用接入令牌"
},
{
"min": 20,
"message": "接入令牌至少需要 20 个字符"
},
{
"max": 256,
"message": "接入令牌不能超过 256 个字符"
}
]
}
]
}
+13 -1
View File
@@ -4,7 +4,19 @@
"items": [
{
"type": "tips",
"helpText": "升级会保留 PostgreSQL 数据库、系统设置、上传任务和录制文件。"
"helpText": "本次升级会自动把原内置 PostgreSQL 数据迁移到共享服务。迁移成功前仍可回退到原数据库,旧数据不会自动删除。"
},
{
"type": "password",
"field": "wizard_postgres_enrollment_token",
"label": "PostgreSQL 应用接入令牌",
"helpText": "首次迁移需要填写共享服务的应用接入令牌;已经完成迁移的后续升级可以留空。",
"rules": [
{
"max": 256,
"message": "接入令牌不能超过 256 个字符"
}
]
}
]
}
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:postgres-admin": "vite build --config vite.postgres.config.ts",
"preview": "vite preview"
},
"dependencies": {
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
type Section = "overview" | "clients" | "databases" | "roles" | "sessions" | "sql" | "backups";
type JsonObject = Record<string, unknown>;
interface Client {
appId: string;
displayName: string;
databaseName: string;
roleName: string;
extensions: string[];
status: string;
updatedAt: string;
}
interface Database {
name: string;
owner: string;
sizeBytes: number;
activeConnections: number;
isManaged: boolean;
}
interface Role {
name: string;
canLogin: boolean;
connectionLimit: number;
isManaged: boolean;
}
interface Session {
processId: number;
database: string;
username: string;
state: string;
query?: string;
queryStartedAt?: string;
}
interface Backup {
fileName: string;
database: string;
sizeBytes: number;
createdAt: string;
sha256: string;
}
const authenticated = ref<boolean | null>(null);
const loading = ref(false);
const active = ref<Section>("overview");
const dark = ref(localStorage.getItem("postgres-admin-theme") === "dark");
const loginForm = reactive({ username: "admin", password: "" });
const overview = ref<JsonObject>({});
const clients = ref<Client[]>([]);
const databases = ref<Database[]>([]);
const roles = ref<Role[]>([]);
const sessions = ref<Session[]>([]);
const backups = ref<Backup[]>([]);
const sql = ref("SELECT current_database(), current_user, now();");
const sqlDatabase = ref("postgres");
const queryResult = ref<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; elapsedMilliseconds: number } | null>(null);
const sections: Array<{ key: Section; label: string }> = [
{ key: "overview", label: "运行概览" },
{ key: "clients", label: "接入应用" },
{ key: "databases", label: "数据库" },
{ key: "roles", label: "角色" },
{ key: "sessions", label: "活动会话" },
{ key: "sql", label: "只读 SQL" },
{ key: "backups", label: "备份恢复" }
];
const activeLabel = computed(() => sections.find(item => item.key === active.value)?.label ?? "管理面板");
function applyTheme() {
document.documentElement.dataset.theme = dark.value ? "dark" : "light";
localStorage.setItem("postgres-admin-theme", dark.value ? "dark" : "light");
}
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
credentials: "same-origin",
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
}
});
if (response.status === 401) {
authenticated.value = false;
throw new Error("管理会话已过期,请重新登录。");
}
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `请求失败(${response.status}` })) as { error?: string };
throw new Error(body.error || `请求失败(${response.status}`);
}
return response.status === 204 ? undefined as T : await response.json() as T;
}
async function login() {
loading.value = true;
try {
await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify(loginForm) });
authenticated.value = true;
loginForm.password = "";
await loadAll();
} catch (error) {
ElMessage.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function logout() {
await api("/api/v1/auth/logout", { method: "POST" }).catch(() => undefined);
authenticated.value = false;
}
async function loadAll() {
loading.value = true;
try {
const results = await Promise.all([
api<JsonObject>("/api/v1/overview"),
api<Client[]>("/api/v1/clients"),
api<Database[]>("/api/v1/databases"),
api<Role[]>("/api/v1/roles"),
api<Session[]>("/api/v1/sessions"),
api<Backup[]>("/api/v1/backups")
]);
[overview.value, clients.value, databases.value, roles.value, sessions.value, backups.value] = results;
authenticated.value = true;
if (!databases.value.some(item => item.name === sqlDatabase.value)) {
sqlDatabase.value = databases.value[0]?.name || "postgres";
}
} catch (error) {
if (authenticated.value !== false) ElMessage.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function rotateEnrollmentToken() {
const result = await api<{ token: string }>("/api/v1/enrollment-token/rotate", { method: "POST" });
await ElMessageBox.alert(result.token, "新的接入令牌(仅显示一次)", {
confirmButtonText: "我已保存",
customClass: "secret-dialog"
});
}
async function rotateClient(client: Client) {
await ElMessageBox.confirm(`轮换 ${client.displayName} 的数据库密码后,客户端必须立即更新凭据。`, "轮换密码", { type: "warning" });
const result = await api<{ password: string }>(`/api/v1/clients/${encodeURIComponent(client.appId)}/rotate`, { method: "POST" });
await ElMessageBox.alert(result.password, "新密码(仅显示一次)", { confirmButtonText: "我已保存" });
}
async function revokeClient(client: Client) {
await ElMessageBox.confirm(`吊销后 ${client.displayName} 将无法连接数据库,但不会删除数据。`, "吊销客户端", { type: "warning" });
await api(`/api/v1/clients/${encodeURIComponent(client.appId)}/revoke`, { method: "POST" });
await loadAll();
}
async function createDatabase() {
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) });
await loadAll();
}
async function dropDatabase(database: Database) {
const { value } = await ElMessageBox.prompt(`请输入 ${database.name} 确认删除。`, "删除数据库", { type: "warning" });
await api(`/api/v1/databases/${encodeURIComponent(database.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
await loadAll();
}
async function createRole() {
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建登录角色", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "角色名称格式无效" });
const { value: password } = await ElMessageBox.prompt("密码至少 16 个字符。", "设置角色密码", { inputType: "password", inputValidator: value => value.length >= 16 || "密码至少需要 16 个字符" });
await api("/api/v1/roles", { method: "POST", body: JSON.stringify({ name, password }) });
await loadAll();
}
async function dropRole(role: Role) {
const { value } = await ElMessageBox.prompt(`请输入 ${role.name} 确认删除。`, "删除角色", { type: "warning" });
await api(`/api/v1/roles/${encodeURIComponent(role.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
await loadAll();
}
async function terminateSession(session: Session) {
await ElMessageBox.confirm(`确认终止 PID ${session.processId} 的连接?`, "终止会话", { type: "warning" });
await api(`/api/v1/sessions/${session.processId}/terminate`, { method: "POST" });
await loadAll();
}
async function runQuery() {
loading.value = true;
try {
queryResult.value = await api("/api/v1/query", { method: "POST", body: JSON.stringify({ database: sqlDatabase.value, sql: sql.value }) });
} catch (error) {
ElMessage.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function createBackup(database: string) {
await api("/api/v1/backups", { method: "POST", body: JSON.stringify({ database }) });
ElMessage.success("备份已完成");
await loadAll();
}
async function restoreBackup(backup: Backup) {
const { value: targetDatabase } = await ElMessageBox.prompt("建议恢复到新的数据库名称。", "恢复备份", { inputValue: `${backup.database}_restored`, inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
const exists = databases.value.some(item => item.name === targetDatabase);
const { value: confirmation } = await ElMessageBox.prompt(`请输入 ${targetDatabase} 确认恢复${exists ? "并覆盖现有数据库" : ""}`, "确认恢复", { type: "warning" });
await api("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ backupFileName: backup.fileName, targetDatabase, overwrite: exists, confirmation }) });
ElMessage.success("备份已恢复");
await loadAll();
}
function formatBytes(value: unknown) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
onMounted(async () => {
applyTheme();
await loadAll();
});
</script>
<template>
<main v-if="authenticated === false" class="login-screen">
<section class="login-card">
<div class="brand-mark">PG</div>
<h1>PostgreSQL 服务</h1>
<p>使用安装时设置的独立管理密码登录</p>
<el-form label-position="top" @submit.prevent="login">
<el-form-item label="用户名"><el-input v-model="loginForm.username" autocomplete="username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="loginForm.password" type="password" show-password autocomplete="current-password" @keyup.enter="login" /></el-form-item>
<el-button type="primary" :loading="loading" class="full" @click="login">登录</el-button>
</el-form>
</section>
</main>
<div v-else class="shell" v-loading="loading && authenticated === null">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">PG</span><div><strong>PostgreSQL</strong><small>共享数据库服务</small></div></div>
<nav>
<button v-for="item in sections" :key="item.key" :class="{ active: active === item.key }" @click="active = item.key">{{ item.label }}</button>
</nav>
<div class="sidebar-foot"><span class="health-dot" />仅回环数据库端口</div>
</aside>
<section class="content">
<header class="topbar">
<div><h1>{{ activeLabel }}</h1><small>127.0.0.1:{{ overview.port || 15432 }}</small></div>
<div class="top-actions">
<el-button size="small" @click="dark = !dark; applyTheme()">{{ dark ? "浅色" : "深色" }}</el-button>
<el-button size="small" @click="loadAll">刷新</el-button>
<el-button size="small" @click="logout">退出</el-button>
</div>
</header>
<div class="mobile-section"><el-select v-model="active"><el-option v-for="item in sections" :key="item.key" :label="item.label" :value="item.key" /></el-select></div>
<section v-if="active === 'overview'" class="page-stack">
<div class="metric-grid">
<article><span>PostgreSQL 版本</span><strong>{{ overview.version || "-" }}</strong></article>
<article><span>活动连接</span><strong>{{ overview.connections || 0 }} / {{ overview.maxConnections || 0 }}</strong></article>
<article><span>数据库</span><strong>{{ overview.databases || 0 }}</strong></article>
<article><span>数据总量</span><strong>{{ formatBytes(overview.sizeBytes) }}</strong></article>
</div>
<section class="panel"><div class="panel-head"><div><h2>安全接入</h2><p>接入令牌只用于本机客户端登记轮换后旧令牌立即失效</p></div><el-button type="warning" plain @click="rotateEnrollmentToken">轮换接入令牌</el-button></div></section>
</section>
<section v-else-if="active === 'clients'" class="panel">
<div class="panel-head"><div><h2>接入应用</h2><p>每个应用使用独立数据库和 SCRAM 角色</p></div></div>
<el-table :data="clients" table-layout="auto">
<el-table-column prop="displayName" label="应用" min-width="160"><template #default="{ row }"><strong>{{ row.displayName }}</strong><small class="block">{{ row.appId }}</small></template></el-table-column>
<el-table-column prop="databaseName" label="数据库" min-width="190" />
<el-table-column prop="roleName" label="角色" min-width="190" />
<el-table-column label="扩展"><template #default="{ row }">{{ row.extensions.join(", ") || "-" }}</template></el-table-column>
<el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "正常" : "已吊销" }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="rotateClient(row)">轮换密码</el-button><el-button size="small" type="danger" plain @click="revokeClient(row)">吊销</el-button></template></el-table-column>
</el-table>
</section>
<section v-else-if="active === 'databases'" class="panel">
<div class="panel-head"><div><h2>数据库</h2><p>托管数据库必须先吊销客户端不能直接删除</p></div><el-button type="primary" @click="createDatabase">新建数据库</el-button></div>
<el-table :data="databases"><el-table-column prop="name" label="名称" min-width="180" /><el-table-column prop="owner" label="所有者" min-width="160" /><el-table-column label="容量"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column prop="activeConnections" label="连接" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" @click="createBackup(row.name)">备份</el-button><el-button size="small" type="danger" plain :disabled="row.isManaged || ['postgres','template0','template1','postgres_service'].includes(row.name)" @click="dropDatabase(row)">删除</el-button></template></el-table-column></el-table>
</section>
<section v-else-if="active === 'roles'" class="panel">
<div class="panel-head"><div><h2>登录角色</h2><p>新角色默认无超级用户建库和建角色权限</p></div><el-button type="primary" @click="createRole">新建角色</el-button></div>
<el-table :data="roles"><el-table-column prop="name" label="名称" min-width="200" /><el-table-column label="可登录"><template #default="{ row }">{{ row.canLogin ? "是" : "否" }}</template></el-table-column><el-table-column prop="connectionLimit" label="连接限制" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain :disabled="row.isManaged" @click="dropRole(row)">删除</el-button></template></el-table-column></el-table>
</section>
<section v-else-if="active === 'sessions'" class="panel">
<div class="panel-head"><div><h2>活动会话</h2><p>终止连接可能中断应用事务请谨慎操作</p></div></div>
<el-table :data="sessions" table-layout="auto"><el-table-column prop="processId" label="PID" width="90" /><el-table-column prop="database" label="数据库" min-width="140" /><el-table-column prop="username" label="用户" min-width="150" /><el-table-column prop="state" label="状态" width="100" /><el-table-column prop="query" label="当前语句" min-width="300" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain @click="terminateSession(row)">终止</el-button></template></el-table-column></el-table>
</section>
<section v-else-if="active === 'sql'" class="page-stack">
<section class="panel"><div class="panel-head"><div><h2>只读 SQL 工作台</h2><p>强制只读事务30 秒超时单语句和最多 1000 行结果</p></div><el-select v-model="sqlDatabase" class="database-select"><el-option v-for="item in databases" :key="item.name" :label="item.name" :value="item.name" /></el-select></div><el-input v-model="sql" type="textarea" :rows="8" class="sql-editor" spellcheck="false" /><div class="query-actions"><el-button type="primary" :loading="loading" @click="runQuery">执行查询</el-button></div></section>
<section v-if="queryResult" class="panel"><div class="panel-head"><div><h2>查询结果</h2><p>{{ queryResult.rowCount }} 行 · {{ queryResult.elapsedMilliseconds }} ms<span v-if="queryResult.truncated"> · 已截断</span></p></div></div><div class="table-scroll"><el-table :data="queryResult.rows"><el-table-column v-for="(column, index) in queryResult.columns" :key="column + index" :label="column" min-width="140"><template #default="{ row }">{{ row[index] }}</template></el-table-column></el-table></div></section>
</section>
<section v-else class="panel">
<div class="panel-head"><div><h2>手动备份与恢复</h2><p>默认不执行定时备份恢复到已有数据库需要二次确认并会中断连接</p></div></div>
<el-table :data="backups"><el-table-column prop="database" label="来源数据库" min-width="160" /><el-table-column prop="fileName" label="文件" min-width="260" /><el-table-column label="大小"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column label="创建时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="sha256" label="SHA-256" min-width="220" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" @click="restoreBackup(row)">恢复</el-button></template></el-table-column></el-table>
</section>
</section>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<!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>PostgreSQL 服务</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
import "./style.css";
createApp(App).use(ElementPlus).mount("#app");
+26
View File
@@ -0,0 +1,26 @@
:root { color-scheme: light; font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --bg:#eef2f6; --surface:#fff; --muted:#f4f6f9; --border:#dde3eb; --text:#172033; --secondary:#68758a; --accent:#2563eb; --side:#f7f8fa; }
:root[data-theme="dark"] { color-scheme: dark; --bg:#0b1220; --surface:#121c2d; --muted:#18253a; --border:#29384e; --text:#edf2fb; --secondary:#91a2bb; --accent:#60a5fa; --side:#0e1727; }
* { box-sizing:border-box; }
body { margin:0; min-width:320px; min-height:100vh; color:var(--text); background:var(--bg); }
button,input,textarea { font:inherit; }
.shell { min-height:100vh; }
.sidebar { position:fixed; inset:0 auto 0 0; width:220px; display:flex; flex-direction:column; padding:14px 12px; background:var(--side); border-right:1px solid var(--border); }
.brand { display:flex; align-items:center; gap:10px; height:52px; padding:0 8px 14px; border-bottom:1px solid var(--border); }
.brand-mark { display:grid; place-items:center; width:36px; height:36px; flex:0 0 auto; border-radius:9px; color:#fff; background:#2563eb; font-weight:800; }
.brand strong,.brand small { display:block; }.brand small { margin-top:2px; color:var(--secondary); font-size:11px; }
nav { display:grid; gap:3px; padding-top:12px; }
nav button { height:38px; padding:0 12px; border:0; border-radius:7px; color:var(--secondary); background:transparent; text-align:left; font-weight:650; cursor:pointer; }
nav button:hover,nav button.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); }
.sidebar-foot { margin-top:auto; padding:12px 8px; color:var(--secondary); font-size:11px; border-top:1px solid var(--border); }.health-dot { display:inline-block; width:7px; height:7px; margin-right:7px; border-radius:50%; background:#16a34a; }
.content { min-height:100vh; margin-left:220px; padding:0 22px 24px; }
.topbar { position:sticky; top:0; z-index:5; display:flex; align-items:center; justify-content:space-between; min-height:58px; margin:0 -22px 18px; padding:8px 22px; background:color-mix(in srgb,var(--surface) 88%,transparent); border-bottom:1px solid var(--border); backdrop-filter:blur(10px); }
.topbar h1 { margin:0; font-size:20px; }.topbar small { color:var(--secondary); }.top-actions { display:flex; gap:7px; }
.page-stack { display:grid; gap:14px; }.panel { padding:16px; border:1px solid var(--border); border-radius:10px; background:var(--surface); overflow:hidden; }
.panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:14px; }.panel h2 { margin:0; font-size:16px; }.panel p { margin:5px 0 0; color:var(--secondary); font-size:12px; }
.metric-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.metric-grid article { display:grid; gap:8px; padding:14px; border:1px solid var(--border); border-radius:9px; background:var(--surface); }.metric-grid span { color:var(--secondary); font-size:12px; }.metric-grid strong { font-size:22px; }
.block { display:block; margin-top:3px; color:var(--secondary); }.full { width:100%; }.database-select { width:220px; }.query-actions { display:flex; justify-content:flex-end; margin-top:12px; }.sql-editor :is(textarea) { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.65; }.table-scroll { overflow:auto; }
.login-screen { min-height:100vh; display:grid; place-items:center; padding:18px; }.login-card { width:min(100%,390px); padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); box-shadow:0 14px 40px #0002; }.login-card .brand-mark { margin-bottom:16px; }.login-card h1 { margin:0; font-size:24px; }.login-card p { margin:8px 0 20px; color:var(--secondary); font-size:13px; }
.mobile-section { display:none; margin-bottom:12px; }
.el-table { --el-table-bg-color:transparent; --el-table-tr-bg-color:transparent; --el-table-header-bg-color:var(--muted); --el-table-row-hover-bg-color:var(--muted); --el-table-border-color:var(--border); color:var(--text); }.el-message-box { max-width:calc(100vw - 24px); }
@media(max-width:960px){ .metric-grid{grid-template-columns:repeat(2,1fr)} }
@media(max-width:720px){ .sidebar{display:none}.content{margin-left:0;padding:0 12px 18px}.topbar{margin:0 -12px 12px;padding:8px 12px}.topbar h1{font-size:17px}.top-actions .el-button:first-child{display:none}.mobile-section{display:block}.panel{padding:12px}.panel-head{flex-direction:column}.metric-grid{grid-template-columns:repeat(2,1fr);gap:8px}.metric-grid strong{font-size:18px}.database-select{width:100%} }
+3 -13
View File
@@ -28,7 +28,7 @@ const route = useRoute();
const authStore = useAuthStore();
const { isMobile } = useViewport();
const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendStatus();
const { sidebarCollapsed, toggleSidebarCollapsed } = useUiPreferences();
const { resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = useUiPreferences();
const mobileNavVisible = ref(false);
@@ -78,14 +78,6 @@ const navigationGroups = [
const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
const isDark = ref(false);
try { isDark.value = localStorage.getItem("lr-theme") === "dark"; } catch { /* noop */ }
function toggleTheme() {
isDark.value = !isDark.value;
document.documentElement.dataset.theme = isDark.value ? "dark" : "light";
try { localStorage.setItem("lr-theme", isDark.value ? "dark" : "light"); } catch { /* noop */ }
}
function isNavItemActive(index: string) {
return route.path === index || route.path.startsWith(`${index}/`);
}
@@ -120,8 +112,6 @@ function closeMobileNav() { mobileNavVisible.value = false; }
watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
// sync dark class on mount
watch(isDark, (v) => { document.documentElement.dataset.theme = v ? "dark" : "light"; }, { immediate: true });
</script>
<template>
@@ -203,8 +193,8 @@ watch(isDark, (v) => { document.documentElement.dataset.theme = v ? "dark" : "li
<div class="app-topbar__spacer" />
<button class="app-topbar__icon-btn" title="切换主题" @click="toggleTheme">
<el-icon :size="17"><component :is="isDark ? Sunny : Moon" /></el-icon>
<button class="app-topbar__icon-btn" title="切换主题" @click="cycleThemeMode">
<el-icon :size="17"><component :is="resolvedTheme === 'dark' ? Sunny : Moon" /></el-icon>
</button>
<button class="app-topbar__icon-btn" @click="router.push({ name: 'logs' })">
<el-icon :size="17"><Bell /></el-icon>
+3 -2
View File
@@ -11,7 +11,7 @@ const STORAGE_KEYS = {
const state = reactive({
themeMode: "system" as ThemeMode,
density: "comfortable" as DensityMode,
density: "compact" as DensityMode,
sidebarCollapsed: false,
systemTheme: "light" as "light" | "dark",
initialized: false
@@ -25,7 +25,8 @@ function readStoredThemeMode(): ThemeMode {
}
function readStoredDensity(): DensityMode {
return window.localStorage.getItem(STORAGE_KEYS.density) === "compact" ? "compact" : "comfortable";
const stored = window.localStorage.getItem(STORAGE_KEYS.density);
return stored === "comfortable" ? "comfortable" : "compact";
}
function readStoredSidebarCollapsed() {
+1 -1
View File
@@ -87,7 +87,7 @@ const router = createRouter({
component: RecoveryView
},
{
path: "settings",
path: "settings/:section?",
name: "settings",
component: SettingsView
}
+18 -25
View File
@@ -1,6 +1,6 @@
/* =============================================================
Live Recorder · Design System
商业级 SaaS · 浅色侧栏 + 层次投影 + 移动适配
紧凑 NAS 管理台 · 信息优先 + 低动画 + 移动适配
Base: Element Plus 2.x overrides
============================================================= */
@@ -71,8 +71,8 @@
--sidebar-w-collapsed: 72px;
--topbar-h: 62px;
--page-max: 1320px;
--page-gap: 22px;
--content-padding: 28px;
--page-gap: 16px;
--content-padding: 20px;
--content-padding-mobile: 14px;
--control-height: 40px;
--control-height-sm: 34px;
@@ -191,9 +191,9 @@ button { font-family: inherit; cursor: pointer; }
/* ============================ Page layout ============================ */
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; animation: fadeUp .3s ease both; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-header > div:first-child { flex: 1; min-width: 0; }
.page-title { margin: 0; font-size: 28px; font-weight: 800; letter-spacing: -.025em; line-height: 1.2; color: var(--text-primary); }
.page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); }
.page-subtitle { max-width: 76ch; margin: 8px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.6; }
.page-kicker { margin-bottom: 7px; color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.page-toolbar, .header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
@@ -202,28 +202,22 @@ button { font-family: inherit; cursor: pointer; }
.surface-card {
--el-card-bg-color: transparent;
border-radius: var(--radius-md); border: 1px solid var(--border-subtle);
background: var(--surface); box-shadow: var(--shadow-sm); transition: box-shadow .18s ease; animation: fadeUp .3s ease both;
background: var(--surface); box-shadow: var(--shadow-xs);
}
.surface-card:hover { box-shadow: var(--shadow-md); }
.surface-card .el-card__body { padding: 24px; }
.surface-card:hover { box-shadow: var(--shadow-sm); }
.surface-card .el-card__body { padding: 18px; }
/* stat grid / KPI cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.stat-card {
display: grid; gap: 11px; min-height: auto; padding: 20px; border-radius: var(--radius-md);
border: 1px solid var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-sm);
transition: box-shadow .18s, transform .18s; position: relative; overflow: hidden;
animation: fadeUp .35s ease both;
display: grid; gap: 6px; min-height: auto; padding: 12px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle); background: var(--surface); box-shadow: none;
position: relative; overflow: hidden;
}
.stat-card:nth-child(2) { animation-delay: .05s; }
.stat-card:nth-child(3) { animation-delay: .10s; }
.stat-card:nth-child(4) { animation-delay: .15s; }
.stat-card:nth-child(5) { animation-delay: .20s; }
.stat-card:nth-child(6) { animation-delay: .25s; }
.stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.stat-card:hover { border-color: var(--border-base); }
.stat-card__label { color: var(--text-muted); font-size: 12.5px; font-weight: 600; }
.stat-card__value { color: var(--text-primary); font-size: 31px; font-weight: 800; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; }
.stat-card__hint { color: var(--text-muted); font-size: 12.5px; line-height: 1.65; font-weight: 500; }
.stat-card__value { color: var(--text-primary); font-size: 24px; font-weight: 750; letter-spacing: -.03em; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat-card__hint { color: var(--text-muted); font-size: 11.5px; line-height: 1.45; font-weight: 500; }
/* section */
.section-title { margin: 0 0 6px; color: var(--text-primary); font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
@@ -280,7 +274,7 @@ button { font-family: inherit; cursor: pointer; }
/* buttons */
.el-button { min-height: var(--control-height); padding: 0 15px; border-radius: var(--radius-sm); font-weight: 700; letter-spacing: 0; transition: all .15s ease; }
.el-button:not(.is-disabled):hover { transform: translateY(-1px); }
.el-button:not(.is-disabled):hover { transform: none; }
.el-button.el-button--default:not(.is-text):not(.is-link) { border-color: var(--border-base); background: var(--surface); color: var(--text-secondary); box-shadow: none; }
.el-button.el-button--default:not(.is-text):not(.is-link):hover { border-color: var(--text-soft); color: var(--text-primary); box-shadow: var(--shadow-xs); }
@@ -370,8 +364,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
.skel--title { height: 18px; width: 50%; }
/* ============================ Motion ============================ */
@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; } }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition-duration: 0.01ms !important; } }
/* ============================ Responsive ============================ */
@media (max-width: 1280px) { .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .highlight-grid { grid-template-columns: 1fr; } }
@@ -379,7 +372,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
@media (max-width: 768px) {
.page-header, .section-header, .toolbar-row { flex-direction: column; }
.page-title { font-size: 22px; }
.page-subtitle { margin-top: 6px; font-size: 12.5px; line-height: 1.6; }
.page-subtitle { display: none; }
.surface-card .el-card__body { padding: 14px; }
.stats-grid, .data-card__grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-card { padding: 14px; gap: 8px; }
+8 -2
View File
@@ -16,7 +16,7 @@ const { themeMode } = useUiPreferences();
const form = reactive({
username: "admin",
password: "Admin@123"
password: ""
});
const currentThemeIcon = computed(() => {
@@ -81,7 +81,12 @@ async function handleLogin() {
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
<el-form-item label="用户名">
<el-input v-model="form.username" :prefix-icon="User" />
<el-input
v-model="form.username"
:prefix-icon="User"
autocomplete="username"
spellcheck="false"
/>
</el-form-item>
<el-form-item label="密码">
@@ -89,6 +94,7 @@ async function handleLogin() {
v-model="form.password"
:prefix-icon="Lock"
type="password"
autocomplete="current-password"
show-password
@keyup.enter="handleLogin"
/>
-17
View File
@@ -924,23 +924,6 @@ onBeforeUnmount(() => {
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
</div>
<div class="record-feature-strip">
<article class="record-feature-card">
<div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">开播自动录制</div>
<p class="record-feature-card__description">继续复用后端轮询自动开录和活动会话保护逻辑不新增任何前端假状态</p>
</article>
<article class="record-feature-card">
<div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">分片后处理</div>
<p class="record-feature-card__description">保留现有转码分片完成事件和实时进度展示聚焦运维可读性</p>
</article>
<article class="record-feature-card">
<div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">上传归档</div>
<p class="record-feature-card__description">继续调用真实上传接口空数据时显示空状态而不是伪造归档数量</p>
</article>
</div>
</div>
<el-card class="surface-card sessions-card" shadow="never">
+317 -169
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type {
CleanupOperation,
@@ -26,7 +26,7 @@ import {
import { useAuthStore } from "@/stores/auth";
import { useViewport } from "@/composables/useViewport";
import { useUiPreferences } from "@/composables/useUiPreferences";
import { useRoute } from "vue-router";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
type ScriptEventType = "live_started" | "live_ended" | "segment_completed";
@@ -41,6 +41,7 @@ type SettingsFormModel = SystemSettings & {
const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
const { isMobile } = useViewport();
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
@@ -74,13 +75,20 @@ const scriptTestResults = reactive<Record<ScriptEventType, EventScriptTestResult
const webhookTestResult = ref<WebhookTestResult | null>(null);
const retentionCleanupOperation = ref<CleanupOperation | null>(null);
const loadError = ref("");
const activeSettingTab = ref("recording");
const settingSections = ["recording", "upload", "automation", "notifications", "platform", "account"] as const;
type SettingSection = typeof settingSections[number];
function normalizeSettingSection(value: unknown): SettingSection {
const section = String(value || "recording") as SettingSection;
return settingSections.includes(section) ? section : "recording";
}
const activeSettingTab = ref<SettingSection>(normalizeSettingSection(route.params.section));
const profileDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
const profileUsername = computed(() => authStore.user?.username || "--");
const profileUserId = computed(() => authStore.user?.userId || "--");
const profileExpiresAt = computed(() => authStore.user?.expiresAt || "--");
const profileInitial = computed(() => profileDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
const qualitySupportHint = "Different platforms expose different quality ladders. If a target quality is unavailable, the recorder automatically falls back to the closest stream that platform offers.";
const qualitySupportHint = "不同平台提供的画质档位并不完全一致;目标画质不可用时,录制器会自动选择最接近的可用流。";
const qualityOptions = qualityOptionList;
const platformRequestPlatforms = platformOptionList;
let retentionCleanupPollTimer: number | null = null;
@@ -201,34 +209,34 @@ const form = reactive<SettingsFormModel>({
emailToAddresses: "",
notifyOnLiveStarted: true,
notifyOnException: true,
emailLiveStartedSubjectTemplate: "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})",
emailLiveStartedSubjectTemplate: "[{{appName}}] 直播已开始:{{anchor}} {{title}}{{roomId}}",
emailLiveStartedBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<h2 style="margin: 0 0 16px; color: #3e5f7c;">直播已开始</h2>
<p>监控的直播间现已开播。</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
<li><strong>平台:</strong> {{platform}}</li>
<li><strong>房间号:</strong> {{roomId}}</li>
<li><strong>标题:</strong> {{title}}</li>
<li><strong>主播:</strong> {{anchor}}</li>
<li><strong>检测时间(北京时间):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
<p><strong>直播地址:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
<div style="margin-top: 16px;">
<strong>Event Script Output:</strong>
<strong>事件脚本输出:</strong>
</div>
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
</div>`,
emailExceptionSubjectTemplate: "[{{appName}}] Exception: {{source}}",
emailExceptionSubjectTemplate: "[{{appName}}] 录制异常:{{source}}",
emailExceptionBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<h2 style="margin: 0 0 16px; color: #8b5e3c;">检测到录制异常</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
<li><strong>来源:</strong> {{source}}</li>
<li><strong>直播间 ID</strong> {{liveRoomId}}</li>
<li><strong>平台房间号:</strong> {{roomId}}</li>
<li><strong>录制任务 ID</strong> {{recordTaskId}}</li>
<li><strong>任务状态:</strong> {{taskStatus}}</li>
<li><strong>发生时间(北京时间):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>`,
@@ -244,6 +252,33 @@ const form = reactive<SettingsFormModel>({
douyinCookie: ""
});
const savedSettingsSnapshot = ref<SettingsFormModel | null>(null);
function cloneSettingsForm(): SettingsFormModel {
return JSON.parse(JSON.stringify(form)) as SettingsFormModel;
}
function markSettingsSaved() {
savedSettingsSnapshot.value = cloneSettingsForm();
}
const isDirty = computed(() => {
if (!savedSettingsSnapshot.value || loading.value) {
return false;
}
return JSON.stringify(form) !== JSON.stringify(savedSettingsSnapshot.value);
});
function discardSettingsChanges() {
if (!savedSettingsSnapshot.value) {
return;
}
Object.assign(form, JSON.parse(JSON.stringify(savedSettingsSnapshot.value)) as SettingsFormModel);
ElMessage.info("已放弃未保存的修改");
}
function normalizePlatformRequestSettings(
value?: Record<string, PlatformRequestSettings> | null
): Record<string, PlatformRequestSettings> {
@@ -364,36 +399,36 @@ const webhookTemplateTokens = [
];
const eventScriptEnvironmentExamples = [
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "All events" },
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "All events" },
{ name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "All events" },
{ name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "All events" },
{ name: "LIVE_RECORDER_TITLE", example: "Casual stream", scope: "All events" },
{ name: "LIVE_RECORDER_ANCHOR", example: "Streamer Name", scope: "All events" },
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "All events" },
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "All events" },
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "所有事件" },
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "所有事件" },
{ name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "所有事件" },
{ name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "所有事件" },
{ name: "LIVE_RECORDER_TITLE", example: "日常直播", scope: "所有事件" },
{ name: "LIVE_RECORDER_ANCHOR", example: "主播名称", scope: "所有事件" },
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "所有事件" },
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "所有事件" },
{
name: "LIVE_RECORDER_SCRIPT_LOG_PATH",
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
scope: "All events"
scope: "所有事件"
},
{ name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "仅分片完成" },
{
name: "LIVE_RECORDER_SEGMENT_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.mp4",
scope: "Segment completed only"
scope: "仅分片完成"
},
{
name: "LIVE_RECORDER_DANMAKU_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.xml",
scope: "Segment completed only"
scope: "仅分片完成"
},
{ name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "Segment completed only" }
{ name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "仅分片完成" }
];
const eventScriptModeOptions = [
@@ -402,25 +437,25 @@ const eventScriptModeOptions = [
];
const uploadTargetOptions = [
{ label: "Do not upload", value: 0 },
{ label: "不上传", value: 0 },
{ label: "WebDAV", value: 1 },
{ label: "S3", value: 2 },
{ label: "OpenList", value: 3 }
];
const retentionVideoFileOptions = [
{ label: "Any file state", value: "any" as CleanupVideoFileCondition },
{ label: "All video files missing", value: "allMissing" as CleanupVideoFileCondition },
{ label: "All video files present", value: "allPresent" as CleanupVideoFileCondition }
{ label: "任意文件状态", value: "any" as CleanupVideoFileCondition },
{ label: "视频文件全部缺失", value: "allMissing" as CleanupVideoFileCondition },
{ label: "视频文件全部存在", value: "allPresent" as CleanupVideoFileCondition }
];
const retentionTaskStatusOptions = Object.entries(taskStatusLabelMap)
.map(([value, label]) => ({ value: Number(value), label }))
.filter((option) => option.value !== 1 && option.value !== 2 && option.value !== 3);
const retentionCleanupStatusLabelMap: Record<CleanupOperation["status"], string> = {
queued: "Queued",
running: "Running",
completed: "Completed",
failed: "Failed"
queued: "等待执行",
running: "执行中",
completed: "已完成",
failed: "失败"
};
const retentionCleanupStatusLabel = computed(() =>
retentionCleanupOperation.value ? retentionCleanupStatusLabelMap[retentionCleanupOperation.value.status] : ""
@@ -453,7 +488,7 @@ const retentionCleanupProgressText = computed(() => {
}
if (retentionCleanupOperation.value.totalSessionCount === 0) {
return retentionCleanupOperation.value.status === "queued" ? "Scanning candidate sessions" : "0 / 0";
return retentionCleanupOperation.value.status === "queued" ? "正在扫描候选录制会话" : "0 / 0";
}
return `${retentionCleanupOperation.value.processedSessionCount} / ${retentionCleanupOperation.value.totalSessionCount}`;
@@ -464,13 +499,13 @@ const retentionCleanupSummary = computed(() => {
}
return [
`sessions ${retentionCleanupOperation.value.deletedSessionCount}`,
`tasks ${retentionCleanupOperation.value.deletedTaskCount}`,
`results ${retentionCleanupOperation.value.deletedResultCount}`,
`logs ${retentionCleanupOperation.value.deletedLogCount}`,
`files ${retentionCleanupOperation.value.deletedFileCount}`,
`danmaku ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
].join(" ");
`会话 ${retentionCleanupOperation.value.deletedSessionCount}`,
`任务 ${retentionCleanupOperation.value.deletedTaskCount}`,
`结果 ${retentionCleanupOperation.value.deletedResultCount}`,
`日志 ${retentionCleanupOperation.value.deletedLogCount}`,
`视频 ${retentionCleanupOperation.value.deletedFileCount}`,
`弹幕 ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
].join(" · ");
});
const retentionCleanupWarningsPreview = computed(() => retentionCleanupOperation.value?.warnings.slice(0, 6) ?? []);
@@ -610,8 +645,9 @@ async function loadSettings() {
Object.assign(form, data);
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
syncLegacyPlatformAliasesFromMap();
markSettingsSaved();
} catch (error) {
loadError.value = getApiErrorMessage(error, "Failed to load system settings. Please try again later.");
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -624,17 +660,17 @@ const pwdForm = reactive({
});
const pwdRules = {
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
currentPassword: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
newPassword: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 6, message: "Password must be at least 6 characters", trigger: "blur" }
{ min: 6, message: "密码至少需要 6 个字符", trigger: "blur" }
],
confirmPassword: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (value !== pwdForm.newPassword) {
callback(new Error("Passwords do not match"));
callback(new Error("两次输入的密码不一致"));
} else {
callback();
}
@@ -653,13 +689,13 @@ async function changePassword() {
changingPassword.value = true;
try {
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
ElMessage.success("Password updated.");
ElMessage.success("密码已更新");
pwdForm.currentPassword = "";
pwdForm.newPassword = "";
pwdForm.confirmPassword = "";
pwdFormRef.value?.resetFields();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "Failed to change password. Please try again later."));
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
} finally {
changingPassword.value = false;
}
@@ -678,7 +714,8 @@ async function saveSettings() {
Object.assign(form, data);
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
syncLegacyPlatformAliasesFromMap();
ElMessage.success("Settings saved.");
markSettingsSaved();
ElMessage.success("设置已保存");
} finally {
saving.value = false;
}
@@ -705,7 +742,7 @@ async function sendTestEmail() {
ElMessage.success("测试邮件已发送,请检查收件箱");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "Failed to send test email."));
ElMessage.error(getApiErrorMessage(error, "测试邮件发送失败。"));
} finally {
testingEmail.value = false;
}
@@ -760,7 +797,7 @@ async function runRetentionCleanup() {
try {
const { data } = await apiClient.post<CleanupOperation>("/settings/retention/run-now");
await startRetentionCleanupTracking(data);
ElMessage.success("Retention cleanup background task created.");
ElMessage.success("保留清理任务已创建");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
} finally {
@@ -823,7 +860,7 @@ async function refreshRetentionCleanupOperation(operationId: string, options?: {
stopRetentionCleanupPolling();
if (!options?.silent) {
ElMessage.error(getApiErrorMessage(error, "Failed to load retention cleanup task status."));
ElMessage.error(getApiErrorMessage(error, "保留清理任务状态加载失败。"));
}
}
}
@@ -869,7 +906,7 @@ async function exportSettingsBackup() {
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
ElMessage.success("Settings backup exported.");
ElMessage.success("配置备份已导出");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
} finally {
@@ -896,7 +933,7 @@ async function importSettingsBackup(event: Event) {
const payload = JSON.parse(await file.text());
const { data } = await apiClient.post<SystemSettings>("/settings/import", payload);
Object.assign(form, data);
ElMessage.success("Settings imported from backup.");
ElMessage.success("配置备份已导入,请检查后保存");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
} finally {
@@ -976,7 +1013,7 @@ async function syncSettingsHash(hash = route.hash) {
}
if (hash === "#security") {
activeSettingTab.value = "security";
activeSettingTab.value = "account";
}
await nextTick();
@@ -984,6 +1021,9 @@ async function syncSettingsHash(hash = route.hash) {
}
onMounted(async () => {
if (String(route.params.section || "") !== activeSettingTab.value) {
await router.replace({ name: "settings", params: { section: activeSettingTab.value }, hash: route.hash });
}
await loadSettings();
await restoreRetentionCleanupTracking();
await syncSettingsHash();
@@ -993,12 +1033,45 @@ onBeforeUnmount(() => {
stopRetentionCleanupPolling();
});
watch(
() => route.params.section,
(section) => {
activeSettingTab.value = normalizeSettingSection(section);
}
);
watch(
activeSettingTab,
(section) => {
if (String(route.params.section || "") !== section) {
void router.replace({ name: "settings", params: { section }, hash: route.hash });
}
}
);
watch(
() => route.hash,
(hash) => {
void syncSettingsHash(hash);
}
);
onBeforeRouteLeave(async () => {
if (!isDirty.value) {
return true;
}
try {
await ElMessageBox.confirm(
"当前设置尚未保存,离开后修改会丢失。",
"确认离开设置页",
{ confirmButtonText: "离开", cancelButtonText: "继续编辑", type: "warning" }
);
return true;
} catch {
return false;
}
});
</script>
<template>
@@ -1015,7 +1088,6 @@ watch(
<div class="page-toolbar">
<el-button :loading="exportingSettings" @click="exportSettingsBackup">导出配置</el-button>
<el-button :loading="importingSettings" @click="triggerImportSettings">导入配置</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
@@ -1029,71 +1101,42 @@ watch(
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="settings-overview-grid">
<el-card id="profile" class="surface-card settings-overview-card" shadow="never">
<div class="settings-overview-card__header">
<div>
<div class="settings-overview-card__eyebrow">个人资料</div>
<h3 class="section-title">当前登录账户</h3>
</div>
</div>
<div class="settings-profile">
<section id="preferences" class="settings-quickbar surface-card" aria-label="账户与显示偏好">
<div class="settings-profile settings-profile--compact">
<div class="settings-profile__avatar">{{ profileInitial }}</div>
<div>
<div class="settings-profile__name">{{ profileDisplayName }}</div>
<div class="settings-profile__meta">{{ profileUsername }}</div>
<div class="settings-profile__meta">{{ profileUsername }} · {{ profileUserId }}</div>
</div>
</div>
<div class="settings-overview-list">
<div><span>用户名</span><strong>{{ profileUsername }}</strong></div>
<div><span>用户 ID</span><strong>{{ profileUserId }}</strong></div>
<div><span>邮箱</span><strong>--</strong></div>
<div><span>角色</span><strong>--</strong></div>
<div><span>当前空间</span><strong>--</strong></div>
<div><span>在线状态</span><strong>在线</strong></div>
<div><span>凭证到期</span><strong>{{ profileExpiresAt }}</strong></div>
</div>
</el-card>
<el-card id="preferences" class="surface-card settings-overview-card" shadow="never">
<div class="settings-overview-card__header">
<div>
<div class="settings-overview-card__eyebrow">偏好设置</div>
<h3 class="section-title">控制台显示偏好</h3>
</div>
</div>
<div class="settings-preferences">
<div class="settings-preferences__row">
<span>主题模式</span>
<el-select v-model="themeMode">
<label class="settings-quickbar__control">
<span>主题</span>
<el-select v-model="themeMode" size="small">
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
</div>
<div class="settings-preferences__row">
<span>显示密度</span>
<el-select v-model="density">
<el-option label="舒适密度" value="comfortable" />
<el-option label="紧凑密度" value="compact" />
</label>
<label class="settings-quickbar__control">
<span>密度</span>
<el-select v-model="density" size="small">
<el-option label="舒适" value="comfortable" />
<el-option label="紧凑" value="compact" />
</el-select>
</div>
<div class="settings-preferences__row settings-preferences__row--switch">
<div>
<strong>侧栏折叠</strong>
<p>继续复用当前前端偏好存储逻辑</p>
</div>
</label>
<label class="settings-quickbar__switch">
<span>折叠侧栏</span>
<el-switch v-model="sidebarCollapsed" />
</div>
</div>
</el-card>
</div>
</label>
</section>
<div class="settings-grid" v-loading="loading">
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
<el-tabs
v-model="activeSettingTab"
type="border-card"
class="settings-tabs"
:tab-position="isMobile ? 'top' : 'left'"
>
<el-tab-pane label="录制" name="recording">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">录制基础</h3>
@@ -1312,7 +1355,7 @@ watch(
</el-form>
<div class="action-strip">
<div class="helper-text">Run now and the daily retention cleanup use the same saved rules. Save this section first if you just changed the filters.</div>
<div class="helper-text">立即执行和每日自动清理使用相同规则刚修改筛选条件时请先保存设置</div>
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
</div>
@@ -1321,10 +1364,10 @@ watch(
class="test-result"
:class="retentionCleanupOperation.status === 'failed' || retentionCleanupOperation.warnings.length ? 'test-result--warning' : 'test-result--success'"
>
<div class="test-result__title">Current cleanup task</div>
<div class="test-result__title">当前清理任务</div>
<div class="test-result__meta">
Status={{ retentionCleanupStatusLabel }}
progress={{ retentionCleanupProgressText }}
状态={{ retentionCleanupStatusLabel }} ·
进度={{ retentionCleanupProgressText }} ·
{{ retentionCleanupSummary }}
</div>
<div v-if="retentionCleanupOperation.errorMessage" class="test-result__detail">
@@ -1334,8 +1377,8 @@ watch(
<li v-for="warning in retentionCleanupWarningsPreview" :key="warning">{{ warning }}</li>
</ul>
<div class="action-strip">
<div class="helper-text">This page keeps polling the same task after refresh until it finishes or fails.</div>
<el-button v-if="retentionCleanupFinished" text @click="clearTrackedRetentionCleanup">Dismiss</el-button>
<div class="helper-text">刷新页面后仍会继续跟踪该任务直到完成或失败</div>
<el-button v-if="retentionCleanupFinished" text @click="clearTrackedRetentionCleanup">关闭</el-button>
<el-tag v-else :type="retentionCleanupTagType">{{ retentionCleanupStatusLabel }}</el-tag>
</div>
</div>
@@ -1343,7 +1386,7 @@ watch(
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</p>
<p class="section-subtitle">控制弹幕 XML 并行录制非聊天事件采集和失败重试节奏</p>
<el-form label-position="top">
<el-row :gutter="16">
@@ -1353,12 +1396,12 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Record non-chat events">
<el-form-item label="记录非聊天事件">
<el-switch v-model="form.danmakuIncludeNonChatEvents" :disabled="!form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Minimum polling interval (ms)">
<el-form-item label="最小轮询间隔(毫秒)">
<el-input-number
v-model="form.danmakuMinPollIntervalMilliseconds"
:min="100"
@@ -1368,7 +1411,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="鏈€澶ч噸璇曢€€閬匡紙绉掞級">
<el-form-item label="最大重试退避(秒)">
<el-input-number
v-model="form.danmakuRetryDelayMaxSeconds"
:min="1"
@@ -1383,7 +1426,7 @@ watch(
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</p>
<p class="section-subtitle">目录和文件名模板均支持变量分片目录结构完全由模板控制</p>
<el-form label-position="top">
<el-form-item label="目录模板">
@@ -1395,7 +1438,7 @@ watch(
/>
</el-form-item>
<el-form-item label="Output filename template">
<el-form-item label="输出文件名模板">
<el-input
v-model="form.outputFileNameTemplate"
type="textarea"
@@ -1412,15 +1455,15 @@ watch(
</div>
<div class="helper-panel">
<code>{fileStem}</code> is only for directory templates and represents the rendered filename stem. <code>{segmentSuffix}</code> is only for filename templates and expands to suffixes like <code>_00001</code> in segmented mode while staying empty in single-file mode.
<code>{fileStem}</code> 仅用于目录模板表示渲染后的文件主名<code>{segmentSuffix}</code> 仅用于文件名模板分片模式下生成 <code>_00001</code> 一类后缀单文件模式下为空
</div>
<div class="helper-panel">
Time variables in both directory and filename templates are rendered in Beijing time (UTC+8), and the examples above use the same timezone.
目录和文件名模板中的时间变量统一使用北京时间UTC+8上方示例也使用相同时区
</div>
<div class="helper-panel">
<code>{quality}</code> renders a stable quality key such as <code>origin</code>, <code>FULL_HD</code>, <code>HD</code>, or <code>SD</code>, which works well in directory names or automation scripts.
<code>{quality}</code> 会生成 <code>origin</code><code>FULL_HD</code><code>HD</code><code>SD</code> 等稳定画质标识适合用于目录或自动化脚本
</div>
<div class="token-list">
@@ -1429,7 +1472,7 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="轮询与上传" name="polling">
<el-tab-pane label="轮询与上传" name="upload">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">后台巡检</h3>
@@ -1501,7 +1544,7 @@ watch(
<div class="template-section__header">
<div>
<h4 class="template-section__title">WebDAV 目标</h4>
<p class="template-section__subtitle">Create remote directories from recording-relative paths and upload the video plus danmaku files.</p>
<p class="template-section__subtitle">按录制相对路径创建远端目录并上传视频及对应弹幕文件</p>
</div>
</div>
@@ -1535,7 +1578,7 @@ watch(
<div class="template-section__header">
<div>
<h4 class="template-section__title">S3 目标</h4>
<p class="template-section__subtitle">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</p>
<p class="template-section__subtitle">支持兼容对象存储服务的自定义端点存储桶区域和前缀</p>
</div>
</div>
@@ -1740,7 +1783,7 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="事件脚本" name="scripts">
<el-tab-pane label="自动化脚本" name="automation">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
@@ -1942,26 +1985,26 @@ watch(
Live-started and live-ended scripts receive the shared event variables. Segment-completed scripts also receive file paths, danmaku paths, duration, file size, and task status values. Missing values are passed as empty strings.
</div>
<div class="event-script-help__intro">
Inline script mode runs with <code>/bin/sh -c</code> on Linux / Docker and with <code>PowerShell -Command</code> on Windows.
内联脚本在 Linux / Docker 下通过 <code>/bin/sh -c</code> 执行 Windows 下通过 <code>PowerShell -Command</code> 执行
</div>
<div class="event-script-help__intro">
If a script wants to append custom content to the system log, write text into the temporary file pointed to by <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code>.
脚本需要向系统日志追加内容时请写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件
</div>
<div class="event-script-help__intro">
The variable name <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> is kept for compatibility, but the actual value is rendered in Beijing time (UTC+8).
变量名 <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> 为兼容旧版本而保留实际值使用北京时间UTC+8
</div>
<div class="event-script-help__intro">
The official Docker image includes <code>curl</code> and <code>jq</code> by default. If you run on a host machine or a custom image, rely on the commands available in that environment.
官方 Docker 镜像默认包含 <code>curl</code> <code>jq</code>宿主机或自定义镜像只能使用对应环境中已有的命令
</div>
<div class="event-script-help__intro">
Set <code>Retry attempts</code> to <code>0</code> to disable automatic retries. Retry exhaustion failures are sent through the existing exception notification channel.
将重试次数设为 <code>0</code> 可关闭自动重试重试耗尽后会通过现有异常通知渠道告警
</div>
<div class="event-script-example">
<div class="event-script-example__label">Environment variable examples</div>
<div class="event-script-example__label">环境变量示例</div>
<div class="event-script-example__grid">
<div v-for="item in eventScriptEnvironmentExamples" :key="item.name" class="event-script-example__row">
<code>{{ item.name }}</code>
@@ -1983,7 +2026,7 @@ watch(
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Webhook 通知</h3>
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
<p class="section-subtitle">使用自定义请求头发送 JSON POST异常通知同时覆盖存储不足停录和脚本重试耗尽</p>
<el-form label-position="top">
<el-row :gutter="16">
@@ -2044,7 +2087,7 @@ watch(
</div>
<div class="action-strip">
<div class="helper-text">The test sends a sample live_started payload, including sample event script output, using the current URL, headers, and timeout values from this form.</div>
<div class="helper-text">测试会使用当前 URL请求头和超时设置发送一条包含脚本输出示例的开播通知</div>
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
</div>
@@ -2056,7 +2099,7 @@ watch(
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
<p class="section-subtitle">配置 SMTP开播和异常 HTML 模板异常通知同时覆盖存储不足停录和脚本重试耗尽</p>
<el-form label-position="top">
<el-row :gutter="16">
@@ -2071,7 +2114,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Live started alert">
<el-form-item label="开播提醒">
<el-switch v-model="form.notifyOnLiveStarted" />
</el-form-item>
</el-col>
@@ -2082,23 +2125,23 @@ watch(
</el-col>
<el-col :span="8">
<el-form-item label="SMTP Host">
<el-form-item label="SMTP 主机">
<el-input v-model="form.emailSmtpHost" placeholder="smtp.example.com" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP Port">
<el-form-item label="SMTP 端口">
<el-input-number v-model="form.emailSmtpPort" :min="1" :max="65535" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Sender name">
<el-form-item label="发件人名称">
<el-input v-model="form.emailFromDisplayName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP username">
<el-form-item label="SMTP 用户名">
<el-input v-model="form.emailUsername" />
</el-form-item>
</el-col>
@@ -2114,7 +2157,7 @@ watch(
</el-col>
<el-col :span="24">
<el-form-item label="Recipient list">
<el-form-item label="收件人列表">
<el-input
v-model="form.emailToAddresses"
type="textarea"
@@ -2128,8 +2171,8 @@ watch(
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">Live started template</h4>
<p class="template-section__subtitle">Subject templates render plain text, while the body template supports HTML and can include event script output placeholders.</p>
<h4 class="template-section__title">开播通知模板</h4>
<p class="template-section__subtitle">主题使用纯文本正文支持 HTML 和事件脚本输出占位符</p>
</div>
</div>
@@ -2148,7 +2191,7 @@ watch(
<div class="template-section__header">
<div>
<h4 class="template-section__title">异常提醒模板</h4>
<p class="template-section__subtitle">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</p>
<p class="template-section__subtitle">异常邮件可在 HTML 正文中插入来源摘要详情任务上下文和脚本输出</p>
</div>
</div>
@@ -2165,7 +2208,7 @@ watch(
</el-form>
<div class="template-help">
<div class="template-help__label">Available placeholders</div>
<div class="template-help__label">可用占位符</div>
<div class="token-list">
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
@@ -2176,13 +2219,13 @@ watch(
</div>
<div class="action-strip">
<div class="helper-text">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</div>
<div class="helper-text">发送测试邮件不会保存设置邮件会同时渲染开播和异常模板示例</div>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="Security and platform" name="security">
<el-tab-pane label="账户安全" name="account">
<el-card id="security" class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">账号安全</h3>
@@ -2208,9 +2251,12 @@ watch(
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane label="平台请求" name="platform">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Platform request settings</h3>
<p class="section-subtitle">Configure independent proxy, User-Agent, Referer, and Cookie values for each platform.</p>
<h3 class="section-title">平台请求设置</h3>
<p class="section-subtitle">为每个平台分别配置代理User-AgentReferer Cookie</p>
<div class="event-script-grid">
<div
@@ -2222,7 +2268,7 @@ watch(
<div>
<h4 class="event-script-section__title">{{ platform.label }}</h4>
<p class="event-script-section__subtitle">
These settings apply only to {{ platform.label }} status checks and stream requests.
这些设置仅用于 {{ platform.label }} 的状态检查和直播流请求
</p>
</div>
<el-switch v-model="form.platformRequestSettings[platform.key].proxy.enabled" />
@@ -2253,7 +2299,7 @@ watch(
v-model="form.platformRequestSettings[platform.key].cookie"
type="textarea"
:rows="3"
placeholder="Optional cookies for this platform only"
placeholder="仅用于该平台的可选 Cookie"
/>
</el-form-item>
</el-form>
@@ -2288,12 +2334,13 @@ watch(
</el-tabs>
</div>
<div class="settings-savebar" :style="savebarStyle">
<div v-if="isDirty" class="settings-savebar" :style="savebarStyle">
<div class="settings-savebar__content">
<div class="settings-savebar__copy">
<div class="settings-savebar__title">当前修改不会自动保存</div>
<div class="settings-savebar__subtitle">您可以在此页面任意位置保存当前设置</div>
<div class="settings-savebar__subtitle">保存后立即应用到录制和后台任务</div>
</div>
<el-button :disabled="saving" @click="discardSettingsChanges">放弃修改</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
@@ -2443,6 +2490,37 @@ watch(
gap: 18px;
}
.settings-quickbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 16px;
}
.settings-profile--compact {
min-width: 220px;
margin-right: auto;
}
.settings-quickbar__control {
display: grid;
grid-template-columns: auto 118px;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.settings-quickbar__switch {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.settings-grid__full {
grid-column: auto;
}
@@ -2497,6 +2575,55 @@ watch(
box-shadow: var(--shadow-soft);
}
@media (min-width: 769px) {
.settings-tabs {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
align-items: start;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: var(--surface);
overflow: hidden;
}
.settings-tabs :deep(.el-tabs__header.is-left) {
width: 168px;
min-height: 100%;
margin: 0;
border: 0;
border-right: 1px solid var(--border-subtle);
border-radius: 0;
background: var(--surface-muted);
box-shadow: none;
}
.settings-tabs :deep(.el-tabs__nav-wrap.is-left) {
padding: 10px;
}
.settings-tabs :deep(.el-tabs__item.is-left) {
height: 40px;
margin: 2px 0;
padding: 0 12px;
border-radius: 7px;
text-align: left;
}
.settings-tabs :deep(.el-tabs__item.is-left.is-active) {
color: var(--accent);
background: var(--accent-soft);
box-shadow: none;
}
.settings-tabs :deep(.el-tabs__content) {
min-width: 0;
padding: 16px;
border: 0;
border-radius: 0;
box-shadow: none;
}
}
.settings-card :deep(.el-card__body) {
padding-top: 20px;
}
@@ -2929,6 +3056,27 @@ watch(
padding: 14px;
}
.settings-quickbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-profile--compact {
grid-column: 1 / -1;
min-width: 0;
margin: 0;
}
.settings-quickbar__control {
grid-template-columns: 1fr;
}
.settings-quickbar__switch {
grid-column: 1 / -1;
justify-content: space-between;
}
.settings-card :deep(.el-col) {
flex: 0 0 100%;
max-width: 100%;
+5 -16
View File
@@ -281,8 +281,10 @@ onBeforeUnmount(() => {
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<EmptyState
v-if="!loading && items.length === 0"
v-else-if="items.length === 0"
title="暂无上传任务"
description="当前筛选条件下没有可展示的上传记录。"
action-text="刷新列表"
@@ -293,7 +295,6 @@ onBeforeUnmount(() => {
<div class="table-scroll-shell">
<el-table
:data="items"
v-loading="loading"
class="premium-table upload-table"
table-layout="auto"
row-key="recordTaskId"
@@ -425,20 +426,8 @@ onBeforeUnmount(() => {
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 960px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.upload-card :deep(.el-card__body) {
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "node:path";
import { fileURLToPath, URL } from "node:url";
const root = fileURLToPath(new URL("./postgres-admin", import.meta.url));
export default defineConfig({
root,
plugins: [vue()],
build: {
outDir: resolve(root, "../dist-postgres"),
emptyOutDir: true
}
});
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=1.0.1
VERSION=1.1.0
NODE_VERSION=22.18.0
NODE_ARCHIVE_SHA256=c1bfeecf1d7404fa74728f9db72e697decbd8119ccc6f5a294d795756dfcfca7
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}"
+177
View File
@@ -0,0 +1,177 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=15.1.0
PGVECTOR_VERSION=0.8.6
PGVECTOR_PACKAGE_VERSION=0.8.6-1.pgdg12%2B1
PGVECTOR_SHA256=b27ff894d1e2d23ebd7528fcb986923391977cbd5c5379ed74527875246854ca
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/nxsir-postgresql-${VERSION}-x86_64.fpk}"
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}"
NUGET_FEED="${POSTGRES_SERVICE_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}"
NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}"
DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}"
BUILD_TMP_ROOT="${POSTGRES_SERVICE_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}"
if [ -n "${FNPACK:-}" ]; then
FNPACK_BIN="$FNPACK"
elif [ -x "$ROOT_DIR/.tools/fnpack" ]; then
FNPACK_BIN="$ROOT_DIR/.tools/fnpack"
else
FNPACK_BIN=fnpack
fi
FNPACK_BIN=$(command -v "$FNPACK_BIN") || {
printf 'fnpack is required; install it from the fnOS developer portal or set FNPACK.\n' >&2
exit 1
}
for command_name in npm apt-get curl dpkg-deb node sha256sum tar find realpath; do
command -v "$command_name" >/dev/null 2>&1 || {
printf 'required build command is missing: %s\n' "$command_name" >&2
exit 1
}
done
test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; }
test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; }
mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")"
WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/postgres-service-fnos-build.XXXXXX")
trap 'rm -rf -- "$WORK_DIR"' EXIT
STAGE="$WORK_DIR/stage"
PACKED_ROOT="$WORK_DIR/packed"
FNPACK_TMP_ROOT="$WORK_DIR/fnpack-tmp"
RUNTIME_ROOT="$STAGE/app/runtime"
EXTRACT_ROOT="$WORK_DIR/runtime-extract"
mkdir -p "$STAGE/app/server/wwwroot" "$RUNTIME_ROOT" "$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT"
cp -a "$ROOT_DIR/fnos-postgresql/." "$STAGE/"
printf 'Building PostgreSQL management frontend...\n'
npm run build:postgres-admin --prefix "$ROOT_DIR/frontend"
cp -a "$ROOT_DIR/frontend/dist-postgres/." "$STAGE/app/server/wwwroot/"
printf 'Publishing self-contained PostgreSQL management API...\n'
export NUGET_PACKAGES DOTNET_CLI_HOME
"$DOTNET_BIN" restore "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" \
-r linux-x64 \
--source "$NUGET_FEED" \
--disable-parallel
"$DOTNET_BIN" publish "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" \
-c Release \
-r linux-x64 \
--self-contained true \
--no-restore \
-p:DebugType=None \
-p:DebugSymbols=false \
-p:PublishSingleFile=false \
-p:PublishReadyToRun=false \
-o "$STAGE/app/server" \
/maxcpucount:1
rm -f "$STAGE/app/server/"*.pdb
printf 'Downloading pinned Debian Bookworm PostgreSQL 15 runtime...\n'
APT_ROOT="$WORK_DIR/apt"
mkdir -p \
"$APT_ROOT/etc/apt" \
"$APT_ROOT/var/lib/apt/lists/partial" \
"$APT_ROOT/var/lib/dpkg" \
"$APT_ROOT/var/cache/apt/archives/partial"
cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list"
touch "$APT_ROOT/var/lib/dpkg/status"
APT_OPTIONS=(
-o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list"
-o "Dir::Etc::sourceparts=-"
-o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status"
-o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists"
-o "Dir::Cache=$APT_ROOT/var/cache/apt"
-o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives"
-o "Debug::NoLocking=1"
-o "APT::Architecture=amd64"
-o "Acquire::Languages=none"
)
apt-get "${APT_OPTIONS[@]}" update
apt-get "${APT_OPTIONS[@]}" --download-only --no-install-recommends --yes install \
postgresql-15 postgresql-client-15
shopt -s nullglob
runtime_packages=("$APT_ROOT"/var/cache/apt/archives/*.deb)
test "${#runtime_packages[@]}" -gt 0 || { printf 'APT did not download PostgreSQL runtime packages\n' >&2; exit 1; }
for package_file in "${runtime_packages[@]}"; do
dpkg-deb -x "$package_file" "$EXTRACT_ROOT"
done
shopt -u nullglob
printf 'Downloading pinned pgvector %s extension...\n' "$PGVECTOR_VERSION"
PGVECTOR_DEB="$WORK_DIR/postgresql-15-pgvector.deb"
curl --fail --location --retry 3 \
"https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-15-pgvector_${PGVECTOR_PACKAGE_VERSION}_amd64.deb" \
--output "$PGVECTOR_DEB"
printf '%s %s\n' "$PGVECTOR_SHA256" "$PGVECTOR_DEB" | sha256sum --check --status
test "$(dpkg-deb -f "$PGVECTOR_DEB" Package)" = "postgresql-15-pgvector"
test "$(dpkg-deb -f "$PGVECTOR_DEB" Version)" = "${PGVECTOR_PACKAGE_VERSION//%2B/+}"
dpkg-deb -x "$PGVECTOR_DEB" "$EXTRACT_ROOT"
printf 'Assembling relocatable PostgreSQL runtime...\n'
cp -a "$EXTRACT_ROOT/." "$RUNTIME_ROOT/"
rm -rf \
"$RUNTIME_ROOT/usr/share/doc" \
"$RUNTIME_ROOT/usr/share/man" \
"$RUNTIME_ROOT/usr/share/locale" \
"$RUNTIME_ROOT/var" \
"$RUNTIME_ROOT/etc/init.d" \
"$RUNTIME_ROOT/usr/sbin"
# fnOS provides the matching glibc and loader. Keep other Debian libraries but
# never package a second libc implementation into the application runtime.
find "$RUNTIME_ROOT" -type f \( \
-name 'ld-linux-*.so.*' -o -name 'libc.so.*' -o -name 'libm.so.*' -o \
-name 'libmvec.so.*' -o -name 'libpthread.so.*' -o -name 'libdl.so.*' -o \
-name 'librt.so.*' -o -name 'libresolv.so.*' -o -name 'libutil.so.*' \
\) -delete
# Official fnOS packages reject absolute links. Rewrite Debian links to remain
# inside the staged runtime and fail if a link target was not packaged.
while IFS= read -r -d '' link_path; do
target=$(readlink -- "$link_path")
case "$target" in
/*)
staged_target="$RUNTIME_ROOT$target"
if [ ! -e "$staged_target" ]; then
rm "$link_path"
continue
fi
relative_target=$(realpath --relative-to="$(dirname -- "$link_path")" "$staged_target")
rm "$link_path"
ln -s "$relative_target" "$link_path"
;;
esac
done < <(find "$RUNTIME_ROOT" -type l -print0)
for required_file in \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_ctl" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_dump" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/lib/vector.so" \
"$RUNTIME_ROOT/usr/share/postgresql/15/extension/vector.control"; do
test -e "$required_file" || { printf 'runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
done
node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$STAGE" "$STAGE/app/ui/images"
chmod 0755 "$STAGE/cmd/"*
printf 'Packing PostgreSQL service with official fnOS fnpack...\n'
(
cd "$PACKED_ROOT"
TMPDIR="$FNPACK_TMP_ROOT" "$FNPACK_BIN" build --directory "$STAGE"
)
built_package=$(find "$PACKED_ROOT" -maxdepth 1 -type f -name '*.fpk' -print -quit)
test -n "$built_package" || { printf 'fnpack did not create an FPK\n' >&2; exit 1; }
mv "$built_package" "$OUTPUT"
(
cd "$(dirname -- "$OUTPUT")"
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
)
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
printf 'Built %s\n' "$OUTPUT"
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
set -euo pipefail
LIVE_PACKAGE=${1:?usage: smoke-fnos-migration.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
POSTGRES_PACKAGE=${2:?usage: smoke-fnos-migration.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
SMOKE_TMP_ROOT="${3:-${LIVERECORDER_MIGRATION_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
mkdir -p "$SMOKE_TMP_ROOT"
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-migration-smoke.XXXXXX")
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/liverecorder-migration-state.XXXXXX")
LIVE_PACKAGE_ROOT="$WORK_DIR/live-package"
LIVE_APP_ROOT="$WORK_DIR/live-app"
LIVE_DATA_ROOT="$STATE_DIR/live-var"
LIVE_VOLUME_ROOT="$WORK_DIR/live-volume"
PG_PACKAGE_ROOT="$WORK_DIR/postgres-package"
PG_APP_ROOT="$WORK_DIR/postgres-app"
PG_DATA_ROOT="$STATE_DIR/postgres-var"
PG_VOLUME_ROOT="$WORK_DIR/postgres-volume"
LIVE_PORT=${LIVERECORDER_MIGRATION_SMOKE_PORT:-19680}
PRIVATE_PG_PORT=${LIVERECORDER_MIGRATION_PRIVATE_PG_PORT:-19629}
PG_API_PORT=${POSTGRES_SERVICE_MIGRATION_API_PORT:-19633}
PG_PORT=${POSTGRES_SERVICE_MIGRATION_PG_PORT:-19632}
LIVE_CONTROL="$LIVE_PACKAGE_ROOT/cmd/main"
PG_CONTROL="$PG_PACKAGE_ROOT/cmd/main"
ADMIN_PASSWORD='LiveRecorder-Migration-2026!'
PG_ADMIN_PASSWORD='Postgres-Migration-Admin-2026!'
ENROLLMENT_TOKEN='Postgres-Migration-Enrollment-2026!'
MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
mkdir -p "$WORK_DIR/system-media-stubs"
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffmpeg"
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffprobe"
MEDIA_PATH="$WORK_DIR/system-media-stubs:$MEDIA_PATH"
fi
run_live_control() {
TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
LIVE_RECORDER_POSTGRES_PORT="$PRIVATE_PG_PORT" \
POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
}
run_postgres_control() {
TRIM_APPDEST="$PG_APP_ROOT" TRIM_PKGVAR="$PG_DATA_ROOT" \
TRIM_APPDEST_VOL="$PG_VOLUME_ROOT" TRIM_SERVICE_PORT="$PG_API_PORT" \
POSTGRES_SERVICE_PORT="$PG_PORT" "$PG_CONTROL" "$@"
}
cleanup() {
status=$?
if [ -x "$LIVE_CONTROL" ]; then run_live_control stop >/dev/null 2>&1 || true; fi
if [ -x "$PG_CONTROL" ]; then run_postgres_control stop >/dev/null 2>&1 || true; fi
if [ "$status" -ne 0 ]; then
printf '%s\n' 'fnOS PostgreSQL migration smoke test failed; service logs follow:' >&2
for log_file in \
"$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
"$LIVE_DATA_ROOT/log/postgresql.log" "$LIVE_DATA_ROOT/log/liverecorder.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 200 "$log_file" >&2 || true
fi
done
fi
rm -rf -- "$WORK_DIR"
rm -rf -- "$STATE_DIR"
return "$status"
}
trap cleanup EXIT HUP INT TERM
mkdir -p "$LIVE_PACKAGE_ROOT" "$LIVE_APP_ROOT" "$LIVE_VOLUME_ROOT" \
"$PG_PACKAGE_ROOT" "$PG_APP_ROOT" "$PG_VOLUME_ROOT"
tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
tar -xzf "$POSTGRES_PACKAGE" -C "$PG_PACKAGE_ROOT"
tar -xzf "$PG_PACKAGE_ROOT/app.tgz" -C "$PG_APP_ROOT"
rm -f "$PG_PACKAGE_ROOT/app.tgz"
TRIM_PKGVAR="$LIVE_DATA_ROOT" \
wizard_admin_password="$ADMIN_PASSWORD" wizard_admin_password_confirm="$ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
"$LIVE_PACKAGE_ROOT/cmd/install_callback"
mv "$LIVE_DATA_ROOT/postgres-enrollment-token.seed" "$WORK_DIR/enrollment-token.seed"
PRIVATE_PG_BIN="$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/bin"
PRIVATE_PG_SHARE="$LIVE_APP_ROOT/runtime/usr/share/postgresql/15"
PRIVATE_PG_LIB="$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
PRIVATE_RUNTIME_LIBS="$LIVE_APP_ROOT/runtime/lib:$PRIVATE_PG_LIB"
PRIVATE_PG_DATA="$LIVE_DATA_ROOT/postgres"
PRIVATE_RUN_ROOT="$LIVE_DATA_ROOT/run"
mkdir -p "$PRIVATE_PG_DATA" "$PRIVATE_RUN_ROOT" "$LIVE_DATA_ROOT/log"
chmod 0700 "$PRIVATE_PG_DATA" "$PRIVATE_RUN_ROOT"
env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" "$PRIVATE_PG_BIN/initdb" \
-D "$PRIVATE_PG_DATA" -L "$PRIVATE_PG_SHARE" --username=liverecorder \
--auth-local=trust --auth-host=reject --encoding=UTF8 --no-locale \
>"$LIVE_DATA_ROOT/log/postgresql.log" 2>&1
{
printf "listen_addresses = ''\n"
printf "port = %s\n" "$PRIVATE_PG_PORT"
printf "unix_socket_directories = '%s'\n" "$PRIVATE_RUN_ROOT"
printf "max_connections = 40\nshared_buffers = '64MB'\ntimezone = 'UTC'\nlog_timezone = 'UTC'\n"
} >>"$PRIVATE_PG_DATA/postgresql.conf"
# With a legacy PG15 cluster and no enrollment seed, the package must boot the
# old database. The Web API then creates the exact EF schema and initial admin.
run_live_control start
curl -fsS "http://127.0.0.1:$LIVE_PORT/health/ready" | grep -q '"status":"ready"'
source_user_count=$(env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" \
"$PRIVATE_PG_BIN/psql" -h "$PRIVATE_RUN_ROOT" -p "$PRIVATE_PG_PORT" \
-U liverecorder -d live_recorder -Atqc 'SELECT count(*) FROM "UserAccounts"')
test "$source_user_count" -gt 0
run_live_control stop
TRIM_PKGVAR="$PG_DATA_ROOT" \
wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
"$PG_PACKAGE_ROOT/cmd/install_callback"
mv "$WORK_DIR/enrollment-token.seed" "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
chmod 0600 "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
run_postgres_control start
run_live_control start
curl -fsS "http://127.0.0.1:$LIVE_PORT/health/ready" | grep -q '"status":"ready"'
MARKER="$LIVE_DATA_ROOT/postgres-migration/shared-database.active"
DUMP="$LIVE_DATA_ROOT/postgres-migration/private-postgres-15.dump"
test -s "$MARKER"
grep -q '^migrated_at=' "$MARKER"
test -s "$DUMP"
sha256sum -c "$DUMP.sha256" >/dev/null
test -f "$LIVE_DATA_ROOT/postgres/PG_VERSION"
if env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" \
"$PRIVATE_PG_BIN/pg_ctl" -D "$PRIVATE_PG_DATA" status >/dev/null 2>&1; then
printf '%s\n' 'legacy private PostgreSQL was still running after migration' >&2
exit 1
fi
CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
shared_database=$(sed -n 's/^database=//p' "$CREDENTIALS")
shared_user=$(sed -n 's/^username=//p' "$CREDENTIALS")
shared_password=$(sed -n 's/^password=//p' "$CREDENTIALS")
SHARED_PG_BIN="$PG_APP_ROOT/runtime/usr/lib/postgresql/15/bin"
SHARED_PG_LIBS="$PG_APP_ROOT/runtime/usr/lib/x86_64-linux-gnu:$PG_APP_ROOT/runtime/lib/x86_64-linux-gnu:$PG_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
target_user_count=$(env LD_LIBRARY_PATH="$SHARED_PG_LIBS" PGPASSWORD="$shared_password" \
"$SHARED_PG_BIN/psql" -h 127.0.0.1 -p "$PG_PORT" -U "$shared_user" \
-d "$shared_database" -Atqc 'SELECT count(*) FROM "UserAccounts"')
test "$source_user_count" = "$target_user_count"
run_live_control stop
run_postgres_control status
curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
printf '%s\n' 'fnOS migration smoke test passed: populated legacy PG15 migrated with row-count parity, checksum dump and rollback data preserved'
+110 -57
View File
@@ -1,95 +1,148 @@
#!/bin/bash
set -euo pipefail
PACKAGE=${1:?usage: smoke-fnos-package.sh package.fpk [temporary-directory]}
SMOKE_TMP_ROOT="${2:-${LIVERECORDER_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
LIVE_PACKAGE=${1:?usage: smoke-fnos-package.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
POSTGRES_PACKAGE=${2:?usage: smoke-fnos-package.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
SMOKE_TMP_ROOT="${3:-${LIVERECORDER_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
mkdir -p "$SMOKE_TMP_ROOT"
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-fnos-smoke.XXXXXX")
PACKAGE_ROOT="$WORK_DIR/package"
APP_ROOT="$WORK_DIR/app"
DATA_ROOT="$WORK_DIR/var"
VOLUME_ROOT="$WORK_DIR/volume"
PORT=${LIVERECORDER_SMOKE_PORT:-19180}
CONTROL="$PACKAGE_ROOT/cmd/main"
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-shared-stack-smoke.XXXXXX")
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/liverecorder-stack-state.XXXXXX")
LIVE_PACKAGE_ROOT="$WORK_DIR/live-package"
LIVE_APP_ROOT="$WORK_DIR/live-app"
LIVE_DATA_ROOT="$STATE_DIR/live-var"
LIVE_VOLUME_ROOT="$WORK_DIR/live-volume"
PG_PACKAGE_ROOT="$WORK_DIR/postgres-package"
PG_APP_ROOT="$WORK_DIR/postgres-app"
PG_DATA_ROOT="$STATE_DIR/postgres-var"
PG_VOLUME_ROOT="$WORK_DIR/postgres-volume"
LIVE_PORT=${LIVERECORDER_SMOKE_PORT:-19580}
PG_API_PORT=${POSTGRES_SERVICE_SMOKE_API_PORT:-19533}
PG_PORT=${POSTGRES_SERVICE_SMOKE_PG_PORT:-19532}
LIVE_CONTROL="$LIVE_PACKAGE_ROOT/cmd/main"
PG_CONTROL="$PG_PACKAGE_ROOT/cmd/main"
ADMIN_PASSWORD='LiveRecorder-Smoke-2026!'
PG_ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!'
ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!'
MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
mkdir -p "$WORK_DIR/system-media-stubs"
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffmpeg"
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffprobe"
MEDIA_PATH="$WORK_DIR/system-media-stubs:$MEDIA_PATH"
fi
run_live_control() {
TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
}
run_postgres_control() {
TRIM_APPDEST="$PG_APP_ROOT" TRIM_PKGVAR="$PG_DATA_ROOT" \
TRIM_APPDEST_VOL="$PG_VOLUME_ROOT" TRIM_SERVICE_PORT="$PG_API_PORT" \
POSTGRES_SERVICE_PORT="$PG_PORT" "$PG_CONTROL" "$@"
}
cleanup() {
status=$?
if [ -x "$CONTROL" ]; then
TRIM_APPDEST="$APP_ROOT" \
TRIM_PKGVAR="$DATA_ROOT" \
TRIM_APPDEST_VOL="$VOLUME_ROOT" \
TRIM_SERVICE_PORT="$PORT" \
"$CONTROL" stop >/dev/null 2>&1 || true
fi
if [ -x "$LIVE_CONTROL" ]; then run_live_control stop >/dev/null 2>&1 || true; fi
if [ -x "$PG_CONTROL" ]; then run_postgres_control stop >/dev/null 2>&1 || true; fi
if [ "$status" -ne 0 ]; then
printf '%s\n' 'fnOS smoke test failed; application logs follow:' >&2
for log_file in "$DATA_ROOT/log/postgresql.log" "$DATA_ROOT/log/liverecorder.log"; do
printf '%s\n' 'fnOS shared-stack smoke test failed; service logs follow:' >&2
for log_file in \
"$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
"$LIVE_DATA_ROOT/log/postgresql.log" "$LIVE_DATA_ROOT/log/liverecorder.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 120 "$log_file" >&2 || true
tail -n 160 "$log_file" >&2 || true
fi
done
fi
rm -rf -- "$WORK_DIR"
rm -rf -- "$STATE_DIR"
return "$status"
}
trap cleanup EXIT HUP INT TERM
command -v ffmpeg >/dev/null 2>&1 || { printf 'system ffmpeg is required for the fnOS smoke test\n' >&2; exit 1; }
command -v ffprobe >/dev/null 2>&1 || { printf 'system ffprobe is required for the fnOS smoke test\n' >&2; exit 1; }
mkdir -p "$LIVE_PACKAGE_ROOT" "$LIVE_APP_ROOT" "$LIVE_VOLUME_ROOT" \
"$PG_PACKAGE_ROOT" "$PG_APP_ROOT" "$PG_VOLUME_ROOT"
tar -xzf "$POSTGRES_PACKAGE" -C "$PG_PACKAGE_ROOT"
tar -xzf "$PG_PACKAGE_ROOT/app.tgz" -C "$PG_APP_ROOT"
rm -f "$PG_PACKAGE_ROOT/app.tgz"
tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
mkdir -p "$PACKAGE_ROOT" "$APP_ROOT" "$VOLUME_ROOT"
tar -xzf "$PACKAGE" -C "$PACKAGE_ROOT"
tar -xzf "$PACKAGE_ROOT/app.tgz" -C "$APP_ROOT"
TRIM_PKGVAR="$PG_DATA_ROOT" \
wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
"$PG_PACKAGE_ROOT/cmd/install_callback"
TRIM_PKGVAR="$LIVE_DATA_ROOT" \
wizard_admin_password="$ADMIN_PASSWORD" \
wizard_admin_password_confirm="$ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
"$LIVE_PACKAGE_ROOT/cmd/install_callback"
export TRIM_APPDEST="$APP_ROOT"
export TRIM_PKGVAR="$DATA_ROOT"
export TRIM_APPDEST_VOL="$VOLUME_ROOT"
export TRIM_SERVICE_PORT="$PORT"
run_postgres_control start
run_postgres_control status
curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
SMOKE_PASSWORD='LiveRecorder-Smoke-2026!'
wizard_admin_password="$SMOKE_PASSWORD" \
wizard_admin_password_confirm="$SMOKE_PASSWORD" \
"$PACKAGE_ROOT/cmd/install_callback"
"$CONTROL" start
"$CONTROL" status
BASE_URL="http://127.0.0.1:$PORT"
curl -fsS "$BASE_URL/" >"$WORK_DIR/index.html"
grep -q '<div id="app"></div>' "$WORK_DIR/index.html"
run_live_control start
run_live_control status
BASE_URL="http://127.0.0.1:$LIVE_PORT"
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
curl -fsS "$BASE_URL/" | grep -q '<div id="app"></div>'
login_response=$(curl -fsS \
-H 'Content-Type: application/json' \
--data "{\"username\":\"admin\",\"password\":\"$SMOKE_PASSWORD\"}" \
CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
MARKER="$LIVE_DATA_ROOT/postgres-migration/shared-database.active"
test -s "$CREDENTIALS"
test "$(stat -c '%a' "$CREDENTIALS")" = "600"
grep -q '^host=127\.0\.0\.1$' "$CREDENTIALS"
grep -q "^port=$PG_PORT$" "$CREDENTIALS"
grep -q '^database=appdb_liverecorder_' "$CREDENTIALS"
grep -q '^username=app_liverecorder_' "$CREDENTIALS"
test -s "$MARKER"
grep -q '^fresh_install_at=' "$MARKER"
test ! -f "$LIVE_DATA_ROOT/postgres/PG_VERSION"
test ! -e "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
login_response=$(curl -fsS -H 'Content-Type: application/json' \
--data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \
"$BASE_URL/api/auth/login")
token=$(printf '%s' "$login_response" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
test -n "$token"
settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings")
expected_record_root="$VOLUME_ROOT/@appshare/liverecorder/records"
expected_record_root="$LIVE_VOLUME_ROOT/@appshare/liverecorder/records"
printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\""
runtime_libs="$APP_ROOT/runtime/lib:$APP_ROOT/runtime/usr/lib/postgresql/15/lib"
ca_bundle="$APP_ROOT/runtime/etc/ssl/certs/ca-certificates.crt"
node_bin="$APP_ROOT/runtime/bin/node"
curl_bin="$APP_ROOT/runtime/bin/curl"
signer="$APP_ROOT/server/Platforms/Douyin/Signing/sign-xbogus.js"
runtime_libs="$LIVE_APP_ROOT/runtime/lib:$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
ca_bundle="$LIVE_APP_ROOT/runtime/etc/ssl/certs/ca-certificates.crt"
node_bin="$LIVE_APP_ROOT/runtime/bin/node"
curl_bin="$LIVE_APP_ROOT/runtime/bin/curl"
signer="$LIVE_APP_ROOT/server/Platforms/Douyin/Signing/sign-xbogus.js"
LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -q '^v22\.18\.0$'
signature=$(LD_LIBRARY_PATH="$runtime_libs" "$node_bin" "$signer" \
'aid=6383&device_platform=web&room_id=1' \
'Mozilla/5.0 LiveRecorder fnOS package smoke test')
'aid=6383&device_platform=web&room_id=1' 'Mozilla/5.0 LiveRecorder fnOS shared-stack smoke test')
test -n "$signature"
LD_LIBRARY_PATH="$runtime_libs" SSL_CERT_FILE="$ca_bundle" CURL_CA_BUNDLE="$ca_bundle" \
"$curl_bin" -fsS "$BASE_URL/health/ready" >/dev/null
ffmpeg -version >/dev/null 2>&1
ffprobe -version >/dev/null 2>&1
"$CONTROL" stop
"$CONTROL" start
"$CONTROL" status
run_live_control stop
if run_live_control status; then
printf '%s\n' 'Live Recorder remained running after stop' >&2
exit 1
fi
run_postgres_control status
curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
run_live_control start
run_live_control status
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
printf 'fnOS native smoke test passed: frontend, API, PostgreSQL, packaged Node/curl and system FFmpeg/FFprobe are ready\n'
printf '%s\n' 'fnOS shared-stack smoke test passed: independent PostgreSQL stayed running, Live Recorder enrolled, persisted credentials and restarted without Docker'
+148
View File
@@ -0,0 +1,148 @@
#!/bin/bash
set -euo pipefail
PACKAGE=${1:?usage: smoke-postgresql-fnos-package.sh postgresql-service.fpk [temporary-directory]}
SMOKE_TMP_ROOT="${2:-${POSTGRES_SERVICE_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
mkdir -p "$SMOKE_TMP_ROOT"
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/postgres-service-fnos-smoke.XXXXXX")
PACKAGE_ROOT="$WORK_DIR/package"
APP_ROOT="$WORK_DIR/app"
DATA_ROOT="$WORK_DIR/var"
VOLUME_ROOT="$WORK_DIR/volume"
API_PORT=${POSTGRES_SERVICE_SMOKE_API_PORT:-19433}
PG_PORT=${POSTGRES_SERVICE_SMOKE_PG_PORT:-19432}
ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!'
ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!'
CONTROL="$PACKAGE_ROOT/cmd/main"
cleanup() {
status=$?
if [ -x "$CONTROL" ]; then
TRIM_APPDEST="$APP_ROOT" TRIM_PKGVAR="$DATA_ROOT" TRIM_APPDEST_VOL="$VOLUME_ROOT" \
TRIM_SERVICE_PORT="$API_PORT" POSTGRES_SERVICE_PORT="$PG_PORT" \
"$CONTROL" stop >/dev/null 2>&1 || true
fi
if [ "$status" -ne 0 ]; then
printf '%s\n' 'PostgreSQL fnOS smoke test failed; service logs follow:' >&2
for log_file in "$DATA_ROOT/log/postgresql.log" "$DATA_ROOT/log/postgres-service.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 160 "$log_file" >&2 || true
fi
done
fi
rm -rf -- "$WORK_DIR"
return "$status"
}
trap cleanup EXIT HUP INT TERM
mkdir -p "$PACKAGE_ROOT" "$APP_ROOT" "$VOLUME_ROOT"
tar -xzf "$PACKAGE" -C "$PACKAGE_ROOT"
tar -xzf "$PACKAGE_ROOT/app.tgz" -C "$APP_ROOT"
if [ -n "${POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY:-}" ]; then
test -x "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY/PostgresService.WebApi" || {
printf 'invalid API overlay: %s\n' "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY" >&2
exit 1
}
cp -a "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY/." "$APP_ROOT/server/"
fi
export TRIM_APPDEST="$APP_ROOT"
export TRIM_PKGVAR="$DATA_ROOT"
export TRIM_APPDEST_VOL="$VOLUME_ROOT"
export TRIM_SERVICE_PORT="$API_PORT"
export POSTGRES_SERVICE_PORT="$PG_PORT"
wizard_postgres_admin_password="$ADMIN_PASSWORD" \
wizard_postgres_admin_password_confirm="$ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
"$PACKAGE_ROOT/cmd/install_callback"
"$CONTROL" start
"$CONTROL" status
BASE_URL="http://127.0.0.1:$API_PORT"
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
curl -fsS "$BASE_URL/" | grep -q '<div id="app"></div>'
COOKIE_JAR="$WORK_DIR/cookies.txt"
curl -fsS -c "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \
"$BASE_URL/api/v1/auth/login" >/dev/null
curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/overview" | grep -q '"version"'
enroll() {
app_id=$1
display_name=$2
extensions=$3
destination=$4
curl -fsS \
-H "Authorization: Bearer $ENROLLMENT_TOKEN" \
-H 'Content-Type: application/json' \
--data "{\"appId\":\"$app_id\",\"displayName\":\"$display_name\",\"requestedExtensions\":$extensions}" \
"$BASE_URL/internal/v1/enroll" >"$destination"
}
enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json"
enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json"
json_field() {
field=$1
file=$2
sed -n "s/.*\"$field\":\"\([^\"]*\)\".*/\1/p" "$file"
}
LIVE_DB=$(json_field database "$WORK_DIR/live.json")
LIVE_USER=$(json_field username "$WORK_DIR/live.json")
LIVE_PASSWORD=$(json_field password "$WORK_DIR/live.json")
IMAGE_DB=$(json_field database "$WORK_DIR/image.json")
IMAGE_USER=$(json_field username "$WORK_DIR/image.json")
IMAGE_PASSWORD=$(json_field password "$WORK_DIR/image.json")
test -n "$LIVE_DB" && test -n "$LIVE_USER" && test -n "$LIVE_PASSWORD"
test -n "$IMAGE_DB" && test -n "$IMAGE_USER" && test -n "$IMAGE_PASSWORD"
test "$LIVE_DB" != "$IMAGE_DB"
test "$LIVE_USER" != "$IMAGE_USER"
PG_BIN="$APP_ROOT/runtime/usr/lib/postgresql/15/bin"
PG_LIB="$APP_ROOT/runtime/usr/lib/postgresql/15/lib"
RUNTIME_LIBS="$APP_ROOT/runtime/usr/lib/x86_64-linux-gnu:$APP_ROOT/runtime/lib/x86_64-linux-gnu:$PG_LIB"
run_client_psql() {
password=$1
shift
env LD_LIBRARY_PATH="$RUNTIME_LIBS" PGPASSWORD="$password" "$PG_BIN/psql" "$@"
}
run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$LIVE_DB" \
-v ON_ERROR_STOP=1 -c 'CREATE TABLE smoke_live(id integer PRIMARY KEY, value text); INSERT INTO smoke_live VALUES (1, '\''live'\'');' >/dev/null
run_client_psql "$IMAGE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$IMAGE_USER" -d "$IMAGE_DB" \
-v ON_ERROR_STOP=1 -c 'CREATE TABLE smoke_vectors(id integer PRIMARY KEY, embedding vector(3)); INSERT INTO smoke_vectors VALUES (1, '\''[1,2,3]'\''); SELECT embedding <-> '\''[1,2,4]'\'' FROM smoke_vectors;' >/dev/null
if run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$IMAGE_DB" -Atqc 'SELECT 1' >/dev/null 2>&1; then
printf '%s\n' 'role isolation failed: Live Recorder connected to ImageFind database' >&2
exit 1
fi
curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \
"$BASE_URL/api/v1/query" | grep -q '"rowCount":1'
if curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"database\":\"$IMAGE_DB\",\"sql\":\"DELETE FROM smoke_vectors\"}" \
"$BASE_URL/api/v1/query" >/dev/null 2>&1; then
printf '%s\n' 'read-only SQL console accepted a write statement' >&2
exit 1
fi
curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"database\":\"$LIVE_DB\"}" "$BASE_URL/api/v1/backups" | grep -q '"sha256"'
POSTMASTER_PID=$(sed -n '1p' "$DATA_ROOT/postgres/postmaster.pid")
test -n "$POSTMASTER_PID"
kill -0 "$POSTMASTER_PID"
"$CONTROL" restart
"$CONTROL" status
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$LIVE_DB" -Atqc 'SELECT value FROM smoke_live WHERE id = 1' | grep -q '^live$'
printf '%s\n' 'PostgreSQL fnOS smoke test passed: shared process, SCRAM isolation, pgvector, read-only SQL, backup and restart are ready'
+21 -9
View File
@@ -5,7 +5,7 @@ PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk}
VERIFY_TMP_ROOT="${LIVERECORDER_VERIFY_TMPDIR:-${TMPDIR:-/tmp}}"
MAX_APP_UNCOMPRESSED_BYTES=$((512 * 1024 * 1024))
mkdir -p "$VERIFY_TMP_ROOT"
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/liverecorder-fnos-verify.XXXXXX")
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/fnos-package-verify.XXXXXX")
trap 'rm -rf -- "$WORK_DIR"' EXIT
manifest_value() {
@@ -13,8 +13,13 @@ manifest_value() {
}
tar -xzf "$PACKAGE" -C "$WORK_DIR"
test "$(manifest_value appname)" = "liverecorder"
test "$(manifest_value version)" = "1.0.1"
appname=$(manifest_value appname)
version=$(manifest_value version)
case "$appname" in
liverecorder|nxsir.postgresql) ;;
*) printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1 ;;
esac
test -n "$version"
test "$(manifest_value platform)" = "x86"
test -x "$WORK_DIR/cmd/main"
test -x "$WORK_DIR/cmd/install_callback"
@@ -50,7 +55,7 @@ if awk '
offset = index($0, marker)
if (offset > 0) {
target = substr($0, offset + length(marker))
if (target ~ /^\// || target ~ /(^|\/)\.\.($|\/)/) { unsafe = 1; exit }
if (target ~ /^\//) { unsafe = 1; exit }
}
}
/^h/ {
@@ -58,7 +63,7 @@ if awk '
offset = index($0, marker)
if (offset > 0) {
target = substr($0, offset + length(marker))
if (target ~ /^\// || target ~ /(^|\/)\.\.($|\/)/) { unsafe = 1; exit }
if (target ~ /^\//) { unsafe = 1; exit }
}
}
END { exit unsafe ? 0 : 1 }
@@ -67,18 +72,25 @@ if awk '
exit 1
fi
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt"
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
if [ "$appname" = "liverecorder" ]; then
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/bin/node$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt"
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
else
grep -q '^server/PostgresService.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/lib/vector.so$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/share/postgresql/15/extension/vector.control$' "$WORK_DIR/app-files.txt"
fi
if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then
printf 'ffmpeg and ffprobe must come from the fnOS system environment\n' >&2
+72
View File
@@ -0,0 +1,72 @@
namespace PostgresService.WebApi;
public sealed record LoginRequest(string Username, string Password);
public sealed record EnrollRequest(
string AppId,
string DisplayName,
IReadOnlyList<string>? RequestedExtensions);
public sealed record PostgresClientCredential(
string Host,
int Port,
string Database,
string Username,
string Password,
string SslMode,
string ServiceVersion);
public sealed record CreateDatabaseRequest(string Name, string? Owner);
public sealed record CreateRoleRequest(string Name, string Password);
public sealed record QueryRequest(string Database, string Sql);
public sealed record BackupRequest(string Database);
public sealed record RestoreRequest(
string BackupFileName,
string TargetDatabase,
bool Overwrite,
string Confirmation);
public sealed record ManagedClient(
string AppId,
string DisplayName,
string DatabaseName,
string RoleName,
string[] Extensions,
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record DatabaseSummary(
string Name,
string Owner,
long SizeBytes,
int ActiveConnections,
bool IsManaged);
public sealed record RoleSummary(
string Name,
bool CanLogin,
int ConnectionLimit,
bool IsManaged);
public sealed record SessionSummary(
int ProcessId,
string Database,
string Username,
string State,
string? Query,
DateTimeOffset? QueryStartedAt);
public sealed record BackupSummary(
string FileName,
string Database,
long SizeBytes,
DateTimeOffset CreatedAt,
string Sha256);
public sealed record QueryResult(
string[] Columns,
IReadOnlyList<object?[]> Rows,
int RowCount,
bool Truncated,
long ElapsedMilliseconds);
@@ -0,0 +1,810 @@
using System.Data;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using Npgsql;
namespace PostgresService.WebApi;
public sealed class PostgresAdminService
{
private const string MetadataDatabase = "postgres_service";
private const string ConsoleRole = "postgres_console";
private static readonly Regex IdentifierPattern = new("^[a-z][a-z0-9_]{2,62}$", RegexOptions.Compiled);
private static readonly Regex AppIdPattern = new("^[a-z][a-z0-9._-]{2,63}$", RegexOptions.Compiled);
private static readonly Regex ReadOnlySqlPattern = new(
"^\\s*(select|with|explain|show|values|table)\\b",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly HashSet<string> SystemDatabases = new(StringComparer.Ordinal)
{
"postgres", "template0", "template1", MetadataDatabase
};
private readonly string _host;
private readonly int _port;
private readonly string _username;
private readonly string _pgBin;
private readonly string _backupRoot;
private readonly string _auditPath;
private readonly SemaphoreSlim _provisionLock = new(1, 1);
private readonly SemaphoreSlim _auditLock = new(1, 1);
public PostgresAdminService(IConfiguration configuration)
{
_host = configuration["POSTGRES_SERVICE_SOCKET_ROOT"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_SOCKET_ROOT")
?? "/tmp";
_port = int.TryParse(
configuration["POSTGRES_SERVICE_PORT"] ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_PORT"),
out var port) ? port : 15432;
_username = configuration["POSTGRES_SERVICE_ADMIN_USER"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_ADMIN_USER")
?? "postgres_service";
_pgBin = configuration["POSTGRES_SERVICE_PG_BIN"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_PG_BIN")
?? "/usr/lib/postgresql/15/bin";
var dataRoot = configuration["POSTGRES_SERVICE_DATA_ROOT"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_DATA_ROOT")
?? Path.Combine(AppContext.BaseDirectory, "data");
_backupRoot = configuration["POSTGRES_SERVICE_BACKUP_ROOT"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_BACKUP_ROOT")
?? Path.Combine(dataRoot, "backups");
_auditPath = Path.Combine(dataRoot, "audit.jsonl");
Directory.CreateDirectory(dataRoot);
Directory.CreateDirectory(_backupRoot);
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await using (var connection = await OpenAsync("postgres", cancellationToken))
{
if (!await DatabaseExistsAsync(connection, MetadataDatabase, cancellationToken))
{
await ExecuteNonQueryAsync(
connection,
$"CREATE DATABASE {QuoteIdentifier(MetadataDatabase)} OWNER {QuoteIdentifier(_username)}",
cancellationToken);
}
if (!await RoleExistsAsync(connection, ConsoleRole, cancellationToken))
{
await ExecuteNonQueryAsync(
connection,
$"CREATE ROLE {QuoteIdentifier(ConsoleRole)} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT",
cancellationToken);
}
await ExecuteNonQueryAsync(
connection,
$"ALTER ROLE {QuoteIdentifier(ConsoleRole)} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; GRANT pg_read_all_data TO {QuoteIdentifier(ConsoleRole)}",
cancellationToken);
}
await using var metadata = await OpenAsync(MetadataDatabase, cancellationToken);
const string schema = """
CREATE TABLE IF NOT EXISTS managed_clients (
app_id text PRIMARY KEY,
display_name text NOT NULL,
database_name text NOT NULL UNIQUE,
role_name text NOT NULL UNIQUE,
extensions text[] NOT NULL DEFAULT '{}',
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
""";
await ExecuteNonQueryAsync(metadata, schema, cancellationToken);
}
public async Task<PostgresClientCredential> EnrollAsync(
EnrollRequest request,
CancellationToken cancellationToken)
{
var appId = request.AppId.Trim().ToLowerInvariant();
if (!AppIdPattern.IsMatch(appId))
{
throw new ArgumentException("appId 格式无效,只允许小写字母、数字、点、下划线和连字符。");
}
var displayName = request.DisplayName.Trim();
if (displayName.Length is < 1 or > 100)
{
throw new ArgumentException("displayName 长度必须为 1 到 100 个字符。");
}
var extensions = (request.RequestedExtensions ?? [])
.Select(item => item.Trim().ToLowerInvariant())
.Where(item => item.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (extensions.Any(item => item != "vector"))
{
throw new ArgumentException("存在不受支持的 PostgreSQL 扩展。");
}
await _provisionLock.WaitAsync(cancellationToken);
try
{
var existing = await FindClientAsync(appId, cancellationToken);
var databaseName = existing?.DatabaseName ?? BuildResourceName("appdb", appId);
var roleName = existing?.RoleName ?? BuildResourceName("app", appId);
var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(36));
await using (var postgres = await OpenAsync("postgres", cancellationToken))
{
if (!await RoleExistsAsync(postgres, roleName, cancellationToken))
{
var passwordLiteral = await QuoteLiteralAsync(postgres, password, cancellationToken);
await ExecuteNonQueryAsync(
postgres,
$"CREATE ROLE {QuoteIdentifier(roleName)} LOGIN PASSWORD {passwordLiteral} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 30",
cancellationToken);
}
else
{
await SetRolePasswordAsync(postgres, roleName, password, cancellationToken);
}
if (!await DatabaseExistsAsync(postgres, databaseName, cancellationToken))
{
await ExecuteNonQueryAsync(
postgres,
$"CREATE DATABASE {QuoteIdentifier(databaseName)} OWNER {QuoteIdentifier(roleName)}",
cancellationToken);
}
await ExecuteNonQueryAsync(
postgres,
$"REVOKE ALL ON DATABASE {QuoteIdentifier(databaseName)} FROM PUBLIC",
cancellationToken);
await ExecuteNonQueryAsync(
postgres,
$"GRANT CONNECT, TEMPORARY ON DATABASE {QuoteIdentifier(databaseName)} TO {QuoteIdentifier(roleName)}",
cancellationToken);
}
await using (var target = await OpenAsync(databaseName, cancellationToken))
{
await ExecuteNonQueryAsync(
target,
$"REVOKE ALL ON SCHEMA public FROM PUBLIC; ALTER SCHEMA public OWNER TO {QuoteIdentifier(roleName)}; GRANT USAGE ON SCHEMA public TO {QuoteIdentifier(ConsoleRole)}",
cancellationToken);
if (extensions.Contains("vector", StringComparer.Ordinal))
{
await ExecuteNonQueryAsync(target, "CREATE EXTENSION IF NOT EXISTS vector", cancellationToken);
}
}
await UpsertClientAsync(
appId, displayName, databaseName, roleName, extensions, cancellationToken);
await AuditAsync("client.enroll", appId, new { databaseName, roleName, extensions }, cancellationToken);
return new PostgresClientCredential(
"127.0.0.1", _port, databaseName, roleName, password, "Disable", "15");
}
finally
{
_provisionLock.Release();
}
}
public async Task<object> GetOverviewAsync(CancellationToken cancellationToken)
{
await using var connection = await OpenAsync("postgres", cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT
current_setting('server_version') AS version,
EXTRACT(EPOCH FROM (clock_timestamp() - pg_postmaster_start_time()))::bigint AS uptime_seconds,
(SELECT count(*) FROM pg_stat_activity) AS connections,
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections,
(SELECT count(*) FROM pg_database WHERE datallowconn) AS databases,
COALESCE((SELECT sum(pg_database_size(datname)) FROM pg_database WHERE datallowconn), 0) AS size_bytes
""", connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
await reader.ReadAsync(cancellationToken);
return new
{
version = reader.GetString(0),
uptimeSeconds = reader.GetInt64(1),
connections = reader.GetInt64(2),
maxConnections = reader.GetInt32(3),
databases = reader.GetInt64(4),
sizeBytes = reader.GetInt64(5),
host = "127.0.0.1",
port = _port
};
}
public async Task<IReadOnlyList<ManagedClient>> ListClientsAsync(CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(MetadataDatabase, cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT app_id, display_name, database_name, role_name, extensions, status, created_at, updated_at
FROM managed_clients
ORDER BY display_name, app_id
""", connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var items = new List<ManagedClient>();
while (await reader.ReadAsync(cancellationToken))
{
items.Add(ReadClient(reader));
}
return items;
}
public async Task<string> RotateClientPasswordAsync(string appId, CancellationToken cancellationToken)
{
var client = await FindClientAsync(appId, cancellationToken)
?? throw new KeyNotFoundException("客户端不存在。");
var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(36));
await using var connection = await OpenAsync("postgres", cancellationToken);
await SetRolePasswordAsync(connection, client.RoleName, password, cancellationToken);
await AuditAsync("client.rotate", appId, new { client.RoleName }, cancellationToken);
return password;
}
public async Task RevokeClientAsync(string appId, CancellationToken cancellationToken)
{
var client = await FindClientAsync(appId, cancellationToken)
?? throw new KeyNotFoundException("客户端不存在。");
await using (var connection = await OpenAsync("postgres", cancellationToken))
{
await ExecuteNonQueryAsync(
connection,
$"ALTER ROLE {QuoteIdentifier(client.RoleName)} NOLOGIN",
cancellationToken);
}
await using (var metadata = await OpenAsync(MetadataDatabase, cancellationToken))
await using (var command = new NpgsqlCommand(
"UPDATE managed_clients SET status = 'revoked', updated_at = now() WHERE app_id = @appId",
metadata))
{
command.Parameters.AddWithValue("appId", appId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await AuditAsync("client.revoke", appId, new { client.RoleName }, cancellationToken);
}
public async Task<IReadOnlyList<DatabaseSummary>> ListDatabasesAsync(CancellationToken cancellationToken)
{
var clients = await ListClientsAsync(cancellationToken);
var managed = clients.Select(item => item.DatabaseName).ToHashSet(StringComparer.Ordinal);
await using var connection = await OpenAsync("postgres", cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT d.datname, pg_get_userbyid(d.datdba), pg_database_size(d.datname),
count(a.pid) FILTER (WHERE a.pid IS NOT NULL)::int
FROM pg_database d
LEFT JOIN pg_stat_activity a ON a.datid = d.oid
WHERE d.datallowconn
GROUP BY d.datname, d.datdba
ORDER BY d.datname
""", connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<DatabaseSummary>();
while (await reader.ReadAsync(cancellationToken))
{
var name = reader.GetString(0);
result.Add(new DatabaseSummary(
name, reader.GetString(1), reader.GetInt64(2), reader.GetInt32(3), managed.Contains(name)));
}
return result;
}
public async Task<IReadOnlyList<RoleSummary>> ListRolesAsync(CancellationToken cancellationToken)
{
var clients = await ListClientsAsync(cancellationToken);
var managed = clients.Select(item => item.RoleName).ToHashSet(StringComparer.Ordinal);
await using var connection = await OpenAsync("postgres", cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT rolname, rolcanlogin, rolconnlimit
FROM pg_roles
WHERE rolname !~ '^pg_' AND rolname <> 'postgres_service' AND rolname <> 'postgres_console'
ORDER BY rolname
""", connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<RoleSummary>();
while (await reader.ReadAsync(cancellationToken))
{
var name = reader.GetString(0);
result.Add(new RoleSummary(name, reader.GetBoolean(1), reader.GetInt32(2), managed.Contains(name)));
}
return result;
}
public async Task<IReadOnlyList<SessionSummary>> ListSessionsAsync(CancellationToken cancellationToken)
{
await using var connection = await OpenAsync("postgres", cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT pid, COALESCE(datname, ''), usename, COALESCE(state, ''),
left(query, 1000), query_start
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
ORDER BY query_start DESC NULLS LAST
""", connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<SessionSummary>();
while (await reader.ReadAsync(cancellationToken))
{
result.Add(new SessionSummary(
reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3),
reader.IsDBNull(4) ? null : reader.GetString(4),
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5)));
}
return result;
}
public async Task CreateDatabaseAsync(CreateDatabaseRequest request, CancellationToken cancellationToken)
{
var name = ValidateIdentifier(request.Name, "数据库名称");
if (SystemDatabases.Contains(name))
{
throw new ArgumentException("不能创建或覆盖系统数据库。");
}
var owner = string.IsNullOrWhiteSpace(request.Owner)
? _username
: ValidateIdentifier(request.Owner, "所有者");
await using var connection = await OpenAsync("postgres", cancellationToken);
if (await DatabaseExistsAsync(connection, name, cancellationToken))
{
throw new InvalidOperationException("数据库已存在。");
}
if (!await RoleExistsAsync(connection, owner, cancellationToken))
{
throw new InvalidOperationException("指定角色不存在。");
}
await ExecuteNonQueryAsync(
connection,
$"CREATE DATABASE {QuoteIdentifier(name)} OWNER {QuoteIdentifier(owner)}",
cancellationToken);
await ExecuteNonQueryAsync(
connection,
$"REVOKE ALL ON DATABASE {QuoteIdentifier(name)} FROM PUBLIC; GRANT CONNECT, TEMPORARY ON DATABASE {QuoteIdentifier(name)} TO {QuoteIdentifier(owner)}",
cancellationToken);
await using (var target = await OpenAsync(name, cancellationToken))
{
await ExecuteNonQueryAsync(
target,
$"REVOKE ALL ON SCHEMA public FROM PUBLIC; ALTER SCHEMA public OWNER TO {QuoteIdentifier(owner)}; GRANT USAGE ON SCHEMA public TO {QuoteIdentifier(ConsoleRole)}",
cancellationToken);
}
await AuditAsync("database.create", name, new { owner }, cancellationToken);
}
public async Task DropDatabaseAsync(string name, string confirmation, CancellationToken cancellationToken)
{
name = ValidateIdentifier(name, "数据库名称");
if (confirmation != name)
{
throw new ArgumentException("确认名称不匹配。");
}
if (SystemDatabases.Contains(name) || await IsManagedDatabaseAsync(name, cancellationToken))
{
throw new InvalidOperationException("系统数据库或应用托管数据库不能直接删除。");
}
await using var connection = await OpenAsync("postgres", cancellationToken);
await ExecuteNonQueryAsync(
connection,
$"DROP DATABASE IF EXISTS {QuoteIdentifier(name)} WITH (FORCE)",
cancellationToken);
await AuditAsync("database.drop", name, null, cancellationToken);
}
public async Task CreateRoleAsync(CreateRoleRequest request, CancellationToken cancellationToken)
{
var name = ValidateIdentifier(request.Name, "角色名称");
if (string.IsNullOrEmpty(request.Password) || request.Password.Length < 16)
{
throw new ArgumentException("角色密码至少需要 16 个字符。");
}
await using var connection = await OpenAsync("postgres", cancellationToken);
if (await RoleExistsAsync(connection, name, cancellationToken))
{
throw new InvalidOperationException("角色已存在。");
}
var passwordLiteral = await QuoteLiteralAsync(connection, request.Password, cancellationToken);
await ExecuteNonQueryAsync(
connection,
$"CREATE ROLE {QuoteIdentifier(name)} LOGIN PASSWORD {passwordLiteral} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 20",
cancellationToken);
await AuditAsync("role.create", name, null, cancellationToken);
}
public async Task DropRoleAsync(string name, string confirmation, CancellationToken cancellationToken)
{
name = ValidateIdentifier(name, "角色名称");
if (confirmation != name)
{
throw new ArgumentException("确认名称不匹配。");
}
if (await IsManagedRoleAsync(name, cancellationToken))
{
throw new InvalidOperationException("应用托管角色不能直接删除,请先吊销客户端。");
}
await using var connection = await OpenAsync("postgres", cancellationToken);
await ExecuteNonQueryAsync(connection, $"DROP ROLE IF EXISTS {QuoteIdentifier(name)}", cancellationToken);
await AuditAsync("role.drop", name, null, cancellationToken);
}
public async Task TerminateSessionAsync(int processId, CancellationToken cancellationToken)
{
await using var connection = await OpenAsync("postgres", cancellationToken);
await using var command = new NpgsqlCommand("SELECT pg_terminate_backend(@pid)", connection);
command.Parameters.AddWithValue("pid", processId);
if (await command.ExecuteScalarAsync(cancellationToken) is not true)
{
throw new InvalidOperationException("会话不存在或无法终止。");
}
await AuditAsync("session.terminate", processId.ToString(), null, cancellationToken);
}
public async Task<QueryResult> ExecuteReadOnlyQueryAsync(QueryRequest request, CancellationToken cancellationToken)
{
var database = ValidateIdentifier(request.Database, "数据库名称");
var sql = request.Sql.Trim();
if (sql.Length is < 1 or > 100_000 || !ReadOnlySqlPattern.IsMatch(sql))
{
throw new ArgumentException("SQL 工作台仅允许 SELECT、WITH、EXPLAIN、SHOW、VALUES 或 TABLE 查询。");
}
var statements = sql.TrimEnd().TrimEnd(';');
if (statements.Contains(';'))
{
throw new ArgumentException("SQL 工作台每次只允许执行一条语句。");
}
var stopwatch = Stopwatch.StartNew();
await using var connection = await OpenAsync(database, cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken);
await ExecuteNonQueryAsync(
connection,
$"SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = '30s'; SET LOCAL lock_timeout = '5s'; SET LOCAL ROLE {QuoteIdentifier(ConsoleRole)}",
cancellationToken,
transaction);
await using var command = new NpgsqlCommand(statements, connection, transaction) { CommandTimeout = 30 };
string[] columns;
var rows = new List<object?[]>();
var truncated = false;
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
columns = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToArray();
while (await reader.ReadAsync(cancellationToken))
{
if (rows.Count >= 1000)
{
truncated = true;
break;
}
var row = new object?[reader.FieldCount];
for (var index = 0; index < reader.FieldCount; index++)
{
row[index] = reader.IsDBNull(index) ? null : NormalizeValue(reader.GetValue(index));
}
rows.Add(row);
}
}
await transaction.RollbackAsync(cancellationToken);
stopwatch.Stop();
await AuditAsync("query.read", database, new { rows = rows.Count, truncated }, cancellationToken);
return new QueryResult(columns, rows, rows.Count, truncated, stopwatch.ElapsedMilliseconds);
}
public async Task<IReadOnlyList<BackupSummary>> ListBackupsAsync(CancellationToken cancellationToken)
{
var result = new List<BackupSummary>();
foreach (var path in Directory.EnumerateFiles(_backupRoot, "*.dump", SearchOption.TopDirectoryOnly))
{
cancellationToken.ThrowIfCancellationRequested();
var file = new FileInfo(path);
var fileName = file.Name;
var separator = fileName.IndexOf("--", StringComparison.Ordinal);
var database = separator > 0 ? fileName[..separator] : "未知";
var checksumPath = path + ".sha256";
var checksum = File.Exists(checksumPath)
? (await File.ReadAllTextAsync(checksumPath, cancellationToken)).Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? ""
: await ComputeSha256Async(path, cancellationToken);
result.Add(new BackupSummary(fileName, database, file.Length, file.CreationTimeUtc, checksum));
}
return result.OrderByDescending(item => item.CreatedAt).ToArray();
}
public async Task<BackupSummary> CreateBackupAsync(string database, CancellationToken cancellationToken)
{
database = ValidateIdentifier(database, "数据库名称");
await EnsureDatabaseExistsAsync(database, cancellationToken);
var fileName = $"{database}--{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.dump";
var destination = Path.Combine(_backupRoot, fileName);
await RunPostgresToolAsync(
"pg_dump",
["--format=custom", "--no-owner", "--no-acl", "--file", destination, database],
cancellationToken);
var checksum = await ComputeSha256Async(destination, cancellationToken);
await File.WriteAllTextAsync(destination + ".sha256", $"{checksum} {fileName}\n", cancellationToken);
var file = new FileInfo(destination);
await AuditAsync("backup.create", database, new { fileName, checksum }, cancellationToken);
return new BackupSummary(fileName, database, file.Length, file.CreationTimeUtc, checksum);
}
public async Task RestoreBackupAsync(RestoreRequest request, CancellationToken cancellationToken)
{
var fileName = Path.GetFileName(request.BackupFileName);
if (!string.Equals(fileName, request.BackupFileName, StringComparison.Ordinal) || !fileName.EndsWith(".dump", StringComparison.Ordinal))
{
throw new ArgumentException("备份文件名称无效。");
}
var source = Path.Combine(_backupRoot, fileName);
if (!File.Exists(source))
{
throw new FileNotFoundException("备份文件不存在。", fileName);
}
var target = ValidateIdentifier(request.TargetDatabase, "目标数据库名称");
if (request.Confirmation != target)
{
throw new ArgumentException("确认名称不匹配。");
}
if (SystemDatabases.Contains(target))
{
throw new InvalidOperationException("不能恢复到系统数据库。");
}
await using (var connection = await OpenAsync("postgres", cancellationToken))
{
var exists = await DatabaseExistsAsync(connection, target, cancellationToken);
if (exists && !request.Overwrite)
{
throw new InvalidOperationException("目标数据库已存在,请使用新的名称或明确选择覆盖恢复。");
}
if (exists)
{
await ExecuteNonQueryAsync(connection, $"DROP DATABASE {QuoteIdentifier(target)} WITH (FORCE)", cancellationToken);
}
await ExecuteNonQueryAsync(connection, $"CREATE DATABASE {QuoteIdentifier(target)} OWNER {QuoteIdentifier(_username)}", cancellationToken);
}
try
{
await RunPostgresToolAsync(
"pg_restore",
["--no-owner", "--no-acl", "--exit-on-error", "--dbname", target, source],
cancellationToken);
}
catch
{
await AuditAsync("backup.restore.failed", target, new { fileName }, CancellationToken.None);
throw;
}
await AuditAsync("backup.restore", target, new { fileName, request.Overwrite }, cancellationToken);
}
private async Task<ManagedClient?> FindClientAsync(string appId, CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(MetadataDatabase, cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT app_id, display_name, database_name, role_name, extensions, status, created_at, updated_at
FROM managed_clients WHERE app_id = @appId
""", connection);
command.Parameters.AddWithValue("appId", appId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadClient(reader) : null;
}
private async Task UpsertClientAsync(
string appId,
string displayName,
string databaseName,
string roleName,
string[] extensions,
CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(MetadataDatabase, cancellationToken);
await using var command = new NpgsqlCommand("""
INSERT INTO managed_clients (app_id, display_name, database_name, role_name, extensions, status)
VALUES (@appId, @displayName, @databaseName, @roleName, @extensions, 'active')
ON CONFLICT (app_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
extensions = EXCLUDED.extensions,
status = 'active',
updated_at = now()
""", connection);
command.Parameters.AddWithValue("appId", appId);
command.Parameters.AddWithValue("displayName", displayName);
command.Parameters.AddWithValue("databaseName", databaseName);
command.Parameters.AddWithValue("roleName", roleName);
command.Parameters.AddWithValue("extensions", extensions);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private async Task<bool> IsManagedDatabaseAsync(string name, CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(MetadataDatabase, cancellationToken);
await using var command = new NpgsqlCommand(
"SELECT EXISTS (SELECT 1 FROM managed_clients WHERE database_name = @name)", connection);
command.Parameters.AddWithValue("name", name);
return await command.ExecuteScalarAsync(cancellationToken) is true;
}
private async Task<bool> IsManagedRoleAsync(string name, CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(MetadataDatabase, cancellationToken);
await using var command = new NpgsqlCommand(
"SELECT EXISTS (SELECT 1 FROM managed_clients WHERE role_name = @name)", connection);
command.Parameters.AddWithValue("name", name);
return await command.ExecuteScalarAsync(cancellationToken) is true;
}
private async Task EnsureDatabaseExistsAsync(string name, CancellationToken cancellationToken)
{
await using var connection = await OpenAsync("postgres", cancellationToken);
if (!await DatabaseExistsAsync(connection, name, cancellationToken))
{
throw new KeyNotFoundException("数据库不存在。");
}
}
private async Task<NpgsqlConnection> OpenAsync(string database, CancellationToken cancellationToken)
{
var builder = new NpgsqlConnectionStringBuilder
{
Host = _host,
Port = _port,
Username = _username,
Database = database,
Timeout = 10,
CommandTimeout = 30,
Pooling = true,
MaxPoolSize = 20,
ApplicationName = "nxsir-postgresql-admin"
};
var connection = new NpgsqlConnection(builder.ConnectionString);
await connection.OpenAsync(cancellationToken);
return connection;
}
private static async Task<bool> DatabaseExistsAsync(
NpgsqlConnection connection,
string name,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand("SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = @name)", connection);
command.Parameters.AddWithValue("name", name);
return await command.ExecuteScalarAsync(cancellationToken) is true;
}
private static async Task<bool> RoleExistsAsync(
NpgsqlConnection connection,
string name,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = @name)", connection);
command.Parameters.AddWithValue("name", name);
return await command.ExecuteScalarAsync(cancellationToken) is true;
}
private static async Task ExecuteNonQueryAsync(
NpgsqlConnection connection,
string sql,
CancellationToken cancellationToken,
NpgsqlTransaction? transaction = null)
{
await using var command = new NpgsqlCommand(sql, connection, transaction);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task SetRolePasswordAsync(
NpgsqlConnection connection,
string roleName,
string password,
CancellationToken cancellationToken)
{
var passwordLiteral = await QuoteLiteralAsync(connection, password, cancellationToken);
await ExecuteNonQueryAsync(
connection,
$"ALTER ROLE {QuoteIdentifier(roleName)} LOGIN PASSWORD {passwordLiteral}",
cancellationToken);
}
private static async Task<string> QuoteLiteralAsync(
NpgsqlConnection connection,
string value,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand("SELECT quote_literal(@value)", connection);
command.Parameters.AddWithValue("value", value);
return (string)(await command.ExecuteScalarAsync(cancellationToken)
?? throw new InvalidOperationException("无法安全处理角色密码。"));
}
private async Task RunPostgresToolAsync(
string tool,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken)
{
var path = Path.Combine(_pgBin, tool);
if (!File.Exists(path))
{
throw new FileNotFoundException($"缺少 PostgreSQL 工具:{tool}", path);
}
var startInfo = new ProcessStartInfo(path)
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false
};
startInfo.Environment["PGHOST"] = _host;
startInfo.Environment["PGPORT"] = _port.ToString();
startInfo.Environment["PGUSER"] = _username;
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException($"无法启动 {tool}。");
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"{tool} 执行失败:{stderr.Trim()}");
}
}
private async Task AuditAsync(string action, string target, object? detail, CancellationToken cancellationToken)
{
var entry = JsonSerializer.Serialize(new
{
timestamp = DateTimeOffset.UtcNow,
action,
target,
detail
});
await _auditLock.WaitAsync(cancellationToken);
try
{
await File.AppendAllTextAsync(_auditPath, entry + "\n", cancellationToken);
}
finally
{
_auditLock.Release();
}
}
private static ManagedClient ReadClient(NpgsqlDataReader reader) => new(
reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3),
reader.GetFieldValue<string[]>(4), reader.GetString(5),
reader.GetFieldValue<DateTimeOffset>(6), reader.GetFieldValue<DateTimeOffset>(7));
private static string ValidateIdentifier(string value, string label)
{
var normalized = value.Trim().ToLowerInvariant();
if (!IdentifierPattern.IsMatch(normalized))
{
throw new ArgumentException($"{label}格式无效,只允许小写字母、数字和下划线,且必须以字母开头。");
}
return normalized;
}
private static string BuildResourceName(string prefix, string appId)
{
var slug = Regex.Replace(appId, "[^a-z0-9]+", "_").Trim('_');
slug = slug.Length > 36 ? slug[..36] : slug;
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(appId)))
.ToLowerInvariant()[..10];
return $"{prefix}_{slug}_{hash}";
}
private static string QuoteIdentifier(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
private static object? NormalizeValue(object value) => value switch
{
string or bool or byte or short or int or long or float or double or decimal => value,
DateTime dateTime => dateTime,
DateTimeOffset dateTimeOffset => dateTimeOffset,
Guid guid => guid,
byte[] bytes => Convert.ToBase64String(bytes),
_ => value.ToString()
};
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="8.0.3" />
</ItemGroup>
</Project>
+199
View File
@@ -0,0 +1,199 @@
using System.Net;
using System.Text.Json;
using PostgresService.WebApi;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddSingleton<SecretStore>();
builder.Services.AddSingleton<AdminSessionStore>();
builder.Services.AddSingleton<PostgresAdminService>();
var app = builder.Build();
var secretStore = app.Services.GetRequiredService<SecretStore>();
var sessionStore = app.Services.GetRequiredService<AdminSessionStore>();
var postgres = app.Services.GetRequiredService<PostgresAdminService>();
secretStore.EnsureInitialized();
await postgres.InitializeAsync(CancellationToken.None);
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (Exception exception)
{
app.Logger.LogError(exception, "PostgreSQL 管理请求失败:{Method} {Path}", context.Request.Method, context.Request.Path);
if (context.Response.HasStarted)
{
throw;
}
var status = exception switch
{
ArgumentException => StatusCodes.Status400BadRequest,
FileNotFoundException or KeyNotFoundException => StatusCodes.Status404NotFound,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
InvalidOperationException => StatusCodes.Status409Conflict,
OperationCanceledException when context.RequestAborted.IsCancellationRequested => 499,
_ => StatusCodes.Status500InternalServerError
};
context.Response.StatusCode = status;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsJsonAsync(new
{
error = status == 500 ? "数据库管理服务发生内部错误。" : exception.Message
});
}
});
app.Use(async (context, next) =>
{
var path = context.Request.Path;
if (path.StartsWithSegments("/api/v1") && path != "/api/v1/auth/login")
{
context.Request.Cookies.TryGetValue("pg_admin_session", out var token);
if (!sessionStore.Validate(token))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { error = "管理会话无效或已过期。" });
return;
}
if (!HttpMethods.IsGet(context.Request.Method) &&
context.Request.Headers.TryGetValue("Origin", out var origin) &&
Uri.TryCreate(origin.ToString(), UriKind.Absolute, out var originUri) &&
!string.Equals(originUri.Authority, context.Request.Host.Value, StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "跨来源管理请求已被拒绝。" });
return;
}
}
await next();
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }));
app.MapGet("/health/ready", async (CancellationToken cancellationToken) =>
{
await postgres.GetOverviewAsync(cancellationToken);
return Results.Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow });
});
app.MapPost("/internal/v1/enroll", async (
HttpContext context,
EnrollRequest request,
CancellationToken cancellationToken) =>
{
if (context.Connection.RemoteIpAddress is null || !IPAddress.IsLoopback(context.Connection.RemoteIpAddress))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var authorization = context.Request.Headers.Authorization.ToString();
const string prefix = "Bearer ";
if (!authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
!secretStore.VerifyEnrollmentToken(authorization[prefix.Length..]))
{
return Results.Unauthorized();
}
var credential = await postgres.EnrollAsync(request, cancellationToken);
return Results.Ok(credential);
});
app.MapPost("/api/v1/auth/login", (HttpContext context, LoginRequest request) =>
{
if (!string.Equals(request.Username, "admin", StringComparison.OrdinalIgnoreCase) ||
!secretStore.VerifyAdminPassword(request.Password))
{
return Results.Json(new { error = "用户名或密码错误。" }, statusCode: StatusCodes.Status401Unauthorized);
}
var token = sessionStore.Create();
context.Response.Cookies.Append("pg_admin_session", token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = context.Request.IsHttps,
MaxAge = TimeSpan.FromHours(12),
Path = "/"
});
return Results.Ok(new { username = "admin" });
});
app.MapPost("/api/v1/auth/logout", (HttpContext context) =>
{
context.Request.Cookies.TryGetValue("pg_admin_session", out var token);
sessionStore.Revoke(token);
context.Response.Cookies.Delete("pg_admin_session", new CookieOptions { Path = "/" });
return Results.NoContent();
});
app.MapGet("/api/v1/overview", postgres.GetOverviewAsync);
app.MapGet("/api/v1/clients", postgres.ListClientsAsync);
app.MapPost("/api/v1/clients/{appId}/rotate", async (string appId, CancellationToken cancellationToken) =>
Results.Ok(new { password = await postgres.RotateClientPasswordAsync(appId, cancellationToken) }));
app.MapPost("/api/v1/clients/{appId}/revoke", async (string appId, CancellationToken cancellationToken) =>
{
await postgres.RevokeClientAsync(appId, cancellationToken);
return Results.NoContent();
});
app.MapPost("/api/v1/enrollment-token/rotate", () =>
Results.Ok(new { token = secretStore.RotateEnrollmentToken() }));
app.MapGet("/api/v1/databases", postgres.ListDatabasesAsync);
app.MapPost("/api/v1/databases", async (CreateDatabaseRequest request, CancellationToken cancellationToken) =>
{
await postgres.CreateDatabaseAsync(request, cancellationToken);
return Results.NoContent();
});
app.MapDelete("/api/v1/databases/{name}", async (
string name, string confirmation, CancellationToken cancellationToken) =>
{
await postgres.DropDatabaseAsync(name, confirmation, cancellationToken);
return Results.NoContent();
});
app.MapGet("/api/v1/roles", postgres.ListRolesAsync);
app.MapPost("/api/v1/roles", async (CreateRoleRequest request, CancellationToken cancellationToken) =>
{
await postgres.CreateRoleAsync(request, cancellationToken);
return Results.NoContent();
});
app.MapDelete("/api/v1/roles/{name}", async (
string name, string confirmation, CancellationToken cancellationToken) =>
{
await postgres.DropRoleAsync(name, confirmation, cancellationToken);
return Results.NoContent();
});
app.MapGet("/api/v1/sessions", postgres.ListSessionsAsync);
app.MapPost("/api/v1/sessions/{processId:int}/terminate", async (
int processId, CancellationToken cancellationToken) =>
{
await postgres.TerminateSessionAsync(processId, cancellationToken);
return Results.NoContent();
});
app.MapPost("/api/v1/query", postgres.ExecuteReadOnlyQueryAsync);
app.MapGet("/api/v1/backups", postgres.ListBackupsAsync);
app.MapPost("/api/v1/backups", async (BackupRequest request, CancellationToken cancellationToken) =>
Results.Ok(await postgres.CreateBackupAsync(request.Database, cancellationToken)));
app.MapPost("/api/v1/backups/restore", async (RestoreRequest request, CancellationToken cancellationToken) =>
{
await postgres.RestoreBackupAsync(request, cancellationToken);
return Results.NoContent();
});
app.MapFallbackToFile("index.html");
app.Run();
public partial class Program;
+160
View File
@@ -0,0 +1,160 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace PostgresService.WebApi;
public sealed class SecretStore
{
private const int Iterations = 210_000;
private readonly string _dataRoot;
private readonly object _sync = new();
public SecretStore(IConfiguration configuration)
{
_dataRoot = configuration["POSTGRES_SERVICE_DATA_ROOT"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_DATA_ROOT")
?? Path.Combine(AppContext.BaseDirectory, "data");
Directory.CreateDirectory(_dataRoot);
}
public bool VerifyAdminPassword(string value) => Verify("admin-password", value);
public bool VerifyEnrollmentToken(string value) => Verify("enrollment-token", value);
public string RotateEnrollmentToken()
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
WriteHash("enrollment-token", token);
return token;
}
public void EnsureInitialized()
{
lock (_sync)
{
PromoteSeed("admin-password");
PromoteSeed("enrollment-token");
}
}
private void PromoteSeed(string name)
{
var hashPath = Path.Combine(_dataRoot, $"{name}.hash");
if (File.Exists(hashPath))
{
return;
}
var seedPath = Path.Combine(_dataRoot, $"{name}.seed");
if (!File.Exists(seedPath))
{
throw new InvalidOperationException($"缺少 {name} 初始化文件。");
}
var seed = File.ReadAllText(seedPath).TrimEnd('\r', '\n');
if (string.IsNullOrWhiteSpace(seed))
{
throw new InvalidOperationException($"{name} 不能为空。");
}
WriteHash(name, seed);
File.Delete(seedPath);
}
private bool Verify(string name, string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
var path = Path.Combine(_dataRoot, $"{name}.hash");
if (!File.Exists(path))
{
return false;
}
var parts = File.ReadAllText(path).Trim().Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2-sha256" || !int.TryParse(parts[1], out var iterations))
{
return false;
}
try
{
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(value), salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch (FormatException)
{
return false;
}
}
private void WriteHash(string name, string value)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(value), salt, Iterations, HashAlgorithmName.SHA256, 32);
var content = $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}\n";
var destination = Path.Combine(_dataRoot, $"{name}.hash");
var temporary = destination + ".tmp";
File.WriteAllText(temporary, content, new UTF8Encoding(false));
File.Move(temporary, destination, true);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
}
}
public sealed class AdminSessionStore
{
private static readonly TimeSpan Lifetime = TimeSpan.FromHours(12);
private readonly ConcurrentDictionary<string, DateTimeOffset> _sessions = new();
public string Create()
{
RemoveExpired();
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
_sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime);
return token;
}
public bool Validate(string? token)
{
if (string.IsNullOrWhiteSpace(token) || !_sessions.TryGetValue(token, out var expiresAt))
{
return false;
}
if (expiresAt <= DateTimeOffset.UtcNow)
{
_sessions.TryRemove(token, out _);
return false;
}
_sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime);
return true;
}
public void Revoke(string? token)
{
if (!string.IsNullOrWhiteSpace(token))
{
_sessions.TryRemove(token, out _);
}
}
private void RemoveExpired()
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in _sessions.Where(item => item.Value <= now))
{
_sessions.TryRemove(entry.Key, out _);
}
}
}
@@ -22,6 +22,7 @@
<ProjectReference Include="..\..\src\LiveRecorder.Application\LiveRecorder.Application.csproj" />
<ProjectReference Include="..\..\src\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
<ProjectReference Include="..\..\src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\PostgresService.WebApi\PostgresService.WebApi.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Configuration;
using PostgresService.WebApi;
namespace LiveRecorder.Tests;
public sealed class PostgresServiceSecurityTests : IDisposable
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), $"postgres-service-tests-{Guid.NewGuid():N}");
[Fact]
public void SecretStore_PromotesSeedsAndUsesIndependentHashes()
{
Directory.CreateDirectory(_dataRoot);
File.WriteAllText(Path.Combine(_dataRoot, "admin-password.seed"), "Admin-Password-For-Tests!\n");
File.WriteAllText(Path.Combine(_dataRoot, "enrollment-token.seed"), "Enrollment-Token-For-Tests-2026!\n");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["POSTGRES_SERVICE_DATA_ROOT"] = _dataRoot
})
.Build();
var store = new SecretStore(configuration);
store.EnsureInitialized();
Assert.True(store.VerifyAdminPassword("Admin-Password-For-Tests!"));
Assert.False(store.VerifyAdminPassword("Enrollment-Token-For-Tests-2026!"));
Assert.True(store.VerifyEnrollmentToken("Enrollment-Token-For-Tests-2026!"));
Assert.False(File.Exists(Path.Combine(_dataRoot, "admin-password.seed")));
Assert.False(File.Exists(Path.Combine(_dataRoot, "enrollment-token.seed")));
Assert.StartsWith("pbkdf2-sha256$", File.ReadAllText(Path.Combine(_dataRoot, "admin-password.hash")));
}
[Fact]
public void SecretStore_RotatedEnrollmentTokenInvalidatesOldToken()
{
Directory.CreateDirectory(_dataRoot);
File.WriteAllText(Path.Combine(_dataRoot, "admin-password.seed"), "Admin-Password-For-Tests!\n");
File.WriteAllText(Path.Combine(_dataRoot, "enrollment-token.seed"), "Enrollment-Token-For-Tests-2026!\n");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["POSTGRES_SERVICE_DATA_ROOT"] = _dataRoot
})
.Build();
var store = new SecretStore(configuration);
store.EnsureInitialized();
var replacement = store.RotateEnrollmentToken();
Assert.Equal(64, replacement.Length);
Assert.True(store.VerifyEnrollmentToken(replacement));
Assert.False(store.VerifyEnrollmentToken("Enrollment-Token-For-Tests-2026!"));
}
[Fact]
public void AdminSessionStore_CreatesValidAndRevocableSession()
{
var sessions = new AdminSessionStore();
var token = sessions.Create();
Assert.True(sessions.Validate(token));
sessions.Revoke(token);
Assert.False(sessions.Validate(token));
Assert.False(sessions.Validate("unknown"));
}
public void Dispose()
{
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, recursive: true);
}
}
}