commit 01976310e73862920f2f35e52d874cccf3005a28 Author: nanxun Date: Sun Aug 2 19:57:24 2026 +0800 feat: add native fnOS PostgreSQL shared service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d70861 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +**/bin/ +**/obj/ +frontend/node_modules/ +frontend/dist-postgres/ +.cache/ +.tools/ +artifacts/ +*.fpk +*.sha256 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbd7893 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# PostgreSQL FPK for fnOS + +`nxsir.postgresql` 是面向 fnOS 原生应用的共享 PostgreSQL 15 服务,不依赖 Docker。多个应用共用一个数据库进程,但通过回环注册 API 获得相互隔离的数据库、SCRAM 角色和随机密码。 + +主要功能: + +- PostgreSQL 15,仅监听 `127.0.0.1:15432` +- 独立管理界面,默认端口 `15433` +- 回环限定的应用注册与凭据签发 +- pgvector 扩展申请 +- 客户端、数据库、角色与会话管理 +- 默认只读的 SQL 工作台(30 秒、1000 行上限) +- 手动 custom-format 备份、SHA-256 与恢复 +- 独立管理密码、HttpOnly 会话和接入令牌轮换 + +## 目录 + +```text +fnos-postgresql/ fnOS 包清单、向导和生命周期脚本 +src/PostgresService.WebApi/ 管理与注册 API +frontend/postgres-admin/ Vue 管理界面 +scripts/ 构建、包校验和运行级冒烟测试 +tests/PostgresService.Tests/ 安全凭据单元测试 +docs/ 第三方 fnOS 应用接入文档 +``` + +## 构建 + +环境要求:Linux x86_64、.NET 8 SDK、Node.js/npm、`apt-get`、`dpkg-deb`、curl,以及 fnOS 官方 `fnpack`。可把 `fnpack` 放到 `.tools/fnpack`,或通过 `FNPACK` 指定。 + +```bash +cd frontend +npm ci +cd .. +./scripts/build-postgresql-fnos-package.sh +``` + +默认输出: + +```text +artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk.sha256 +``` + +构建脚本会下载 Debian Bookworm PostgreSQL 15 运行时,并对官方 pgvector `0.8.6-1.pgdg12+1` 包执行固定 SHA-256 校验。`app.tgz` 还会检查校验和、路径穿越、绝对/越界链接和 512 MB 解包上限。 + +如有离线 NuGet 源,可设置 `POSTGRES_SERVICE_NUGET_FEED=/path/to/feed`;否则使用 nuget.org。 + +## 验证 + +```bash +dotnet test tests/PostgresService.Tests/PostgresService.Tests.csproj +./scripts/verify-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +``` + +冒烟测试会临时启动实际包内 PostgreSQL 和管理 API,验证两个隔离客户端、SCRAM、pgvector、跨库拒绝、只读 SQL、写语句拒绝、备份和重启持久性,退出时停止进程并删除临时数据。 + +## 应用接入 + +其他 fnOS 应用请直接阅读 [fnOS PostgreSQL 共享服务接入指南](docs/fnos-postgresql-client-integration.md),其中包含 manifest 依赖、注册 API、凭据落盘、连接示例、pgvector、迁移和故障恢复规范。 diff --git a/docs/fnos-postgresql-client-integration.md b/docs/fnos-postgresql-client-integration.md new file mode 100644 index 0000000..b2a5817 --- /dev/null +++ b/docs/fnos-postgresql-client-integration.md @@ -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=;Username=;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/?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 能在自己的数据库执行。 +- 已验证无法连接另一个测试应用的数据库。 +- 共享服务重启后应用能用原凭据恢复。 +- 已准备凭据丢失、密码轮换和迁移失败的明确恢复流程。 diff --git a/fnos-postgresql/app/ui/config b/fnos-postgresql/app/ui/config new file mode 100644 index 0000000..64638f6 --- /dev/null +++ b/fnos-postgresql/app/ui/config @@ -0,0 +1,13 @@ +{ + ".url": { + "nxsir.postgresql.Application": { + "title": "PostgreSQL 共享服务", + "icon": "images/icon_{0}.png", + "type": "url", + "protocol": "", + "port": "15433", + "url": "/", + "allUsers": false + } + } +} diff --git a/fnos-postgresql/cmd/config_callback b/fnos-postgresql/cmd/config_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/config_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/config_init b/fnos-postgresql/cmd/config_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/config_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/install_callback b/fnos-postgresql/cmd/install_callback new file mode 100644 index 0000000..4bef5ad --- /dev/null +++ b/fnos-postgresql/cmd/install_callback @@ -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 diff --git a/fnos-postgresql/cmd/install_init b/fnos-postgresql/cmd/install_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/install_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/main b/fnos-postgresql/cmd/main new file mode 100644 index 0000000..f72623c --- /dev/null +++ b/fnos-postgresql/cmd/main @@ -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 diff --git a/fnos-postgresql/cmd/uninstall_callback b/fnos-postgresql/cmd/uninstall_callback new file mode 100644 index 0000000..437d571 --- /dev/null +++ b/fnos-postgresql/cmd/uninstall_callback @@ -0,0 +1,3 @@ +#!/bin/bash +# 数据目录与手动备份默认保留,避免误删多个应用的共享数据。 +exit 0 diff --git a/fnos-postgresql/cmd/uninstall_init b/fnos-postgresql/cmd/uninstall_init new file mode 100644 index 0000000..61a7c23 --- /dev/null +++ b/fnos-postgresql/cmd/uninstall_init @@ -0,0 +1,4 @@ +#!/bin/bash +set -eu +"$(dirname -- "$0")/main" stop || true +exit 0 diff --git a/fnos-postgresql/cmd/upgrade_callback b/fnos-postgresql/cmd/upgrade_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/upgrade_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/upgrade_init b/fnos-postgresql/cmd/upgrade_init new file mode 100644 index 0000000..27b9a7d --- /dev/null +++ b/fnos-postgresql/cmd/upgrade_init @@ -0,0 +1,6 @@ +#!/bin/bash +set -eu + +# 当前服务固定在 PostgreSQL 15 主版本;数据目录与管理凭据位于 +# TRIM_PKGVAR,fnOS 替换不可变应用文件时无需复制。 +exit 0 diff --git a/fnos-postgresql/config/privilege b/fnos-postgresql/config/privilege new file mode 100644 index 0000000..dc2d99d --- /dev/null +++ b/fnos-postgresql/config/privilege @@ -0,0 +1,5 @@ +{ + "defaults": { + "run-as": "package" + } +} diff --git a/fnos-postgresql/config/resource b/fnos-postgresql/config/resource new file mode 100644 index 0000000..44392ea --- /dev/null +++ b/fnos-postgresql/config/resource @@ -0,0 +1,18 @@ +{ + "data-share": { + "shares": [ + { + "name": "postgresql", + "permission": { + "rw": ["nxsir.postgresql"] + } + }, + { + "name": "postgresql/backups", + "permission": { + "rw": ["nxsir.postgresql"] + } + } + ] + } +} diff --git a/fnos-postgresql/manifest b/fnos-postgresql/manifest new file mode 100644 index 0000000..5a4d973 --- /dev/null +++ b/fnos-postgresql/manifest @@ -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 diff --git a/fnos-postgresql/wizard/install b/fnos-postgresql/wizard/install new file mode 100644 index 0000000..0036aad --- /dev/null +++ b/fnos-postgresql/wizard/install @@ -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": "请再次输入接入令牌" } + ] + } + ] + } +] diff --git a/fnos-postgresql/wizard/upgrade b/fnos-postgresql/wizard/upgrade new file mode 100644 index 0000000..e305f38 --- /dev/null +++ b/fnos-postgresql/wizard/upgrade @@ -0,0 +1,11 @@ +[ + { + "stepTitle": "升级 PostgreSQL 共享服务", + "items": [ + { + "type": "tips", + "helpText": "升级期间所有依赖应用会暂时失去数据库连接。当前版本仅执行 PostgreSQL 15 同主版本升级,并保留数据、凭据、审计记录和备份。" + } + ] + } +] diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0c463f7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1691 @@ +{ + "name": "nxsir-postgresql-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nxsir-postgresql-admin", + "version": "0.1.0", + "dependencies": { + "element-plus": "^2.10.1", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bb34258 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "nxsir-postgresql-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --config vite.postgres.config.ts", + "build": "vite build --config vite.postgres.config.ts", + "build:postgres-admin": "vite build --config vite.postgres.config.ts", + "preview": "vite preview --config vite.postgres.config.ts" + }, + "dependencies": { + "element-plus": "^2.10.1", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/postgres-admin/App.vue b/frontend/postgres-admin/App.vue new file mode 100644 index 0000000..d1678a3 --- /dev/null +++ b/frontend/postgres-admin/App.vue @@ -0,0 +1,318 @@ + + + diff --git a/frontend/postgres-admin/index.html b/frontend/postgres-admin/index.html new file mode 100644 index 0000000..150a48f --- /dev/null +++ b/frontend/postgres-admin/index.html @@ -0,0 +1,13 @@ + + + + + + + PostgreSQL 服务 + + +
+ + + diff --git a/frontend/postgres-admin/main.ts b/frontend/postgres-admin/main.ts new file mode 100644 index 0000000..c75ba69 --- /dev/null +++ b/frontend/postgres-admin/main.ts @@ -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"); diff --git a/frontend/postgres-admin/style.css b/frontend/postgres-admin/style.css new file mode 100644 index 0000000..3feecd2 --- /dev/null +++ b/frontend/postgres-admin/style.css @@ -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%} } diff --git a/frontend/vite.postgres.config.ts b/frontend/vite.postgres.config.ts new file mode 100644 index 0000000..bc22697 --- /dev/null +++ b/frontend/vite.postgres.config.ts @@ -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 + } +}); diff --git a/scripts/build-postgresql-fnos-package.sh b/scripts/build-postgresql-fnos-package.sh new file mode 100755 index 0000000..b1ac020 --- /dev/null +++ b/scripts/build-postgresql-fnos-package.sh @@ -0,0 +1,180 @@ +#!/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}" +DOTNET_BIN="${DOTNET:-dotnet}" +NUGET_FEED="${POSTGRES_SERVICE_NUGET_FEED:-}" +NUGET_PACKAGES="${NUGET_PACKAGES:-$ROOT_DIR/.cache/nuget-packages}" +DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$ROOT_DIR/.cache/dotnet-cli-home}" +BUILD_TMP_ROOT="${POSTGRES_SERVICE_BUILD_TMPDIR:-$ROOT_DIR/.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 +DOTNET_BIN=$(command -v "$DOTNET_BIN") || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; } +if [ -n "$NUGET_FEED" ] && [ ! -d "$NUGET_FEED" ]; then + printf 'offline NuGet feed does not exist: %s\n' "$NUGET_FEED" >&2 + exit 1 +fi + +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 +restore_args=(-r linux-x64 --source "https://api.nuget.org/v3/index.json" --disable-parallel) +if [ -n "$NUGET_FEED" ]; then + restore_args+=(--source "$NUGET_FEED") +fi +"$DOTNET_BIN" restore "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" "${restore_args[@]}" +"$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" diff --git a/scripts/fnos-bookworm.sources.list b/scripts/fnos-bookworm.sources.list new file mode 100644 index 0000000..ad67c67 --- /dev/null +++ b/scripts/fnos-bookworm.sources.list @@ -0,0 +1,3 @@ +deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main +deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main +deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main diff --git a/scripts/generate-fnos-icons.mjs b/scripts/generate-fnos-icons.mjs new file mode 100644 index 0000000..f30c32d --- /dev/null +++ b/scripts/generate-fnos-icons.mjs @@ -0,0 +1,131 @@ +import { deflateSync } from "node:zlib"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const [packageRoot, uiImageRoot] = process.argv.slice(2); +if (!packageRoot || !uiImageRoot) { + throw new Error("usage: node generate-fnos-icons.mjs package-root ui-image-root"); +} + +const crcTable = new Uint32Array(256); +for (let index = 0; index < 256; index++) { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + crcTable[index] = value >>> 0; +} + +function crc32(buffer) { + let value = 0xffffffff; + for (const byte of buffer) { + value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8); + } + return (value ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const typeBuffer = Buffer.from(type, "ascii"); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data]))); + return Buffer.concat([length, typeBuffer, data, checksum]); +} + +function roundedRectangleDistance(x, y, left, top, right, bottom, radius) { + const centerX = (left + right) / 2; + const centerY = (top + bottom) / 2; + const halfWidth = (right - left) / 2 - radius; + const halfHeight = (bottom - top) / 2 - radius; + const dx = Math.max(Math.abs(x - centerX) - halfWidth, 0); + const dy = Math.max(Math.abs(y - centerY) - halfHeight, 0); + return Math.hypot(dx, dy) - radius; +} + +function render(size) { + const pixels = Buffer.alloc(size * size * 4); + const samples = 3; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const rgba = [0, 0, 0, 0]; + for (let sy = 0; sy < samples; sy++) { + for (let sx = 0; sx < samples; sx++) { + const px = x + (sx + 0.5) / samples; + const py = y + (sy + 0.5) / samples; + const scale = size / 256; + let color = [0, 0, 0, 0]; + const background = roundedRectangleDistance(px, py, 12 * scale, 12 * scale, 244 * scale, 244 * scale, 50 * scale); + if (background <= 0) { + const mix = Math.min(1, Math.max(0, (px + py) / (512 * scale))); + color = [Math.round(27 + 33 * mix), Math.round(94 + 66 * mix), Math.round(180 + 49 * mix), 255]; + } + + const body = roundedRectangleDistance(px, py, 51 * scale, 75 * scale, 190 * scale, 185 * scale, 22 * scale); + if (body <= 0) { + color = [245, 249, 255, 255]; + } + + const lensDistance = Math.hypot(px - 120 * scale, py - 130 * scale); + if (lensDistance <= 36 * scale) { + color = lensDistance <= 23 * scale ? [42, 109, 196, 255] : [128, 185, 239, 255]; + } + + const viewfinder = roundedRectangleDistance(px, py, 73 * scale, 56 * scale, 122 * scale, 86 * scale, 9 * scale); + if (viewfinder <= 0) { + color = [232, 241, 253, 255]; + } + + if (px >= 190 * scale && px <= 222 * scale && py >= 101 * scale && py <= 159 * scale) { + const edge = Math.abs(py - 130 * scale) / (29 * scale); + const leftEdge = 190 * scale + edge * 16 * scale; + if (px >= leftEdge) { + color = [237, 244, 253, 255]; + } + } + + const statusDistance = Math.hypot(px - 165 * scale, py - 98 * scale); + if (statusDistance <= 9 * scale) { + color = [244, 85, 93, 255]; + } + + for (let channel = 0; channel < 4; channel++) { + rgba[channel] += color[channel]; + } + } + } + const offset = (y * size + x) * 4; + for (let channel = 0; channel < 4; channel++) { + pixels[offset + channel] = Math.round(rgba[channel] / (samples * samples)); + } + } + } + + const scanlines = Buffer.alloc((size * 4 + 1) * size); + for (let row = 0; row < size; row++) { + const target = row * (size * 4 + 1); + scanlines[target] = 0; + pixels.copy(scanlines, target + 1, row * size * 4, (row + 1) * size * 4); + } + + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; + header[9] = 6; + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(scanlines, { level: 9 })), + chunk("IEND", Buffer.alloc(0)) + ]); +} + +mkdirSync(packageRoot, { recursive: true }); +mkdirSync(uiImageRoot, { recursive: true }); +const icon64 = render(64); +const icon256 = render(256); +writeFileSync(join(packageRoot, "ICON.PNG"), icon64); +writeFileSync(join(packageRoot, "ICON_256.PNG"), icon256); +writeFileSync(join(uiImageRoot, "icon_64.png"), icon64); +writeFileSync(join(uiImageRoot, "icon_256.png"), icon256); diff --git a/scripts/smoke-postgresql-fnos-package.sh b/scripts/smoke-postgresql-fnos-package.sh new file mode 100755 index 0000000..fb7bcf6 --- /dev/null +++ b/scripts/smoke-postgresql-fnos-package.sh @@ -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 '
' + +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' diff --git a/scripts/verify-fnos-package.sh b/scripts/verify-fnos-package.sh new file mode 100755 index 0000000..30e443f --- /dev/null +++ b/scripts/verify-fnos-package.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +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%/}/fnos-package-verify.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT + +manifest_value() { + sed -n "s/^$1[[:space:]]*=[[:space:]]*//p" "$WORK_DIR/manifest" | head -n 1 | tr -d '\r' +} + +tar -xzf "$PACKAGE" -C "$WORK_DIR" +appname=$(manifest_value appname) +version=$(manifest_value version) +test "$appname" = "nxsir.postgresql" || { printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1; } +test -n "$version" +test "$(manifest_value platform)" = "x86" +test -x "$WORK_DIR/cmd/main" +test -x "$WORK_DIR/cmd/install_callback" +test -s "$WORK_DIR/wizard/install" +test -s "$WORK_DIR/wizard/upgrade" +test -s "$WORK_DIR/ICON.PNG" +test -s "$WORK_DIR/ICON_256.PNG" + +expected=$(manifest_value checksum) +actual=$(md5sum "$WORK_DIR/app.tgz" | cut -d' ' -f1) +test -n "$expected" +test "$expected" = "$actual" +gzip -t "$WORK_DIR/app.tgz" + +gzip -dc "$WORK_DIR/app.tgz" >"$WORK_DIR/app.tar" +uncompressed_bytes=$(wc -c <"$WORK_DIR/app.tar") +if [ "$uncompressed_bytes" -gt "$MAX_APP_UNCOMPRESSED_BYTES" ]; then + printf 'app.tgz expands to %s bytes; limit is %s bytes\n' \ + "$uncompressed_bytes" "$MAX_APP_UNCOMPRESSED_BYTES" >&2 + exit 1 +fi + +tar -tf "$WORK_DIR/app.tar" >"$WORK_DIR/app-files.txt" +if awk '/^\// || /(^|\/)\.\.($|\/)/ { unsafe = 1; exit } END { exit unsafe ? 0 : 1 }' "$WORK_DIR/app-files.txt"; then + printf 'app.tgz contains an unsafe member path\n' >&2 + exit 1 +fi + +tar -tvf "$WORK_DIR/app.tar" >"$WORK_DIR/app-metadata.txt" +if awk ' + /^l/ { + marker = " -> " + offset = index($0, marker) + if (offset > 0) { + target = substr($0, offset + length(marker)) + if (target ~ /^\//) { unsafe = 1; exit } + } + } + /^h/ { + marker = " link to " + offset = index($0, marker) + if (offset > 0) { + target = substr($0, offset + length(marker)) + if (target ~ /^\//) { unsafe = 1; exit } + } + } + END { exit unsafe ? 0 : 1 } +' "$WORK_DIR/app-metadata.txt"; then + printf 'app.tgz contains an unsafe symbolic or hard link\n' >&2 + exit 1 +fi + +grep -q '^server/wwwroot/index.html$' "$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" + +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" + +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 + exit 1 +fi + +if grep -Eq '^runtime/lib/(ld-linux-.*|libc\.so\..*|libBrokenLocale\.so\..*|libanl\.so\..*|libdl\.so\..*|libm(vec)?\.so\..*|libnss_(compat|dns|files|hesiod)\.so\..*|libpthread\.so\..*|libresolv\.so\..*|librt\.so\..*|libthread_db\.so\..*|libutil\.so\..*)$' "$WORK_DIR/app-files.txt"; then + printf 'the FPK must use the fnOS glibc and matching system loader\n' >&2 + exit 1 +fi + +if grep -Eq '^(data|records|postgres|log|var)/' "$WORK_DIR/app-files.txt"; then + printf 'persistent data must not be included in app.tgz\n' >&2 + exit 1 +fi + +mkdir -p "$WORK_DIR/app" +tar -xf "$WORK_DIR/app.tar" -C "$WORK_DIR/app" +while IFS= read -r -d '' link_path; do + target=$(readlink -- "$link_path") + case "$target" in + /*) printf 'absolute symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;; + esac + resolved=$(realpath -m -- "$(dirname -- "$link_path")/$target") + case "$resolved" in + "$WORK_DIR/app"/*) ;; + *) printf 'escaping symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;; + esac +done < <(find "$WORK_DIR/app" -type l -print0) + +printf 'fnOS package verified: %s (%s bytes uncompressed)\n' "$PACKAGE" "$uncompressed_bytes" diff --git a/src/PostgresService.WebApi/Models.cs b/src/PostgresService.WebApi/Models.cs new file mode 100644 index 0000000..dc4ad5f --- /dev/null +++ b/src/PostgresService.WebApi/Models.cs @@ -0,0 +1,72 @@ +namespace PostgresService.WebApi; + +public sealed record LoginRequest(string Username, string Password); + +public sealed record EnrollRequest( + string AppId, + string DisplayName, + IReadOnlyList? 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 Rows, + int RowCount, + bool Truncated, + long ElapsedMilliseconds); diff --git a/src/PostgresService.WebApi/PostgresAdminService.cs b/src/PostgresService.WebApi/PostgresAdminService.cs new file mode 100644 index 0000000..affbe9c --- /dev/null +++ b/src/PostgresService.WebApi/PostgresAdminService.cs @@ -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 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 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 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> 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(); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(ReadClient(reader)); + } + return items; + } + + public async Task 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> 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(); + 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> 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(); + 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> 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(); + 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(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 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(); + 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> ListBackupsAsync(CancellationToken cancellationToken) + { + var result = new List(); + 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 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 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 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 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 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 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 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 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 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(4), reader.GetString(5), + reader.GetFieldValue(6), reader.GetFieldValue(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 ComputeSha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/PostgresService.WebApi/PostgresService.WebApi.csproj b/src/PostgresService.WebApi/PostgresService.WebApi.csproj new file mode 100644 index 0000000..73b327d --- /dev/null +++ b/src/PostgresService.WebApi/PostgresService.WebApi.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + + diff --git a/src/PostgresService.WebApi/Program.cs b/src/PostgresService.WebApi/Program.cs new file mode 100644 index 0000000..462cd14 --- /dev/null +++ b/src/PostgresService.WebApi/Program.cs @@ -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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +var secretStore = app.Services.GetRequiredService(); +var sessionStore = app.Services.GetRequiredService(); +var postgres = app.Services.GetRequiredService(); + +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; diff --git a/src/PostgresService.WebApi/Security.cs b/src/PostgresService.WebApi/Security.cs new file mode 100644 index 0000000..c660bd1 --- /dev/null +++ b/src/PostgresService.WebApi/Security.cs @@ -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 _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 _); + } + } +} diff --git a/tests/PostgresService.Tests/PostgresService.Tests.csproj b/tests/PostgresService.Tests/PostgresService.Tests.csproj new file mode 100644 index 0000000..45febf7 --- /dev/null +++ b/tests/PostgresService.Tests/PostgresService.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/PostgresService.Tests/PostgresServiceSecurityTests.cs b/tests/PostgresService.Tests/PostgresServiceSecurityTests.cs new file mode 100644 index 0000000..c7fd8e3 --- /dev/null +++ b/tests/PostgresService.Tests/PostgresServiceSecurityTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Configuration; +using PostgresService.WebApi; + +namespace PostgresService.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 + { + ["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 + { + ["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); + } + } +} diff --git a/tests/PostgresService.Tests/Usings.cs b/tests/PostgresService.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/PostgresService.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit;