diff --git a/.gitignore b/.gitignore index 6a0b585..00187f4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ gitea/data/ hedgedoc/data/ uptime-kuma/data/ homepage/config/ +site/data/ +pocket-id/data/ +ai-gateway/data/ +hedgedoc2/data/ # 忽略 Docker 相关文件 *.log @@ -45,8 +49,49 @@ seaweedfs/data/ # 忽略 remark42 数据 remark42/var/ +cat-bodhi/data/assets-ai/ +cat-bodhi/sprite_alpha_seg_pytorch/outputs/ remark42/.env -# 忽略 OpenWebUI 数据和密钥 -openwebui/data/ -openwebui/.env + +# 忽略 Beszel 数据和 Agent 密钥 +beszel/data/ +beszel/socket/ +beszel/agent-data/ +beszel/.env + +# 忽略 ComfyUI 模型、节点和生成结果 +comfyui/models/ +comfyui/custom_nodes/ +comfyui/output/ +comfyui/.env + +# 忽略 InvokeAI 模型、配置和生成结果 +invokeai/data/ +invokeai/.env + +# 忽略 code-server 配置、本地扩展和本地环境变量 +code-server/config/ +code-server/local/ +code-server/.env + +# 已退役 Pocket ID/OAuth2 Proxy 数据与密钥仍保持忽略,避免历史敏感数据误提交 +pocket-id/secrets/* +authentik/secrets/* +!authentik/secrets/.gitkeep +ai-gateway/secrets/* +!ai-gateway/secrets/README.md +oauth2-proxy/secrets/* + + +# 忽略 Chat Provider 代理桥 Unix socket +chat/run/ +chat/node_modules/ +chat/.next/ +chat/dist/ +chat/cache/ +chat/data/ + +# 忽略本地诊断输出 +/0 +chat/data/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f930e83 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository manages a Docker Compose homelab. The root `compose.yml` defines the shared `homelab_net` network. Each service lives in its own directory with a local Compose file, for example `traefik/compose.yml`, `authentik/compose.yml`, `gitea/compose.yml`, `seaweedfs/compose.yml`, and `chat/compose.yml`. Runtime state and secrets belong in service-local `data/`, `var/`, `letsencrypt/`, `.env`, or similar untracked paths. + +`cat-bodhi/` is the main application code: `index.html`, `game.js`, `styles.css`, `server.mjs`, `assets/`, `data/`, and Python sprite tooling under `tools/` and `sprite_alpha_seg_pytorch/`. + +## Build, Test, and Development Commands + +- `docker compose -f compose.yml up -d`: create or update the shared network. +- `docker compose -f traefik/compose.yml up -d`: start a service stack; swap the path for other services. +- `docker compose -f /compose.yml config`: validate a service Compose file before deploying it. +- `docker compose -f /compose.yml logs -f`: inspect service logs during troubleshooting. +- `cd cat-bodhi && npm run dev`: run the local game and sprite import server on port `8080`. +- `cd cat-bodhi && npm run dev:ai:proxy`: run the same server with Node environment proxy support. + +## Coding Style & Naming Conventions + +Use two-space indentation in YAML, HTML, CSS, and JavaScript. Keep Compose service names, directories, and network aliases lowercase with hyphens, matching paths such as `uptime-kuma` and `outpost-seaweedfs`. Prefer explicit image tags over floating versions. Keep Traefik labels grouped with their service and preserve existing label patterns. + +For `cat-bodhi`, keep the vanilla HTML/CSS/JS structure. Name JavaScript functions and variables in `camelCase`; use descriptive asset filenames such as `decor-cat-bed.png`. + +## Testing Guidelines + +There is no centralized test suite. Validate infrastructure edits with `docker compose -f /compose.yml config` and, when practical, restart only the affected service. For `cat-bodhi`, run `npm run dev`, open `http://localhost:8080`, and manually verify the changed workflow. If sprite processing changes, verify `/api/sprite-status` and `/api/sprite-import`. + +## Commit & Pull Request Guidelines + +Recent history uses short Conventional Commit-style prefixes such as `feat:`, `fix:`, `docs:`, and `refactor:`, sometimes with Chinese descriptions. Keep commits focused on one service or feature. Pull requests should summarize changed services, include validation commands, call out environment or secret changes, and attach screenshots for UI changes. + +## Security & Configuration Tips + +Do not commit real `.env` files, tunnel tokens, private keys, certificates, or generated service data. When adding a public route, confirm the intended Authentik mode: app-level OAuth/OIDC or Traefik ForwardAuth. Keep exposed domains and Traefik routers consistent with `README.md`. diff --git a/README.md b/README.md index 5fb7fe0..374d7f5 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,21 @@ ``` 公网用户 │ - ├─ Web (443) ──→ Cloudflare Tunnel ──→ cloudflared (Docker, HTTP/2) - │ │ - └─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc (Docker) ──→ Gitea - │ - Traefik v3.7.1 (反向代理) - │ - ┌───────────────┼───────────────────┐ - ▼ ▼ ▼ - (有 Authentik SSO) (ForwardAuth) (免认证) - Gitea / HedgeDoc SeaweedFS / Gitea SSH - Homepage / OpenWebUI Uptime Kuma + ├─ Web (443) ──→ Cloudflare Tunnel ──→ Traefik + │ │ + │ ┌─────────────────┼─────────────────┐ + │ ▼ ▼ ▼ + │ xiteng.site OAuth2/OIDC ForwardAuth + │ 唯一公开目录 应用层认证 入口层认证 + │ │ + │ ├─→ Edge Cache Controller ──→ Traefik + Cloudflare Cache Rule + │ ▼ + │ Site Registry ──→ Docker API + │ │ (Label + 容器状态) + │ ├─→ HTTP 探测器 + │ └─→ SQLite (生命周期 + 历史 + 可用率) + │ + └─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc ──→ Gitea ``` | 入口 | 域名 | 传输 | 延迟 | @@ -25,41 +29,58 @@ | Web | `*.xiteng.site` | Cloudflare Tunnel (HTTP/2) → Traefik | ~50ms | | Git SSH | `git.xiteng.site` | 阿里云 VPS → frp → Gitea | ~5ms | -## 服务一览 +## 服务与组件目录 + +[xiteng.site](https://xiteng.site) 是唯一门户。未登录与已登录用户看到相同的服务、基础设施目录及 CPU/MEM/DISK/GPU 实时设备状态;认证只决定能否读取服务数据或执行操作,不用于隐藏组件的存在。 + +门户不维护硬编码服务清单。Docker 容器通过 `xiteng.site.component..*` Label 自行声明名称、分组、说明、入口、访问方式和可选的 HTTP 探测。Registry 以组件 ID 为唯一键,统一保存生命周期、检查历史与可用率。Label 规范见 [`site/README.md`](site/README.md)。 + +服务可另用 `xiteng.site.cache..*` Label 声明公开静态目录。Edge Cache Controller 将其编译为高优先级 Traefik 路由及一条合并的 Cloudflare Cache Rule;规范和安全边界见 [`edge-cache/README.md`](edge-cache/README.md)。 | 服务 | 地址 | 认证 | 说明 | |------|------|------|------| -| Homepage | [home.xiteng.site](https://home.xiteng.site) | Authentik OIDC | 导航面板 | -| Authentik | [auth.xiteng.site](https://auth.xiteng.site) | 自身 | 统一 SSO (v2026.5.0) | +| Xiteng Site | [xiteng.site](https://xiteng.site) | 无 | 个人主页、服务目录与基础设施目录 | +| Authentik | 内部 | Portal 管理 | 隐藏的 OAuth2/OIDC、ForwardAuth 与身份数据引擎 (v2026.5.0) | +| Key Vault | 内网 | Authentik 身份 | 加密 Backend Credential、Provider Registry 与审计 | | Gitea | [gitea.xiteng.site](https://gitea.xiteng.site) | Authentik OAuth2 | 代码托管 | | HedgeDoc | [notes.xiteng.site](https://notes.xiteng.site) | Authentik OIDC | Markdown 协作 | -| OpenWebUI | [ai.xiteng.site](https://ai.xiteng.site) | Authentik OIDC | AI 对话界面 (v0.9.5) | +| Xiteng Chat | [chat.xiteng.site](https://chat.xiteng.site) | Authentik ForwardAuth | assistant-ui 聊天界面,支持双连接 Provider 与跨刷新聊天历史 | +| Code Server | [code.xiteng.site](https://code.xiteng.site) | Authentik ForwardAuth | 浏览器中的 VS Code 工作台 | +| ComfyUI | [comfy.xiteng.site](https://comfy.xiteng.site) | Authentik ForwardAuth | 节点式图像生成工作台 | +| InvokeAI | [invoke.xiteng.site](https://invoke.xiteng.site) | Authentik ForwardAuth | 图像生成、画布编辑与模型管理 | | SeaweedFS | [file.xiteng.site](https://file.xiteng.site) | Authentik ForwardAuth | 对象存储 (v4.28) | -| SeaweedFS S3 | [minio-api.xiteng.site](https://minio-api.xiteng.site) | Access Key | S3 API | -| Uptime Kuma | [uptime.xiteng.site](https://uptime.xiteng.site) | Authentik ForwardAuth | 服务监控 | +| SeaweedFS S3 | [s3.xiteng.site](https://s3.xiteng.site) | Access Key | S3 API | | Remark42 | [remark.xiteng.site](https://remark.xiteng.site) | Authentik OAuth2 | 评论系统 | | Traefik | 内网 | — | 反向代理 | > **注意**: SeaweedFS 已替代原 MinIO。域名于 2026-05-27 从 `s3.xiteng.site` 迁移至 `file.xiteng.site`。 -## 认证架构 +## 认证与 Key Vault 架构 ``` -用户请求 → Cloudflare → cloudflared → Traefik - │ - ┌─────────────┤ - ▼ ▼ - ForwardAuth OAuth2/OIDC - (中间件子请求) (应用层重定向) - │ │ - ▼ ▼ - Authentik Outpost Authentik Server - (proxy container) (Django) +用户请求 → Cloudflare → cloudflared → Traefik → Authentik + │ issuer + sub + ┌──────────────────────────┴─────────────────────────┐ + ▼ ▼ + xiteng.site/account Xiteng Chat + Backend Credential / Provider assistant-ui / AI SDK + │ ┌───────────┴───────────┐ + ▼ ▼ ▼ + Key Vault Backend Provider Frontend Provider + 加密存储 / Registry / Audit Chat 服务端直连 浏览器直接连接 + │ IndexedDB Credential + └──────────┬──────────────┘ + ▼ + AI Provider ``` -- **OAuth2/OIDC**: Gitea、HedgeDoc、Homepage、OpenWebUI 各自持有 client_id/secret,用户从应用发起登录 -- **ForwardAuth**: SeaweedFS、Uptime Kuma 由 Traefik 中间件在请求到达前拦截验证 -- **独立 Outpost**: SeaweedFS 和 Uptime Kuma 各用独立 proxy outpost 容器(避免 cookie domain 冲突) +- **Authentik**: 隐藏的身份引擎,负责 OAuth2/OIDC、ForwardAuth、用户与策略存储;日常用户、用户组和应用权限管理统一在 `https://xiteng.site/admin` +- **Key Vault**: 用 Authentik `(issuer, sub)` 关联用户,以 AES-256-GCM 信封加密保存 Backend Credential,并维护 Provider Registry;只有 `xiteng-chat` 服务端解析接口会短暂取得属于当前用户的明文 +- **Xiteng Chat**: 基于 assistant-ui 与 AI SDK;Backend Provider 由 Chat 服务端使用 Key Vault Credential 直连,Frontend Provider 由浏览器使用 IndexedDB 本地 Credential 直连;聊天记录按 Authentik `(issuer, sub)` 隔离并持久化到 `chat/data/chat.db`,不会保存 Credential 明文 +- **Portal**: Authentik ForwardAuth 保护 `/admin` 与 `/account`;`/admin` 只允许 `liooil`,并提供用户、用户组、密码恢复、会话注销和 `liuhome` 应用权限矩阵 +- **公开目录**: `xiteng.site` 不做登录判断,只展示 Label 明确声明的公开元数据;受控服务在点击后执行 Authentik 或服务自身认证 + +Authentik 数据库、Key Vault 的 `vault_master_key`、Portal HMAC Key 与 Chat 的 `chat/data/chat.db` 必须分别备份,不能放入 Docker Label 或 Git。 ## 目录结构 @@ -73,6 +94,10 @@ homelab/ ├── cloudflared/ │ ├── compose.yml │ └── config.yml +├── edge-cache/ +│ ├── compose.yml # Docker Label → Traefik/Cloudflare 缓存控制面 +│ ├── controller.mjs # 动态路由、TTL 与 Cache Rule 生成器 +│ └── README.md # 静态路径 Label 规范及安全边界 ├── frpc/ │ ├── compose.yml │ └── frpc.toml # 敏感,gitignore @@ -82,15 +107,48 @@ homelab/ ├── hedgedoc/ │ ├── compose.yml │ └── data/ -├── homepage/ +├── site/ │ ├── compose.yml -│ └── config/ -├── openwebui/ +│ ├── index.html # 页面结构,不包含服务清单 +│ ├── admin.html # 用户、权限、Vault 与生命周期管理页 +│ ├── styles.css +│ ├── app.js # 动态渲染组件卡片 +│ ├── server.mjs # 公网站点与同源 API +│ ├── registry.mjs # Label 发现、HTTP 探测与生命周期控制面 +│ ├── import-kuma.mjs # 一次性旧历史迁移工具 +│ ├── metrics.py # 只读主机与 NVIDIA GPU 指标 +│ ├── data/ # Registry SQLite,gitignore +│ └── README.md # 组件 Label 规范 +├── ai-gateway/ # 内部 Key Vault 服务(保留目录名以避免数据路径迁移) │ ├── compose.yml -│ ├── .env # OPENWEBUI_SECRET_KEY -│ └── data/ # gitignore +│ ├── vault.mjs # 信封加密、所有权、Provider Registry 与审计 +│ ├── providers.json # 内置 Provider Catalog +│ ├── providers.mjs # Provider 校验与合并 +│ ├── server.mjs +│ └── data/ # Vault SQLite,gitignore +├── homelab-emergency # Authentik 与 Vault 本机恢复入口 +├── chat/ +│ ├── app/ # Next.js 页面与流式聊天 API +│ ├── components/ # assistant-ui 线程与页面壳层 +│ ├── data/ # 用户聊天历史 SQLite,gitignore +│ ├── Dockerfile +│ └── compose.yml # chat.xiteng.site / Authentik ForwardAuth +├── code-server/ +│ ├── compose.yml +│ ├── .env # 本地 UID/GID 等环境变量,gitignore +│ ├── config/ # VS Code Server 配置,gitignore +│ └── local/ # 扩展与用户本地数据,gitignore +├── comfyui/ +│ ├── compose.yml +│ ├── compose.gpu.yml # 可选 NVIDIA GPU override +│ ├── models/ # gitignore +│ ├── custom_nodes/ # gitignore +│ └── output/ # gitignore +├── invokeai/ +│ ├── compose.yml +│ └── data/ # gitignore,模型、配置和生成结果 ├── outpost/ -│ ├── compose.yml # Uptime Kuma outpost +│ ├── compose.yml # Portal Admin / ComfyUI / InvokeAI outpost │ └── ... ├── outpost-seaweedfs/ │ └── compose.yml # SeaweedFS 独立 outpost @@ -101,12 +159,9 @@ homelab/ │ ├── compose.yml │ ├── security.toml # JWT 已全部注释(社区版 UI 不支持 OIDC) │ └── data/ -├── traefik/ -│ ├── compose.yml -│ └── letsencrypt/ # gitignore -└── uptime-kuma/ +└── traefik/ ├── compose.yml - └── data/ + └── letsencrypt/ # gitignore ``` ## 网络 @@ -118,21 +173,71 @@ homelab/ ```bash # 按依赖顺序启动 docker compose -f compose.yml up -d # 创建网络 +docker compose -f edge-cache/compose.yml up -d docker compose -f traefik/compose.yml up -d docker compose -f authentik/compose.yml up -d docker compose -f gitea/compose.yml up -d docker compose -f hedgedoc/compose.yml up -d docker compose -f seaweedfs/compose.yml up -d -docker compose -f uptime-kuma/compose.yml up -d -docker compose -f homepage/compose.yml up -d -docker compose -f openwebui/compose.yml up -d +./homelab-emergency init-secrets +./homelab-emergency identity-bootstrap +docker compose -f ai-gateway/compose.yml up -d +docker compose -f site/compose.yml up -d +docker compose -f chat/compose.yml up -d --build +docker compose --env-file code-server/.env -f code-server/compose.yml up -d +docker compose -f comfyui/compose.yml up -d +docker compose -f invokeai/compose.yml up -d docker compose -f remark42/compose.yml up -d -docker compose -f outpost/compose.yml up -d +docker compose --env-file .env -f outpost/compose.yml up -d docker compose -f outpost-seaweedfs/compose.yml up -d docker compose -f cloudflared/compose.yml up -d docker compose -f frpc/compose.yml up -d ``` + +## 最终恢复入口 + +日常身份管理位于 `https://xiteng.site/admin`。Authentik 原生管理界面已隐藏;根目录的 +`homelab-emergency` 提供不依赖 Portal 的恢复能力: + +```bash +./homelab-emergency status +./homelab-emergency identity-recovery liooil +./homelab-emergency identity-set-password liooil +./homelab-emergency identity-reset-2fa liooil +./homelab-emergency identity-reset-passkeys liooil +./homelab-emergency identity-bootstrap +./homelab-emergency vault-list +./homelab-emergency vault-audit 100 +./homelab-emergency vault-delete +./homelab-emergency vault-backup ai-gateway/data/backups/vault.db +``` + +`identity-bootstrap` 保证 `liooil` 是唯一人类管理员、`liooil` 与 `ziyue` 属于 `liuhome`, +并将当前非开放应用的准入用户组统一为 `liuhome`。`identity-reset-2fa` 与 +`identity-reset-passkeys` 需要交互确认,只删除指定用户的认证器并写入身份审计。应急脚本不输出 +Provider Key、TOTP Secret 或 Passkey 凭据明文。 + +Homepage、Beszel、Uptime Kuma 和 AutoKuma 已退役。它们不再有活动 Compose 定义;现有 `homepage/config/`、`beszel/data/` 与 `uptime-kuma/data/` 仅作为迁移后的回滚数据保留,不会被 Portal 或启动流程读取。 + +code-server 将当前仓库挂载到 `/home/coder/homelab`,并持久化 VS Code 配置与扩展到 +`code-server/config/` 和 `code-server/local/`。公网入口必须保持 Authentik ForwardAuth 保护; +code-server 内置密码认证已关闭,避免重复登录。默认不挂载 Docker socket,如需从浏览器终端管理 +Docker,应改用更窄的专用运维入口。 + +ComfyUI 默认配置不要求 Docker GPU runtime,模型、Custom Nodes 和生成结果分别持久化到 `comfyui/models/`、`comfyui/custom_nodes/`、`comfyui/output/`。ComfyUI Manager 可安装第三方节点,公网入口必须保持 Authentik ForwardAuth 保护。 + +启用 NVIDIA GPU 前,先在宿主安装并配置 NVIDIA Container Toolkit,然后用 override 启动: + +```bash +sudo pacman -S --needed nvidia-container-toolkit +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker +docker compose -f comfyui/compose.yml -f comfyui/compose.gpu.yml up -d +``` + +InvokeAI 使用 NVIDIA GPU,模型、配置和生成结果统一持久化到 `invokeai/data/`。公网入口必须保持 Authentik ForwardAuth 保护;首次进入后在 Model Manager 中安装需要的模型。 + ## 所需外部资源 | 资源 | 用途 | diff --git a/ai-gateway/README.md b/ai-gateway/README.md new file mode 100644 index 0000000..1ab723c --- /dev/null +++ b/ai-gateway/README.md @@ -0,0 +1,22 @@ +# Key Vault and Provider Registry + +This internal service is the credential boundary between Authentik identities and Xiteng Chat. It stores +Backend Provider credentials with AES-256-GCM envelope encryption. Every credential has a random data-encryption +key; the master key wraps only those data keys, so master-key rotation is independent of provider-key rotation. + +`providers.json` is the built-in Provider Catalog. Administrators can add Custom Providers in SQLite without +editing the built-in file. A provider declares its API protocol, frontend/backend connection type, endpoint, +optional proxy, authentication shape, default-model preference, and a model-discovery type plus URL. The Vault +does not store model lists; Xiteng Chat fetches them dynamically from each configured Provider. + +The service is internal-only. `xiteng.site` and `xiteng-chat` sign requests with a dedicated service token after +Authentik checks. The Vault independently treats only the exact username `liooil` as an administrator. Other +identities can access only records whose `(issuer, sub)` pair matches their session. + +Normal metadata APIs never return plaintext. The dedicated `/v1/resolve` endpoint is restricted to the exact +service name `xiteng-chat`; it decrypts one active Backend Credential owned by the signed user and returns it to +the Chat server for one provider request. Frontend Provider credentials never enter this service and remain in +the browser's IndexedDB. + +Xiteng Chat connects directly: Backend Providers from the Chat server, Frontend Providers from the browser. +Vault audit records cover credential lifecycle, verification, reassignment, resolution, and Custom Provider changes. diff --git a/ai-gateway/cli.mjs b/ai-gateway/cli.mjs new file mode 100644 index 0000000..e683168 --- /dev/null +++ b/ai-gateway/cli.mjs @@ -0,0 +1,77 @@ +import {existsSync} from "node:fs"; +import {loadBuiltinProviders} from "./providers.mjs"; +import {breakGlassActor, loadKeyFile, Vault} from "./vault.mjs"; + +const databasePath = process.env.DATABASE_PATH || "/data/vault.db"; +const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key"; +const [command, ...args] = process.argv.slice(2); + +function usage() { + console.log(`Usage: + node cli.mjs list [owner-sub] + node cli.mjs audit [limit] + node cli.mjs verify + node cli.mjs delete + node cli.mjs reassign + node cli.mjs rotate-master + node cli.mjs backup `); +} + +if (!command) { + usage(); + process.exit(1); +} + +const vault = new Vault({ + databasePath, + masterKey: loadKeyFile(masterKeyFile), + builtinProviders: loadBuiltinProviders(process.env.PROVIDER_CATALOG_FILE || "/app/providers.json") +}); +const actor = breakGlassActor(); + +try { + if (command === "list") { + const credentials = vault.listCredentials(actor, {all: true}); + const filtered = args[0] + ? credentials.filter((credential) => credential.owner.sub === args[0]) + : credentials; + console.table(filtered.map((credential) => ({ + id: credential.id, + owner: credential.owner.username, + sub: credential.owner.sub, + provider: credential.provider, + name: credential.name, + fingerprint: credential.fingerprint, + status: credential.status + }))); + } else if (command === "audit") { + console.table(vault.listAudit(actor, {all: true, limit: args[0] || 100})); + } else if (command === "verify" && args[0]) { + console.log(vault.verifyCredential(actor, args[0])); + } else if (command === "delete" && args[0]) { + console.log(vault.deleteCredential(actor, args[0])); + } else if (command === "reassign" && args.length >= 4) { + console.log(vault.reassignCredential(actor, args[0], { + issuer: args[1], + sub: args[2], + username: args[3], + type: "user" + })); + } else if (command === "rotate-master" && args[0]) { + if (!existsSync(args[0])) { + throw new Error(`New key file not found: ${args[0]}`); + } + console.log(vault.rotateMasterKey(actor, loadKeyFile(args[0]))); + console.error("Rotation succeeded. Replace the mounted vault_master_key before restarting the service."); + } else if (command === "backup" && args[0]) { + if (existsSync(args[0])) { + throw new Error(`Refusing to overwrite existing backup: ${args[0]}`); + } + console.log(vault.backup(args[0])); + } else { + usage(); + process.exitCode = 1; + } +} finally { + vault.close(); +} diff --git a/ai-gateway/compose.yml b/ai-gateway/compose.yml new file mode 100644 index 0000000..f972d8f --- /dev/null +++ b/ai-gateway/compose.yml @@ -0,0 +1,50 @@ +services: + ai-gateway: + image: node:24-alpine + container_name: ai-gateway + restart: unless-stopped + command: ["node", "/app/server.mjs"] + environment: + PORT: "8093" + DATABASE_PATH: /data/vault.db + MASTER_KEY_FILE: /run/secrets/vault_master_key + SERVICE_TOKEN_FILE: /run/secrets/portal_gateway_hmac + PROVIDER_CATALOG_FILE: /app/providers.json + ADMIN_USERNAME: liooil + volumes: + - ./vault.mjs:/app/vault.mjs:ro + - ./server.mjs:/app/server.mjs:ro + - ./providers.mjs:/app/providers.mjs:ro + - ./providers.json:/app/providers.json:ro + - ./cli.mjs:/app/cli.mjs:ro + - ./data:/data + - ./secrets:/run/secrets:ro + read_only: true + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8093/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - homelab_net + labels: + - "traefik.enable=false" + - "xiteng.site.component.ai-gateway.enabled=true" + - "xiteng.site.component.ai-gateway.name=Key Vault" + - "xiteng.site.component.ai-gateway.description=按用户保存 Backend Provider 凭据,并提供内置与 Custom Provider Registry。" + - "xiteng.site.component.ai-gateway.section=infrastructure" + - "xiteng.site.component.ai-gateway.category=身份与访问" + - "xiteng.site.component.ai-gateway.access=internal" + - "xiteng.site.component.ai-gateway.access-label=仅通过 Portal/API 使用" + - "xiteng.site.component.ai-gateway.icon=KV" + - "xiteng.site.component.ai-gateway.accent=blue" + - "xiteng.site.component.ai-gateway.order=220" + - "xiteng.site.component.ai-gateway.monitor.enabled=true" + - "xiteng.site.component.ai-gateway.monitor.url=http://ai-gateway:8093/healthz" + +networks: + homelab_net: + external: true diff --git a/ai-gateway/providers.json b/ai-gateway/providers.json new file mode 100644 index 0000000..e278656 --- /dev/null +++ b/ai-gateway/providers.json @@ -0,0 +1,462 @@ +[ + { + "id": "openai", + "name": "OpenAI", + "api": "openai-responses", + "connection": { + "type": "backend", + "baseUrl": "https://api.openai.com/v1", + "proxy": { + "type": "socks5", + "url": "socks5://provider-proxy-bridge:17897" + } + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "gpt-5.4" + }, + { + "id": "anthropic", + "name": "Anthropic", + "api": "anthropic-messages", + "connection": { + "type": "backend", + "baseUrl": "https://api.anthropic.com", + "proxy": { + "type": "socks5", + "url": "socks5://provider-proxy-bridge:17897" + } + }, + "auth": { + "type": "header", + "header": "x-api-key" + }, + "defaultModel": "claude-sonnet-4-6" + }, + { + "id": "google", + "name": "Google Gemini", + "api": "google-generative-ai", + "connection": { + "type": "backend", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "proxy": null + }, + "auth": { + "type": "header", + "header": "x-goog-api-key" + }, + "defaultModel": "gemini-3.1-pro-preview" + }, + { + "id": "openrouter", + "name": "OpenRouter", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://openrouter.ai/api/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "headers": { + "HTTP-Referer": "https://chat.xiteng.site", + "X-Title": "Xiteng Chat" + }, + "defaultModel": "anthropic/claude-sonnet-4.6" + }, + { + "id": "rust.cat", + "name": "rust.cat", + "api": "openai-responses", + "connection": { + "type": "backend", + "baseUrl": "https://rust.cat/codex/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "gpt-5.3-codex" + }, + { + "id": "deepseek", + "name": "DeepSeek", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.deepseek.com", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "deepseek-chat" + }, + { + "id": "groq", + "name": "Groq", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.groq.com/openai/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "openai/gpt-oss-120b" + }, + { + "id": "mistral", + "name": "Mistral AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.mistral.ai/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "mistral-large-latest" + }, + { + "id": "xai", + "name": "xAI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.x.ai/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "grok-4" + }, + { + "id": "moonshot", + "name": "Moonshot AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.moonshot.cn/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "kimi-k3" + }, + { + "id": "siliconflow", + "name": "SiliconFlow", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.siliconflow.com/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "deepseek-ai/DeepSeek-V3.2" + }, + { + "id": "siliconflow-cn", + "name": "SiliconFlow CN", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.siliconflow.cn/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "deepseek-ai/DeepSeek-V3.2" + }, + { + "id": "minimax", + "name": "MiniMax", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.minimax.io/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "MiniMax-M2.1" + }, + { + "id": "zai", + "name": "Z.AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.z.ai/api/paas/v4", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "glm-5" + }, + { + "id": "qianfan", + "name": "Baidu Qianfan", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://qianfan.baidubce.com/v2", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "ernie-4.5-8k-preview" + }, + { + "id": "dashscope", + "name": "Alibaba DashScope", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "qwen3.5-plus" + }, + { + "id": "together", + "name": "Together AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.together.xyz/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8" + }, + { + "id": "fireworks", + "name": "Fireworks AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.fireworks.ai/inference/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "accounts/fireworks/models/deepseek-v3p2" + }, + { + "id": "cerebras", + "name": "Cerebras", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.cerebras.ai/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "gpt-oss-120b" + }, + { + "id": "nvidia", + "name": "NVIDIA NIM", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://integrate.api.nvidia.com/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "nvidia/llama-3.1-nemotron-ultra-253b-v1" + }, + { + "id": "huggingface", + "name": "Hugging Face", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://router.huggingface.co/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "deepseek-ai/DeepSeek-V3.2" + }, + { + "id": "novita", + "name": "Novita AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.novita.ai/openai", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "deepseek/deepseek-v3.2" + }, + { + "id": "aimlapi", + "name": "AIML API", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.aimlapi.com/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "gpt-5.4" + }, + { + "id": "venice", + "name": "Venice AI", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://api.venice.ai/api/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "llama-3.3-70b" + }, + { + "id": "nanogpt", + "name": "NanoGPT", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://nano-gpt.com/api/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "gpt-5.4" + }, + { + "id": "vercel-ai-gateway", + "name": "Vercel AI Gateway", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://ai-gateway.vercel.sh/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "openai/gpt-5.4" + }, + { + "id": "cloudflare-ai-gateway", + "name": "Cloudflare AI Gateway", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "https://gateway.ai.cloudflare.com/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "openai/gpt-5.4" + }, + { + "id": "litellm", + "name": "LiteLLM", + "api": "openai-completions", + "connection": { + "type": "backend", + "baseUrl": "http://litellm:4000/v1", + "proxy": null + }, + "auth": { + "type": "bearer" + }, + "defaultModel": "gpt-5.4" + }, + { + "id": "ollama", + "name": "Ollama", + "api": "openai-completions", + "connection": { + "type": "frontend", + "baseUrl": "http://127.0.0.1:11434/v1", + "proxy": null + }, + "auth": { + "type": "none" + }, + "defaultModel": "qwen3:8b" + }, + { + "id": "lm-studio", + "name": "LM Studio", + "api": "openai-completions", + "connection": { + "type": "frontend", + "baseUrl": "http://127.0.0.1:1234/v1", + "proxy": null + }, + "auth": { + "type": "none" + }, + "defaultModel": "local-model" + }, + { + "id": "llama.cpp", + "name": "llama.cpp", + "api": "openai-completions", + "connection": { + "type": "frontend", + "baseUrl": "http://127.0.0.1:8080/v1", + "proxy": null + }, + "auth": { + "type": "none" + }, + "defaultModel": "local-model" + }, + { + "id": "vllm", + "name": "vLLM", + "api": "openai-completions", + "connection": { + "type": "frontend", + "baseUrl": "http://127.0.0.1:8000/v1", + "proxy": null + }, + "auth": { + "type": "none" + }, + "defaultModel": "local-model" + } +] diff --git a/ai-gateway/providers.mjs b/ai-gateway/providers.mjs new file mode 100644 index 0000000..5e0a40c --- /dev/null +++ b/ai-gateway/providers.mjs @@ -0,0 +1,97 @@ +import {readFileSync} from "node:fs"; + +const allowedApis = new Set([ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai" +]); + +function stringValue(value, field, maximum = 200) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`); + return value.trim().slice(0, maximum); +} + +function optionalUrl(value, field, protocols) { + if (value === null || value === undefined || value === "") return null; + const url = new URL(stringValue(value, field, 2000)); + if (!protocols.includes(url.protocol)) throw new Error(`${field} uses an unsupported protocol`); + return url.toString().replace(/\/$/, ""); +} + +function normalizeHeaders(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).map(([name, headerValue]) => [ + stringValue(name, "header name", 120), + stringValue(headerValue, `header ${name}`, 1000) + ])); +} + + +export function normalizeProvider(input, {builtin = false} = {}) { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Provider definition is required"); + const id = stringValue(input.id, "provider.id", 80).toLowerCase(); + if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id contains unsupported characters"); + const api = stringValue(input.api, "provider.api", 80); + if (!allowedApis.has(api)) throw new Error(`Unsupported provider API: ${api}`); + const connectionType = input.connection?.type === "frontend" ? "frontend" : "backend"; + const proxyInput = input.connection?.proxy; + let proxy = null; + if (proxyInput) { + const proxyType = stringValue(proxyInput.type, "provider.connection.proxy.type", 30); + const allowedProxyTypes = connectionType === "frontend" ? ["relay"] : ["http", "https", "socks5"]; + if (!allowedProxyTypes.includes(proxyType)) throw new Error(`Unsupported ${connectionType} proxy type: ${proxyType}`); + proxy = { + type: proxyType, + url: optionalUrl(proxyInput.url, "provider.connection.proxy.url", proxyType === "socks5" ? ["socks5:"] : ["http:", "https:"]) + }; + } + const authType = ["bearer", "header", "none"].includes(input.auth?.type) ? input.auth.type : "bearer"; + const auth = { + type: authType, + ...(authType === "header" ? {header: stringValue(input.auth?.header, "provider.auth.header", 120)} : {}) + }; + const defaultModel = typeof input.defaultModel === "string" ? input.defaultModel.trim().slice(0, 300) : ""; + if (!builtin && !defaultModel) throw new Error("provider.defaultModel is required"); + const baseUrl = optionalUrl(input.connection?.baseUrl, "provider.connection.baseUrl", ["http:", "https:"]); + const inferredDiscoveryType = api === "anthropic-messages" + ? "anthropic-models-list" + : api === "google-generative-ai" ? "google-models-list" : "openai-models-list"; + const discoveryType = ["openai-models-list", "anthropic-models-list", "google-models-list"].includes(input.discovery?.type) + ? input.discovery.type + : inferredDiscoveryType; + const inferredDiscoveryUrl = discoveryType === "anthropic-models-list" + ? `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200` + : discoveryType === "google-models-list" ? `${baseUrl}/models?pageSize=200` : `${baseUrl}/models`; + const discovery = { + type: discoveryType, + url: optionalUrl(input.discovery?.url, "provider.discovery.url", ["http:", "https:"]) || inferredDiscoveryUrl + }; + return { + id, + name: stringValue(input.name, "provider.name", 160), + api, + connection: { + type: connectionType, + baseUrl, + proxy + }, + auth, + headers: normalizeHeaders(input.headers), + defaultModel, + discovery, + builtin + }; +} + +export function loadBuiltinProviders(path) { + const definitions = JSON.parse(readFileSync(path, "utf8")); + if (!Array.isArray(definitions)) throw new Error("Provider catalog must be an array"); + const providers = new Map(); + for (const definition of definitions) { + const provider = normalizeProvider(definition, {builtin: true}); + if (providers.has(provider.id)) throw new Error(`Duplicate built-in provider: ${provider.id}`); + providers.set(provider.id, provider); + } + return providers; +} diff --git a/ai-gateway/secrets/README.md b/ai-gateway/secrets/README.md new file mode 100644 index 0000000..28b6c60 --- /dev/null +++ b/ai-gateway/secrets/README.md @@ -0,0 +1,9 @@ +# Runtime secrets + +`../../homelab-emergency init-secrets` creates these untracked files: + +- `vault_master_key`: 32-byte base64 key used only to wrap per-credential data keys. +- `portal_gateway_hmac`: 32-byte base64 key used to authenticate Portal-to-Gateway requests. + +Back up both outside this repository. Never store either key in `vault.db`, Authentik, Docker labels, +or the Portal UI. diff --git a/ai-gateway/server.mjs b/ai-gateway/server.mjs new file mode 100644 index 0000000..700c058 --- /dev/null +++ b/ai-gateway/server.mjs @@ -0,0 +1,183 @@ +import {timingSafeEqual} from "node:crypto"; +import {readFileSync} from "node:fs"; +import http from "node:http"; +import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs"; +import {loadKeyFile, Vault} from "./vault.mjs"; + +const port = Number.parseInt(process.env.PORT || "8093", 10); +const databasePath = process.env.DATABASE_PATH || "/data/vault.db"; +const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key"; +const serviceTokenFile = process.env.SERVICE_TOKEN_FILE || "/run/secrets/portal_gateway_hmac"; +const providerCatalogFile = process.env.PROVIDER_CATALOG_FILE || "/app/providers.json"; +const adminUsername = process.env.ADMIN_USERNAME || "liooil"; +const serviceToken = readFileSync(serviceTokenFile, "utf8").trim(); +const vault = new Vault({ + databasePath, + masterKey: loadKeyFile(masterKeyFile), + builtinProviders: loadBuiltinProviders(providerCatalogFile) +}); + +const securityHeaders = { + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY" +}; + +function sendJson(response, statusCode, payload) { + const body = JSON.stringify(payload); + response.writeHead(statusCode, { + ...securityHeaders, + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body) + }); + response.end(body); +} + +async function readBody(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 1048576) { + const error = new Error("Request body too large"); + error.statusCode = 413; + throw error; + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function parseJson(body) { + if (!body.length) return {}; + try { + return JSON.parse(body.toString("utf8")); + } catch { + const error = new Error("Invalid JSON body"); + error.statusCode = 400; + throw error; + } +} + +function safeEqual(left, right) { + const a = Buffer.from(left || ""); + const b = Buffer.from(right || ""); + return a.length === b.length && a.length > 0 && timingSafeEqual(a, b); +} + +function authenticateService(request) { + const authorization = request.headers.authorization || ""; + const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; + if (!safeEqual(token, serviceToken)) return null; + const issuer = String(request.headers["x-key-vault-actor-issuer"] || "").trim(); + const sub = String(request.headers["x-key-vault-actor-sub"] || "").trim(); + const username = String(request.headers["x-key-vault-actor-username"] || "").trim(); + if (!issuer || !sub || !username) return null; + return {issuer, sub, username, admin: username === adminUsername}; +} + +const server = http.createServer(async (request, response) => { + try { + if (!request.url) { + sendJson(response, 400, {error: "Bad request"}); + return; + } + const url = new URL(request.url, "http://localhost"); + const pathname = url.pathname; + if (request.method === "GET" && pathname === "/healthz") { + sendJson(response, 200, {status: "ok", vault: "sealed-at-rest"}); + return; + } + + const actor = authenticateService(request); + if (!actor) { + sendJson(response, 401, {error: "Trusted service authentication required"}); + return; + } + const body = ["POST", "PUT", "PATCH"].includes(request.method || "") + ? await readBody(request) + : Buffer.alloc(0); + + if (request.method === "GET" && pathname === "/v1/session") { + sendJson(response, 200, {actor}); + return; + } + + if (request.method === "GET" && pathname === "/v1/providers") { + sendJson(response, 200, {providers: vault.listProviders(actor)}); + return; + } + + if (request.method === "POST" && pathname === "/v1/providers") { + const provider = normalizeProvider(parseJson(body), {builtin: false}); + sendJson(response, 200, {provider: vault.saveCustomProvider(actor, provider)}); + return; + } + + const providerDelete = pathname.match(/^\/v1\/providers\/([a-z0-9._-]+)\/delete$/); + if (request.method === "POST" && providerDelete) { + sendJson(response, 200, vault.deleteCustomProvider(actor, providerDelete[1])); + return; + } + + if (request.method === "GET" && pathname === "/v1/credentials") { + sendJson(response, 200, { + credentials: vault.listCredentials(actor, {all: actor.admin && url.searchParams.get("scope") === "all"}) + }); + return; + } + + if (request.method === "POST" && pathname === "/v1/credentials") { + sendJson(response, 201, {credential: vault.createCredential(actor, parseJson(body))}); + return; + } + + const credentialAction = pathname.match(/^\/v1\/credentials\/([0-9a-f-]+)\/(replace|verify|delete)$/); + if (request.method === "POST" && credentialAction) { + const [, id, action] = credentialAction; + if (action === "replace") { + sendJson(response, 200, {credential: vault.replaceCredential(actor, id, parseJson(body))}); + } else if (action === "verify") { + sendJson(response, 200, vault.verifyCredential(actor, id)); + } else { + sendJson(response, 200, vault.deleteCredential(actor, id)); + } + return; + } + + if (request.method === "POST" && pathname === "/v1/resolve") { + sendJson(response, 200, vault.resolve(actor, parseJson(body))); + return; + } + + if (request.method === "GET" && pathname === "/v1/audit") { + sendJson(response, 200, { + events: vault.listAudit(actor, { + all: actor.admin && url.searchParams.get("scope") === "all", + limit: url.searchParams.get("limit") + }) + }); + return; + } + + sendJson(response, 404, {error: "Not found"}); + } catch (error) { + console.error("Key Vault request failed", error.message); + sendJson(response, error.statusCode || 400, {error: error.message || "Request failed"}); + } +}); + +server.listen(port, "0.0.0.0", () => { + console.log(`key-vault listening on :${port}`); +}); + +function shutdown() { + server.close(() => { + vault.close(); + process.exit(0); + }); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/ai-gateway/server.test.mjs b/ai-gateway/server.test.mjs new file mode 100644 index 0000000..0fb19af --- /dev/null +++ b/ai-gateway/server.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {randomBytes} from "node:crypto"; +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import http from "node:http"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; + +const directory = mkdtempSync(join(tmpdir(), "xiteng-key-vault-api-test-")); +writeFileSync(join(directory, "master-key"), randomBytes(32).toString("base64")); +writeFileSync(join(directory, "service-token"), "integration-service-token"); + +const portProbe = http.createServer(); +await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve)); +const port = portProbe.address().port; +await new Promise((resolve) => portProbe.close(resolve)); + +const service = spawn(process.execPath, ["server.mjs"], { + cwd: new URL(".", import.meta.url), + env: { + ...process.env, + PORT: String(port), + DATABASE_PATH: join(directory, "vault.db"), + MASTER_KEY_FILE: join(directory, "master-key"), + SERVICE_TOKEN_FILE: join(directory, "service-token"), + PROVIDER_CATALOG_FILE: new URL("providers.json", import.meta.url).pathname + }, + stdio: ["ignore", "pipe", "pipe"] +}); +let logs = ""; +service.stdout.on("data", (chunk) => { logs += chunk; }); +service.stderr.on("data", (chunk) => { logs += chunk; }); + +function request(pathname, {method = "GET", body} = {}) { + return fetch(`http://127.0.0.1:${port}${pathname}`, { + method, + headers: { + "Authorization": "Bearer integration-service-token", + "Accept": "application/json", + ...(body ? {"Content-Type": "application/json"} : {}), + "X-Key-Vault-Actor-Issuer": "https://auth.xiteng.site", + "X-Key-Vault-Actor-Sub": "integration-user", + "X-Key-Vault-Actor-Username": "integration" + }, + body: body ? JSON.stringify(body) : undefined + }); +} + +try { + let ready = false; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + if ((await fetch(`http://127.0.0.1:${port}/healthz`)).ok) { + ready = true; + break; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.equal(ready, true, logs || "Key Vault did not start"); + + const providersResponse = await request("/v1/providers"); + assert.equal(providersResponse.status, 200); + const providers = await providersResponse.json(); + assert.ok(providers.providers.some((provider) => provider.id === "openai")); + assert.ok(providers.providers.some((provider) => provider.id === "ollama" && provider.credentialState === "local")); + + const customResponse = await request("/v1/providers", { + method: "POST", + body: { + id: "integration-provider", + name: "Integration Provider", + api: "openai-completions", + connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null}, + auth: {type: "bearer"}, + defaultModel: "test-model", + discovery: {type: "openai-models-list", url: "https://example.com/v1/models"} + } + }); + assert.equal(customResponse.status, 200); + const savedCustom = await customResponse.json(); + assert.equal(savedCustom.provider.discovery.url, "https://example.com/v1/models"); + assert.equal("models" in savedCustom.provider, false); + + const createdResponse = await request("/v1/credentials", { + method: "POST", + body: {providerId: "integration-provider", name: "default", secret: {provider: {apiKey: "provider-secret"}}} + }); + assert.equal(createdResponse.status, 201); + + const resolvedResponse = await request("/v1/resolve", { + method: "POST", + body: {providerId: "integration-provider", credentialName: "default"} + }); + assert.equal(resolvedResponse.status, 200); + const resolved = await resolvedResponse.json(); + assert.equal(resolved.provider.connection.baseUrl, "https://example.com/v1"); + assert.equal(resolved.credential.secret.provider.apiKey, "provider-secret"); + + const frontendCredential = await request("/v1/credentials", { + method: "POST", + body: {providerId: "ollama", name: "default", secret: "browser-only"} + }); + assert.equal(frontendCredential.status, 409); + console.log("Key Vault API tests passed"); +} finally { + service.kill("SIGTERM"); + await new Promise((resolve) => service.once("exit", resolve)); + rmSync(directory, {recursive: true, force: true}); +} diff --git a/ai-gateway/test.mjs b/ai-gateway/test.mjs new file mode 100644 index 0000000..b696e02 --- /dev/null +++ b/ai-gateway/test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import {randomBytes} from "node:crypto"; +import {mkdtempSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs"; +import {Vault} from "./vault.mjs"; + +const directory = mkdtempSync(join(tmpdir(), "xiteng-vault-test-")); +const vault = new Vault({ + databasePath: join(directory, "vault.db"), + masterKey: randomBytes(32), + builtinProviders: loadBuiltinProviders(new URL("providers.json", import.meta.url)) +}); +const admin = {issuer: "https://id.xiteng.site", sub: "admin-sub", username: "liooil", admin: true}; +const user = {issuer: "https://id.xiteng.site", sub: "user-sub", username: "ziyue", admin: false}; + +try { + const providers = vault.listProviders(user); + assert.ok(providers.length >= 25); + assert.equal(providers.find((provider) => provider.id === "ollama").connection.type, "frontend"); + + assert.throws(() => vault.createCredential(user, { + providerId: "ollama", + name: "default", + secret: "must-not-enter-server" + }), /remain in the browser/i); + + const custom = normalizeProvider({ + id: "team-proxy", + name: "Team Proxy", + api: "openai-completions", + connection: { + type: "backend", + baseUrl: "https://models.example.com/v1", + proxy: {type: "socks5", url: "socks5://proxy.example.com:1080"} + }, + auth: {type: "bearer"}, + defaultModel: "team-model", + discovery: {type: "openai-models-list", url: "https://models.example.com/v1/catalog"} + }); + vault.saveCustomProvider(user, custom); + assert.equal(vault.getProvider(user, "team-proxy").connection.proxy.type, "socks5"); + assert.equal(vault.getProvider(user, "team-proxy").discovery.url, "https://models.example.com/v1/catalog"); + assert.equal("models" in vault.getProvider(user, "team-proxy"), false); + + const credential = vault.createCredential(user, { + providerId: "team-proxy", + name: "default", + secret: { + provider: {apiKey: "secret-one"}, + proxy: {username: "proxy-user", password: "proxy-pass"} + } + }); + assert.equal(vault.listCredentials(user).length, 1); + assert.equal(vault.listCredentials(admin, {all: true}).length, 1); + assert.equal(vault.verifyCredential(user, credential.id).verified, true); + + const resolved = vault.resolve(user, {providerId: "team-proxy", credentialName: "default"}); + assert.equal(resolved.provider.id, "team-proxy"); + assert.equal(resolved.credential.secret.provider.apiKey, "secret-one"); + assert.equal(resolved.credential.secret.proxy.username, "proxy-user"); + + vault.replaceCredential(user, credential.id, {secret: "secret-two"}); + assert.equal(vault.resolve(user, {providerId: "team-proxy"}).credential.secret.provider.apiKey, "secret-two"); + assert.throws(() => vault.resolve(admin, {providerId: "team-proxy"}), /provider not found/i); + + vault.deleteCredential(user, credential.id); + assert.equal(vault.listCredentials(user).length, 0); + vault.deleteCustomProvider(user, "team-proxy"); + assert.equal(vault.getProvider(user, "team-proxy"), null); + assert.ok(vault.listAudit(admin, {all: true}).length >= 7); + console.log("Vault tests passed"); +} finally { + vault.close(); + rmSync(directory, {recursive: true, force: true}); +} diff --git a/ai-gateway/vault.mjs b/ai-gateway/vault.mjs new file mode 100644 index 0000000..f058a82 --- /dev/null +++ b/ai-gateway/vault.mjs @@ -0,0 +1,552 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + randomUUID +} from "node:crypto"; +import {mkdirSync, readFileSync} from "node:fs"; +import {DatabaseSync} from "node:sqlite"; +import {normalizeProvider} from "./providers.mjs"; + +function now() { + return new Date().toISOString(); +} + +function limitedString(value, field, maximum = 120) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`); + return value.trim().slice(0, maximum); +} + +function secretBuffer(value) { + let normalized; + if (typeof value === "string") { + if (!value) throw new Error("secret is required"); + normalized = {provider: {apiKey: value}}; + } else if (value && typeof value === "object" && !Array.isArray(value)) { + normalized = value; + } else { + throw new Error("secret is required"); + } + const buffer = Buffer.from(JSON.stringify(normalized)); + if (buffer.length > 65536) throw new Error("secret is too large"); + return buffer; +} + +function parseSecret(buffer) { + const text = buffer.toString("utf8"); + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch {} + return {provider: {apiKey: text}}; +} + +export function loadKeyFile(path) { + const value = readFileSync(path); + const text = value.toString("utf8").trim(); + const decoded = /^[A-Fa-f0-9]{64}$/.test(text) ? Buffer.from(text, "hex") : Buffer.from(text, "base64"); + if (decoded.length !== 32) { + throw new Error(`Key file ${path} must contain exactly 32 random bytes encoded as base64 or hex`); + } + return decoded; +} + +function encrypt(key, plaintext, associatedData) { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + cipher.setAAD(Buffer.from(associatedData)); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return {ciphertext, nonce, tag: cipher.getAuthTag()}; +} + +function decrypt(key, ciphertext, nonce, tag, associatedData) { + const decipher = createDecipheriv("aes-256-gcm", key, nonce); + decipher.setAAD(Buffer.from(associatedData)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +function credentialAad(record) { + return `credential:v1:${record.id}:${record.owner_issuer}:${record.owner_sub}:${record.provider}`; +} + +function wrappedDekAad(record) { + return `wrapped-dek:v1:${record.id}`; +} + +function fingerprint(secret) { + return createHash("sha256").update(secret).digest("hex").slice(0, 16); +} + +function publicCredential(row) { + return { + id: row.id, + owner: { + issuer: row.owner_issuer, + sub: row.owner_sub, + username: row.owner_username + }, + providerId: row.provider, + name: row.name, + fingerprint: row.fingerprint, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastAccessedAt: row.last_used_at + }; +} + +export class Vault { + constructor({databasePath, masterKey, builtinProviders}) { + this.masterKey = masterKey; + this.builtinProviders = builtinProviders; + mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true}); + this.database = new DatabaseSync(databasePath); + this.database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; + + CREATE TABLE IF NOT EXISTS vault_credential ( + id TEXT PRIMARY KEY, + owner_issuer TEXT NOT NULL, + owner_sub TEXT NOT NULL, + owner_username TEXT NOT NULL, + owner_type TEXT NOT NULL DEFAULT 'user', + provider TEXT NOT NULL, + name TEXT NOT NULL, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + auth_tag BLOB NOT NULL, + wrapped_dek BLOB NOT NULL, + wrap_nonce BLOB NOT NULL, + wrap_tag BLOB NOT NULL, + key_version INTEGER NOT NULL DEFAULT 1, + fingerprint TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT + ); + + CREATE INDEX IF NOT EXISTS vault_credential_owner + ON vault_credential(owner_issuer, owner_sub, provider, name); + + CREATE TABLE IF NOT EXISTS custom_provider ( + id TEXT PRIMARY KEY, + owner_issuer TEXT NOT NULL, + owner_sub TEXT NOT NULL, + owner_username TEXT NOT NULL, + provider_id TEXT NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(owner_issuer, owner_sub, provider_id) + ); + + CREATE INDEX IF NOT EXISTS custom_provider_owner + ON custom_provider(owner_issuer, owner_sub, provider_id); + + CREATE TABLE IF NOT EXISTS vault_audit_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_issuer TEXT NOT NULL, + actor_sub TEXT NOT NULL, + actor_username TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT, + owner_issuer TEXT, + owner_sub TEXT, + result TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS vault_audit_owner_time + ON vault_audit_event(owner_issuer, owner_sub, created_at DESC); + + DROP TABLE IF EXISTS credential_grant; + UPDATE vault_credential SET status = 'active', revoked_at = NULL; + `); + } + + close() { + this.database.close(); + } + + audit(actor, action, targetType, targetId, owner, result = "success", detail = null) { + this.database.prepare(` + INSERT INTO vault_audit_event ( + actor_issuer, actor_sub, actor_username, action, target_type, target_id, + owner_issuer, owner_sub, result, detail, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + actor.issuer, + actor.sub, + actor.username, + action, + targetType, + targetId, + owner?.issuer || null, + owner?.sub || null, + result, + detail, + now() + ); + } + + ownerFromInput(actor, input) { + return input.owner && actor.admin + ? { + issuer: limitedString(input.owner.issuer, "owner.issuer", 300), + sub: limitedString(input.owner.sub, "owner.sub", 200), + username: limitedString(input.owner.username, "owner.username", 80) + } + : {issuer: actor.issuer, sub: actor.sub, username: actor.username}; + } + + customProviderDefinition(value) { + return normalizeProvider(JSON.parse(value)); + } + + listCustomProviders(actor) { + return this.database.prepare(` + SELECT * FROM custom_provider + WHERE owner_issuer = ? AND owner_sub = ? + ORDER BY provider_id COLLATE NOCASE + `).all(actor.issuer, actor.sub).map((row) => ({ + ...this.customProviderDefinition(row.definition_json), + createdAt: row.created_at, + updatedAt: row.updated_at + })); + } + + getProviderForOwner(owner, providerId) { + const id = limitedString(providerId, "providerId", 80).toLowerCase(); + const custom = this.database.prepare(` + SELECT definition_json FROM custom_provider + WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ? + `).get(owner.issuer, owner.sub, id); + return custom ? this.customProviderDefinition(custom.definition_json) : this.builtinProviders.get(id) || null; + } + + getProvider(actor, providerId) { + return this.getProviderForOwner(actor, providerId); + } + + listProviders(actor) { + const effective = new Map([...this.builtinProviders].map(([id, provider]) => [id, {...provider}])); + for (const provider of this.listCustomProviders(actor)) effective.set(provider.id, provider); + const credentials = this.listCredentials(actor); + return [...effective.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((provider) => ({ + ...provider, + credentials: provider.connection.type === "backend" + ? credentials.filter((credential) => credential.providerId === provider.id) + : [], + credentialState: provider.connection.type === "frontend" + ? "local" + : credentials.some((credential) => credential.providerId === provider.id) ? "configured" : "missing" + })); + } + + saveCustomProvider(actor, provider) { + const timestamp = now(); + const existing = this.database.prepare(` + SELECT id, created_at FROM custom_provider + WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ? + `).get(actor.issuer, actor.sub, provider.id); + const id = existing?.id || randomUUID(); + this.database.prepare(` + INSERT INTO custom_provider ( + id, owner_issuer, owner_sub, owner_username, provider_id, definition_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(owner_issuer, owner_sub, provider_id) DO UPDATE SET + owner_username = excluded.owner_username, + definition_json = excluded.definition_json, + updated_at = excluded.updated_at + `).run( + id, + actor.issuer, + actor.sub, + actor.username, + provider.id, + JSON.stringify({...provider, builtin: false}), + existing?.created_at || timestamp, + timestamp + ); + this.audit(actor, existing ? "provider.update" : "provider.create", "provider", provider.id, actor, "success", provider.connection.type); + return this.getProvider(actor, provider.id); + } + + deleteCustomProvider(actor, providerId) { + const id = limitedString(providerId, "providerId", 80).toLowerCase(); + const result = this.database.prepare(` + DELETE FROM custom_provider WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ? + `).run(actor.issuer, actor.sub, id); + if (!result.changes) { + const error = new Error("Custom provider not found"); + error.statusCode = 404; + throw error; + } + this.audit(actor, "provider.delete", "provider", id, actor); + return {deleted: true, id}; + } + + listCredentials(actor, {all = false} = {}) { + const rows = all && actor.admin + ? this.database.prepare(`SELECT * FROM vault_credential ORDER BY owner_username, provider, name`).all() + : this.database.prepare(` + SELECT * FROM vault_credential + WHERE owner_issuer = ? AND owner_sub = ? + ORDER BY provider, name + `).all(actor.issuer, actor.sub); + return rows.map(publicCredential); + } + + getCredential(id) { + return this.database.prepare("SELECT * FROM vault_credential WHERE id = ?").get(id); + } + + assertAccess(actor, row) { + if (!row || (!actor.admin && (row.owner_issuer !== actor.issuer || row.owner_sub !== actor.sub))) { + const error = new Error("Credential not found"); + error.statusCode = 404; + throw error; + } + } + + createCredential(actor, input) { + const owner = this.ownerFromInput(actor, input); + const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase(); + const provider = this.getProviderForOwner(owner, providerId); + if (!provider) { + const error = new Error("Provider not found"); + error.statusCode = 404; + throw error; + } + if (provider.connection.type !== "backend") { + const error = new Error("Frontend credentials must remain in the browser"); + error.statusCode = 409; + throw error; + } + const name = limitedString(input.name || "default", "name", 120); + const duplicate = this.database.prepare(` + SELECT id FROM vault_credential WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ? + `).get(owner.issuer, owner.sub, providerId, name); + if (duplicate) { + const error = new Error("Credential already exists"); + error.statusCode = 409; + throw error; + } + const id = randomUUID(); + const secret = secretBuffer(input.secret); + const record = {id, owner_issuer: owner.issuer, owner_sub: owner.sub, provider: providerId}; + const dek = randomBytes(32); + const encryptedSecret = encrypt(dek, secret, credentialAad(record)); + const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(record)); + const timestamp = now(); + this.database.prepare(` + INSERT INTO vault_credential ( + id, owner_issuer, owner_sub, owner_username, owner_type, provider, name, + ciphertext, nonce, auth_tag, wrapped_dek, wrap_nonce, wrap_tag, + fingerprint, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + `).run( + id, owner.issuer, owner.sub, owner.username, providerId, name, + encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag, + encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, + fingerprint(secret), timestamp, timestamp + ); + secret.fill(0); + dek.fill(0); + this.audit(actor, "credential.create", "credential", id, owner, "success", providerId); + return publicCredential(this.getCredential(id)); + } + + decryptCredential(row) { + const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row)); + try { + return decrypt(dek, row.ciphertext, row.nonce, row.auth_tag, credentialAad(row)); + } finally { + dek.fill(0); + } + } + + replaceCredential(actor, id, input) { + const row = this.getCredential(id); + this.assertAccess(actor, row); + const secret = secretBuffer(input.secret); + const dek = randomBytes(32); + const encryptedSecret = encrypt(dek, secret, credentialAad(row)); + const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(row)); + const timestamp = now(); + this.database.prepare(` + UPDATE vault_credential + SET ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, + fingerprint = ?, updated_at = ? + WHERE id = ? + `).run( + encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag, + encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, + fingerprint(secret), timestamp, id + ); + secret.fill(0); + dek.fill(0); + this.audit(actor, "credential.replace", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}); + return publicCredential(this.getCredential(id)); + } + + verifyCredential(actor, id) { + const row = this.getCredential(id); + this.assertAccess(actor, row); + const plaintext = this.decryptCredential(row); + const verified = fingerprint(plaintext) === row.fingerprint; + plaintext.fill(0); + this.audit(actor, "credential.verify", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}, verified ? "success" : "failure"); + return {verified, fingerprint: row.fingerprint}; + } + + deleteCredential(actor, id) { + const row = this.getCredential(id); + this.assertAccess(actor, row); + this.database.prepare("DELETE FROM vault_credential WHERE id = ?").run(id); + this.audit(actor, "credential.delete", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}); + return {deleted: true, id}; + } + + reassignCredential(actor, id, ownerInput) { + if (!actor.admin) { + const error = new Error("Administrator required"); + error.statusCode = 403; + throw error; + } + const row = this.getCredential(id); + this.assertAccess(actor, row); + const owner = { + issuer: limitedString(ownerInput.issuer, "owner.issuer", 300), + sub: limitedString(ownerInput.sub, "owner.sub", 200), + username: limitedString(ownerInput.username, "owner.username", 80) + }; + const provider = this.getProviderForOwner(owner, row.provider); + if (!provider || provider.connection.type !== "backend") { + const error = new Error("Target owner has no matching Backend Provider"); + error.statusCode = 409; + throw error; + } + const plaintext = this.decryptCredential(row); + const next = {...row, owner_issuer: owner.issuer, owner_sub: owner.sub}; + const dek = randomBytes(32); + const encryptedSecret = encrypt(dek, plaintext, credentialAad(next)); + const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(next)); + this.database.prepare(` + UPDATE vault_credential + SET owner_issuer = ?, owner_sub = ?, owner_username = ?, + ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, updated_at = ? + WHERE id = ? + `).run( + owner.issuer, owner.sub, owner.username, + encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag, + encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, now(), id + ); + plaintext.fill(0); + dek.fill(0); + this.audit(actor, "credential.reassign", "credential", id, owner, "success", `previous owner ${row.owner_username}`); + return publicCredential(this.getCredential(id)); + } + + resolve(actor, input) { + const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase(); + const provider = this.getProvider(actor, providerId); + if (!provider) { + const error = new Error("Provider not found"); + error.statusCode = 404; + throw error; + } + if (provider.connection.type !== "backend") { + const error = new Error("Frontend credentials are stored in the browser"); + error.statusCode = 409; + throw error; + } + const name = limitedString(input.credentialName || "default", "credentialName", 120); + const row = this.database.prepare(` + SELECT * FROM vault_credential + WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ? + `).get(actor.issuer, actor.sub, providerId, name); + if (!row) { + const error = new Error("Credential not found"); + error.statusCode = 404; + throw error; + } + const plaintext = this.decryptCredential(row); + const secret = parseSecret(plaintext); + plaintext.fill(0); + this.database.prepare("UPDATE vault_credential SET last_used_at = ? WHERE id = ?").run(now(), row.id); + this.audit(actor, "credential.resolve", "credential", row.id, actor, "success", providerId); + return {provider, credential: {id: row.id, name: row.name, secret}}; + } + + listAudit(actor, {all = false, limit = 100} = {}) { + const boundedLimit = Math.max(1, Math.min(500, Number.parseInt(limit, 10) || 100)); + const rows = all && actor.admin + ? this.database.prepare("SELECT * FROM vault_audit_event ORDER BY created_at DESC LIMIT ?").all(boundedLimit) + : this.database.prepare(` + SELECT * FROM vault_audit_event + WHERE owner_issuer = ? AND owner_sub = ? + ORDER BY created_at DESC LIMIT ? + `).all(actor.issuer, actor.sub, boundedLimit); + return rows.map((row) => ({ + id: row.id, + actorUsername: row.actor_username, + action: row.action, + targetType: row.target_type, + targetId: row.target_id, + result: row.result, + detail: row.detail, + createdAt: row.created_at + })); + } + + rotateMasterKey(actor, newMasterKey) { + if (!actor.admin) throw new Error("Administrator required"); + const rows = this.database.prepare("SELECT * FROM vault_credential").all(); + this.database.exec("BEGIN IMMEDIATE"); + try { + const update = this.database.prepare(` + UPDATE vault_credential + SET wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, key_version = key_version + 1, updated_at = ? + WHERE id = ? + `); + for (const row of rows) { + const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row)); + const wrapped = encrypt(newMasterKey, dek, wrappedDekAad(row)); + dek.fill(0); + update.run(wrapped.ciphertext, wrapped.nonce, wrapped.tag, now(), row.id); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + this.masterKey = newMasterKey; + this.audit(actor, "master-key.rotate", "vault", null, null, "success", `${rows.length} DEKs rewrapped`); + return {rewrapped: rows.length}; + } + + backup(destination) { + const escaped = destination.replaceAll("'", "''"); + this.database.exec(`VACUUM INTO '${escaped}'`); + return {destination}; + } +} + +export function breakGlassActor() { + return { + issuer: "urn:xiteng:break-glass", + sub: "local-emergency-script", + username: "liooil", + admin: true + }; +} diff --git a/authentik/assets/xiteng-background-v1.svg b/authentik/assets/xiteng-background-v1.svg new file mode 100644 index 0000000..a4002d2 --- /dev/null +++ b/authentik/assets/xiteng-background-v1.svg @@ -0,0 +1,15 @@ + + Xiteng login background + A warm cream grid with simple red, yellow, blue and green geometric shapes. + + + + + + + + + + + + diff --git a/authentik/assets/xiteng-favicon-v1.svg b/authentik/assets/xiteng-favicon-v1.svg new file mode 100644 index 0000000..7469295 --- /dev/null +++ b/authentik/assets/xiteng-favicon-v1.svg @@ -0,0 +1,7 @@ + + Xiteng + A red geometric X on a cream tile with a yellow background. + + + + diff --git a/authentik/assets/xiteng-wordmark-v1.svg b/authentik/assets/xiteng-wordmark-v1.svg new file mode 100644 index 0000000..95b9ac1 --- /dev/null +++ b/authentik/assets/xiteng-wordmark-v1.svg @@ -0,0 +1,9 @@ + + Xiteng + Xiteng wordmark with the red X symbol. + + + + Xiteng + HOME SERVICES + diff --git a/authentik/blueprints/xiteng-brand.yaml b/authentik/blueprints/xiteng-brand.yaml new file mode 100644 index 0000000..24ca2f1 --- /dev/null +++ b/authentik/blueprints/xiteng-brand.yaml @@ -0,0 +1,102 @@ +version: 1 +metadata: + name: Xiteng - Brand and login flow + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_brands.brand + state: present + identifiers: + domain: authentik-default + default: true + attrs: + branding_title: Xiteng + # Versioned repository assets live in Authentik's built-in static tree. + branding_logo: /static/dist/assets/xiteng/xiteng-wordmark-v1.svg + branding_favicon: /static/dist/assets/xiteng/xiteng-favicon-v1.svg + branding_default_flow_background: /static/dist/assets/xiteng/xiteng-background-v1.svg + attributes: + settings: + locale: zh-hans + branding_custom_css: | + :root { + --ak-font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --ak-font-family-heading: var(--ak-font-family-sans-serif); + --ak-global--background-color: #f7efe0; + --ak-c-login--MaxWidth: 31rem; + --ak-c-login__content-BoxShadow: none; + --ak-c-login__main--BackgroundColor: #fffaf0; + --ak-c-login__main--Color: #1c1712; + --ak-c-login__main--BoxShadow: 8px 8px 0 #1c1712; + --ak-c-login__footer--Color: #675f55; + --pf-global--primary-color--100: #d83b21; + --pf-global--primary-color--200: #a92a18; + --pf-global--Color--100: #1c1712; + --pf-global--Color--200: #675f55; + --pf-global--BorderColor--100: #1c1712; + --pf-global--BorderRadius--sm: 6px; + --pf-c-login__main--BackgroundColor: #fffaf0; + --pf-c-card--BackgroundColor: #fffaf0; + --pf-c-button--m-primary--BackgroundColor: #d83b21; + --pf-c-button--m-primary--hover--BackgroundColor: #bc321d; + --pf-c-button--m-primary--active--BackgroundColor: #a92a18; + --pf-c-button--m-secondary--Color: #1c1712; + --pf-c-button--m-secondary--BorderColor: #1c1712; + } + + ak-flow-executor::part(locale-select) { + display: none; + } + + .pf-c-login__main, + .pf-c-card { + border: 2px solid #1c1712; + border-radius: 10px; + } + + .pf-c-button.pf-m-primary { + border: 2px solid #1c1712; + border-radius: 6px; + box-shadow: 3px 3px 0 #1c1712; + font-weight: 800; + } + + .pf-c-button.pf-m-primary:hover { + box-shadow: 1px 1px 0 #1c1712; + transform: translate(2px, 2px); + } + + .pf-c-form-control { + border: 2px solid #1c1712; + border-radius: 6px; + } + + @media (max-width: 576px) { + :root { + --ak-c-login--MaxWidth: calc(100vw - 2rem); + --ak-c-login__main--BoxShadow: 5px 5px 0 #1c1712; + } + } + + + - model: authentik_core.application + state: present + identifiers: + slug: xiteng-portal + attrs: + name: Xiteng Portal + meta_launch_url: https://xiteng.site/?focus=authentik#infrastructure + open_in_new_tab: false + meta_description: 返回 xiteng.site 服务目录 + meta_publisher: Xiteng + meta_icon: https://xiteng.site/favicon.svg + - model: authentik_flows.flow + state: present + identifiers: + slug: default-authentication-flow + attrs: + name: Xiteng 登录 + title: 欢迎回来 + designation: authentication + authentication: none + layout: stacked diff --git a/authentik/blueprints/xiteng-chat.yaml b/authentik/blueprints/xiteng-chat.yaml new file mode 100644 index 0000000..56e2cdb --- /dev/null +++ b/authentik/blueprints/xiteng-chat.yaml @@ -0,0 +1,39 @@ +version: 1 +# Managed ForwardAuth application and outpost assignment for chat.xiteng.site. +metadata: + name: Xiteng Chat ForwardAuth + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - id: xiteng-chat-provider + model: authentik_providers_proxy.proxyprovider + state: present + identifiers: + name: Xiteng Chat + attrs: + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + external_host: https://chat.xiteng.site + mode: forward_single + + - model: authentik_core.application + state: present + identifiers: + slug: xiteng-chat + attrs: + name: Xiteng Chat + provider: !KeyOf xiteng-chat-provider + meta_launch_url: https://chat.xiteng.site + open_in_new_tab: true + + - model: authentik_outposts.outpost + state: present + identifiers: + name: uptime-kuma-outpost + attrs: + providers: + - !Find [authentik_core.provider, [name, Xiteng Portal Admin]] + - !Find [authentik_core.provider, [name, comfyui-proxy]] + - !Find [authentik_core.provider, [name, invokeai-proxy]] + - !Find [authentik_core.provider, [name, code-server-proxy]] + - !KeyOf xiteng-chat-provider diff --git a/authentik/blueprints/xiteng-passwordless.yaml b/authentik/blueprints/xiteng-passwordless.yaml new file mode 100644 index 0000000..7bc371b --- /dev/null +++ b/authentik/blueprints/xiteng-passwordless.yaml @@ -0,0 +1,35 @@ +version: 1 +metadata: + name: Xiteng - Passkey passwordless login +entries: + - model: authentik_stages_identification.identificationstage + state: present + identifiers: + name: default-authentication-identification + attrs: + user_fields: + - email + - username + password_stage: null + captcha_stage: null + webauthn_stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]] + case_insensitive_matching: true + show_matched_user: true + pretend_user_exists: true + enable_remember_me: false + enrollment_flow: null + recovery_flow: null + passwordless_flow: null + show_source_labels: false + + # Authentik 2026.5 ships the password/MFA skip policies in its default + # authentication blueprint. Remove the temporary local copies if present. + - model: authentik_policies_expression.expressionpolicy + state: absent + identifiers: + name: xiteng-authentication-password-optional + + - model: authentik_policies_expression.expressionpolicy + state: absent + identifiers: + name: xiteng-authentication-mfa-after-passkey diff --git a/authentik/blueprints/xiteng-recovery.yaml b/authentik/blueprints/xiteng-recovery.yaml new file mode 100644 index 0000000..b866152 --- /dev/null +++ b/authentik/blueprints/xiteng-recovery.yaml @@ -0,0 +1,98 @@ +version: 1 +metadata: + name: Xiteng password recovery + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - id: recovery-flow + model: authentik_flows.flow + state: present + identifiers: + slug: xiteng-recovery + attrs: + name: Xiteng 密码恢复 + title: 设置新密码 + designation: recovery + authentication: require_unauthenticated + + - id: password + model: authentik_stages_prompt.prompt + state: present + identifiers: + name: xiteng-recovery-password + attrs: + field_key: password + label: 新密码 + type: password + required: true + order: 0 + placeholder: 新密码 + placeholder_expression: false + + - id: password-repeat + model: authentik_stages_prompt.prompt + state: present + identifiers: + name: xiteng-recovery-password-repeat + attrs: + field_key: password_repeat + label: 再次输入新密码 + type: password + required: true + order: 1 + placeholder: 再次输入新密码 + placeholder_expression: false + + - id: password-stage + model: authentik_stages_prompt.promptstage + state: present + identifiers: + name: Xiteng 设置新密码 + attrs: + fields: + - !KeyOf password + - !KeyOf password-repeat + validation_policies: [] + + - id: user-write + model: authentik_stages_user_write.userwritestage + state: present + identifiers: + name: xiteng-recovery-user-write + attrs: + user_creation_mode: never_create + + - id: user-login + model: authentik_stages_user_login.userloginstage + state: present + identifiers: + name: xiteng-recovery-user-login + + - model: authentik_flows.flowstagebinding + state: present + identifiers: + target: !KeyOf recovery-flow + stage: !KeyOf password-stage + order: 10 + + - model: authentik_flows.flowstagebinding + state: present + identifiers: + target: !KeyOf recovery-flow + stage: !KeyOf user-write + order: 20 + + - model: authentik_flows.flowstagebinding + state: present + identifiers: + target: !KeyOf recovery-flow + stage: !KeyOf user-login + order: 30 + + - model: authentik_brands.brand + state: present + identifiers: + domain: authentik-default + default: true + attrs: + flow_recovery: !KeyOf recovery-flow diff --git a/authentik/bootstrap.py b/authentik/bootstrap.py new file mode 100644 index 0000000..73508e8 --- /dev/null +++ b/authentik/bootstrap.py @@ -0,0 +1,66 @@ +from pathlib import Path + +from authentik.core.models import Application, Group, Token, TokenIntents, User +from authentik.policies.models import PolicyBinding + +ADMIN_USERNAME = "liooil" +FAMILY_GROUP = "liuhome" +TOKEN_IDENTIFIER = "xiteng-portal-admin" +TOKEN_PATH = Path("/run/secrets/portal_api_token") +MANAGED_APPLICATIONS = [ + "xiteng-portal-admin", + "xiteng-portal", + "xiteng-chat", + "code-server", + "comfyui", + "invokeai", + "gitea", + "hedgedoc", + "hedgedoc2", + "minio", + "seaweedfs", + "remark42", +] + +liooil = User.objects.get(username=ADMIN_USERNAME) +admin_group = Group.objects.get(name="authentik Admins") +admin_group.users.add(liooil) + +User.objects.filter(username="akadmin").delete() + +liuhome, _ = Group.objects.get_or_create( + name=FAMILY_GROUP, + defaults={"is_superuser": False}, +) +if liuhome.is_superuser: + liuhome.is_superuser = False + liuhome.save(update_fields=["is_superuser"]) +for username in ["liooil", "ziyue"]: + liuhome.users.add(User.objects.get(username=username)) + +for application in Application.objects.filter(slug__in=MANAGED_APPLICATIONS): + PolicyBinding.objects.filter(target=application).delete() + PolicyBinding.objects.create( + target=application, + group=liuhome, + order=0, + enabled=True, + negate=False, + failure_result=False, + ) + +Token.objects.filter(identifier=TOKEN_IDENTIFIER).delete() +token = Token.objects.create( + identifier=TOKEN_IDENTIFIER, + intent=TokenIntents.INTENT_API, + user=liooil, + description="Xiteng Portal Authentik administration", + expiring=False, +) +TOKEN_PATH.write_text(token.key, encoding="utf-8") +TOKEN_PATH.chmod(0o600) + +print( + f"Identity bootstrap complete: administrator={ADMIN_USERNAME}, " + f"group={FAMILY_GROUP}, applications={len(MANAGED_APPLICATIONS)}" +) diff --git a/authentik/compose.yml b/authentik/compose.yml index c28ef6b..ec5f1f2 100644 --- a/authentik/compose.yml +++ b/authentik/compose.yml @@ -1,6 +1,6 @@ services: authentik: - image: ghcr.io/goauthentik/server:2026.5.0 + image: ghcr.io/goauthentik/server:2026.5.6 container_name: authentik restart: unless-stopped command: server @@ -18,6 +18,13 @@ services: volumes: - ./media:/media - ./custom-templates:/templates + - ./assets:/web/dist/assets/xiteng:ro + - ./blueprints/xiteng-brand.yaml:/blueprints/xiteng-brand.yaml:ro + - ./blueprints/xiteng-chat.yaml:/blueprints/xiteng-chat.yaml:ro + - ./blueprints/xiteng-recovery.yaml:/blueprints/xiteng-recovery.yaml:ro + - ./blueprints/xiteng-passwordless.yaml:/blueprints/xiteng-passwordless.yaml:ro + - ./bootstrap.py:/bootstrap/portal_identity.py:ro + - ./secrets:/run/secrets labels: # --- Traefik 路由 --- - "traefik.enable=true" @@ -34,37 +41,113 @@ services: - "traefik.http.routers.authentik-http.rule=Host(`auth.xiteng.site`)" - "traefik.http.routers.authentik-http.service=authentik" - "traefik.http.routers.authentik-http.entrypoints=web" - # --- Homepage --- - - "homepage.group=我的服务" - - "homepage.name=Authentik" - - "homepage.icon=authentik" - - "homepage.href=https://auth.xiteng.site" - - "homepage.description=统一认证" - # --- AutoKuma --- - - "kuma.authentik.http.name=Authentik" - - "kuma.authentik.http.url=http://authentik:9000/-/health/ready/" - - "kuma.authentik.http.interval=60" - - "kuma.authentik.http.max_retries=3" + # Label-managed static paths. The shared edge-cache controller creates + # higher-priority Traefik routes and the matching Cloudflare Cache Rule. + - "xiteng.site.cache.frontend.enabled=true" + - "xiteng.site.cache.frontend.routers=authentik,authentik-http" + - "xiteng.site.cache.frontend.paths=/static/dist/" + - "xiteng.site.cache.frontend.edge-ttl=604800" + - "xiteng.site.cache.frontend.stale-while-revalidate=86400" + # --- Xiteng Site dynamic catalog --- + - "xiteng.site.component.authentik.enabled=true" + - "xiteng.site.component.authentik.name=Authentik" + - "xiteng.site.component.authentik.description=隐藏的 OAuth2/OIDC、ForwardAuth 与身份数据引擎;通过 Portal 管理。" + - "xiteng.site.component.authentik.section=infrastructure" + - "xiteng.site.component.authentik.category=身份与访问" + - "xiteng.site.component.authentik.access=internal" + - "xiteng.site.component.authentik.access-label=后台身份引擎 · 通过 Portal 管理" + - "xiteng.site.component.authentik.icon=AK" + - "xiteng.site.component.authentik.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg" + - "xiteng.site.component.authentik.accent=red" + - "xiteng.site.component.authentik.order=200" + - "xiteng.site.component.authentik.monitor.enabled=true" + - "xiteng.site.component.authentik.monitor.url=http://authentik:9000/-/health/ready/" + networks: + - homelab_net + + worker: + image: ghcr.io/goauthentik/server:2026.5.6 + container_name: authentik-worker + restart: unless-stopped + command: worker + env_file: + - .env + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgres + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD} + AUTHENTIK_AVATARS: none + AUTHENTIK_LOG_LEVEL: info + user: root + volumes: + - ./media:/media + - ./custom-templates:/templates + - ./blueprints/xiteng-recovery.yaml:/blueprints/xiteng-recovery.yaml:ro + - ./blueprints/xiteng-brand.yaml:/blueprints/xiteng-brand.yaml:ro + - ./blueprints/xiteng-chat.yaml:/blueprints/xiteng-chat.yaml:ro + - ./blueprints/xiteng-passwordless.yaml:/blueprints/xiteng-passwordless.yaml:ro + labels: + - "traefik.enable=false" + - "xiteng.site.component.authentik-worker.enabled=true" + - "xiteng.site.component.authentik-worker.name=Authentik Worker" + - "xiteng.site.component.authentik-worker.description=执行身份系统后台任务并自动应用 Blueprint。" + - "xiteng.site.component.authentik-worker.section=infrastructure" + - "xiteng.site.component.authentik-worker.category=身份与访问" + - "xiteng.site.component.authentik-worker.access=internal" + - "xiteng.site.component.authentik-worker.access-label=后台任务组件" + - "xiteng.site.component.authentik-worker.icon=WK" + - "xiteng.site.component.authentik-worker.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg" + - "xiteng.site.component.authentik-worker.accent=yellow" + - "xiteng.site.component.authentik-worker.order=205" + depends_on: + - postgres + - redis networks: - homelab_net postgres: - image: postgres:16-alpine + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 container_name: authentik-db restart: unless-stopped environment: POSTGRES_USER: authentik POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD} POSTGRES_DB: authentik + labels: + - "xiteng.site.component.authentik-db.enabled=true" + - "xiteng.site.component.authentik-db.name=Authentik PostgreSQL" + - "xiteng.site.component.authentik-db.description=保存身份、Provider、应用与策略配置。" + - "xiteng.site.component.authentik-db.section=infrastructure" + - "xiteng.site.component.authentik-db.category=数据层" + - "xiteng.site.component.authentik-db.access=internal" + - "xiteng.site.component.authentik-db.access-label=仅容器网络" + - "xiteng.site.component.authentik-db.icon=PG" + - "xiteng.site.component.authentik-db.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg" + - "xiteng.site.component.authentik-db.accent=blue" + - "xiteng.site.component.authentik-db.order=510" volumes: - ./pgdata:/var/lib/postgresql/data networks: - homelab_net redis: - image: redis:alpine + image: redis:alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241 container_name: authentik-redis restart: unless-stopped + labels: + - "xiteng.site.component.authentik-redis.enabled=true" + - "xiteng.site.component.authentik-redis.name=Authentik Redis" + - "xiteng.site.component.authentik-redis.description=为身份服务提供缓存与任务状态。" + - "xiteng.site.component.authentik-redis.section=infrastructure" + - "xiteng.site.component.authentik-redis.category=数据层" + - "xiteng.site.component.authentik-redis.access=internal" + - "xiteng.site.component.authentik-redis.access-label=仅容器网络" + - "xiteng.site.component.authentik-redis.icon=RD" + - "xiteng.site.component.authentik-redis.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg" + - "xiteng.site.component.authentik-redis.accent=red" + - "xiteng.site.component.authentik-redis.order=520" volumes: - ./redis:/data networks: diff --git a/authentik/secrets/.gitkeep b/authentik/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cat-bodhi/.dockerignore b/cat-bodhi/.dockerignore new file mode 100644 index 0000000..7553924 --- /dev/null +++ b/cat-bodhi/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +*.md +start-local-ai.bat +/tmp diff --git a/cat-bodhi/Dockerfile b/cat-bodhi/Dockerfile new file mode 100644 index 0000000..e2d290b --- /dev/null +++ b/cat-bodhi/Dockerfile @@ -0,0 +1,43 @@ +# CAT-BODHI Sprite Segmentation API +# Node.js + Python + PyTorch in one container +# +# Build (CPU, default): +# docker build -t cat-bodhi . +# Build (GPU, requires nvidia-container-toolkit): +# docker build --build-arg TORCH_INDEX=https://download.pytorch.org/whl/cu126 -t cat-bodhi . + +FROM python:3.11-slim + +ARG NODE_MAJOR=22 +ARG TORCH_INDEX=https://download.pytorch.org/whl/cpu +ARG PIP_MIRROR=https://pypi.tuna.tsinghua.edu.cn/simple + +# 中科大 Debian 镜像源 +RUN sed -i 's|http://deb.debian.org/debian|https://mirrors.ustc.edu.cn/debian|g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's|http://deb.debian.org/debian-security|https://mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources + +# 安装 Node.js LTS +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# 安装 PyTorch(从官方 index)+ 其他 Python 包(从清华镜像) +RUN pip install --no-cache-dir \ + --index-url ${TORCH_INDEX} \ + --extra-index-url ${PIP_MIRROR} \ + torch torchvision \ + opencv-python-headless \ + numpy \ + Pillow + +WORKDIR /app +COPY . . +RUN mkdir -p assets/ai/cats assets/ai/beads + +EXPOSE 8080 +ENV PORT=8080 +CMD ["node", "server.mjs"] diff --git a/cat-bodhi/compose.yml b/cat-bodhi/compose.yml new file mode 100644 index 0000000..571f1cd --- /dev/null +++ b/cat-bodhi/compose.yml @@ -0,0 +1,71 @@ +services: + cat-bodhi: + build: + context: . + # GPU 加速:取消下面注释(需先安装 nvidia-container-toolkit) + # args: + # TORCH_INDEX: https://download.pytorch.org/whl/cu126 + container_name: cat-bodhi + restart: unless-stopped + env_file: + - .env + environment: + - PORT=8080 + - SPRITE_SEG_ROOT=/opt/sprite_alpha_seg_pytorch + - SPRITE_SEG_PYTHON=/usr/local/bin/python + - SPRITE_SEG_CHECKPOINT=/opt/sprite_alpha_seg_pytorch/checkpoints/unet_sprite_ft.pt + - SPRITE_SEG_OUT_DIR=/opt/sprite_alpha_seg_pytorch/outputs/cat_match + volumes: + # 模型文件目录(需要手动放入 infer_spritesheet_hybrid.py + checkpoints/) + - ./sprite_alpha_seg_pytorch:/opt/sprite_alpha_seg_pytorch:rw + # AI 生成图片持久化(可选,不挂载则容器重启后丢失) + - ./data/assets-ai:/app/assets/ai:rw + networks: + - homelab_net + # GPU 支持:取消下面注释(需先安装 nvidia-container-toolkit) + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] + + labels: + # ========== Traefik ========== + - "traefik.enable=true" + + # HTTPS router (cert management) + - "traefik.http.routers.cat-bodhi.rule=Host(`cat-bodhi.xiteng.site`)" + - "traefik.http.routers.cat-bodhi.entrypoints=websecure" + - "traefik.http.routers.cat-bodhi.tls=true" + - "traefik.http.routers.cat-bodhi.tls.certresolver=cfresolver" + - "traefik.http.services.cat-bodhi.loadbalancer.server.port=8080" + + # HTTP router (Cloudflare Tunnel ingress) + - "traefik.http.routers.cat-bodhi-http.rule=Host(`cat-bodhi.xiteng.site`)" + - "traefik.http.routers.cat-bodhi-http.service=cat-bodhi" + - "traefik.http.routers.cat-bodhi-http.entrypoints=web" + - "xiteng.site.cache.cat-bodhi.enabled=true" + - "xiteng.site.cache.cat-bodhi.routers=cat-bodhi,cat-bodhi-http" + - "xiteng.site.cache.cat-bodhi.paths=/assets/,/data/assets-ai/" + - "xiteng.site.cache.cat-bodhi.edge-ttl=604800" + - "xiteng.site.cache.cat-bodhi.stale-while-revalidate=86400" + + - "xiteng.site.component.cat-bodhi.enabled=true" + - "xiteng.site.component.cat-bodhi.name=猫猫盘珠日记" + - "xiteng.site.component.cat-bodhi.description=文玩手串与猫咪主题游戏,以及 Sprite 抠图导入工具。" + - "xiteng.site.component.cat-bodhi.section=services" + - "xiteng.site.component.cat-bodhi.category=作品与实验" + - "xiteng.site.component.cat-bodhi.url=https://cat-bodhi.xiteng.site" + - "xiteng.site.component.cat-bodhi.access=public" + - "xiteng.site.component.cat-bodhi.access-label=无需登录" + - "xiteng.site.component.cat-bodhi.icon=猫" + - "xiteng.site.component.cat-bodhi.accent=yellow" + - "xiteng.site.component.cat-bodhi.order=300" + - "xiteng.site.component.cat-bodhi.navigation=new-tab" + - "xiteng.site.component.cat-bodhi.portal-link=embedded" + +networks: + homelab_net: + external: true diff --git a/chat/.dockerignore b/chat/.dockerignore new file mode 100644 index 0000000..cbfaa50 --- /dev/null +++ b/chat/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.next +package-lock.json +npm-debug.log +data +run diff --git a/chat/Dockerfile b/chat/Dockerfile new file mode 100644 index 0000000..a8cd0f2 --- /dev/null +++ b/chat/Dockerfile @@ -0,0 +1,25 @@ +FROM oven/bun:1.3.5-alpine AS dependencies +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production + +FROM oven/bun:1.3.5-alpine AS builder +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . +RUN bun run build + +FROM oven/bun:1.3.5-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV STATIC_ROOT=/app/dist +RUN apk add --no-cache su-exec && addgroup -S app && adduser -S app -G app +COPY --from=dependencies --chown=app:app /app/node_modules ./node_modules +COPY --from=builder --chown=app:app /app/dist ./dist +COPY --from=builder --chown=app:app /app/src/server.ts ./src/server.ts +COPY --from=builder --chown=app:app /app/lib ./lib +COPY --chmod=755 entrypoint.sh /entrypoint.sh +EXPOSE 3000 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/chat/build.ts b/chat/build.ts new file mode 100644 index 0000000..c69f028 --- /dev/null +++ b/chat/build.ts @@ -0,0 +1,20 @@ +import {cpSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; + +rmSync("dist", {recursive: true, force: true}); +mkdirSync("dist/assets", {recursive: true}); +const result = await Bun.build({ + entrypoints: ["src/client.ts"], + outdir: "dist/assets", + target: "browser", + minify: true, + sourcemap: "none", + naming: "client.js" +}); +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} +const version = Bun.hash(`${readFileSync("src/client.ts", "utf8")}\0${readFileSync("src/styles.css", "utf8")}`).toString(36); +writeFileSync("dist/index.html", readFileSync("src/index.html", "utf8").replaceAll("__ASSET_VERSION__", version)); +cpSync("src/styles.css", "dist/styles.css"); +cpSync("public", "dist", {recursive: true}); diff --git a/chat/bun.lock b/chat/bun.lock new file mode 100644 index 0000000..53d69fc --- /dev/null +++ b/chat/bun.lock @@ -0,0 +1,139 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "xiteng-chat", + "dependencies": { + "@ai-sdk/anthropic": "4.0.36", + "@ai-sdk/google": "4.0.39", + "@ai-sdk/openai": "4.0.36", + "@ai-sdk/openai-compatible": "3.0.28", + "ai": "7.0.58", + "dompurify": "3.2.6", + "marked": "15.0.12", + "node-fetch": "3.3.2", + "proxy-agent": "8.0.2", + }, + "devDependencies": { + "@types/bun": "1.3.5", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wg5jfray0X4+qkrr73GZ7U7K2JNGx+S+rNY9LorVlXhkhTqnvtNLYWRA1WxSxlCDCw3yjRCJdL9kyc+b7naAog=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="], + + "@ai-sdk/google": ["@ai-sdk/google@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+mRx7UBZn9PkJ4J6YXowaRZKMZYa290cknVqqOw/roZaDg186IUOLn9JHNQkvgaj91/MLW1AZNESy1ZD2yXDCg=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wHJNArBdjJPXb8GXcA+FslbRt+7qIE1KoHSAVi+CeBFidinVrTVBZKFMJz3mNZMj8YILBMl/VIvTD/oGFjX6/g=="], + + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@3.0.28", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vF/852mCFiASq2fAPE1mA0E0w78NH68/X2Gukuh4TeJrMIp+CJfoR5QLuY2+pTx7UiLmuYWDpCQhWaupuM7+Xg=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.25", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + + "ai": ["ai@7.0.58", "", { "dependencies": { "@ai-sdk/gateway": "4.0.46", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfgO90CQQ0yYuoxJAUOeQ6tviyYw1BUIDygSZ1q3Ce6kSc93tYmB5eltKY/NxC0YOouAax7JvDqVnYxvIAr04Q=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "degenerator": ["degenerator@7.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w=="], + + "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "get-uri": ["get-uri@8.0.1", "", { "dependencies": { "basic-ftp": "^5.3.1", "data-uri-to-buffer": "8.0.0", "debug": "^4.3.4" } }, "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww=="], + + "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], + + "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], + + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "pac-proxy-agent": ["pac-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "get-uri": "8.0.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "pac-resolver": "9.0.1", "quickjs-wasi": "^2.2.0", "socks-proxy-agent": "10.1.0" } }, "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA=="], + + "pac-resolver": ["pac-resolver@9.0.1", "", { "dependencies": { "degenerator": "7.0.1", "netmask": "^2.0.2" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw=="], + + "proxy-agent": ["proxy-agent@8.0.2", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "lru-cache": "^7.14.1", "pac-proxy-agent": "9.1.0", "proxy-from-env": "^2.0.0", "socks-proxy-agent": "10.1.0" } }, "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A=="], + + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "quickjs-wasi": ["quickjs-wasi@2.2.0", "", {}, "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@10.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@8.0.0", "", {}, "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ=="], + } +} diff --git a/chat/compose.yml b/chat/compose.yml new file mode 100644 index 0000000..6d4e764 --- /dev/null +++ b/chat/compose.yml @@ -0,0 +1,103 @@ +services: + provider-proxy-host: + image: node:24-alpine + container_name: xiteng-provider-proxy-host + restart: unless-stopped + network_mode: host + command: ["node", "/app/proxy-bridge.mjs"] + environment: + BRIDGE_MODE: host + volumes: + - ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro + - ./run:/run/provider-proxy + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "node", "-e", "const s=require('net').connect(7897,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(2000,()=>process.exit(1));s.on('error',()=>process.exit(1))"] + interval: 15s + timeout: 3s + retries: 3 + start_period: 5s + + provider-proxy-bridge: + image: node:24-alpine + container_name: xiteng-provider-proxy-bridge + restart: unless-stopped + command: ["node", "/app/proxy-bridge.mjs"] + environment: + BRIDGE_MODE: network + volumes: + - ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro + - ./run:/run/provider-proxy + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "node", "-e", "const s=require('net').connect(17897,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(2000,()=>process.exit(1));s.on('error',()=>process.exit(1))"] + interval: 15s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - homelab_net + + chat: + build: + context: . + image: xiteng-chat:local + container_name: xiteng-chat + restart: unless-stopped + environment: + KEY_VAULT_URL: http://ai-gateway:8093 + KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac + CHAT_DATABASE_PATH: /data/chat.db + PORTAL_URL: http://xiteng-site:8080 + volumes: + - ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro + - ./data:/data + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - homelab_net + labels: + - "traefik.enable=true" + - "traefik.http.services.xiteng-chat.loadbalancer.server.port=3000" + - "traefik.http.middlewares.xiteng-chat-scheme.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.xiteng-chat-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.xiteng-chat-auth.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.xiteng-chat-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-email,X-authentik-name,X-authentik-uid" + - "traefik.http.routers.xiteng-chat.rule=Host(`chat.xiteng.site`)" + - "traefik.http.routers.xiteng-chat.entrypoints=websecure" + - "traefik.http.routers.xiteng-chat.tls=true" + - "traefik.http.routers.xiteng-chat.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-chat.service=xiteng-chat" + - "traefik.http.routers.xiteng-chat.middlewares=xiteng-chat-scheme,xiteng-chat-auth" + - "traefik.http.routers.xiteng-chat-http.rule=Host(`chat.xiteng.site`)" + - "traefik.http.routers.xiteng-chat-http.entrypoints=web" + - "traefik.http.routers.xiteng-chat-http.service=xiteng-chat" + - "traefik.http.routers.xiteng-chat-http.middlewares=xiteng-chat-scheme,xiteng-chat-auth" + - "xiteng.site.component.chat.enabled=true" + - "xiteng.site.component.chat.name=Xiteng Chat" + - "xiteng.site.component.chat.description=基于 assistant-ui 与个人 Key Vault 的轻量 AI 对话界面。" + - "xiteng.site.component.chat.section=services" + - "xiteng.site.component.chat.category=AI" + - "xiteng.site.component.chat.url=https://chat.xiteng.site" + - "xiteng.site.component.chat.access=sso" + - "xiteng.site.component.chat.access-label=需要登录" + - "xiteng.site.component.chat.icon=AI" + - "xiteng.site.component.chat.icon-url=https://xiteng.site/icons/services/chat.svg" + - "xiteng.site.component.chat.accent=blue" + - "xiteng.site.component.chat.order=190" + - "xiteng.site.component.chat.navigation=new-tab" + - "xiteng.site.component.chat.portal-link=embedded" + - "xiteng.site.component.chat.monitor.enabled=true" + - "xiteng.site.component.chat.monitor.url=http://xiteng-chat:3000/api/health" + +networks: + homelab_net: + external: true diff --git a/chat/entrypoint.sh b/chat/entrypoint.sh new file mode 100755 index 0000000..46b4c51 --- /dev/null +++ b/chat/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +install -m 0400 -o app -g app "$KEY_VAULT_TOKEN_FILE" /tmp/key_vault_service_token +export KEY_VAULT_TOKEN_FILE=/tmp/key_vault_service_token +install -d -m 0700 -o app -g app /data +exec su-exec app bun src/server.ts diff --git a/chat/history.test.mjs b/chat/history.test.mjs new file mode 100644 index 0000000..fa26053 --- /dev/null +++ b/chat/history.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import http from "node:http"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {spawn} from "node:child_process"; + +const root = process.cwd(); +const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "xiteng-chat-history-test-")); +const databasePath = path.join(temporaryDirectory, "chat.db"); +const tokenPath = path.join(temporaryDirectory, "service-token"); +writeFileSync(tokenPath, "history-test-token"); + +function availablePort() { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); +} + +async function waitForServer(url, child) { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Bun server exited with code ${child.exitCode}`); + try { + if ((await fetch(url)).ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for history test server"); +} + +async function startServer() { + const port = await availablePort(); + const child = spawn(process.execPath, [path.join(root, "src/server.ts")], { + cwd: root, + env: { + ...process.env, + PORT: String(port), + STATIC_ROOT: path.join(root, "dist"), + CHAT_DATABASE_PATH: databasePath, + KEY_VAULT_URL: "http://127.0.0.1:1", + KEY_VAULT_TOKEN_FILE: tokenPath + }, + stdio: "ignore" + }); + await waitForServer(`http://127.0.0.1:${port}/api/health`, child); + return {child, origin: `http://127.0.0.1:${port}`}; +} + +function stopServer(child) { + if (child.exitCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + child.once("exit", resolve); + child.kill("SIGTERM"); + }); +} + +async function api(origin, identity, pathname, init = {}) { + const response = await fetch(`${origin}${pathname}`, { + ...init, + headers: { + "X-Authentik-Username": identity.username, + "X-Authentik-Uid": identity.sub, + ...(init.body ? {"Content-Type": "application/json"} : {}), + ...(init.headers || {}) + } + }); + const payload = response.status === 204 ? null : await response.json(); + return {response, payload}; +} + +const owner = {username: "history-owner", sub: "owner-sub"}; +const other = {username: "history-other", sub: "other-sub"}; +let server; +try { + server = await startServer(); + const initialSettings = {reasoning: "low", showReasoningSummary: false, temperature: null, maxOutputTokens: null}; + const createdResult = await api(server.origin, owner, "/api/conversations", { + method: "POST", + body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings}) + }); + assert.equal(createdResult.response.status, 201); + assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings); + const conversationId = createdResult.payload.conversation.id; + const messages = [ + {id: "user-1", role: "user", parts: [{type: "text", text: "persistent question"}]}, + {id: "assistant-1", role: "assistant", parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}], metadata: {custom: {response: {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20}}}} + ]; + const savedResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, { + method: "PUT", + body: JSON.stringify({providerId: "openai", model: "gpt-test", messages}) + }); + assert.equal(savedResult.response.status, 200); + assert.equal(savedResult.payload.conversation.title, "persistent question"); + assert.equal(savedResult.payload.conversation.messageCount, 2); + const updatedSettings = {reasoning: "high", showReasoningSummary: true, temperature: 0.7, maxOutputTokens: 4096}; + const settingsResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, { + method: "PATCH", + body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: updatedSettings}) + }); + assert.equal(settingsResult.response.status, 200); + + await stopServer(server.child); + server = await startServer(); + const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`); + assert.equal(restored.response.status, 200); + assert.deepEqual(restored.payload.conversation.messages, messages); + assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings); + + const ownerList = await api(server.origin, owner, "/api/conversations"); + assert.equal(ownerList.payload.conversations.length, 1); + assert.equal(ownerList.payload.conversations[0].id, conversationId); + + const otherList = await api(server.origin, other, "/api/conversations"); + assert.deepEqual(otherList.payload.conversations, []); + assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`)).response.status, 404); + assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 404); + + assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 204); + assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`)).response.status, 404); + console.log("Chat history API tests passed"); +} finally { + if (server) await stopServer(server.child); + rmSync(temporaryDirectory, {recursive: true, force: true}); +} diff --git a/chat/lib/browser-provider-fetch.ts b/chat/lib/browser-provider-fetch.ts new file mode 100644 index 0000000..56f525b --- /dev/null +++ b/chat/lib/browser-provider-fetch.ts @@ -0,0 +1,23 @@ +import type {ProviderDefinition, ProviderSecret} from "./provider-types"; + +export function createBrowserProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) { + const proxy = provider.connection.proxy; + if (!proxy) return fetch; + if (proxy.type !== "relay") throw new Error(`Unsupported frontend proxy: ${proxy.type}`); + return async (input: RequestInfo | URL, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + const relayHeaders: Record = {"Content-Type": "application/json"}; + if (secret.proxy?.token) relayHeaders.Authorization = `Bearer ${secret.proxy.token}`; + return fetch(proxy.url, { + method: "POST", + headers: relayHeaders, + body: JSON.stringify({ + url: String(input), + method: init.method || "GET", + headers: Object.fromEntries(headers.entries()), + body: typeof init.body === "string" ? init.body : null + }), + signal: init.signal + }); + }; +} diff --git a/chat/lib/browser-provider-settings.ts b/chat/lib/browser-provider-settings.ts new file mode 100644 index 0000000..423b479 --- /dev/null +++ b/chat/lib/browser-provider-settings.ts @@ -0,0 +1,24 @@ +import type {ProviderDefinition, ProviderSecret} from "./provider-types"; + +function inferredDiscoveryUrl(provider: ProviderDefinition, baseUrl: string) { + if (provider.discovery.type === "anthropic-models-list") return `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`; + if (provider.discovery.type === "google-models-list") return `${baseUrl}/models?pageSize=200`; + return `${baseUrl}/models`; +} + +export function applyBrowserProviderSettings(provider: ProviderDefinition, secret: ProviderSecret) { + const configuredBaseUrl = secret.provider?.baseUrl?.trim(); + if (!configuredBaseUrl) return provider; + const url = new URL(configuredBaseUrl); + if (!["http:", "https:"].includes(url.protocol)) throw new Error("Provider Base URL 必须使用 http 或 https"); + const baseUrl = configuredBaseUrl.replace(/\/+$/, ""); + const originalBaseUrl = provider.connection.baseUrl.replace(/\/+$/, ""); + const discoveryUrl = provider.discovery.url.startsWith(originalBaseUrl) + ? inferredDiscoveryUrl(provider, baseUrl) + : provider.discovery.url; + return { + ...provider, + connection: {...provider.connection, baseUrl}, + discovery: {...provider.discovery, url: discoveryUrl} + }; +} diff --git a/chat/lib/conversation-client.ts b/chat/lib/conversation-client.ts new file mode 100644 index 0000000..cd1e9c8 --- /dev/null +++ b/chat/lib/conversation-client.ts @@ -0,0 +1,159 @@ +import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types"; +import type {GenerationSettings} from "./generation-settings"; +import { + cacheConversation, + cacheConversationSummaries, + listPendingConversationChanges, + loadCachedConversation, + loadCachedConversationSummaries, + queueConversationChange, + removeCachedConversation, + removePendingConversationChange +} from "./offline-history"; + +class ConversationHttpError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } +} + +async function conversationRequest(path: string, init?: RequestInit) { + const response = await fetch(path, { + cache: "no-store", + ...init, + headers: { + "Accept": "application/json", + ...(init?.body ? {"Content-Type": "application/json"} : {}), + ...(init?.headers || {}) + } + }); + if (response.status === 204) return undefined as T; + const payload = await response.json(); + if (!response.ok) throw new ConversationHttpError(payload.error || `HTTP ${response.status}`, response.status); + return payload as T; +} + +function isNetworkFailure(error: unknown) { + return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine); +} + +export async function listConversationHistory() { + try { + const payload = await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations"); + await cacheConversationSummaries(payload.conversations); + return payload.conversations; + } catch (error) { + if (!isNetworkFailure(error)) throw error; + const cached = await loadCachedConversationSummaries(); + if (cached.length === 0) throw error; + return cached; + } +} + +export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) { + const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", { + method: "POST", + body: JSON.stringify({providerId, model, generationSettings}) + }); + await cacheConversation(payload.conversation); + return payload.conversation; +} + +export async function getConversationHistory(id: string) { + try { + const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`); + await cacheConversation(payload.conversation); + return payload.conversation; + } catch (error) { + if (!isNetworkFailure(error)) throw error; + const cached = await loadCachedConversation(id); + if (!cached) throw error; + return cached; + } +} + +export async function updateConversationHistory(id: string, providerId: string, model: string, generationSettings: GenerationSettings) { + const body = JSON.stringify({providerId, model, generationSettings}); + try { + return await conversationRequest<{updated: true}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PATCH", body}); + } catch (error) { + if (!isNetworkFailure(error)) throw error; + const cached = await loadCachedConversation(id); + if (cached) await cacheConversation({...cached, providerId, model, generationSettings, updatedAt: new Date().toISOString()}); + await queueConversationChange({conversationId: id, method: "PATCH", body}); + return {updated: true as const}; + } +} + +export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) { + const body = JSON.stringify({providerId, model, messages}); + try { + const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PUT", body}); + await cacheConversation(payload.conversation); + return payload.conversation; + } catch (error) { + if (!isNetworkFailure(error)) throw error; + const cached = await loadCachedConversation(id); + if (!cached) throw error; + const firstUserText = messages.find((message) => message.role === "user")?.parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => String(part.text)).join(" ").replace(/\s+/g, " ").trim(); + const updated: Conversation = { + ...cached, + title: firstUserText?.slice(0, 48) || cached.title, + providerId, + model, + messages, + messageCount: messages.length, + updatedAt: new Date().toISOString() + }; + await cacheConversation(updated); + await queueConversationChange({conversationId: id, method: "PUT", body}); + return updated; + } +} + +export async function deleteConversationHistory(id: string) { + try { + await conversationRequest(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"}); + } catch (error) { + if (!isNetworkFailure(error)) throw error; + await queueConversationChange({conversationId: id, method: "DELETE"}); + } + await removeCachedConversation(id); +} + +export async function flushPendingConversationChanges() { + const pending = await listPendingConversationChanges(); + for (const change of pending) { + try { + await conversationRequest(`/api/conversations/${encodeURIComponent(change.conversationId)}`, { + method: change.method, + body: change.body + }); + await removePendingConversationChange(change.cacheKey); + } catch (error) { + if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) { + await removePendingConversationChange(change.cacheKey); + continue; + } + if (isNetworkFailure(error)) break; + throw error; + } + } +} + +export async function synchronizeOfflineConversationHistory(summaries?: ConversationSummary[]) { + const history = summaries || (await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations")).conversations; + await cacheConversationSummaries(history); + let cursor = 0; + const worker = async () => { + while (cursor < history.length) { + const summary = history[cursor++]; + const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(summary.id)}`); + await cacheConversation(payload.conversation); + } + }; + await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker())); + return history; +} diff --git a/chat/lib/conversation-hash.test.mjs b/chat/lib/conversation-hash.test.mjs new file mode 100644 index 0000000..e76d733 --- /dev/null +++ b/chat/lib/conversation-hash.test.mjs @@ -0,0 +1,18 @@ +import {describe, expect, test} from "bun:test"; +import {conversationHash, conversationIdFromHash} from "./conversation-hash.ts"; + +describe("conversation hash routing", () => { + test("round-trips a conversation id", () => { + const id = "f60dbe18-92ca-4a7d-9d5d-242d0ed4d042"; + expect(conversationIdFromHash(conversationHash(id))).toBe(id); + }); + + test("preserves URL-sensitive ids", () => { + expect(conversationIdFromHash(conversationHash("local/id + draft"))).toBe("local/id + draft"); + }); + + test("ignores unrelated or oversized hashes", () => { + expect(conversationIdFromHash("#services")).toBe(""); + expect(conversationIdFromHash(`#conversation=${"x".repeat(121)}`)).toBe(""); + }); +}); diff --git a/chat/lib/conversation-hash.ts b/chat/lib/conversation-hash.ts new file mode 100644 index 0000000..d00995c --- /dev/null +++ b/chat/lib/conversation-hash.ts @@ -0,0 +1,13 @@ +const conversationHashKey = "conversation"; + +export function conversationIdFromHash(hash: string) { + const input = hash.startsWith("#") ? hash.slice(1) : hash; + const id = new URLSearchParams(input).get(conversationHashKey)?.trim() || ""; + return id && id.length <= 120 ? id : ""; +} + +export function conversationHash(id: string) { + const parameters = new URLSearchParams(); + parameters.set(conversationHashKey, id); + return `#${parameters.toString()}`; +} diff --git a/chat/lib/conversation-types.ts b/chat/lib/conversation-types.ts new file mode 100644 index 0000000..96385a3 --- /dev/null +++ b/chat/lib/conversation-types.ts @@ -0,0 +1,31 @@ +import type {GenerationSettings} from "./generation-settings"; + +export type ResponseMetadata = { + providerId: string; + model: string; + durationMs: number; + outputTokens: number | null; + tokensPerSecond: number | null; +}; + +export type StoredChatMessage = { + id: string; + role: "system" | "user" | "assistant"; + parts: Array & {type: string}>; + metadata?: {custom?: {response?: ResponseMetadata}}; +}; + +export type ConversationSummary = { + id: string; + title: string; + providerId: string; + model: string; + messageCount: number; + createdAt: string; + updatedAt: string; +}; + +export type Conversation = ConversationSummary & { + generationSettings: GenerationSettings; + messages: StoredChatMessage[]; +}; diff --git a/chat/lib/conversations.ts b/chat/lib/conversations.ts new file mode 100644 index 0000000..c7af41a --- /dev/null +++ b/chat/lib/conversations.ts @@ -0,0 +1,235 @@ +import {randomUUID} from "node:crypto"; +import {mkdirSync} from "node:fs"; +import path from "node:path"; +import {Database} from "bun:sqlite"; +import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings"; +import type {ChatIdentity} from "./key-vault"; +import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "./conversation-types"; + +const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db"; +let database: Database | undefined; + +function getDatabase() { + if (database) return database; + mkdirSync(path.dirname(databasePath), {recursive: true}); + const opened = new Database(databasePath, {create: true, strict: true}); + opened.run(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS chat_conversation ( + id TEXT PRIMARY KEY, + owner_issuer TEXT NOT NULL, + owner_sub TEXT NOT NULL, + title TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + settings_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS chat_conversation_owner_updated + ON chat_conversation (owner_issuer, owner_sub, updated_at DESC); + CREATE TABLE IF NOT EXISTS chat_message ( + conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, + id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), + parts_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (conversation_id, id), + UNIQUE (conversation_id, ordinal) + ); + `); + const columns = opened.query("PRAGMA table_info(chat_conversation)").all() as Array<{name: string}>; + if (!columns.some((column) => column.name === "settings_json")) { + opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'"); + } + database = opened; + return opened; +} + +type ConversationRow = { + id: string; + title: string; + provider_id: string; + model: string; + settings_json: string; + message_count: number; + created_at: string; + updated_at: string; +}; + +type MessageRow = {id: string; role: StoredChatMessage["role"]; parts_json: string}; + +function now() { + return new Date().toISOString(); +} + +function requiredString(value: unknown, field: string, maximum: number) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`); + return value.trim().slice(0, maximum); +} + +function conversationSummary(row: ConversationRow): ConversationSummary { + return { + id: row.id, + title: row.title, + providerId: row.provider_id, + model: row.model, + messageCount: Number(row.message_count), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function normalizeMessages(value: unknown): StoredChatMessage[] { + if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries"); + return value.map((message, index) => { + if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`); + const record = message as Record; + const role = record.role; + if (!["system", "user", "assistant"].includes(String(role))) throw new Error(`messages[${index}].role is invalid`); + if (!Array.isArray(record.parts)) throw new Error(`messages[${index}].parts is required`); + const partsJson = JSON.stringify(record.parts); + if (Buffer.byteLength(partsJson) > 1024 * 1024) throw new Error(`messages[${index}] is too large`); + const metadataJson = JSON.stringify(record.metadata || {}); + if (Buffer.byteLength(metadataJson) > 64 * 1024) throw new Error(`messages[${index}].metadata is too large`); + return { + id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(), + role: role as StoredChatMessage["role"], + parts: JSON.parse(partsJson) as StoredChatMessage["parts"], + ...(record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? {metadata: JSON.parse(metadataJson) as StoredChatMessage["metadata"]} : {}) + }; + }); +} + +function inferredTitle(messages: StoredChatMessage[]) { + const userMessage = messages.find((message) => message.role === "user"); + const text = userMessage?.parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => String(part.text)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + return text ? text.slice(0, 60) : "新对话"; +} + +function ownedConversation(identity: ChatIdentity, id: string) { + return getDatabase().query(` + SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at, + (SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count + FROM chat_conversation c + WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ? + `).get(id, identity.issuer, identity.sub) as ConversationRow | undefined; +} + +export function listConversations(identity: ChatIdentity): ConversationSummary[] { + return (getDatabase().query(` + SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at, + (SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count + FROM chat_conversation c + WHERE c.owner_issuer = ? AND c.owner_sub = ? + ORDER BY c.updated_at DESC + LIMIT 100 + `).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationSummary); +} + +export function createConversation(identity: ChatIdentity, input: {providerId: unknown; model: unknown; generationSettings?: unknown}): Conversation { + const providerId = requiredString(input.providerId, "providerId", 80); + const model = requiredString(input.model, "model", 300); + const generationSettings = normalizeGenerationSettings(input.generationSettings); + const timestamp = now(); + const id = randomUUID(); + getDatabase().run("BEGIN IMMEDIATE"); + try { + getDatabase().query(` + DELETE FROM chat_conversation + WHERE owner_issuer = ? AND owner_sub = ? + AND NOT EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id) + `).run(identity.issuer, identity.sub); + getDatabase().query(` + INSERT INTO chat_conversation (id, owner_issuer, owner_sub, title, provider_id, model, settings_json, created_at, updated_at) + VALUES (?, ?, ?, '新对话', ?, ?, ?, ?, ?) + `).run(id, identity.issuer, identity.sub, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp); + getDatabase().run("COMMIT"); + } catch (error) { + getDatabase().run("ROLLBACK"); + throw error; + } + return {...conversationSummary({id, title: "新对话", provider_id: providerId, model, settings_json: JSON.stringify(generationSettings), message_count: 0, created_at: timestamp, updated_at: timestamp}), generationSettings, messages: []}; +} + +export function getConversation(identity: ChatIdentity, id: string): Conversation | null { + const row = ownedConversation(identity, id); + if (!row) return null; + const messages = (getDatabase().query(` + SELECT id, role, parts_json FROM chat_message WHERE conversation_id = ? ORDER BY ordinal + `).all(id) as MessageRow[]).map((message) => { + const parsed = JSON.parse(message.parts_json) as StoredChatMessage["parts"]; + const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object"); + return { + id: message.id, + role: message.role, + parts: parsed.filter((part) => part.type !== "data-response-metadata"), + ...(metadataPart ? {metadata: {custom: {response: metadataPart.data as ResponseMetadata}}} : {}) + }; + }); + return {...conversationSummary(row), generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), messages}; +} + +export function updateConversationSettings(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; generationSettings?: unknown}) { + if (!ownedConversation(identity, id)) return false; + const providerId = requiredString(input.providerId, "providerId", 80); + const model = requiredString(input.model, "model", 300); + const generationSettings = normalizeGenerationSettings(input.generationSettings); + getDatabase().query(` + UPDATE chat_conversation SET provider_id = ?, model = ?, settings_json = ?, updated_at = ? + WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).run(providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub); + return true; +} + +export function saveConversationMessages(identity: ChatIdentity, id: string, input: { + providerId: unknown; + model: unknown; + messages: unknown; +}) { + const existing = ownedConversation(identity, id); + if (!existing) return null; + const providerId = requiredString(input.providerId, "providerId", 80); + const model = requiredString(input.model, "model", 300); + const messages = normalizeMessages(input.messages); + const timestamp = now(); + const title = existing.title === "新对话" ? inferredTitle(messages) : existing.title; + getDatabase().run("BEGIN IMMEDIATE"); + try { + getDatabase().query("DELETE FROM chat_message WHERE conversation_id = ?").run(id); + const insert = getDatabase().query(` + INSERT INTO chat_message (conversation_id, id, ordinal, role, parts_json, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + messages.forEach((message, ordinal) => { + const parts = message.metadata?.custom?.response + ? [...message.parts, {type: "data-response-metadata", data: message.metadata.custom.response}] + : message.parts; + insert.run(id, message.id, ordinal, message.role, JSON.stringify(parts), timestamp); + }); + getDatabase().query(` + UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ? + WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub); + getDatabase().run("COMMIT"); + } catch (error) { + getDatabase().run("ROLLBACK"); + throw error; + } + return getConversation(identity, id); +} + +export function deleteConversation(identity: ChatIdentity, id: string) { + const result = getDatabase().query(` + DELETE FROM chat_conversation WHERE id = ? AND owner_issuer = ? AND owner_sub = ? + `).run(id, identity.issuer, identity.sub); + return result.changes > 0; +} diff --git a/chat/lib/generation-settings.test.mjs b/chat/lib/generation-settings.test.mjs new file mode 100644 index 0000000..50b2e02 --- /dev/null +++ b/chat/lib/generation-settings.test.mjs @@ -0,0 +1,64 @@ +import {describe, expect, test} from "bun:test"; +import {generationCallOptions, normalizeGenerationSettings} from "./generation-settings.ts"; + +function provider(api) { + return { + id: api, + name: api, + api, + connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null}, + auth: {type: "bearer"}, + headers: {}, + defaultModel: "test-model", + discovery: {type: "openai-models-list", url: "https://example.com/v1/models"}, + builtin: false, + credentialState: "configured", + credentials: [] + }; +} + +describe("generation settings", () => { + test("normalizes user-controlled values", () => { + expect(normalizeGenerationSettings({reasoning: "high", showReasoningSummary: true, temperature: 5, maxOutputTokens: 12.8})).toEqual({ + reasoning: "high", + showReasoningSummary: true, + temperature: 2, + maxOutputTokens: 12 + }); + }); + + test("maps OpenAI reasoning and summary", () => { + expect(generationCallOptions(provider("openai-responses"), { + reasoning: "high", + showReasoningSummary: true, + temperature: null, + maxOutputTokens: 4096 + })).toEqual({ + reasoning: "high", + maxOutputTokens: 4096, + providerOptions: {openai: {reasoningSummary: "auto"}} + }); + }); + + test("maps Anthropic adaptive thinking", () => { + expect(generationCallOptions(provider("anthropic-messages"), { + reasoning: "medium", + showReasoningSummary: true, + temperature: 0.4, + maxOutputTokens: null + })).toEqual({ + reasoning: "medium", + temperature: 0.4, + providerOptions: {anthropic: {thinking: {type: "adaptive", display: "summarized"}}} + }); + }); + + test("keeps auto mode provider-default", () => { + expect(generationCallOptions(provider("openai-completions"), { + reasoning: "auto", + showReasoningSummary: false, + temperature: null, + maxOutputTokens: null + })).toEqual({}); + }); +}); diff --git a/chat/lib/generation-settings.ts b/chat/lib/generation-settings.ts new file mode 100644 index 0000000..f7d91fc --- /dev/null +++ b/chat/lib/generation-settings.ts @@ -0,0 +1,55 @@ +import type {ProviderDefinition} from "./provider-types"; + +type JsonValue = null | string | number | boolean | JsonValue[] | {[key: string]: JsonValue}; + +export type ReasoningLevel = "auto" | "none" | "low" | "medium" | "high"; + +export type GenerationSettings = { + reasoning: ReasoningLevel; + showReasoningSummary: boolean; + temperature: number | null; + maxOutputTokens: number | null; +}; + +export const defaultGenerationSettings: GenerationSettings = { + reasoning: "auto", + showReasoningSummary: false, + temperature: null, + maxOutputTokens: null +}; + +export function normalizeGenerationSettings(value: unknown): GenerationSettings { + const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + const reasoning = ["auto", "none", "low", "medium", "high"].includes(String(input.reasoning)) + ? input.reasoning as ReasoningLevel + : "auto"; + const temperature = typeof input.temperature === "number" && Number.isFinite(input.temperature) + ? Math.min(2, Math.max(0, input.temperature)) + : null; + const maxOutputTokens = typeof input.maxOutputTokens === "number" && Number.isFinite(input.maxOutputTokens) + ? Math.min(1_000_000, Math.max(1, Math.floor(input.maxOutputTokens))) + : null; + return { + reasoning, + showReasoningSummary: input.showReasoningSummary === true, + temperature, + maxOutputTokens + }; +} + +export function generationCallOptions(provider: ProviderDefinition, settings: GenerationSettings) { + const providerOptions: Record = {}; + if (provider.api === "openai-responses") { + providerOptions.openai = {reasoningSummary: settings.showReasoningSummary ? "auto" : null}; + } else if (provider.api === "anthropic-messages" && settings.showReasoningSummary && settings.reasoning !== "none") { + providerOptions.anthropic = {thinking: {type: "adaptive", display: "summarized"}}; + } else if (provider.api === "google-generative-ai" && settings.showReasoningSummary) { + providerOptions.google = {thinkingConfig: {includeThoughts: true}}; + } + return { + ...(settings.reasoning !== "auto" ? {reasoning: settings.reasoning} : {}), + ...(settings.temperature !== null ? {temperature: settings.temperature} : {}), + ...(settings.maxOutputTokens !== null ? {maxOutputTokens: settings.maxOutputTokens} : {}), + ...(Object.keys(providerOptions).length ? {providerOptions} : {}) + }; +} diff --git a/chat/lib/key-vault.ts b/chat/lib/key-vault.ts new file mode 100644 index 0000000..62e515e --- /dev/null +++ b/chat/lib/key-vault.ts @@ -0,0 +1,53 @@ +import {readFileSync} from "node:fs"; + +export type ChatIdentity = {issuer: string; sub: string; username: string; name: string; email: string}; +type HeaderReader = Pick; + +const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093"; +const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site"; +const serviceTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac"; +let serviceToken: string | null = null; + +function loadServiceToken() { + if (!serviceToken) serviceToken = readFileSync(/* turbopackIgnore: true */ serviceTokenFile, "utf8").trim(); + return serviceToken; +} + +export function identityFromHeaders(headers: HeaderReader): ChatIdentity { + const username = headers.get("x-authentik-username")?.trim() || ""; + const sub = headers.get("x-authentik-uid")?.trim() || ""; + if (!username || !sub) { + const error = new Error("Authenticated user context is required"); + Object.assign(error, {statusCode: 401}); + throw error; + } + return { + issuer: authentikIssuer, + sub, + username, + name: headers.get("x-authentik-name")?.trim() || username, + email: headers.get("x-authentik-email")?.trim() || "" + }; +} + +export function keyVaultFetch( + pathname: string, + identity: ChatIdentity, + init: {method?: string; body?: string; headers?: HeadersInit} = {} +) { + const method = init.method || "GET"; + return fetch(new URL(pathname, keyVaultUrl), { + method, + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${loadServiceToken()}`, + ...init.headers, + "X-Key-Vault-Actor-Issuer": identity.issuer, + "X-Key-Vault-Actor-Sub": identity.sub, + "X-Key-Vault-Actor-Username": identity.username + }, + body: init.body, + cache: "no-store", + signal: AbortSignal.timeout(300000) + }); +} diff --git a/chat/lib/llama-cpp-discovery.test.mjs b/chat/lib/llama-cpp-discovery.test.mjs new file mode 100644 index 0000000..e096c99 --- /dev/null +++ b/chat/lib/llama-cpp-discovery.test.mjs @@ -0,0 +1,44 @@ +import {describe, expect, test} from "bun:test"; +import {applyBrowserProviderSettings} from "./browser-provider-settings.ts"; +import {discoverProviderModels} from "./provider-connectivity.ts"; + +function llamaProvider() { + return { + id: "llama.cpp", + name: "llama.cpp", + api: "openai-completions", + connection: {type: "frontend", baseUrl: "http://127.0.0.1:8080/v1", proxy: null}, + auth: {type: "none"}, + headers: {}, + defaultModel: "local-model", + discovery: {type: "openai-models-list", url: "http://127.0.0.1:8080/v1/models"}, + builtin: true, + credentialState: "local", + credentials: [] + }; +} + +describe("llama.cpp discovery", () => { + test("applies a browser-local endpoint override", () => { + const provider = applyBrowserProviderSettings(llamaProvider(), {provider: {baseUrl: "http://192.168.4.20:8081/v1/"}}); + expect(provider.connection.baseUrl).toBe("http://192.168.4.20:8081/v1"); + expect(provider.discovery.url).toBe("http://192.168.4.20:8081/v1/models"); + }); + + test("falls back to /props when /v1/models is unavailable", async () => { + const requests = []; + const result = await discoverProviderModels(llamaProvider(), {}, async (input) => { + requests.push(String(input)); + if (String(input).endsWith("/v1/models")) { + return new Response(JSON.stringify({error: "Not Found"}), {status: 404, headers: {"Content-Type": "application/json"}}); + } + return new Response(JSON.stringify({ + model_path: "/models/Qwen3.5-9B-Q4_K_M.gguf", + default_generation_settings: {n_ctx: 32768} + }), {status: 200, headers: {"Content-Type": "application/json"}}); + }); + expect(requests).toEqual(["http://127.0.0.1:8080/v1/models", "http://127.0.0.1:8080/props"]); + expect(result.endpoint).toBe("http://127.0.0.1:8080/props"); + expect(result.models).toEqual([{id: "Qwen3.5-9B-Q4_K_M.gguf", name: "Qwen3.5-9B-Q4_K_M.gguf", ownedBy: "llamacpp", contextWindow: 32768}]); + }); +}); diff --git a/chat/lib/local-credentials.ts b/chat/lib/local-credentials.ts new file mode 100644 index 0000000..817bbcf --- /dev/null +++ b/chat/lib/local-credentials.ts @@ -0,0 +1,66 @@ +import type {ProviderSecret} from "./provider-types"; + +export type LocalCredential = { + id: string; + providerId: string; + name: string; + secret: ProviderSecret; + createdAt: string; + updatedAt: string; +}; + +const databaseName = "xiteng-chat-local-vault"; +const storeName = "credentials"; + +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(storeName)) database.createObjectStore(storeName, {keyPath: "id"}); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("Unable to open local credential store")); + }); +} + +async function transaction(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest) { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = operation(database.transaction(storeName, mode).objectStore(storeName)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("Local credential operation failed")); + }).finally(() => database.close()); +} + +export function localCredentialId(providerId: string, name = "default") { + return `${providerId}/${name}`; +} + +export function getLocalCredential(providerId: string, name = "default") { + return transaction("readonly", (store) => store.get(localCredentialId(providerId, name))); +} + +export async function listLocalCredentials() { + return transaction("readonly", (store) => store.getAll()); +} + +export async function saveLocalCredential(providerId: string, name: string, secret: ProviderSecret) { + const id = localCredentialId(providerId, name); + const existing = await getLocalCredential(providerId, name); + const timestamp = new Date().toISOString(); + const credential: LocalCredential = { + id, + providerId, + name, + secret, + createdAt: existing?.createdAt || timestamp, + updatedAt: timestamp + }; + await transaction("readwrite", (store) => store.put(credential)); + return credential; +} + +export function deleteLocalCredential(providerId: string, name = "default") { + return transaction("readwrite", (store) => store.delete(localCredentialId(providerId, name))); +} diff --git a/chat/lib/offline-history.ts b/chat/lib/offline-history.ts new file mode 100644 index 0000000..fca9b35 --- /dev/null +++ b/chat/lib/offline-history.ts @@ -0,0 +1,173 @@ +import type {Conversation, ConversationSummary} from "./conversation-types"; + +const databaseName = "xiteng-chat-offline"; +const databaseVersion = 1; +const activeProfileKey = "xiteng-chat-offline-profile"; + +type CachedProfile = { + id: string; + config: T; + summaries: ConversationSummary[]; + updatedAt: string; +}; + +type CachedConversation = Conversation & {cacheKey: string; profileId: string}; + +export type PendingConversationChange = { + cacheKey: string; + profileId: string; + conversationId: string; + method: "PUT" | "PATCH" | "DELETE"; + body?: string; + createdAt: string; +}; + +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, databaseVersion); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains("profiles")) database.createObjectStore("profiles", {keyPath: "id"}); + if (!database.objectStoreNames.contains("conversations")) { + const conversations = database.createObjectStore("conversations", {keyPath: "cacheKey"}); + conversations.createIndex("profileId", "profileId"); + } + if (!database.objectStoreNames.contains("pending")) { + const pending = database.createObjectStore("pending", {keyPath: "cacheKey"}); + pending.createIndex("profileId", "profileId"); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("Unable to open offline history")); + }); +} + +async function transaction(storeName: string, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest) { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const current = database.transaction(storeName, mode); + const request = run(current.objectStore(storeName)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error(`Offline ${storeName} operation failed`)); + current.oncomplete = () => database.close(); + current.onerror = () => reject(current.error || new Error(`Offline ${storeName} transaction failed`)); + }); +} + +function activeProfileId() { + return window.localStorage.getItem(activeProfileKey) || ""; +} + +function conversationCacheKey(profileId: string, conversationId: string) { + return `${profileId}:${conversationId}`; +} + +export function activateOfflineProfile(profileId: string) { + window.localStorage.setItem(activeProfileKey, profileId); +} + +export async function cacheChatConfig(profileId: string, config: T) { + activateOfflineProfile(profileId); + const current = await transaction | undefined>("profiles", "readonly", (store) => store.get(profileId)); + const profile: CachedProfile = { + id: profileId, + config, + summaries: current?.summaries || [], + updatedAt: new Date().toISOString() + }; + await transaction("profiles", "readwrite", (store) => store.put(profile)); +} + +export async function loadCachedChatConfig() { + const profileId = activeProfileId(); + if (!profileId) return null; + const profile = await transaction | undefined>("profiles", "readonly", (store) => store.get(profileId)); + return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt} : null; +} + +export async function cacheConversationSummaries(summaries: ConversationSummary[]) { + const profileId = activeProfileId(); + if (!profileId) return; + const current = await transaction("profiles", "readonly", (store) => store.get(profileId)); + if (!current) return; + await transaction("profiles", "readwrite", (store) => store.put({ + ...current, + summaries, + updatedAt: new Date().toISOString() + })); +} + +export async function loadCachedConversationSummaries() { + const profileId = activeProfileId(); + if (!profileId) return []; + const profile = await transaction("profiles", "readonly", (store) => store.get(profileId)); + return profile?.summaries || []; +} + +export async function cacheConversation(conversation: Conversation) { + const profileId = activeProfileId(); + if (!profileId) return; + const record: CachedConversation = { + ...conversation, + cacheKey: conversationCacheKey(profileId, conversation.id), + profileId + }; + await transaction("conversations", "readwrite", (store) => store.put(record)); + const summaries = await loadCachedConversationSummaries(); + const summary: ConversationSummary = { + id: conversation.id, + title: conversation.title, + providerId: conversation.providerId, + model: conversation.model, + messageCount: conversation.messageCount, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt + }; + await cacheConversationSummaries([summary, ...summaries.filter((item) => item.id !== conversation.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); +} + +export async function loadCachedConversation(id: string) { + const profileId = activeProfileId(); + if (!profileId) return null; + const record = await transaction("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id))); + if (!record) return null; + const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record; + return conversation; +} + +export async function removeCachedConversation(id: string) { + const profileId = activeProfileId(); + if (!profileId) return; + await transaction("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id))); + const summaries = await loadCachedConversationSummaries(); + await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id)); +} + +export async function queueConversationChange(change: Omit) { + const profileId = activeProfileId(); + if (!profileId) return; + const record: PendingConversationChange = { + ...change, + cacheKey: `${profileId}:${change.conversationId}:${change.method}`, + profileId, + createdAt: new Date().toISOString() + }; + await transaction("pending", "readwrite", (store) => store.put(record)); +} + +export async function listPendingConversationChanges() { + const profileId = activeProfileId(); + if (!profileId) return []; + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const current = database.transaction("pending", "readonly"); + const request = current.objectStore("pending").index("profileId").getAll(profileId); + request.onsuccess = () => resolve(request.result.sort((left, right) => left.createdAt.localeCompare(right.createdAt))); + request.onerror = () => reject(request.error || new Error("Unable to read pending history changes")); + current.oncomplete = () => database.close(); + }); +} + +export async function removePendingConversationChange(cacheKey: string) { + await transaction("pending", "readwrite", (store) => store.delete(cacheKey)); +} diff --git a/chat/lib/profile-types.ts b/chat/lib/profile-types.ts new file mode 100644 index 0000000..213aa9c --- /dev/null +++ b/chat/lib/profile-types.ts @@ -0,0 +1,5 @@ +export type ChatProfile = { + username: string; + name: string; + email: string; +}; diff --git a/chat/lib/provider-connectivity.ts b/chat/lib/provider-connectivity.ts new file mode 100644 index 0000000..e466c5c --- /dev/null +++ b/chat/lib/provider-connectivity.ts @@ -0,0 +1,188 @@ +import {applyProviderAuthentication} from "./provider-model"; +import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types"; + +type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type JsonRecord = Record; + + +function accountEndpoints(provider: ProviderDefinition) { + const baseUrl = provider.connection.baseUrl.replace(/\/+$/, ""); + const url = new URL(baseUrl); + if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"]; + if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`]; + if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`]; + if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`]; + if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`]; + return []; +} + +async function responseText(response: Response, maximum = 4 * 1024 * 1024) { + const text = await response.text(); + if (text.length > maximum) throw new Error("Provider response is too large"); + return text; +} + +function jsonRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null; +} + +function publicMetadata(value: unknown, depth = 0): unknown { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; + if (depth >= 3) return undefined; + if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined); + const record = jsonRecord(value); + if (!record) return undefined; + return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined)); +} + +function selectedMetadata(record: JsonRecord, pattern: RegExp) { + return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined)); +} + +export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] { + const root = jsonRecord(payload); + if (!root) return []; + const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : []; + return source.slice(0, 300).map((value) => { + const model = jsonRecord(value); + if (!model) return null; + const rawId = model.id || model.name || model.model; + if (typeof rawId !== "string" || !rawId.trim()) return null; + const id = rawId.replace(/^models\//, ""); + const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i); + return { + id, + name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id, + ...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}), + ...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}), + ...(Object.keys(pricing).length ? {pricing} : {}) + }; + }).filter((model): model is ProviderModel => model !== null); +} + +function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] { + const root = jsonRecord(payload); + if (!root) return []; + const modelPath = typeof root.model_path === "string" ? root.model_path : ""; + const modelAlias = typeof root.model_alias === "string" ? root.model_alias : ""; + const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel; + const generationSettings = jsonRecord(root.default_generation_settings); + const contextWindow = Number(generationSettings?.n_ctx); + return [{ + id, + name: id, + ownedBy: "llamacpp", + ...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {}) + }]; +} + +function responseRateLimits(response: Response) { + return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name))); +} + +async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) { + const response = await providerFetch(endpoint, { + method: "GET", + headers, + signal: AbortSignal.timeout(timeoutMs) + }); + const text = await responseText(response); + let payload: unknown = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = {preview: text.slice(0, 1000)}; + } + return {response, payload}; +} + +async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) { + const endpoint = provider.discovery.url; + const headers = applyProviderAuthentication(provider, secret, { + "Accept": "application/json", + ...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {}) + }); + const startedAt = performance.now(); + const initial = await fetchJson(providerFetch, endpoint, headers); + let {response, payload} = initial; + let resolvedEndpoint = endpoint; + let models = response.ok ? normalizeDiscoveredModels(payload) : []; + if (provider.id === "llama.cpp" && models.length === 0) { + const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`; + try { + const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000); + const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : []; + if (propsModels.length > 0) { + response = props.response; + payload = props.payload; + resolvedEndpoint = propsEndpoint; + models = propsModels; + } + } catch { + // Preserve the primary /v1/models error when the compatibility probe also fails. + } + } + const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); + if (!response.ok) { + const root = jsonRecord(payload); + const detail = root?.preview || root?.error || root?.message; + const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`); + Object.assign(error, {statusCode: response.status}); + throw error; + } + return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models}; +} + +export async function discoverProviderModels( + provider: ProviderDefinition, + secret: ProviderSecret, + providerFetch: ProviderFetch = fetch +) { + const probe = await modelProbe(provider, secret, providerFetch); + return { + endpoint: probe.endpoint, + status: probe.response.status, + latencyMs: probe.latencyMs, + models: probe.models + }; +} + +export async function testProviderConnectivity( + provider: ProviderDefinition, + secret: ProviderSecret, + providerFetch: ProviderFetch = fetch +) { + const probe = await modelProbe(provider, secret, providerFetch); + const {endpoint, headers, response, payload, latencyMs, models} = probe; + const root = jsonRecord(payload) || {}; + const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i); + const rateLimits = responseRateLimits(response); + const accountUrls = accountEndpoints(provider); + let accountProbe: unknown = null; + for (const accountUrl of accountUrls) { + try { + const probe = await fetchJson(providerFetch, accountUrl, headers, 5000); + if (!probe.response.ok) continue; + accountProbe = { + endpoint: accountUrl, + status: probe.response.status, + data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload) + }; + break; + } catch { + // Account metadata is best-effort and must not fail a successful model probe. + } + } + + return { + ok: true, + status: response.status, + latencyMs, + endpoint, + modelCount: models.length, + models, + account: Object.keys(account).length ? account : null, + accountProbe, + rateLimits: Object.keys(rateLimits).length ? rateLimits : null + }; +} diff --git a/chat/lib/provider-model.ts b/chat/lib/provider-model.ts new file mode 100644 index 0000000..f043dc6 --- /dev/null +++ b/chat/lib/provider-model.ts @@ -0,0 +1,66 @@ +import type {ProviderDefinition, ProviderSecret} from "./provider-types"; + +type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export function applyProviderAuthentication( + provider: ProviderDefinition, + secret: ProviderSecret, + inputHeaders?: HeadersInit +) { + const headers = new Headers(inputHeaders); + headers.delete("authorization"); + headers.delete("x-api-key"); + headers.delete("x-goog-api-key"); + for (const [name, value] of Object.entries(provider.headers || {})) headers.set(name, value); + for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value); + const apiKey = secret.provider?.apiKey || ""; + if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`); + if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey); + return headers; +} + +export async function createProviderModel( + provider: ProviderDefinition, + secret: ProviderSecret, + modelId: string, + providerFetch: ProviderFetch +) { + const authenticatedFetch: ProviderFetch = (input, init = {}) => providerFetch(input, { + ...init, + headers: applyProviderAuthentication(provider, secret, init.headers) + }); + // Provider API comes from the runtime Registry; dynamic imports keep unused SDKs out of the initial client chunk. + if (provider.api === "openai-completions") { + const {createOpenAICompatible} = await import("@ai-sdk/openai-compatible"); + return createOpenAICompatible({ + name: provider.id, + baseURL: provider.connection.baseUrl, + fetch: authenticatedFetch as typeof fetch + }).chatModel(modelId); + } + if (provider.api === "openai-responses") { + const {createOpenAI} = await import("@ai-sdk/openai"); + return createOpenAI({ + name: provider.id, + baseURL: provider.connection.baseUrl, + apiKey: secret.provider?.apiKey || "browser-managed", + fetch: authenticatedFetch as typeof fetch + }).responses(modelId); + } + if (provider.api === "anthropic-messages") { + const {createAnthropic} = await import("@ai-sdk/anthropic"); + return createAnthropic({ + name: provider.id, + baseURL: provider.connection.baseUrl, + apiKey: secret.provider?.apiKey || "browser-managed", + fetch: authenticatedFetch as typeof fetch + }).messages(modelId); + } + const {createGoogleGenerativeAI} = await import("@ai-sdk/google"); + return createGoogleGenerativeAI({ + name: provider.id, + baseURL: provider.connection.baseUrl, + apiKey: secret.provider?.apiKey || "browser-managed", + fetch: authenticatedFetch as typeof fetch + }).chat(modelId); +} diff --git a/chat/lib/provider-types.ts b/chat/lib/provider-types.ts new file mode 100644 index 0000000..a5eb187 --- /dev/null +++ b/chat/lib/provider-types.ts @@ -0,0 +1,45 @@ +export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai"; + +export type ProviderModel = { + id: string; + name: string; + contextWindow?: number; + maxTokens?: number; + input?: string[]; + reasoning?: boolean; + ownedBy?: string; + pricing?: Record; +}; + +export type ProviderDiscovery = { + type: "openai-models-list" | "anthropic-models-list" | "google-models-list"; + url: string; +}; + +export type ProviderDefinition = { + id: string; + name: string; + api: ProviderApi; + connection: { + type: "frontend" | "backend"; + baseUrl: string; + proxy: null | {type: "relay" | "http" | "https" | "socks5"; url: string}; + }; + auth: {type: "bearer" | "header" | "none"; header?: string}; + headers: Record; + defaultModel: string; + discovery: ProviderDiscovery; + builtin: boolean; + credentialState: "configured" | "missing" | "local"; + credentials: Array<{id: string; providerId: string; name: string; fingerprint: string}>; +}; + +export type ProviderSecret = { + provider?: {apiKey?: string; headers?: Record; baseUrl?: string}; + proxy?: {username?: string; password?: string; token?: string}; +}; + +export type ResolvedBackendProvider = { + provider: ProviderDefinition; + credential: {id: string; name: string; secret: ProviderSecret}; +}; diff --git a/chat/lib/response-metadata.test.mjs b/chat/lib/response-metadata.test.mjs new file mode 100644 index 0000000..dbf6ac6 --- /dev/null +++ b/chat/lib/response-metadata.test.mjs @@ -0,0 +1,19 @@ +import {describe, expect, test} from "bun:test"; +import {responseMetadata} from "./response-metadata.ts"; + +describe("response metadata", () => { + test("calculates output token throughput", () => { + const metadata = responseMetadata("rust.cat", "gpt-test", performance.now() - 2000, 40); + expect(metadata.providerId).toBe("rust.cat"); + expect(metadata.model).toBe("gpt-test"); + expect(metadata.durationMs).toBeGreaterThanOrEqual(1900); + expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(19); + expect(metadata.tokensPerSecond).toBeLessThanOrEqual(21); + }); + + test("keeps speed unavailable when provider omits token usage", () => { + const metadata = responseMetadata("local", "model", performance.now() - 100, undefined); + expect(metadata.outputTokens).toBeNull(); + expect(metadata.tokensPerSecond).toBeNull(); + }); +}); diff --git a/chat/lib/response-metadata.ts b/chat/lib/response-metadata.ts new file mode 100644 index 0000000..1a4ad0f --- /dev/null +++ b/chat/lib/response-metadata.ts @@ -0,0 +1,12 @@ +import type {ResponseMetadata} from "./conversation-types"; + +export function responseMetadata(providerId: string, model: string, startedAt: number, outputTokens: number | undefined | null): ResponseMetadata { + const durationMs = Math.max(1, Math.round(performance.now() - startedAt)); + const normalizedTokens = typeof outputTokens === "number" && Number.isFinite(outputTokens) && outputTokens >= 0 + ? Math.round(outputTokens) + : null; + const tokensPerSecond = normalizedTokens === null + ? null + : Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10; + return {providerId, model, durationMs, outputTokens: normalizedTokens, tokensPerSecond}; +} diff --git a/chat/lib/server-provider-fetch.ts b/chat/lib/server-provider-fetch.ts new file mode 100644 index 0000000..d83a2b1 --- /dev/null +++ b/chat/lib/server-provider-fetch.ts @@ -0,0 +1,16 @@ +import nodeFetch from "node-fetch"; +import {ProxyAgent} from "proxy-agent"; +import type {ProviderDefinition, ProviderSecret} from "./provider-types"; + +export function createServerProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) { + const proxy = provider.connection.proxy; + if (!proxy) return fetch; + const proxyUrl = new URL(proxy.url); + if (secret.proxy?.username) proxyUrl.username = secret.proxy.username; + if (secret.proxy?.password) proxyUrl.password = secret.proxy.password; + const agent = new ProxyAgent({getProxyForUrl: () => proxyUrl.toString()}); + return async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await nodeFetch(String(input), {...init, agent} as never); + return response as unknown as Response; + }; +} diff --git a/chat/package.json b/chat/package.json new file mode 100644 index 0000000..ed0afec --- /dev/null +++ b/chat/package.json @@ -0,0 +1,28 @@ +{ + "name": "xiteng-chat", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "bun --hot src/server.ts", + "typecheck": "tsc --noEmit", + "build": "bun run typecheck && bun run build.ts", + "start": "bun src/server.ts", + "test": "bun test", + "test:history": "bun run build && bun history.test.mjs" + }, + "dependencies": { + "@ai-sdk/anthropic": "4.0.36", + "@ai-sdk/google": "4.0.39", + "@ai-sdk/openai": "4.0.36", + "@ai-sdk/openai-compatible": "3.0.28", + "ai": "7.0.58", + "dompurify": "3.2.6", + "marked": "15.0.12", + "node-fetch": "3.3.2", + "proxy-agent": "8.0.2" + }, + "devDependencies": { + "@types/bun": "1.3.5", + "typescript": "5.9.3" + } +} diff --git a/chat/proxy-bridge.mjs b/chat/proxy-bridge.mjs new file mode 100644 index 0000000..d9d6be8 --- /dev/null +++ b/chat/proxy-bridge.mjs @@ -0,0 +1,48 @@ +import {mkdirSync, rmSync} from "node:fs"; +import net from "node:net"; + +const mode = process.env.BRIDGE_MODE || "network"; +const socketPath = process.env.BRIDGE_SOCKET || "/run/provider-proxy/upstream.sock"; +const listenPort = Number.parseInt(process.env.LISTEN_PORT || "17897", 10); +const upstreamHost = process.env.UPSTREAM_HOST || "127.0.0.1"; +const upstreamPort = Number.parseInt(process.env.UPSTREAM_PORT || "7897", 10); + +if (mode === "host") { + mkdirSync("/run/provider-proxy", {recursive: true}); + rmSync(socketPath, {force: true}); +} + +const server = net.createServer((client) => { + client.pause(); + const upstream = mode === "host" + ? net.connect({host: upstreamHost, port: upstreamPort}) + : net.connect(socketPath); + upstream.once("connect", () => { + client.pipe(upstream); + upstream.pipe(client); + client.resume(); + }); + const close = () => { + client.destroy(); + upstream.destroy(); + }; + client.on("error", close); + upstream.on("error", close); +}); + +const listenTarget = mode === "host" ? socketPath : {host: "0.0.0.0", port: listenPort}; +server.listen(listenTarget, () => { + const target = mode === "host" ? socketPath : `0.0.0.0:${listenPort}`; + const upstream = mode === "host" ? `${upstreamHost}:${upstreamPort}` : socketPath; + console.log(`provider proxy ${mode} bridge ${target} -> ${upstream}`); +}); + +function shutdown() { + server.close(() => { + if (mode === "host") rmSync(socketPath, {force: true}); + process.exit(0); + }); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/chat/public/favicon.svg b/chat/public/favicon.svg new file mode 100644 index 0000000..365e132 --- /dev/null +++ b/chat/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/chat/public/icons/apple-touch-icon.png b/chat/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..fbe56d5 Binary files /dev/null and b/chat/public/icons/apple-touch-icon.png differ diff --git a/chat/public/icons/icon-192.png b/chat/public/icons/icon-192.png new file mode 100644 index 0000000..198f474 Binary files /dev/null and b/chat/public/icons/icon-192.png differ diff --git a/chat/public/icons/icon-512.png b/chat/public/icons/icon-512.png new file mode 100644 index 0000000..00b2c06 Binary files /dev/null and b/chat/public/icons/icon-512.png differ diff --git a/chat/public/icons/icon-maskable-512.png b/chat/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..00b2c06 Binary files /dev/null and b/chat/public/icons/icon-maskable-512.png differ diff --git a/chat/public/manifest.webmanifest b/chat/public/manifest.webmanifest new file mode 100644 index 0000000..69fb5c1 --- /dev/null +++ b/chat/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Xiteng Chat", + "short_name": "Chat", + "description": "使用个人 Key Vault 凭据的轻量 AI 对话界面", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4f4f0", + "theme_color": "#171717", + "orientation": "any", + "lang": "zh-CN", + "categories": ["productivity", "utilities"], + "icons": [ + {"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png"}, + {"src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} + ] +} diff --git a/chat/public/sw.js b/chat/public/sw.js new file mode 100644 index 0000000..2aedee1 --- /dev/null +++ b/chat/public/sw.js @@ -0,0 +1,49 @@ +const cacheName = "xiteng-chat-shell-v4"; +const shellAssets = [ + "/", + "/manifest.webmanifest", + "/favicon.svg", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/icon-maskable-512.png", + "/icons/apple-touch-icon.png" +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(cacheName).then(async (cache) => { + for (const asset of shellAssets) { + try { + const response = await fetch(asset, {cache: "reload"}); + if (response.ok) await cache.put(asset, response); + } catch {} + } + }).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key !== cacheName).map((key) => caches.delete(key)))) + .then(() => self.clients.claim())); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin || url.pathname.startsWith("/api/") || url.pathname.startsWith("/outpost.goauthentik.io/")) return; + + if (request.mode === "navigate") { + event.respondWith(fetch(request).then((response) => { + if (response.ok) caches.open(cacheName).then((cache) => cache.put("/", response.clone())); + return response; + }).catch(async () => (await caches.match("/")) || Response.error())); + return; + } + + if (shellAssets.includes(url.pathname) || /\.(?:js|css|png|svg|ico|webmanifest)$/.test(url.pathname)) { + event.respondWith(caches.match(request).then((cached) => cached || fetch(request).then((response) => { + if (response.ok) caches.open(cacheName).then((cache) => cache.put(request, response.clone())); + return response; + }))); + } +}); diff --git a/chat/src/client.ts b/chat/src/client.ts new file mode 100644 index 0000000..08d758b --- /dev/null +++ b/chat/src/client.ts @@ -0,0 +1,757 @@ +import DOMPurify from "dompurify"; +import {marked} from "marked"; +import {applyBrowserProviderSettings} from "../lib/browser-provider-settings"; +import {createBrowserProviderFetch} from "../lib/browser-provider-fetch"; +import { + createConversationHistory, + deleteConversationHistory, + flushPendingConversationChanges, + getConversationHistory, + listConversationHistory, + saveConversationHistory, + synchronizeOfflineConversationHistory, + updateConversationHistory +} from "../lib/conversation-client"; +import {conversationHash, conversationIdFromHash} from "../lib/conversation-hash"; +import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "../lib/conversation-types"; +import {defaultGenerationSettings, type GenerationSettings} from "../lib/generation-settings"; +import { + activateOfflineProfile, + cacheChatConfig, + loadCachedChatConfig +} from "../lib/offline-history"; +import { + deleteLocalCredential, + getLocalCredential, + listLocalCredentials, + saveLocalCredential, + type LocalCredential +} from "../lib/local-credentials"; +import type {ChatProfile} from "../lib/profile-types"; +import type {ProviderDefinition, ProviderModel, ProviderSecret} from "../lib/provider-types"; +import {responseMetadata} from "../lib/response-metadata"; + +type ChatProvider = ProviderDefinition & {models: ProviderModel[]; modelDiscoveryError?: string}; +type ChatConfig = {providers: ChatProvider[]; profile: ChatProfile}; +type ServerChatConfig = ChatConfig & {identityKey: string}; +type CachedChatBootstrap = {config: ChatConfig; frontendProviders: ChatProvider[]}; +type StreamEvent = {type: string; text?: string; error?: string; metadata?: ResponseMetadata}; +type HashNavigationMode = "push" | "replace" | "none"; + +const rootElement = document.querySelector("#app"); +if (!rootElement) throw new Error("Application root is missing"); +const root: HTMLDivElement = rootElement; + +const state = { + config: null as ChatConfig | null, + frontendProviders: [] as ChatProvider[], + localCredentials: [] as LocalCredential[], + conversations: [] as ConversationSummary[], + conversation: null as Conversation | null, + providerId: "", + model: "", + generationSettings: {...defaultGenerationSettings}, + recentModelKeys: [] as string[], + historyOpen: window.matchMedia("(min-width: 681px)").matches, + offline: false, + loading: true, + error: "", + modelQuery: "", + streaming: false, + streamController: null as AbortController | null, + renderFrame: 0, + settingsTimer: 0 +}; + +const icons: Record = { + history: '', + plus: '', + close: '', + trash: '', + down: '', + search: '', + clock: '', + more: '', + settings: '', + send: '', + stop: '', + copy: '', + retry: '', + scroll: '', + offline: '' +}; +function uuid() { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; +} + +function escapeHtml(value: unknown) { + return String(value ?? "").replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]!); +} + +function messagePartText(message: StoredChatMessage, type: "text" | "reasoning") { + return message.parts.filter((part) => part.type === type && typeof part.text === "string").map((part) => String(part.text)).join(""); +} + +function markdown(value: string) { + return DOMPurify.sanitize(marked.parse(value, {async: false, gfm: true, breaks: false}) as string); +} + +function provider() { + return state.config?.providers.find((item) => item.id === state.providerId) || null; +} + +function localCredential(providerId = state.providerId) { + return state.localCredentials.find((item) => item.providerId === providerId && item.name === "default") + || state.localCredentials.find((item) => item.providerId === providerId) + || null; +} + +function updateConversationHash(id: string, mode: Exclude) { + const hash = conversationHash(id); + if (window.location.hash === hash) return; + const url = `${window.location.pathname}${window.location.search}${hash}`; + window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", url); +} + +function settingsForProvider(item: ChatProvider) { + const saved = window.localStorage.getItem(`xiteng-chat-model:${item.id}`) || ""; + const model = item.models.some((candidate) => candidate.id === saved) + ? saved + : item.models.some((candidate) => candidate.id === item.defaultModel) ? item.defaultModel : item.models[0]?.id || ""; + return {model}; +} + +function rememberModel(providerId: string, model: string) { + if (!providerId || !model) return; + const key = `${providerId}/${model}`; + state.recentModelKeys = [key, ...state.recentModelKeys.filter((item) => item !== key)].slice(0, 20); + window.localStorage.setItem("xiteng-chat-recent-models", JSON.stringify(state.recentModelKeys)); +} + +function avatarPlaceholder(profile: ChatProfile) { + const source = String(profile.name || profile.username || "U").trim() || "U"; + const parts = source.split(/\s+/).filter(Boolean); + const initials = (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)![0]}` : [...source].slice(0, 2).join("")).toUpperCase(); + let hash = 0; + for (const character of String(profile.username || profile.name || initials)) hash = ((hash << 5) - hash + character.codePointAt(0)!) | 0; + const hue = Math.abs(hash) % 360; + const svg = `${escapeHtml(initials)}`; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +async function updateAvatar() { + const image = root.querySelector(".header-avatar"); + const profile = state.config?.profile; + if (!image || !profile?.email.trim()) return; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(profile.email.trim().toLowerCase())); + const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + for (const source of [`https://www.gravatar.com/avatar/${hash}?d=404&s=256`, `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256`]) { + const loaded = await new Promise((resolve) => { + const candidate = new Image(); + const timer = window.setTimeout(() => resolve(false), 5000); + candidate.onload = () => { window.clearTimeout(timer); resolve(true); }; + candidate.onerror = () => { window.clearTimeout(timer); resolve(false); }; + candidate.referrerPolicy = "no-referrer"; + candidate.src = source; + }); + if (loaded && image.isConnected) { + image.src = source; + break; + } + } +} + +function renderModelPicker() { + const active = provider(); + if (!active || !state.config) return ""; + const query = state.modelQuery.trim().toLowerCase(); + const choices = state.config.providers.flatMap((item) => item.models.map((model) => ({provider: item, model, key: `${item.id}/${model.id}`}))); + const matches = choices.filter((choice) => !query || choice.key.toLowerCase().includes(query) || choice.model.name.toLowerCase().includes(query)); + const recent = state.recentModelKeys.map((key) => choices.find((choice) => choice.key === key)).filter((choice): choice is NonNullable => Boolean(choice)).filter((choice) => matches.includes(choice)); + const option = (choice: typeof choices[number]) => ``; + const groups = state.config.providers.map((item) => { + const items = matches.filter((choice) => choice.provider.id === item.id); + return items.length ? `

${escapeHtml(item.name)}

${items.map(option).join("")}
` : ""; + }).join(""); + return `
${escapeHtml(state.providerId)}/${escapeHtml(state.model)}${icons.down}
${recent.length ? `

${icons.clock}最近使用

${recent.map(option).join("")}
` : ""}${groups || '

没有匹配的模型

'}
`; +} + +function renderLocalMenu() { + if (!state.frontendProviders.length) return ""; + return `
${icons.more}
Frontend Provider 设置${state.frontendProviders.map((item) => { + const configured = state.localCredentials.some((credential) => credential.providerId === item.id); + return `
${escapeHtml(item.name)}${escapeHtml(item.auth.type === "none" ? item.connection.baseUrl : item.id)}${item.modelDiscoveryError ? `${escapeHtml(item.modelDiscoveryError)}` : ""}
${configured ? `` : ""}${item.auth.type === "none" ? `` : ""}
`; + }).join("")}
`; +} + +function renderGenerationMenu() { + const settings = state.generationSettings; + return `
${icons.settings}
生成参数
`; +} + +function renderHistory() { + return ``; +} + +function renderMessage(message: StoredChatMessage, index: number) { + if (message.role === "user") { + return `

${escapeHtml(messagePartText(message, "text"))}

`; + } + if (message.role !== "assistant") return ""; + const reasoning = messagePartText(message, "reasoning"); + const text = messagePartText(message, "text"); + const error = message.parts.find((part) => part.type === "error" && typeof part.text === "string")?.text; + const response = message.metadata?.custom?.response; + const modelLabel = response?.model ? `${response.providerId || state.providerId}/${response.model}` : `${state.providerId}/${state.model}`; + const speed = typeof response?.tokensPerSecond === "number" ? `${response.tokensPerSecond.toFixed(1)} tok/s` : "速度 —"; + const detail = response?.durationMs ? `${(response.durationMs / 1000).toFixed(1)} 秒${typeof response.outputTokens === "number" ? ` · ${response.outputTokens} tokens` : ""}` : "历史回复未记录速度"; + return `
${reasoning ? `
思考过程
${escapeHtml(reasoning)}
` : ""}${text ? `
${markdown(text)}
` : state.streaming && index === state.conversation!.messages.length - 1 ? '' : ""}${error ? `
${escapeHtml(error)}
` : ""}
`; +} + +function renderMessagesMarkup() { + const messages = state.conversation?.messages || []; + if (!messages.length) { + const description = provider()?.connection.type === "frontend" + ? "Frontend Provider 由当前浏览器直连;对话记录按 Authentik 身份保存在服务端。" + : "Backend Provider 由 Chat 服务端直连;对话记录按 Authentik 身份保存在服务端。"; + return `
XT

今天想聊什么?

${escapeHtml(description)}

`; + } + return messages.map(renderMessage).join(""); +} + +function renderThread() { + return `
${renderMessagesMarkup()}
`; +} + +function renderApp() { + if (state.error) { + root.innerHTML = `
${renderLocalMenu()}!

聊天服务暂时不可用

${escapeHtml(state.error)}

`; + return; + } + if (state.loading || !state.config || !provider() || !state.conversation) { + root.innerHTML = '

正在读取聊天历史与 Provider Registry…

'; + return; + } + const profile = state.config.profile; + root.innerHTML = `
XTXiteng Chat${state.offline ? `${icons.offline}离线历史` : ""}
${renderModelPicker()}${renderLocalMenu()}${renderGenerationMenu()}
${renderHistory()}${renderThread()}
`; + void updateAvatar(); +} + +function renderMessages(scroll = false) { + const list = root.querySelector("#message-list"); + if (!list) { + renderApp(); + return; + } + list.innerHTML = renderMessagesMarkup(); + const footer = root.querySelector(".thread-footer"); + if (footer) footer.outerHTML = new DOMParser().parseFromString(renderThread(), "text/html").querySelector(".thread-footer")!.outerHTML; + if (scroll) scrollBottom(); +} + +function scheduleMessagesRender(scroll = true) { + if (state.renderFrame) return; + state.renderFrame = window.requestAnimationFrame(() => { + state.renderFrame = 0; + renderMessages(scroll); + }); +} + +function scrollBottom() { + const viewport = root.querySelector("#thread-viewport"); + if (viewport) viewport.scrollTop = viewport.scrollHeight; +} + +function closeHistoryOnMobile() { + if (window.matchMedia("(max-width: 680px)").matches) state.historyOpen = false; +} + +function providerHeaders(item: ChatProvider, secret: ProviderSecret, initial?: HeadersInit) { + const headers = new Headers(initial); + for (const [name, value] of Object.entries(item.headers || {})) headers.set(name, value); + for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value); + const apiKey = secret.provider?.apiKey || ""; + if (item.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`); + if (item.auth.type === "header" && item.auth.header && apiKey) headers.set(item.auth.header, apiKey); + return headers; +} + +function normalizeModels(payload: unknown): ProviderModel[] { + const root = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record : {}; + const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : []; + return source.slice(0, 300).flatMap((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const model = value as Record; + const rawId = model.id || model.name || model.model; + if (typeof rawId !== "string" || !rawId.trim()) return []; + const id = rawId.replace(/^models\//, ""); + return [{id, name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id}]; + }); +} + +async function discoverFrontendProvider(item: ChatProvider, secret: ProviderSecret) { + const effective = applyBrowserProviderSettings(item, secret) as ChatProvider; + const providerFetch = createBrowserProviderFetch(effective, secret); + const response = await providerFetch(effective.discovery.url, { + headers: providerHeaders(effective, secret, {"Accept": "application/json"}), + signal: AbortSignal.timeout(15000) + }); + const payload = await response.json().catch(() => null); + let models = response.ok ? normalizeModels(payload) : []; + if (effective.id === "llama.cpp" && !models.length) { + const propsUrl = `${effective.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`; + const props = await providerFetch(propsUrl, {headers: providerHeaders(effective, secret), signal: AbortSignal.timeout(5000)}); + const value = await props.json() as {model_alias?: string; model_path?: string}; + const id = value.model_alias?.trim() || value.model_path?.split(/[\\/]/).filter(Boolean).at(-1) || effective.defaultModel; + if (props.ok) models = [{id, name: id}]; + } + if (!response.ok && !models.length) throw new Error(`Provider HTTP ${response.status}`); + return {...effective, models, modelDiscoveryError: undefined}; +} + +async function parseLines(response: Response, onLine: (line: string) => void | Promise) { + if (!response.ok) { + const payload = await response.json().catch(() => null) as {error?: string} | null; + throw new Error(payload?.error || `HTTP ${response.status}`); + } + if (!response.body) throw new Error("Streaming response body is unavailable"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const {done, value} = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), {stream: !done}); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) if (line.trim()) await onLine(line.trim()); + if (done) break; + } + if (buffer.trim()) await onLine(buffer.trim()); +} + +async function streamBackend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal) { + const item = provider()!; + const credential = item.credentials.find((value) => value.name === "default") || item.credentials[0]; + if (!credential) throw new Error(`请先在 Key Vault 中配置 ${item.name}`); + const response = await fetch("/api/chat", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({conversationId: state.conversation!.id, providerId: item.id, credentialName: credential.name, model: state.model, generationSettings: state.generationSettings, messages}), + signal + }); + await parseLines(response, (line) => onEvent(JSON.parse(line) as StreamEvent)); +} + +async function streamFrontend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal) { + const item = provider()!; + if (item.api !== "openai-completions") throw new Error(`Frontend Provider 暂不支持 ${item.api}`); + const credential = localCredential(item.id); + const secret = credential?.secret || {}; + const effective = applyBrowserProviderSettings(item, secret) as ChatProvider; + const providerFetch = createBrowserProviderFetch(effective, secret); + const startedAt = performance.now(); + let outputTokens: number | null = null; + const response = await providerFetch(`${effective.connection.baseUrl.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: providerHeaders(effective, secret, {"Content-Type": "application/json", "Accept": "text/event-stream"}), + body: JSON.stringify({ + model: state.model, + messages: messages.filter((message) => message.role !== "system" || messagePartText(message, "text")).map((message) => ({role: message.role, content: messagePartText(message, "text")})), + stream: true, + stream_options: {include_usage: true}, + ...(state.generationSettings.temperature !== null ? {temperature: state.generationSettings.temperature} : {}), + ...(state.generationSettings.maxOutputTokens !== null ? {max_tokens: state.generationSettings.maxOutputTokens} : {}), + ...(state.generationSettings.reasoning !== "auto" && state.generationSettings.reasoning !== "none" ? {reasoning_effort: state.generationSettings.reasoning} : {}) + }), + signal + }); + await parseLines(response, (line) => { + if (!line.startsWith("data:")) return; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") return; + const payload = JSON.parse(data) as {choices?: Array<{delta?: {content?: string; reasoning?: string; reasoning_content?: string}}>; usage?: {completion_tokens?: number; output_tokens?: number}}; + const delta = payload.choices?.[0]?.delta; + if (delta?.reasoning_content || delta?.reasoning) onEvent({type: "reasoning-delta", text: delta.reasoning_content || delta.reasoning}); + if (delta?.content) onEvent({type: "text-delta", text: delta.content}); + const reported = payload.usage?.completion_tokens ?? payload.usage?.output_tokens; + if (typeof reported === "number") outputTokens = reported; + }); + onEvent({type: "finish", metadata: responseMetadata(item.id, state.model, startedAt, outputTokens)}); +} + +async function refreshConversations() { + state.conversations = await listConversationHistory(); +} + +async function generateAssistant(baseMessages: StoredChatMessage[]) { + const assistant: StoredChatMessage = {id: uuid(), role: "assistant", parts: []}; + state.conversation!.messages = [...baseMessages, assistant]; + state.streaming = true; + state.streamController = new AbortController(); + renderApp(); + scrollBottom(); + let text = ""; + let reasoning = ""; + let finished = false; + const onEvent = (event: StreamEvent) => { + if (event.type === "text-delta" && event.text) text += event.text; + if (event.type === "reasoning-delta" && event.text) reasoning += event.text; + assistant.parts = [ + ...(reasoning ? [{type: "reasoning", text: reasoning}] : []), + ...(text ? [{type: "text", text}] : []) + ]; + if (event.type === "finish" && event.metadata) { + assistant.metadata = {custom: {response: event.metadata}}; + finished = true; + } + if (event.type === "error") throw new Error(event.error || "生成失败"); + scheduleMessagesRender(); + }; + try { + if (provider()!.connection.type === "backend") await streamBackend(baseMessages, onEvent, state.streamController.signal); + else await streamFrontend(baseMessages, onEvent, state.streamController.signal); + if (!finished) throw new Error("Provider 未返回完成事件"); + state.conversation = await saveConversationHistory(state.conversation!.id, state.providerId, state.model, [...baseMessages, assistant]); + await refreshConversations(); + } catch (error) { + if (state.streamController.signal.aborted) { + assistant.parts = [ + ...(reasoning ? [{type: "reasoning", text: reasoning}] : []), + ...(text ? [{type: "text", text}] : []), + {type: "error", text: "已停止生成"} + ]; + } else { + assistant.parts = [ + ...(reasoning ? [{type: "reasoning", text: reasoning}] : []), + ...(text ? [{type: "text", text}] : []), + {type: "error", text: error instanceof Error ? error.message : "生成失败"} + ]; + } + } finally { + state.streaming = false; + state.streamController = null; + renderApp(); + scrollBottom(); + } +} + +async function sendMessage(text: string) { + if (state.streaming || state.offline || !state.conversation || !text.trim()) return; + const user: StoredChatMessage = {id: uuid(), role: "user", parts: [{type: "text", text: text.trim()}]}; + const messages = [...state.conversation.messages, user]; + state.conversation.messages = messages; + renderApp(); + scrollBottom(); + state.conversation = await saveConversationHistory(state.conversation.id, state.providerId, state.model, messages); + await refreshConversations(); + await generateAssistant(messages); +} + +async function regenerate(index: number) { + if (state.streaming || state.offline || !state.conversation) return; + const message = state.conversation.messages[index]; + if (!message || message.role !== "assistant") return; + const base = state.conversation.messages.slice(0, index); + if (!base.some((item) => item.role === "user")) return; + state.conversation.messages = base; + await saveConversationHistory(state.conversation.id, state.providerId, state.model, base); + await generateAssistant(base); +} + +async function selectConversation(id: string, navigation: HashNavigationMode = "push") { + if (!state.config || state.conversation?.id === id) return; + const selected = await getConversationHistory(id); + const selectedProvider = state.config.providers.find((item) => item.id === selected.providerId) || state.config.providers[0]; + if (!selectedProvider) return; + state.conversation = selected; + state.providerId = selectedProvider.id; + state.model = selected.model || settingsForProvider(selectedProvider).model; + state.generationSettings = selected.generationSettings; + rememberModel(state.providerId, state.model); + closeHistoryOnMobile(); + if (navigation !== "none") updateConversationHash(selected.id, navigation); + renderApp(); +} + +async function newConversation() { + const item = provider(); + if (state.offline || !item || !state.model) return; + const created = await createConversationHistory(item.id, state.model, state.generationSettings); + state.conversation = created; + state.generationSettings = created.generationSettings; + await refreshConversations(); + closeHistoryOnMobile(); + updateConversationHash(created.id, "push"); + renderApp(); +} + +async function removeConversation(id: string) { + const target = state.conversations.find((item) => item.id === id); + if (!target || !window.confirm(`删除对话“${target.title}”?`)) return; + await deleteConversationHistory(id); + state.conversations = await listConversationHistory(); + if (state.conversation?.id === id) { + if (state.conversations[0]) await selectConversation(state.conversations[0].id, "replace"); + else if (!state.offline) await newConversation(); + else state.error = "离线缓存中已没有聊天记录,请联网后新建对话。"; + } + renderApp(); +} + +function chooseModel(providerId: string, model: string) { + const item = state.config?.providers.find((candidate) => candidate.id === providerId); + if (!item || !model || !state.conversation) return; + state.providerId = item.id; + state.model = model; + state.conversation = {...state.conversation, providerId: item.id, model}; + state.modelQuery = ""; + window.localStorage.setItem("xiteng-chat-provider", item.id); + window.localStorage.setItem(`xiteng-chat-model:${item.id}`, model); + rememberModel(item.id, model); + scheduleSettingsSave(); + renderApp(); +} + +function scheduleSettingsSave() { + window.clearTimeout(state.settingsTimer); + state.settingsTimer = window.setTimeout(() => { + if (!state.conversation) return; + void updateConversationHistory(state.conversation.id, state.providerId, state.model, state.generationSettings).catch((error) => console.error("Unable to save conversation settings", error)); + }, 400); +} + +async function configureLocal(providerId: string) { + const item = state.frontendProviders.find((candidate) => candidate.id === providerId); + if (!item) return; + const current = await getLocalCredential(item.id); + if (item.auth.type === "none") { + const baseUrl = window.prompt(`${item.name} Base URL:`, current?.secret.provider?.baseUrl || item.connection.baseUrl); + if (baseUrl === null) return; + try { + const parsed = new URL(baseUrl); + if (!["http:", "https:"].includes(parsed.protocol)) throw new Error(); + } catch { + window.alert("Base URL 必须是有效的 http 或 https URL"); + return; + } + await saveLocalCredential(item.id, "default", {...current?.secret, provider: {...current?.secret.provider, baseUrl: baseUrl.replace(/\/+$/, "")}}); + } else { + const apiKey = window.prompt(`${item.name} API Key:`, current?.secret.provider?.apiKey || ""); + if (apiKey === null) return; + const proxyToken = item.connection.proxy?.type === "relay" ? window.prompt("Relay Token(没有则留空):", current?.secret.proxy?.token || "") || "" : ""; + await saveLocalCredential(item.id, "default", {provider: {apiKey}, ...(proxyToken ? {proxy: {token: proxyToken}} : {})}); + } + window.location.reload(); +} + +async function probeLocal(providerId: string) { + const item = state.frontendProviders.find((candidate) => candidate.id === providerId); + if (!item) return; + try { + const detected = await discoverFrontendProvider(item, (await getLocalCredential(item.id))?.secret || {}); + state.frontendProviders = state.frontendProviders.map((candidate) => candidate.id === detected.id ? detected : candidate); + if (state.config) state.config.providers = [...state.config.providers.filter((candidate) => candidate.id !== detected.id), detected]; + window.alert(`探测成功:发现 ${detected.models.length} 个模型`); + renderApp(); + } catch (error) { + window.alert(`探测失败:${error instanceof Error ? error.message : "未知错误"}\n\n请确认浏览器已允许 chat.xiteng.site 的“本地网络访问”权限。`); + } +} + +async function initialize() { + let onlineBootstrap = true; + let identityKey = ""; + let rawProviders: ChatProvider[] = []; + let profile: ChatProfile = {username: "", name: "", email: ""}; + let cached: CachedChatBootstrap | null = null; + try { + const response = await fetch("/api/config", {cache: "no-store"}); + const payload = await response.json() as ServerChatConfig & {error?: string}; + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + identityKey = payload.identityKey; + rawProviders = payload.providers; + profile = payload.profile; + activateOfflineProfile(identityKey); + await flushPendingConversationChanges(); + } catch (error) { + const stored = await loadCachedChatConfig(); + if (!stored) throw error; + onlineBootstrap = false; + identityKey = stored.profileId; + cached = stored.config; + activateOfflineProfile(identityKey); + } + state.localCredentials = await listLocalCredentials(); + let configured: ChatConfig; + if (onlineBootstrap) { + const providers = await Promise.all(rawProviders.map(async (item) => { + if (item.connection.type === "backend") return item; + const credential = state.localCredentials.find((value) => value.providerId === item.id && value.name === "default") || state.localCredentials.find((value) => value.providerId === item.id); + if (item.auth.type !== "none" && !credential) return {...item, models: []}; + try { + return await discoverFrontendProvider(item, credential?.secret || {}); + } catch (error) { + return {...item, models: [], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"}; + } + })); + state.frontendProviders = providers.filter((item) => item.connection.type === "frontend"); + configured = { + profile, + providers: providers.filter((item) => item.models.length > 0 && (item.connection.type === "backend" ? item.credentials.length > 0 : item.auth.type === "none" || state.localCredentials.some((credential) => credential.providerId === item.id))) + }; + await cacheChatConfig(identityKey, {config: configured, frontendProviders: state.frontendProviders}); + } else { + configured = cached!.config; + state.frontendProviders = cached!.frontendProviders; + } + state.config = configured; + state.offline = !onlineBootstrap || !navigator.onLine; + state.conversations = await listConversationHistory(); + try { + const recent = JSON.parse(window.localStorage.getItem("xiteng-chat-recent-models") || "[]"); + if (Array.isArray(recent)) state.recentModelKeys = recent.filter((item) => typeof item === "string").slice(0, 20); + } catch { + window.localStorage.removeItem("xiteng-chat-recent-models"); + } + const hashId = conversationIdFromHash(window.location.hash); + const selectedSummary = state.conversations.find((item) => item.id === hashId) || state.conversations[0]; + if (selectedSummary) { + const selected = await getConversationHistory(selectedSummary.id); + const selectedProvider = configured.providers.find((item) => item.id === selected.providerId) || configured.providers[0]; + if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据"); + state.conversation = selected; + state.providerId = selectedProvider.id; + state.model = selected.model || settingsForProvider(selectedProvider).model; + state.generationSettings = selected.generationSettings; + rememberModel(state.providerId, state.model); + updateConversationHash(selected.id, "replace"); + if (onlineBootstrap) void synchronizeOfflineConversationHistory(state.conversations).catch((error) => console.error("Unable to refresh offline history", error)); + } else { + if (!onlineBootstrap) throw new Error("离线缓存中还没有聊天记录,请联网后再试。"); + const savedProviderId = window.localStorage.getItem("xiteng-chat-provider") || ""; + const selectedProvider = configured.providers.find((item) => item.id === savedProviderId) || configured.providers[0]; + if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据"); + const selection = settingsForProvider(selectedProvider); + state.conversation = await createConversationHistory(selectedProvider.id, selection.model, defaultGenerationSettings); + state.conversations = [state.conversation]; + state.providerId = selectedProvider.id; + state.model = selection.model; + state.generationSettings = state.conversation.generationSettings; + rememberModel(state.providerId, state.model); + updateConversationHash(state.conversation.id, "replace"); + } + state.loading = false; + renderApp(); +} + +root.addEventListener("submit", (event) => { + if (!(event.target instanceof HTMLFormElement) || event.target.id !== "composer") return; + event.preventDefault(); + const input = event.target.elements.namedItem("message"); + if (input instanceof HTMLTextAreaElement) void sendMessage(input.value).catch(showError); +}); + +root.addEventListener("keydown", (event) => { + if (!(event.target instanceof HTMLTextAreaElement) || event.target.name !== "message") return; + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void sendMessage(event.target.value).catch(showError); + } +}); + +root.addEventListener("input", (event) => { + const target = event.target; + if (target instanceof HTMLTextAreaElement && target.name === "message") { + target.style.height = "auto"; + target.style.height = `${Math.min(target.scrollHeight, 180)}px`; + } + if (target instanceof HTMLInputElement && target.dataset.action === "model-search") { + state.modelQuery = target.value; + const details = target.closest("details"); + renderApp(); + const next = root.querySelector('[data-action="model-search"]'); + const nextDetails = next?.closest("details"); + if (nextDetails) nextDetails.open = true; + next?.focus(); + next?.setSelectionRange(next.value.length, next.value.length); + if (details?.open && nextDetails) nextDetails.open = true; + } +}); + +root.addEventListener("change", (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement) || !target.dataset.setting) return; + const key = target.dataset.setting as keyof GenerationSettings; + if (key === "showReasoningSummary" && target instanceof HTMLInputElement) state.generationSettings.showReasoningSummary = target.checked; + if (key === "reasoning") state.generationSettings.reasoning = target.value as GenerationSettings["reasoning"]; + if (key === "temperature") state.generationSettings.temperature = target.value === "" ? null : Math.min(2, Math.max(0, Number(target.value))); + if (key === "maxOutputTokens") state.generationSettings.maxOutputTokens = target.value === "" ? null : Math.min(1_000_000, Math.max(1, Math.floor(Number(target.value)))); + if (state.conversation) state.conversation.generationSettings = {...state.generationSettings}; + scheduleSettingsSave(); +}); + +root.addEventListener("click", (event) => { + const button = (event.target as Element).closest("[data-action]"); + if (!button) return; + const action = button.dataset.action; + if (action === "toggle-history") { state.historyOpen = !state.historyOpen; renderApp(); } + if (action === "close-history") { state.historyOpen = false; renderApp(); } + if (action === "new-conversation") void newConversation().catch(showError); + if (action === "select-conversation" && button.dataset.id) void selectConversation(button.dataset.id).catch(showError); + if (action === "delete-conversation" && button.dataset.id) void removeConversation(button.dataset.id).catch(showError); + if (action === "choose-model" && button.dataset.provider && button.dataset.model) chooseModel(button.dataset.provider, button.dataset.model); + if (action === "configure-local" && button.dataset.provider) void configureLocal(button.dataset.provider).catch(showError); + if (action === "probe-local" && button.dataset.provider) void probeLocal(button.dataset.provider).catch(showError); + if (action === "delete-local" && button.dataset.provider) { + const item = state.frontendProviders.find((candidate) => candidate.id === button.dataset.provider); + if (item && window.confirm(`删除此浏览器中的 ${item.name} Credential?`)) void deleteLocalCredential(item.id).then(() => window.location.reload()); + } + if (action === "reset-settings") { state.generationSettings = {...defaultGenerationSettings}; scheduleSettingsSave(); renderApp(); } + if (action === "stop") state.streamController?.abort(); + if (action === "scroll-bottom") scrollBottom(); + if (action === "copy-message") { + const index = Number(button.dataset.index); + const message = state.conversation?.messages[index]; + if (message) void navigator.clipboard.writeText(messagePartText(message, "text")).then(() => { + button.classList.add("copied"); + window.setTimeout(() => button.classList.remove("copied"), 1200); + }); + } + if (action === "regenerate-message") void regenerate(Number(button.dataset.index)).catch(showError); +}); + +function showError(error: unknown) { + window.alert(error instanceof Error ? error.message : "操作失败"); +} + +window.addEventListener("hashchange", () => { + const id = conversationIdFromHash(window.location.hash); + if (id && id !== state.conversation?.id && state.conversations.some((item) => item.id === id)) void selectConversation(id, "none").catch(showError); +}); +window.addEventListener("offline", () => { state.offline = true; renderApp(); }); +window.addEventListener("online", () => { + void (async () => { + try { + await flushPendingConversationChanges(); + await synchronizeOfflineConversationHistory(); + window.location.reload(); + } catch (error) { + console.error("Unable to synchronize offline history", error); + } + })(); +}); +window.matchMedia("(min-width: 681px)").addEventListener("change", (event) => { state.historyOpen = event.matches; renderApp(); }); + +if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=4", {scope: "/"}).catch((error) => console.error("Unable to register service worker", error)); + +renderApp(); +initialize().catch((error) => { + state.loading = false; + state.error = error instanceof Error ? error.message : "配置加载失败"; + renderApp(); +}); diff --git a/chat/src/index.html b/chat/src/index.html new file mode 100644 index 0000000..862608f --- /dev/null +++ b/chat/src/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + Xiteng Chat + + + + + + + + +
+ + diff --git a/chat/src/server.ts b/chat/src/server.ts new file mode 100644 index 0000000..d0a13ba --- /dev/null +++ b/chat/src/server.ts @@ -0,0 +1,424 @@ +import {createHash, randomUUID} from "node:crypto"; +import path from "node:path"; +import {convertToModelMessages, streamText, type UIMessage} from "ai"; +import { + createConversation, + deleteConversation, + getConversation, + listConversations, + saveConversationMessages, + updateConversationSettings +} from "../lib/conversations"; +import {generationCallOptions, normalizeGenerationSettings} from "../lib/generation-settings"; +import {identityFromHeaders, keyVaultFetch, type ChatIdentity} from "../lib/key-vault"; +import {discoverProviderModels, testProviderConnectivity} from "../lib/provider-connectivity"; +import {createProviderModel} from "../lib/provider-model"; +import {createServerProviderFetch} from "../lib/server-provider-fetch"; +import type {ProviderDefinition, ProviderSecret, ResolvedBackendProvider} from "../lib/provider-types"; +import {responseMetadata} from "../lib/response-metadata"; +import type {StoredChatMessage} from "../lib/conversation-types"; + +const port = Number.parseInt(process.env.PORT || "3000", 10); +const portalUrl = process.env.PORTAL_URL || "http://xiteng-site:8080"; +const staticRoot = path.resolve(process.env.STATIC_ROOT || "dist"); +const encoder = new TextEncoder(); + +const securityHeaders = { + "Content-Security-Policy": [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "manifest-src 'self'", + "worker-src 'self' blob:", + "connect-src 'self' http: https: ws: wss:" + ].join("; "), + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY" +}; + +function json(payload: unknown, status = 200, headers: HeadersInit = {}) { + return Response.json(payload, {status, headers: {...securityHeaders, "Cache-Control": "no-store", ...headers}}); +} + +function errorStatus(error: unknown, fallback: number) { + return typeof error === "object" && error && "statusCode" in error ? Number(error.statusCode) : fallback; +} + +async function accountProfile(identity: ChatIdentity) { + const fallback = {username: identity.username, name: identity.name || identity.username, email: identity.email}; + try { + const response = await fetch(new URL("/api/account/identity", portalUrl), { + headers: { + "Accept": "application/json", + "X-Portal-Authenticated": "1", + "X-Authentik-Username": identity.username, + "X-Authentik-Uid": identity.sub, + "X-Authentik-Email": identity.email + }, + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) return fallback; + const payload = await response.json() as {profile?: {username?: string; name?: string; email?: string}}; + return { + username: payload.profile?.username?.trim() || fallback.username, + name: payload.profile?.name?.trim() || fallback.name, + email: payload.profile?.email?.trim() || fallback.email + }; + } catch { + return fallback; + } +} + +async function discoverBackendProvider(provider: ProviderDefinition, identity: ChatIdentity) { + if (provider.connection.type !== "backend" || !provider.credentials.length) return {...provider, models: []}; + const credential = provider.credentials.find((item) => item.name === "default") || provider.credentials[0]; + const response = await keyVaultFetch("/v1/resolve", identity, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({providerId: provider.id, credentialName: credential.name}) + }); + const resolved = await response.json() as ResolvedBackendProvider & {error?: string}; + if (!response.ok) return {...provider, models: [], modelDiscoveryError: resolved.error || `Key Vault HTTP ${response.status}`}; + try { + const discovery = await discoverProviderModels( + resolved.provider, + resolved.credential.secret, + createServerProviderFetch(resolved.provider, resolved.credential.secret) + ); + return {...provider, models: discovery.models}; + } catch (error) { + return {...provider, models: [], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"}; + } +} + +async function config(request: Request) { + try { + const identity = identityFromHeaders(request.headers); + const response = await keyVaultFetch("/v1/providers", identity); + const payload = await response.json() as {providers?: ProviderDefinition[]; error?: string}; + if (!response.ok) return json({error: payload.error || `Key Vault HTTP ${response.status}`}, response.status); + const [providers, profile] = await Promise.all([ + Promise.all((payload.providers || []).map((provider) => discoverBackendProvider(provider, identity))), + accountProfile(identity) + ]); + const identityKey = createHash("sha256").update(`${identity.issuer}\0${identity.sub}`).digest("hex").slice(0, 32); + return json({providers, identityKey, profile}); + } catch (error) { + return json({error: error instanceof Error ? error.message : "Provider configuration unavailable"}, errorStatus(error, 503)); + } +} + +async function conversations(request: Request) { + try { + const identity = identityFromHeaders(request.headers); + if (request.method === "GET") return json({conversations: listConversations(identity)}); + if (request.method === "POST") return json({conversation: createConversation(identity, await request.json())}, 201); + return json({error: "Method not allowed"}, 405, {Allow: "GET, POST"}); + } catch (error) { + return json({error: error instanceof Error ? error.message : "Conversation request failed"}, request.method === "POST" ? 400 : 500); + } +} + +async function conversation(request: Request, id: string) { + try { + const identity = identityFromHeaders(request.headers); + if (request.method === "GET") { + const value = getConversation(identity, id); + return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404); + } + if (request.method === "PUT") { + const value = saveConversationMessages(identity, id, await request.json()); + return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404); + } + if (request.method === "PATCH") { + const updated = updateConversationSettings(identity, id, await request.json()); + return updated ? json({updated: true}) : json({error: "Conversation not found"}, 404); + } + if (request.method === "DELETE") { + return deleteConversation(identity, id) + ? new Response(null, {status: 204, headers: securityHeaders}) + : json({error: "Conversation not found"}, 404); + } + return json({error: "Method not allowed"}, 405, {Allow: "GET, PUT, PATCH, DELETE"}); + } catch (error) { + return json({error: error instanceof Error ? error.message : "Conversation request failed"}, 400); + } +} + +function temporaryProvider(value: unknown): ProviderDefinition { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("provider is required"); + const input = value as Record; + const connectionInput = input.connection as Record | undefined; + const authInput = input.auth as Record | undefined; + const discoveryInput = input.discovery as Record | undefined; + const id = String(input.id || "").trim().toLowerCase(); + const name = String(input.name || "").trim(); + const api = String(input.api || ""); + if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id is invalid"); + if (!name) throw new Error("provider.name is required"); + if (!["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"].includes(api)) throw new Error("provider.api is invalid"); + if (connectionInput?.type !== "backend") throw new Error("Only Backend Provider drafts can be tested by the Chat server"); + const baseUrl = new URL(String(connectionInput.baseUrl || "")); + if (!["http:", "https:"].includes(baseUrl.protocol)) throw new Error("provider baseUrl is invalid"); + const proxyInput = connectionInput.proxy as Record | null | undefined; + let proxy: ProviderDefinition["connection"]["proxy"] = null; + if (proxyInput) { + const type = String(proxyInput.type || "") as "http" | "https" | "socks5"; + if (!["http", "https", "socks5"].includes(type)) throw new Error("provider proxy type is invalid"); + const url = new URL(String(proxyInput.url || "")); + if (type === "socks5" ? url.protocol !== "socks5:" : !["http:", "https:"].includes(url.protocol)) throw new Error("provider proxy URL is invalid"); + proxy = {type, url: url.toString().replace(/\/$/, "")}; + } + const defaultModel = String(input.defaultModel || "").trim().slice(0, 300); + if (!defaultModel) throw new Error("provider.defaultModel is required"); + const authType = ["bearer", "header", "none"].includes(String(authInput?.type)) ? String(authInput?.type) as "bearer" | "header" | "none" : "bearer"; + const header = authType === "header" ? String(authInput?.header || "").trim() : ""; + if (authType === "header" && !header) throw new Error("provider auth header is required"); + const discoveryType = String(discoveryInput?.type || ""); + if (!["openai-models-list", "anthropic-models-list", "google-models-list"].includes(discoveryType)) throw new Error("provider.discovery.type is invalid"); + const discoveryUrl = new URL(String(discoveryInput?.url || "")); + if (!["http:", "https:"].includes(discoveryUrl.protocol)) throw new Error("provider.discovery.url is invalid"); + return { + id, + name, + api: api as ProviderDefinition["api"], + connection: {type: "backend", baseUrl: baseUrl.toString().replace(/\/$/, ""), proxy}, + defaultModel, + auth: authType === "header" ? {type: authType, header} : {type: authType}, + headers: {}, + discovery: {type: discoveryType as ProviderDefinition["discovery"]["type"], url: discoveryUrl.toString()}, + builtin: false, + credentialState: "missing", + credentials: [] + }; +} + +async function catalogFor(identity: ChatIdentity) { + const response = await keyVaultFetch("/v1/providers", identity); + const payload = await response.json() as {providers?: ProviderDefinition[]; error?: string}; + if (!response.ok) throw Object.assign(new Error(payload.error || `Key Vault HTTP ${response.status}`), {statusCode: response.status}); + return payload.providers || []; +} + +async function savedSecret(identity: ChatIdentity, provider: ProviderDefinition, credentialName: string) { + if (!provider.credentials.some((credential) => credential.name === credentialName)) return null; + const response = await keyVaultFetch("/v1/resolve", identity, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({providerId: provider.id, credentialName}) + }); + const payload = await response.json() as ResolvedBackendProvider & {error?: string}; + if (!response.ok) throw Object.assign(new Error(payload.error || `Key Vault HTTP ${response.status}`), {statusCode: response.status}); + return payload.credential.secret; +} + +async function providerTest(request: Request) { + try { + const identity = identityFromHeaders(request.headers); + const input = await request.json() as {providerId?: string; credentialName?: string; provider?: unknown; secret?: ProviderSecret}; + const credentialName = input.credentialName?.trim() || "default"; + let provider: ProviderDefinition; + let secret: ProviderSecret = input.secret || {}; + if (input.provider) { + provider = temporaryProvider(input.provider); + if (provider.auth.type !== "none" && !secret.provider?.apiKey) { + const saved = (await catalogFor(identity)).find((item) => item.id === provider.id); + const existingSecret = saved ? await savedSecret(identity, saved, credentialName) : null; + if (!existingSecret) return json({error: "Temporary API Key is required for connectivity testing"}, 409); + secret = existingSecret; + } + } else { + const providerId = input.providerId?.trim(); + if (!providerId) return json({error: "providerId is required"}, 400); + const saved = (await catalogFor(identity)).find((item) => item.id === providerId); + if (!saved) return json({error: "Provider not found"}, 404); + provider = saved; + const existingSecret = await savedSecret(identity, provider, credentialName); + if (existingSecret) secret = existingSecret; + else if (provider.auth.type !== "none") return json({error: `Credential ${credentialName} is required for connectivity testing`}, 409); + } + const result = await testProviderConnectivity(provider, secret, createServerProviderFetch(provider, secret)); + return json({...result, detected: {id: provider.id, name: provider.name, api: provider.api, auth: provider.auth, connection: provider.connection, discovery: provider.discovery}}); + } catch (error) { + return json({error: error instanceof Error ? error.message : "Provider connectivity test failed"}, errorStatus(error, 502)); + } +} + +function streamEvent(controller: ReadableStreamDefaultController, event: unknown) { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); +} + +function cleanMessages(value: unknown): StoredChatMessage[] { + if (!Array.isArray(value)) throw new Error("messages are required"); + return value.map((message) => { + if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("message is invalid"); + const record = message as StoredChatMessage; + return { + id: typeof record.id === "string" ? record.id : randomUUID(), + role: record.role, + parts: Array.isArray(record.parts) ? record.parts.filter((part) => part.type === "text" || part.type === "reasoning") : [], + ...(record.metadata ? {metadata: record.metadata} : {}) + }; + }); +} + +async function chat(request: Request) { + try { + const identity = identityFromHeaders(request.headers); + const input = await request.json() as { + messages?: unknown; + providerId?: string; + credentialName?: string; + model?: string; + conversationId?: string; + generationSettings?: unknown; + }; + const messages = cleanMessages(input.messages); + if (!messages.length) return json({error: "messages are required"}, 400); + if (!input.providerId?.trim() || !input.model?.trim() || !input.conversationId?.trim()) { + return json({error: "conversationId, providerId and model are required"}, 400); + } + const conversationId = input.conversationId.trim(); + if (!getConversation(identity, conversationId)) return json({error: "Conversation not found"}, 404); + const response = await keyVaultFetch("/v1/resolve", identity, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({providerId: input.providerId.trim(), credentialName: input.credentialName?.trim() || "default"}) + }); + const resolved = await response.json() as ResolvedBackendProvider & {error?: string}; + if (!response.ok) return json({error: resolved.error || `Key Vault HTTP ${response.status}`}, response.status); + if (resolved.provider.connection.type !== "backend") return json({error: "Frontend Provider must run in the browser"}, 409); + const providerFetch = createServerProviderFetch(resolved.provider, resolved.credential.secret); + const model = await createProviderModel(resolved.provider, resolved.credential.secret, input.model.trim(), providerFetch); + const generationSettings = normalizeGenerationSettings(input.generationSettings); + const startedAt = performance.now(); + const result = streamText({ + model, + messages: await convertToModelMessages(messages as UIMessage[]), + abortSignal: request.signal, + ...generationCallOptions(resolved.provider, generationSettings) + }); + const body = new ReadableStream({ + async start(controller) { + let text = ""; + let reasoning = ""; + try { + streamEvent(controller, {type: "start"}); + for await (const part of result.fullStream) { + if (part.type === "text-delta") { + text += part.text; + streamEvent(controller, {type: "text-delta", text: part.text}); + } else if (part.type === "reasoning-delta") { + reasoning += part.text; + streamEvent(controller, {type: "reasoning-delta", text: part.text}); + } else if (part.type === "error") { + throw part.error; + } + } + const usage = await result.usage; + const metadata = responseMetadata(input.providerId!.trim(), input.model!.trim(), startedAt, usage.outputTokens); + const assistant: StoredChatMessage = { + id: randomUUID(), + role: "assistant", + parts: [ + ...(reasoning ? [{type: "reasoning", text: reasoning}] : []), + ...(text ? [{type: "text", text}] : []) + ], + metadata: {custom: {response: metadata}} + }; + saveConversationMessages(identity, conversationId, {providerId: input.providerId, model: input.model, messages: [...messages, assistant]}); + streamEvent(controller, {type: "finish", metadata}); + } catch (error) { + console.error("Backend Provider request failed", error instanceof Error ? error.message : error); + streamEvent(controller, {type: "error", error: error instanceof Error ? error.message : "Chat request failed"}); + } finally { + controller.close(); + } + } + }); + return new Response(body, { + headers: { + ...securityHeaders, + "Cache-Control": "no-store", + "Content-Type": "application/x-ndjson; charset=utf-8", + "X-Accel-Buffering": "no" + } + }); + } catch (error) { + console.error("Chat request failed", error instanceof Error ? error.message : error); + return json({error: error instanceof Error ? error.message : "Chat request failed"}, errorStatus(error, 500)); + } +} + +const mimeTypes: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".webp": "image/webp" +}; + +async function staticResponse(pathname: string) { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return json({error: "Invalid path"}, 400); + } + const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, ""); + let filePath = path.resolve(staticRoot, relative); + if (!filePath.startsWith(`${staticRoot}${path.sep}`) && filePath !== path.join(staticRoot, "index.html")) return json({error: "Not found"}, 404); + let file = Bun.file(filePath); + if (!(await file.exists()) && !path.extname(relative)) { + filePath = path.join(staticRoot, "index.html"); + file = Bun.file(filePath); + } + if (!(await file.exists())) return json({error: "Not found"}, 404); + const extension = path.extname(filePath); + const immutable = /-[A-Za-z0-9_-]{8,}\.(?:js|css)$/.test(path.basename(filePath)); + return new Response(file, { + headers: { + ...securityHeaders, + "Content-Type": mimeTypes[extension] || "application/octet-stream", + "Cache-Control": extension === ".html" || path.basename(filePath) === "sw.js" + ? "no-cache" + : immutable ? "public, max-age=31536000, immutable" : "public, max-age=3600" + } + }); +} + +const server = Bun.serve({ + port, + hostname: "0.0.0.0", + idleTimeout: 255, + async fetch(request, server) { + const url = new URL(request.url); + if (url.pathname === "/api/health" && request.method === "GET") return json({status: "ok"}); + if (url.pathname === "/api/config" && request.method === "GET") return config(request); + if (url.pathname === "/api/conversations") return conversations(request); + const conversationMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/); + if (conversationMatch) return conversation(request, decodeURIComponent(conversationMatch[1])); + if (url.pathname === "/api/provider-test" && request.method === "POST") return providerTest(request); + if (url.pathname === "/api/chat" && request.method === "POST") { + server.timeout(request, 0); + return chat(request); + } + if (url.pathname.startsWith("/api/")) return json({error: "Not found"}, 404); + if (request.method !== "GET" && request.method !== "HEAD") return json({error: "Method not allowed"}, 405); + return staticResponse(url.pathname); + } +}); + +console.log(`xiteng-chat Bun server listening on ${server.url}`); diff --git a/chat/src/styles.css b/chat/src/styles.css new file mode 100644 index 0000000..0d22f5f --- /dev/null +++ b/chat/src/styles.css @@ -0,0 +1,201 @@ +svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } + +:root { + color-scheme: light; + --bg: #f7f7f5; + --panel: rgba(255, 255, 255, 0.88); + --text: #171717; + --muted: #6f6f6a; + --line: rgba(23, 23, 23, 0.11); + --soft: #eeeeea; + --accent: #171717; + --accent-text: #ffffff; + --danger: #a3382d; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; margin: 0; } +body { overflow: hidden; background: var(--bg); color: var(--text); } +button, input, select { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } + +.app-shell { position: relative; height: 100dvh; background: radial-gradient(circle at 50% -20%, #fff 0, var(--bg) 42%); } +.app-header { position: absolute; inset: 0 0 auto; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; height: 64px; align-items: center; padding: 0 22px; border-bottom: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); } +.header-leading { display: flex; align-items: center; gap: 8px; justify-self: start; } +.brand { display: flex; align-items: center; gap: 10px; font-weight: 680; letter-spacing: -0.02em; } +.brand-mark, .welcome-mark { display: grid; place-items: center; color: #fff; background: #171717; font-weight: 800; letter-spacing: -0.08em; } +.brand-mark { width: 30px; height: 30px; border-radius: 10px; font-size: 11px; } +.offline-badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid rgba(163,56,45,.2); border-radius: 999px; padding: 4px 7px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 10px; font-weight: 650; letter-spacing: 0; white-space: nowrap; } +.chat-controls { display: flex; align-items: center; justify-self: end; gap: 8px; } +.header-button, .local-key-menu > summary, .generation-menu > summary { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.72); color: var(--text); text-decoration: none; } +.header-button { padding: 0 11px; cursor: pointer; } +.account-button { padding: 4px 10px 4px 5px; } +.header-avatar { width: 26px; height: 26px; border-radius: 8px; object-fit: cover; background: var(--soft); } +.header-button:hover, .local-key-menu > summary:hover, .generation-menu > summary:hover { background: #fff; border-color: rgba(23,23,23,.2); } +.local-key-menu, .generation-menu { position: relative; } +.local-key-menu > summary, .generation-menu > summary { width: 36px; justify-content: center; cursor: pointer; list-style: none; } +.local-key-menu > summary::-webkit-details-marker, .generation-menu > summary::-webkit-details-marker { display: none; } +.local-key-menu > div { position: absolute; z-index: 31; top: 43px; right: 0; display: grid; width: min(320px, calc(100vw - 24px)); max-height: min(520px, calc(100dvh - 80px)); overflow-y: auto; gap: 4px; border: 1px solid var(--line); border-radius: 12px; padding: 8px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); } +.local-key-menu-title { padding: 5px 7px 8px; color: var(--muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } +.local-key-entry { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; padding: 7px; } +.local-key-entry:hover { background: var(--soft); } +.local-key-entry > span { min-width: 0; text-align: left; } +.local-key-entry > span strong, .local-key-entry > span small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.local-key-entry > span small { margin-top: 2px; color: var(--muted); font-size: 9px; } +.local-key-entry > span .local-key-error { max-width: 190px; color: var(--danger); white-space: normal; } +.local-key-entry > div { display: flex; flex: 0 0 auto; gap: 2px; } +.local-key-menu button { border: 0; border-radius: 8px; padding: 8px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } +.local-key-menu button:hover { background: var(--panel); } +.local-key-menu button.dangerous { color: #b42318; } +.generation-menu > div { position: absolute; z-index: 31; top: 43px; right: 0; display: grid; width: min(280px, calc(100vw - 24px)); gap: 10px; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); } +.generation-menu > div > strong { font-size: 12px; } +.generation-menu label { display: grid; gap: 5px; color: var(--muted); font-size: 10px; } +.generation-menu select, .generation-menu input[type="number"] { min-width: 0; width: 100%; box-sizing: border-box; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: var(--bg); color: var(--text); } +.generation-menu .generation-check { display: flex; align-items: center; gap: 7px; color: var(--text); } +.generation-menu button { border: 0; border-radius: 8px; padding: 9px; background: var(--soft); color: var(--text); cursor: pointer; } +.model-picker { position: relative; } +.model-picker > summary { display: flex; min-width: 190px; max-width: 290px; height: 36px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); border-radius: 11px; padding: 0 10px; background: rgba(255,255,255,.72); color: var(--text); cursor: pointer; list-style: none; } +.model-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-picker > summary::-webkit-details-marker { display: none; } +.model-picker[open] > summary { border-color: rgba(23,23,23,.24); background: #fff; } +.model-picker[open] > summary svg { transform: rotate(180deg); } +.model-menu { position: absolute; z-index: 30; top: 43px; left: 0; width: min(430px, calc(100vw - 24px)); max-height: min(670px, calc(100dvh - 82px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 14px; padding: 8px; background: var(--panel); box-shadow: 0 18px 48px rgba(0,0,0,.15); backdrop-filter: blur(18px); } +.model-search { position: sticky; z-index: 1; top: -8px; display: flex; height: 40px; align-items: center; gap: 8px; margin: -1px -1px 5px; padding: 0 9px; border-bottom: 1px solid var(--line); background: var(--panel); color: var(--muted); } +.model-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); } +.model-menu-group { display: grid; gap: 2px; padding: 5px 0 8px; } +.model-menu-group + .model-menu-group { border-top: 1px solid var(--line); } +.model-menu-group h3 { display: flex; align-items: center; gap: 5px; margin: 0; padding: 4px 8px; color: var(--muted); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } +.model-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; border: 0; border-radius: 8px; padding: 8px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } +.model-option:hover, .model-option.active { background: var(--soft); } +.model-option > span { min-width: 0; } +.model-option strong, .model-option small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-option strong { font-size: 12px; font-weight: 620; } +.model-option small { color: var(--muted); font-size: 10px; } +.model-menu-empty { margin: 10px; color: var(--muted); text-align: center; } +.state-card > .model-picker { display: inline-block; margin-bottom: 24px; text-align: left; } +.state-card > .model-picker > summary { min-width: 260px; } +.history-toggle, .history-heading button, .history-delete { display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; } +.history-toggle { width: 32px; height: 32px; border-radius: 9px; } +.history-toggle:hover, .history-heading button:hover, .history-delete:hover { background: var(--soft); } +.history-sidebar { position: absolute; z-index: 9; top: 64px; bottom: 0; left: 0; width: 252px; display: flex; flex-direction: column; transform: translateX(-102%); border-right: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); transition: transform .18s ease; } +.history-sidebar.open { transform: translateX(0); } +.history-heading { display: flex; height: 52px; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 0 12px 0 16px; border-bottom: 1px solid var(--line); font-size: 13px; } +.history-heading > div { display: flex; gap: 2px; } +.history-heading button { width: 30px; height: 30px; border-radius: 8px; } +.history-close { display: none !important; } +.history-list { min-height: 0; flex: 1; overflow-y: auto; padding: 8px; } +.history-item { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; border-radius: 9px; } +.history-item:hover, .history-item.active { background: var(--soft); } +.history-select { min-width: 0; border: 0; padding: 9px 4px 9px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } +.history-select strong, .history-select small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.history-select strong { font-size: 12px; font-weight: 650; } +.history-select small { margin-top: 3px; color: var(--muted); font-size: 9px; } +.history-delete { width: 26px; height: 26px; border-radius: 7px; color: var(--muted); opacity: 0; } +.history-item:hover .history-delete, .history-item.active .history-delete { opacity: 1; } +.history-backdrop { display: none; } +.with-history .thread-root { padding-left: 0; transition: padding-left .18s ease; } +.with-history.history-open .thread-root { padding-left: 252px; } + +.thread-root { height: 100dvh; padding-top: 64px; } +.thread-viewport { position: relative; display: flex; height: 100%; flex-direction: column; overflow-y: auto; scroll-behavior: smooth; padding: 24px 18px 0; } +#message-list { display: flex; flex: 1 0 auto; flex-direction: column; } +.welcome { display: flex; width: min(680px, 100%); flex: 1; flex-direction: column; justify-content: center; margin: 0 auto; padding: 80px 4px 210px; } +.welcome-mark { width: 48px; height: 48px; border-radius: 16px; font-size: 15px; box-shadow: 0 10px 30px rgba(0,0,0,.12); } +.welcome h1 { margin: 22px 0 8px; font-size: clamp(30px, 5vw, 46px); line-height: 1.08; letter-spacing: -.045em; } +.welcome p { max-width: 560px; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.7; } +.message { width: min(760px, 100%); margin: 0 auto; padding: 14px 0; } +.user-message { display: flex; justify-content: flex-end; } +.message-content { overflow-wrap: anywhere; font-size: 15px; line-height: 1.72; } +.user-content { max-width: min(620px, 86%); border-radius: 16px 16px 5px 16px; padding: 7px 13px; background: var(--soft); line-height: 1.5; white-space: pre-wrap; } +.user-content > p { margin: 0; } +.user-content > p + p { margin-top: 6px; } +.assistant-content { padding: 2px 4px; } +.assistant-content .aui-md { color: var(--text); } +.assistant-content .aui-md > :first-child { margin-top: 0; } +.assistant-content .aui-md > :last-child { margin-bottom: 0; } +.assistant-content pre { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: #20201f; color: #f6f6f3; } +.assistant-content code { font-family: "SFMono-Regular", Consolas, monospace; font-size: .88em; } +.assistant-content :not(pre) > code { border-radius: 5px; padding: 2px 5px; background: var(--soft); } +.assistant-content a { color: inherit; text-underline-offset: 3px; } +.message-reasoning { margin: 0 0 12px; border-left: 2px solid var(--line); padding-left: 12px; color: var(--muted); } +.message-reasoning > summary { cursor: pointer; font-size: 12px; font-weight: 650; } +.message-reasoning > div { margin-top: 7px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; } +.message-error { margin-top: 10px; border: 1px solid rgba(163,56,45,.2); border-radius: 10px; padding: 10px 12px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 13px; } +.thread-footer { position: sticky; bottom: 0; display: flex; width: min(760px, 100%); flex-direction: column; align-items: center; margin: auto auto 0; padding: 40px 0 16px; background: linear-gradient(to bottom, transparent, var(--bg) 38%); } +.scroll-button { position: absolute; top: 1px; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); box-shadow: 0 5px 18px rgba(0,0,0,.08); cursor: pointer; } +.message-footer { display: flex; min-height: 30px; align-items: center; justify-content: space-between; gap: 12px; margin: 6px 0 0; } +.response-meta { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--muted); font-size: 10px; } +.response-meta span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.response-meta span + span::before { margin-right: 7px; content: "·"; } +.scroll-button:disabled { visibility: hidden; } +.composer { display: grid; width: 100%; grid-template-columns: 1fr auto; align-items: end; gap: 8px; border: 1px solid rgba(23,23,23,.15); border-radius: 20px; padding: 8px 8px 8px 16px; background: #fff; box-shadow: 0 14px 45px rgba(0,0,0,.09); } +.composer:focus-within { border-color: rgba(23,23,23,.32); box-shadow: 0 14px 45px rgba(0,0,0,.1), 0 0 0 3px rgba(23,23,23,.04); } +.message-actions { display: flex; flex: 0 0 auto; gap: 2px; margin: 0; opacity: 1; visibility: visible; } +.icon-button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; } +.icon-button:hover { background: var(--soft); color: var(--text); } +.composer-input { max-height: 180px; min-height: 38px; resize: none; border: 0; outline: 0; padding: 9px 0 7px; background: transparent; color: var(--text); line-height: 1.5; } +.composer-input:disabled { cursor: not-allowed; color: var(--muted); } +.composer-input::placeholder { color: #9a9a94; } +.send-button { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 13px; background: var(--accent); color: var(--accent-text); cursor: pointer; } +.send-button:disabled { cursor: default; opacity: .35; } +.composer-note { margin: 8px 0 0; color: #90908a; font-size: 11px; } +.composer-note.offline { color: var(--danger); } + +.state-page { position: relative; display: grid; min-height: 100dvh; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 10%, #fff, var(--bg) 52%); } +.state-card { width: min(520px, 100%); text-align: center; } +.state-provider-select { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 11px; padding: 8px 10px; color: var(--muted); background: var(--panel); } +.state-provider-select select { border: 0; outline: 0; color: var(--text); background: transparent; } +.state-mark { display: grid; width: 52px; height: 52px; place-items: center; margin: 0 auto 18px; border-radius: 17px; background: #171717; color: #fff; font-weight: 800; } +.state-card h1 { margin: 0 0 10px; font-size: 28px; letter-spacing: -.035em; } +.state-card p { margin: 0 auto; color: var(--muted); line-height: 1.7; } +.state-card code { border-radius: 5px; padding: 2px 5px; background: var(--soft); color: var(--text); } +.primary-link { display: inline-flex; margin-top: 22px; border-radius: 12px; padding: 11px 16px; background: #171717; color: #fff; text-decoration: none; } +.button-link { border: 0; cursor: pointer; } +.loader { display: block; width: 30px; height: 30px; margin: 0 auto 16px; border: 3px solid var(--line); border-top-color: #171717; border-radius: 50%; animation: spin .75s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.response-loader { display: inline-block; width: 7px; height: 18px; border-radius: 2px; background: currentColor; vertical-align: text-bottom; animation: pulse 1s ease-in-out infinite; } +.icon-button.copied { color: #25824d; } +.icon-button:disabled { cursor: default; opacity: .4; } +@keyframes pulse { 50% { opacity: .25; } } + +@media (max-width: 680px) { + .app-header { grid-template-columns: auto auto 1fr; height: 58px; gap: 10px; padding: 0 12px; } + .thread-root { padding-top: 58px; } + .chat-controls { min-width: 0; gap: 5px; } + .chat-controls > * { flex: 0 0 auto; } + .model-picker > summary { min-width: 0; width: min(132px, 34vw); } + .account-button { width: 36px; } + .app-header { overflow: clip; } + .brand > span:nth-child(2), .header-button span { display: none; } + .offline-badge { padding: 4px 6px; font-size: 0; } + .header-button { width: 36px; justify-content: center; padding: 0; } + .provider-control select { max-width: 105px; } + .model-picker > summary { min-width: 132px; max-width: 170px; } + .model-menu { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); } + .local-key-menu > div { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); } + .generation-menu > div { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); overflow-y: auto; } + .history-sidebar { position: fixed; z-index: 40; top: 0; width: min(300px, 86vw); border-right: 1px solid var(--line); } + .history-heading { height: 58px; } + .history-close { display: grid !important; } + .history-backdrop { position: fixed; z-index: 35; inset: 0; display: block; border: 0; background: rgba(0,0,0,.36); opacity: 0; pointer-events: none; transition: opacity .18s ease; } + .history-backdrop.open { opacity: 1; pointer-events: auto; } + .with-history.history-open .thread-root { padding-left: 0; } + .thread-viewport { padding-inline: 12px; } + .welcome { padding-bottom: 170px; } + .user-content { max-width: 92%; } + .thread-footer { padding-bottom: max(10px, env(safe-area-inset-bottom)); } + .composer-note { display: none; } +} + +@media (prefers-color-scheme: dark) { + :root { color-scheme: dark; --bg: #111210; --panel: rgba(22,23,21,.88); --text: #f2f2ee; --muted: #a1a19a; --line: rgba(255,255,255,.12); --soft: #242521; --accent: #f0f0ec; --accent-text: #171717; } + .app-shell { background: radial-gradient(circle at 50% -20%, #272824 0, var(--bg) 42%); } + .header-button, .local-key-menu > summary, .local-key-menu > div, .generation-menu > summary, .generation-menu > div, .model-picker > summary, .model-menu, .composer, .history-sidebar { background: rgba(31,32,29,.9); } + .model-picker[open] > summary { border-color: rgba(255,255,255,.24); background: #292a26; } + .header-button:hover, .local-key-menu > summary:hover { background: #292a26; } + .assistant-content pre { background: #080908; } + .state-page { background: radial-gradient(circle at 50% 10%, #272824, var(--bg) 52%); } + .brand-mark, .welcome-mark, .state-mark, .primary-link { background: #efefeb; color: #171717; } + .loader { border-top-color: #efefeb; } +} diff --git a/chat/tsconfig.json b/chat/tsconfig.json new file mode 100644 index 0000000..c4f573e --- /dev/null +++ b/chat/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowJs": false, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "lib/**/*.ts", "build.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/cloudflared/compose.yml b/cloudflared/compose.yml index 503d194..5508a53 100644 --- a/cloudflared/compose.yml +++ b/cloudflared/compose.yml @@ -1,12 +1,25 @@ services: cloudflared: - image: cloudflare/cloudflared:2025.5.0 + image: cloudflare/cloudflared:2026.7.3 container_name: cloudflared restart: unless-stopped command: tunnel --protocol http2 run volumes: - ./config.yml:/etc/cloudflared/config.yml:ro - ./credentials.json:/etc/cloudflared/credentials.json:ro + labels: + - "xiteng.site.component.cloudflare-tunnel.enabled=true" + - "xiteng.site.component.cloudflare-tunnel.name=Cloudflare Tunnel" + - "xiteng.site.component.cloudflare-tunnel.description=将 xiteng.site 与通配子域的公网流量送入 Traefik。" + - "xiteng.site.component.cloudflare-tunnel.section=infrastructure" + - "xiteng.site.component.cloudflare-tunnel.category=边缘与网络" + - "xiteng.site.component.cloudflare-tunnel.endpoint=*.xiteng.site → Traefik" + - "xiteng.site.component.cloudflare-tunnel.access=internal" + - "xiteng.site.component.cloudflare-tunnel.access-label=基础设施" + - "xiteng.site.component.cloudflare-tunnel.icon=CF" + - "xiteng.site.component.cloudflare-tunnel.icon-url=https://cdn.simpleicons.org/cloudflare" + - "xiteng.site.component.cloudflare-tunnel.accent=yellow" + - "xiteng.site.component.cloudflare-tunnel.order=100" networks: - homelab_net diff --git a/code-server/compose.yml b/code-server/compose.yml new file mode 100644 index 0000000..44bd42d --- /dev/null +++ b/code-server/compose.yml @@ -0,0 +1,62 @@ +services: + code-server: + image: codercom/code-server:4.132.0-39 + container_name: code-server + restart: unless-stopped + user: "${CODE_SERVER_UID:-1000}:${CODE_SERVER_GID:-1000}" + environment: + DOCKER_USER: ${CODE_SERVER_USER:-coder} + command: + - --bind-addr + - 0.0.0.0:8080 + - --auth + - none + - /home/coder/homelab + volumes: + - ./config:/home/coder/.config:rw + - ./local:/home/coder/.local:rw + - ../:/home/coder/homelab:rw + networks: + - homelab_net + labels: + # Traefik + - "traefik.enable=true" + - "traefik.http.routers.code-server.rule=Host(`code.xiteng.site`)" + - "traefik.http.routers.code-server.entrypoints=websecure" + - "traefik.http.routers.code-server.tls=true" + - "traefik.http.routers.code-server.tls.certresolver=cfresolver" + - "traefik.http.routers.code-server.service=code-server" + - "traefik.http.routers.code-server.middlewares=code-server-scheme,code-server-auth" + - "traefik.http.services.code-server.loadbalancer.server.port=8080" + - "traefik.http.routers.code-server-http.rule=Host(`code.xiteng.site`)" + - "traefik.http.routers.code-server-http.entrypoints=web" + - "traefik.http.routers.code-server-http.service=code-server" + - "traefik.http.routers.code-server-http.middlewares=code-server-scheme,code-server-auth" + - "xiteng.site.cache.code-server.enabled=true" + - "xiteng.site.cache.code-server.routers=code-server,code-server-http" + - "xiteng.site.cache.code-server.paths=/assets/,/static/" + - "xiteng.site.cache.code-server.edge-ttl=604800" + - "xiteng.site.cache.code-server.stale-while-revalidate=86400" + # Authentik ForwardAuth + - "traefik.http.middlewares.code-server-scheme.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.code-server-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.code-server-auth.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.code-server-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name" + - "xiteng.site.component.code-server.enabled=true" + - "xiteng.site.component.code-server.name=Code Server" + - "xiteng.site.component.code-server.description=浏览器中的 VS Code 工作台,用于维护 Homelab 配置与脚本。" + - "xiteng.site.component.code-server.section=services" + - "xiteng.site.component.code-server.category=开发与协作" + - "xiteng.site.component.code-server.url=https://code.xiteng.site" + - "xiteng.site.component.code-server.access=sso" + - "xiteng.site.component.code-server.access-label=需要 Authentik" + - "xiteng.site.component.code-server.icon=CS" + - "xiteng.site.component.code-server.icon-url=https://cdn.simpleicons.org/coder" + - "xiteng.site.component.code-server.accent=blue" + - "xiteng.site.component.code-server.order=130" + - "xiteng.site.component.code-server.monitor.enabled=true" + - "xiteng.site.component.code-server.monitor.url=http://code-server:8080/healthz" + +networks: + homelab_net: + external: true diff --git a/comfyui/Dockerfile b/comfyui/Dockerfile new file mode 100644 index 0000000..af7dc9f --- /dev/null +++ b/comfyui/Dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.nju.edu.cn/lecode-official/comfyui-docker:0.6.3-comfyui-0.8.2-comfyui-manager-4.0.5-pytorch-2.9.1-cuda-12.8-cudnn-9 + +USER root +WORKDIR / + + +RUN rm -rf /opt/comfyui /opt/comfyui-manager \ + && git clone --depth 1 --branch v0.31.0 https://github.com/Comfy-Org/ComfyUI.git /opt/comfyui \ + && git clone --depth 1 --branch 4.2.2 https://github.com/Comfy-Org/ComfyUI-Manager.git /opt/comfyui-manager + +RUN /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui/requirements.txt \ + && if [ -f /opt/comfyui-manager/requirements.txt ]; then /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui-manager/requirements.txt; fi + +WORKDIR /opt/comfyui diff --git a/comfyui/compose.gpu.yml b/comfyui/compose.gpu.yml new file mode 100644 index 0000000..7429a1b --- /dev/null +++ b/comfyui/compose.gpu.yml @@ -0,0 +1,10 @@ +services: + comfyui: + command: ["--enable-manager"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] diff --git a/comfyui/compose.yml b/comfyui/compose.yml new file mode 100644 index 0000000..5071eee --- /dev/null +++ b/comfyui/compose.yml @@ -0,0 +1,62 @@ +services: + comfyui: + build: + context: . + image: xiteng-comfyui:0.31.0-manager-4.2.2 + container_name: comfyui + restart: unless-stopped + entrypoint: ["/bin/bash", "/entrypoint-patched.sh"] + environment: + USER_ID: ${COMFYUI_USER_ID:-1000} + GROUP_ID: ${COMFYUI_GROUP_ID:-1000} + PYTHONPATH: /opt/comfyui-manager + command: ["--cpu", "--enable-manager"] + volumes: + - ./entrypoint.sh:/entrypoint-patched.sh:ro + - ./models:/opt/comfyui/models:rw + - ./custom_nodes:/opt/comfyui/custom_nodes:rw + - ./output:/opt/comfyui/output:rw + networks: + - homelab_net + labels: + # Traefik + - "traefik.enable=true" + - "traefik.http.routers.comfyui.rule=Host(`comfy.xiteng.site`)" + - "traefik.http.routers.comfyui.entrypoints=websecure" + - "traefik.http.routers.comfyui.tls=true" + - "traefik.http.routers.comfyui.tls.certresolver=cfresolver" + - "traefik.http.routers.comfyui.service=comfyui" + - "traefik.http.routers.comfyui.middlewares=comfyui-scheme,comfyui-auth" + - "traefik.http.services.comfyui.loadbalancer.server.port=8188" + - "traefik.http.routers.comfyui-http.rule=Host(`comfy.xiteng.site`)" + - "traefik.http.routers.comfyui-http.entrypoints=web" + - "traefik.http.routers.comfyui-http.service=comfyui" + - "traefik.http.routers.comfyui-http.middlewares=comfyui-scheme,comfyui-auth" + - "xiteng.site.cache.comfyui.enabled=true" + - "xiteng.site.cache.comfyui.routers=comfyui,comfyui-http" + - "xiteng.site.cache.comfyui.paths=/assets/,/static/" + - "xiteng.site.cache.comfyui.edge-ttl=604800" + - "xiteng.site.cache.comfyui.stale-while-revalidate=86400" + # Authentik ForwardAuth + - "traefik.http.middlewares.comfyui-scheme.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.comfyui-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.comfyui-auth.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.comfyui-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name" + - "xiteng.site.component.comfyui.enabled=true" + - "xiteng.site.component.comfyui.name=ComfyUI" + - "xiteng.site.component.comfyui.description=节点式图像生成、模型与工作流实验环境。" + - "xiteng.site.component.comfyui.section=services" + - "xiteng.site.component.comfyui.category=AI" + - "xiteng.site.component.comfyui.url=https://comfy.xiteng.site" + - "xiteng.site.component.comfyui.access=sso" + - "xiteng.site.component.comfyui.access-label=需要 Authentik" + - "xiteng.site.component.comfyui.icon=CU" + - "xiteng.site.component.comfyui.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg" + - "xiteng.site.component.comfyui.accent=red" + - "xiteng.site.component.comfyui.order=210" + - "xiteng.site.component.comfyui.monitor.enabled=true" + - "xiteng.site.component.comfyui.monitor.url=http://comfyui:8188" + +networks: + homelab_net: + external: true diff --git a/comfyui/entrypoint.sh b/comfyui/entrypoint.sh new file mode 100644 index 0000000..36227b6 --- /dev/null +++ b/comfyui/entrypoint.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -e + +echo "Creating directories for models..." +MODEL_DIRECTORIES=( + "checkpoints" + "clip" + "clip_vision" + "configs" + "controlnet" + "diffusers" + "diffusion_models" + "embeddings" + "gligen" + "hypernetworks" + "loras" + "photomaker" + "style_models" + "text_encoders" + "unet" + "upscale_models" + "vae" + "vae_approx" +) +for MODEL_DIRECTORY in "${MODEL_DIRECTORIES[@]}"; do + mkdir -p "/opt/comfyui/models/$MODEL_DIRECTORY" +done + +echo "Preparing ComfyUI Manager..." +export PYTHONPATH="/opt/comfyui-manager${PYTHONPATH:+:$PYTHONPATH}" +rm --force /opt/comfyui/custom_nodes/ComfyUI-Manager +ln -s /opt/comfyui-manager /opt/comfyui/custom_nodes/ComfyUI-Manager +mkdir -p /opt/comfyui/user/__manager +python - <<'PY' +from configparser import ConfigParser +from pathlib import Path + +config_path = Path("/opt/comfyui/user/__manager/config.ini") +config = ConfigParser(strict=False) +config.read(config_path) +if "default" not in config: + config["default"] = {} + +defaults = { + "git_exe": "", + "use_uv": "True", + "channel_url": "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main", + "share_option": "all", + "bypass_ssl": "False", + "file_logging": "True", + "update_policy": "stable-comfyui", + "windows_selector_event_loop_policy": "False", + "model_download_by_agent": "False", + "downgrade_blacklist": "", + "security_level": "normal", + "always_lazy_install": "False", + "verbose": "False", +} +for key, value in defaults.items(): + config["default"].setdefault(key, value) + +config["default"]["network_mode"] = "offline" +config["default"]["db_mode"] = "local" + +with config_path.open("w", encoding="utf-8") as handle: + config.write(handle) +PY + +echo "Installing requirements for custom nodes..." +for CUSTOM_NODE_DIRECTORY in /opt/comfyui/custom_nodes/*; do + if [ "$CUSTOM_NODE_DIRECTORY" != "/opt/comfyui/custom_nodes/ComfyUI-Manager" ]; then + if [ -f "$CUSTOM_NODE_DIRECTORY/requirements.txt" ]; then + CUSTOM_NODE_NAME=${CUSTOM_NODE_DIRECTORY##*/} + CUSTOM_NODE_NAME=${CUSTOM_NODE_NAME//[-_]/ } + echo "Installing requirements for $CUSTOM_NODE_NAME..." + pip install --requirement "$CUSTOM_NODE_DIRECTORY/requirements.txt" + fi + fi +done + +if [ -z "$USER_ID" ] || [ -z "$GROUP_ID" ]; then + echo "Running container as $USER..." + exec /opt/conda/bin/python main.py \ + --port 8188 \ + --listen 0.0.0.0 \ + --disable-auto-launch \ + "$@" +else + echo "Creating non-root user..." + getent group "$GROUP_ID" > /dev/null 2>&1 || groupadd --gid "$GROUP_ID" comfyui-user + id -u "$USER_ID" > /dev/null 2>&1 || useradd --uid "$USER_ID" --gid "$GROUP_ID" --create-home comfyui-user + chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui + chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui-manager + export PATH=$PATH:/home/comfyui-user/.local/bin + + echo "Running container as comfyui-user ($USER_ID:$GROUP_ID)..." + sudo --set-home --preserve-env=PATH,PYTHONPATH --user "#$USER_ID" \ + /opt/conda/bin/python main.py \ + --port 8188 \ + --listen 0.0.0.0 \ + --disable-auto-launch \ + "$@" +fi diff --git a/compose.yml b/compose.yml index c569fa8..0a99166 100644 --- a/compose.yml +++ b/compose.yml @@ -2,6 +2,4 @@ networks: homelab_net: driver: bridge -services: - # 这里只定义网络和基础服务,具体服务在各自子目录 - # 可扩展如 traefik/nginx-proxy-manager 等 +services: {} diff --git a/deck/compose.yml b/deck/compose.yml new file mode 100644 index 0000000..9193322 --- /dev/null +++ b/deck/compose.yml @@ -0,0 +1,45 @@ +services: + deck: + image: nginx:alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752 + container_name: deck + restart: unless-stopped + volumes: + - ./index.html:/usr/share/nginx/html/index.html:ro + networks: + - homelab_net + labels: + # Traefik + - "traefik.enable=true" + - "traefik.http.routers.deck.rule=Host(`deck.xiteng.site`)" + - "traefik.http.routers.deck.entrypoints=websecure" + - "traefik.http.routers.deck.tls=true" + - "traefik.http.routers.deck.tls.certresolver=cfresolver" + - "traefik.http.routers.deck.service=deck" + - "traefik.http.services.deck.loadbalancer.server.port=80" + - "traefik.http.routers.deck-http.rule=Host(`deck.xiteng.site`)" + - "traefik.http.routers.deck-http.service=deck" + - "traefik.http.routers.deck-http.entrypoints=web" + - "xiteng.site.cache.deck.enabled=true" + - "xiteng.site.cache.deck.routers=deck,deck-http" + - "xiteng.site.cache.deck.paths=/assets/,/static/,/css/,/js/" + - "xiteng.site.cache.deck.edge-ttl=604800" + - "xiteng.site.cache.deck.stale-while-revalidate=86400" + - "xiteng.site.component.deck.enabled=true" + - "xiteng.site.component.deck.name=Xiteng Deck" + - "xiteng.site.component.deck.description=介绍个人基础设施与 xiteng.site 的公开演示文稿。" + - "xiteng.site.component.deck.section=services" + - "xiteng.site.component.deck.category=作品与实验" + - "xiteng.site.component.deck.url=https://deck.xiteng.site" + - "xiteng.site.component.deck.access=public" + - "xiteng.site.component.deck.access-label=无需登录" + - "xiteng.site.component.deck.icon=DK" + - "xiteng.site.component.deck.accent=red" + - "xiteng.site.component.deck.order=310" + - "xiteng.site.component.deck.navigation=new-tab" + - "xiteng.site.component.deck.portal-link=embedded" + - "xiteng.site.component.deck.monitor.enabled=true" + - "xiteng.site.component.deck.monitor.url=http://deck:80" + +networks: + homelab_net: + external: true diff --git a/deck/index.html b/deck/index.html new file mode 100644 index 0000000..0368b82 --- /dev/null +++ b/deck/index.html @@ -0,0 +1,522 @@ + + + + + +xiteng.site — Personal Infrastructure + + + + + + + + +
+
← xiteng.site all systems operational
+
UTC+8 · Arch Linux · Docker · UP 42d
+
+ +
+ + +
+
+ // Personal Infrastructure +

xiteng.site

+

+ 自建开发基础设施。一台 Linux 机器,Docker Compose 驱动, + Cloudflare Tunnel 接入,Authentik 统一认证。 +

+
+
+
8
Services
+
1 host
Arch Linux
+
99.9%
Uptime (30d)
+
+
+
+
ssh aliyun — bash — 80×24
+
+ $ docker compose ps
+ NAME        STATUS
+ gitea       Up 7 days
+ hedgedoc     Up 7 days
+ seaweedfs    Up 7 days
+ uptime-kuma  Up 7 days
+ homepage     Up 7 days
+ authentik    Up 7 days
+ deck        Up 5 minutes
+ cloudflared  Up 7 days

+ $ +
+
+
+
+ + +
+
// How It Works
+

Traffic Flow

+
+
Internet
HTTPS
+
+
Cloudflare
Tunnel · CDN
+
+
Traefik
TLS · Router
+
+
Authentik
SSO · Auth
+
+
Services
8 containers
+
+
+ + +
+
// Services
+

What's Running

+ +
+ + +
+
// Stack
+

Technology

+
+
+
Infra
+
+ Docker Compose + Arch Linux + Cloudflare Tunnel + frp Transit +
+
+
+
Proxy
+
+ Traefik v3 + Let's Encrypt + Cloudflare DNS +
+
+
+
Data
+
+ PostgreSQL + Redis + SeaweedFS +
+
+
+
Connect
+
+ Authentik + Hermes Agent + Feishu Bot +
+
+
+
+ + +
+
+ // Try It +

Open a Terminal

+

访问任意服务,Authentik 统一认证。或输入命令探索。

+
+
+ $ + +
+
+ +
+ + + + + + diff --git a/edge-cache/.gitignore b/edge-cache/.gitignore new file mode 100644 index 0000000..60b8d5d --- /dev/null +++ b/edge-cache/.gitignore @@ -0,0 +1,6 @@ +dynamic/* +!dynamic/.gitkeep +state/* +!state/.gitkeep +secrets/* +!secrets/.gitkeep diff --git a/edge-cache/README.md b/edge-cache/README.md new file mode 100644 index 0000000..00cf0c9 --- /dev/null +++ b/edge-cache/README.md @@ -0,0 +1,66 @@ +# Label-managed edge cache + +Services opt paths into the shared edge cache with Docker labels. The controller discovers the +labels, writes higher-priority Traefik routers, and maintains a single Cloudflare Cache Rule that +makes the declared host/path pairs eligible for edge caching. + +```yaml +labels: + - "xiteng.site.cache.frontend.enabled=true" + - "xiteng.site.cache.frontend.routers=example,example-http" + - "xiteng.site.cache.frontend.paths=/static/,/assets/" + - "xiteng.site.cache.frontend.edge-ttl=604800" + - "xiteng.site.cache.frontend.browser-ttl=0" + - "xiteng.site.cache.frontend.stale-while-revalidate=86400" +``` + +The policy id (`frontend`) only needs to be unique inside one Compose service. `routers` refers to +Traefik Docker router names declared on the same service. When omitted, all explicit HTTP routers on +the service are used. Each router must declare its entrypoints and service so it can be referenced +from the file provider. Existing router middlewares are inherited, so request headers and origin-side +authentication are not silently removed. + +| Field | Default | Behavior | +|---|---:|---| +| `enabled` | required | Only the exact value `true` enables a policy. | +| `routers` | auto | Comma-separated Traefik router names on the same container. | +| `paths` | required | Comma-separated directory prefixes. Each must start and end with `/`; `/`, traversal and encoded paths are rejected. | +| `hosts` | from `Host()` | Optional exact hostnames for complex router rules; hosts must be inside `xiteng.site`. | +| `edge-ttl` | `604800` | Cloudflare TTL in seconds, bounded to 60 seconds–365 days. | +| `browser-ttl` | `0` | When non-zero, also overrides the browser `Cache-Control` TTL. | +| `stale-while-revalidate` | `86400` | Stale revalidation window in seconds. | + +The generated router only matches `GET` and `HEAD`, has priority 1000 above its base router, and adds +`Cloudflare-CDN-Cache-Control`. Cloudflare's managed rule makes extensionless files eligible for cache, +respects the TTL header for successful responses, and gives 3xx–5xx responses a zero edge TTL. + +Declaring a path static is a security decision: every response below the prefix must be public and +independent of users, cookies, authorization headers, and request-specific data. Never label HTML, +API, callback, admin, download-token, or user-content paths. A Cloudflare cache hit is served before +Traefik/ForwardAuth, even though cache misses still inherit the base router's middlewares. +Removing a policy does not revoke copies already held by browsers or edge locations; choose bounded +TTLs and purge Cloudflare explicitly when previously public content must be withdrawn immediately. + +## Cloudflare token + +Place an untracked API token at `secrets/cloudflare_cache_api_token`. It needs access to the +`xiteng.site` zone and the permissions required to edit Cache Rules. The existing DNS-01 token is +intentionally not reused. The controller remains healthy without this file and reports +`waiting-for-token`; in that state Traefik headers and default-extension caching work, but arbitrary +extensionless URLs are not guaranteed to be cached by Cloudflare. + +Create the token in Cloudflare, then install it without exposing it in shell history or chat: + +```sh +./edge-cache/configure-token +``` + +Inspect the generated rule without contacting Cloudflare: + +```sh +docker compose -f edge-cache/compose.yml exec controller \ + node /app/cloudflare.mjs plan +``` + +The live controller automatically creates or updates only the rule whose description is +`Xiteng label-managed static paths`; it preserves all unrelated Cache Rules. diff --git a/edge-cache/cloudflare.mjs b/edge-cache/cloudflare.mjs new file mode 100644 index 0000000..a8370bf --- /dev/null +++ b/edge-cache/cloudflare.mjs @@ -0,0 +1,19 @@ +import {readFileSync} from "node:fs"; +import {cloudflareRuleFromSpec, syncCloudflareRule} from "./controller.mjs"; + +const command = process.argv[2] || "plan"; +const specPath = process.env.CLOUDFLARE_SPEC_PATH || "/state/cloudflare-cache-rules.json"; +const tokenPath = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token"; +const zoneId = process.env.CLOUDFLARE_ZONE_ID || ""; +const spec = JSON.parse(readFileSync(specPath, "utf8")); + +if (command === "plan") { + console.log(JSON.stringify(cloudflareRuleFromSpec(spec), null, 2)); +} else if (["check", "apply"].includes(command)) { + const token = readFileSync(tokenPath, "utf8").trim(); + const result = await syncCloudflareRule(spec, {zoneId, token, dryRun: command === "check"}); + console.log(JSON.stringify(result, null, 2)); +} else { + console.error("Usage: node cloudflare.mjs [plan|check|apply]"); + process.exit(2); +} diff --git a/edge-cache/compose.yml b/edge-cache/compose.yml new file mode 100644 index 0000000..312c2b9 --- /dev/null +++ b/edge-cache/compose.yml @@ -0,0 +1,52 @@ +services: + controller: + image: node:24-alpine + container_name: xiteng-edge-cache + restart: unless-stopped + command: ["node", "/app/controller.mjs"] + environment: + PORT: 8094 + DOCKER_SOCKET: /var/run/docker.sock + OUTPUT_DIRECTORY: /dynamic + STATE_DIRECTORY: /state + DISCOVERY_INTERVAL_MS: 5000 + CLOUDFLARE_SYNC_INTERVAL_MS: 60000 + CLOUDFLARE_ZONE_NAME: xiteng.site + CLOUDFLARE_ZONE_ID: c1cce0c846ccc0d2138b9b79c5e91e9f + CLOUDFLARE_API_TOKEN_FILE: /run/secrets/cloudflare_cache_api_token + volumes: + - ./controller.mjs:/app/controller.mjs:ro + - ./cloudflare.mjs:/app/cloudflare.mjs:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./dynamic:/dynamic + - ./state:/state + - ./secrets:/run/secrets:ro + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8094/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + labels: + - "traefik.enable=false" + - "xiteng.site.component.edge-cache.enabled=true" + - "xiteng.site.component.edge-cache.name=Edge Cache Controller" + - "xiteng.site.component.edge-cache.description=将服务声明的静态路径编译为 Traefik 路由与 Cloudflare Cache Rule。" + - "xiteng.site.component.edge-cache.section=infrastructure" + - "xiteng.site.component.edge-cache.category=边缘与网络" + - "xiteng.site.component.edge-cache.access=internal" + - "xiteng.site.component.edge-cache.access-label=仅容器网络" + - "xiteng.site.component.edge-cache.icon=EC" + - "xiteng.site.component.edge-cache.accent=blue" + - "xiteng.site.component.edge-cache.order=115" + networks: + - homelab_net + +networks: + homelab_net: + external: true diff --git a/edge-cache/configure-token b/edge-cache/configure-token new file mode 100755 index 0000000..972584b --- /dev/null +++ b/edge-cache/configure-token @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +EDGE_CACHE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +EDGE_CACHE_TOKEN_FILE="$EDGE_CACHE_DIR/secrets/cloudflare_cache_api_token" +EDGE_CACHE_TOKEN_TMP="$EDGE_CACHE_TOKEN_FILE.tmp.$$" + +if [ ! -t 0 ]; then + echo "Refusing to read a Cloudflare token from a non-interactive stdin." >&2 + exit 1 +fi + +printf 'Cloudflare Cache Rules API token: ' > /dev/tty +stty -echo < /dev/tty +IFS= read -r EDGE_CACHE_TOKEN < /dev/tty || true +stty echo < /dev/tty +printf '\n' > /dev/tty + +if [ "${#EDGE_CACHE_TOKEN}" -lt 20 ]; then + echo "Token is missing or unexpectedly short." >&2 + exit 1 +fi + +umask 077 +printf '%s\n' "$EDGE_CACHE_TOKEN" > "$EDGE_CACHE_TOKEN_TMP" +mv "$EDGE_CACHE_TOKEN_TMP" "$EDGE_CACHE_TOKEN_FILE" +unset EDGE_CACHE_TOKEN + +echo "Token installed with mode 600. The running controller will sync the managed Cache Rule automatically." diff --git a/edge-cache/controller.mjs b/edge-cache/controller.mjs new file mode 100644 index 0000000..bd6e6d3 --- /dev/null +++ b/edge-cache/controller.mjs @@ -0,0 +1,509 @@ +import http from "node:http"; +import {createHash} from "node:crypto"; +import {mkdirSync, readFileSync, renameSync, writeFileSync} from "node:fs"; + +const labelPrefix = "xiteng.site.cache."; +const allowedFields = new Set([ + "enabled", + "routers", + "hosts", + "paths", + "edge-ttl", + "browser-ttl", + "stale-while-revalidate" +]); +const managedRuleDescription = "Xiteng label-managed static paths"; + +function boundedInteger(value, fallback, minimum, maximum) { + const parsed = Number.parseInt(value || "", 10); + return Number.isFinite(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback; +} + +function csv(value) { + return [...new Set(String(value || "").split(",").map((item) => item.trim()).filter(Boolean))]; +} + +function stableName(...parts) { + const source = parts.join("-").toLowerCase(); + const readable = source.replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48); + const digest = createHash("sha256").update(source).digest("hex").slice(0, 10); + return `${readable || "cache"}-${digest}`; +} + +function normalizedLabels(labels) { + return Object.fromEntries(Object.entries(labels || {}).map(([key, value]) => [key.toLowerCase(), String(value)])); +} + +function normalizePathPrefix(value) { + const path = String(value || "").trim(); + if (!path.startsWith("/") || !path.endsWith("/") || path === "/") { + throw new Error(`static path must start and end with / and cannot be root: ${path || "(empty)"}`); + } + if (path.includes("..") || path.includes("//") || !/^\/[A-Za-z0-9._~/-]+\/$/.test(path)) { + throw new Error(`static path contains unsafe characters: ${path}`); + } + return path; +} + +function normalizeHost(value, zoneName = "") { + const host = String(value || "").trim().toLowerCase().replace(/\.$/, ""); + if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host)) { + throw new Error(`invalid hostname: ${value}`); + } + if (zoneName && host !== zoneName && !host.endsWith(`.${zoneName}`)) { + throw new Error(`hostname is outside ${zoneName}: ${host}`); + } + return host; +} + +function extractHosts(rule, zoneName) { + const hosts = []; + for (const match of String(rule || "").matchAll(/\bHost\(([^)]*)\)/g)) { + for (const quoted of match[1].matchAll(/`([^`]+)`/g)) { + hosts.push(normalizeHost(quoted[1], zoneName)); + } + } + return [...new Set(hosts)]; +} + +function qualifyReference(value, provider) { + const reference = String(value || "").trim(); + return !reference || reference.includes("@") ? reference : `${reference}@${provider}`; +} + +export function definitionsFromLabels(rawLabels) { + const labels = normalizedLabels(rawLabels); + const definitions = new Map(); + for (const [key, value] of Object.entries(labels)) { + if (!key.startsWith(labelPrefix)) { + continue; + } + const remainder = key.slice(labelPrefix.length); + const separator = remainder.indexOf("."); + const id = remainder.slice(0, separator); + const field = remainder.slice(separator + 1); + if (separator <= 0 || !/^[a-z0-9][a-z0-9-]*$/.test(id) || !allowedFields.has(field)) { + continue; + } + if (!definitions.has(id)) { + definitions.set(id, {}); + } + definitions.get(id)[field] = value.trim(); + } + return definitions; +} + +function routerNames(labels) { + const names = new Set(); + for (const key of Object.keys(labels)) { + const match = key.match(/^traefik\.http\.routers\.([a-z0-9-]+)\.rule$/); + if (match) { + names.add(match[1]); + } + } + return [...names].sort(); +} + +function serviceNames(labels) { + const names = new Set(); + for (const key of Object.keys(labels)) { + const match = key.match(/^traefik\.http\.services\.([a-z0-9-]+)\.loadbalancer\.server\.port$/); + if (match) { + names.add(match[1]); + } + } + return [...names]; +} + +function cloudflareExpression(policies) { + const clauses = []; + for (const policy of policies) { + const hosts = policy.hosts.map((host) => `http.host eq ${JSON.stringify(host)}`); + const paths = policy.paths.map((path) => `starts_with(http.request.uri.path, ${JSON.stringify(path)})`); + clauses.push(`((${hosts.join(" or ")}) and (${paths.join(" or ")}))`); + } + return clauses.length ? `(${clauses.join(" or ")})` : "(http.host eq \"cache-disabled.invalid\")"; +} + +export function buildConfiguration(containers, {zoneName = "xiteng.site"} = {}) { + const routers = {}; + const middlewares = {}; + const cloudflarePolicies = []; + const errors = []; + const seenPolicies = new Set(); + + for (const container of containers || []) { + const labels = normalizedLabels(container.Labels); + if (labels["traefik.enable"] !== "true") { + continue; + } + const project = labels["com.docker.compose.project"] || "docker"; + const service = labels["com.docker.compose.service"] || String(container.Names?.[0] || container.Id || "service").replace(/^\//, ""); + const definitions = definitionsFromLabels(labels); + + for (const [policyId, definition] of definitions) { + if (definition.enabled !== "true") { + continue; + } + const policyKey = `${project}/${service}/${policyId}`; + if (seenPolicies.has(policyKey)) { + continue; + } + seenPolicies.add(policyKey); + + try { + const paths = csv(definition.paths).map(normalizePathPrefix).sort(); + if (!paths.length) { + throw new Error("at least one static path is required"); + } + const selectedRouters = (csv(definition.routers).length ? csv(definition.routers) : routerNames(labels)).sort(); + if (!selectedRouters.length) { + throw new Error("no Traefik routers found; set .routers explicitly"); + } + const edgeTtl = boundedInteger(definition["edge-ttl"], 604800, 60, 31536000); + const browserTtl = boundedInteger(definition["browser-ttl"], 0, 0, 31536000); + const staleWhileRevalidate = boundedInteger(definition["stale-while-revalidate"], 86400, 0, 604800); + const middlewareName = `xiteng-static-${stableName(policyKey)}`; + const responseHeaders = { + "Cloudflare-CDN-Cache-Control": `public, s-maxage=${edgeTtl}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ""}`, + "X-Xiteng-Cache-Policy": policyKey + }; + if (browserTtl > 0) { + responseHeaders["Cache-Control"] = `public, max-age=${browserTtl}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ""}`; + } + const policyRouters = {}; + const discoveredHosts = new Set(csv(definition.hosts).map((host) => normalizeHost(host, zoneName))); + for (const baseRouter of selectedRouters) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(baseRouter)) { + throw new Error(`invalid router name: ${baseRouter}`); + } + const routerPrefix = `traefik.http.routers.${baseRouter}.`; + const baseRule = labels[`${routerPrefix}rule`]; + if (!baseRule) { + throw new Error(`router ${baseRouter} has no rule on this service`); + } + for (const host of extractHosts(baseRule, zoneName)) { + discoveredHosts.add(host); + } + const entryPoints = csv(labels[`${routerPrefix}entrypoints`]); + if (!entryPoints.length) { + throw new Error(`router ${baseRouter} must declare entrypoints`); + } + let targetService = labels[`${routerPrefix}service`]; + if (!targetService) { + const candidates = serviceNames(labels); + if (candidates.length !== 1) { + throw new Error(`router ${baseRouter} must declare its Traefik service`); + } + [targetService] = candidates; + } + const inheritedMiddlewares = csv(labels[`${routerPrefix}middlewares`]).map((item) => qualifyReference(item, "docker")); + const generatedRouter = { + rule: `(${baseRule}) && (Method(\`GET\`) || Method(\`HEAD\`)) && (${paths.map((path) => `PathPrefix(\`${path}\`)`).join(" || ")})`, + entryPoints, + service: qualifyReference(targetService, "docker"), + // Traefik runs response middleware in reverse order. Put the cache + // middleware first so it is the final writer of cache headers. + middlewares: [middlewareName, ...inheritedMiddlewares], + priority: boundedInteger(labels[`${routerPrefix}priority`], 0, 0, 1000000) + 1000 + }; + if (labels[`${routerPrefix}tls`] && labels[`${routerPrefix}tls`] !== "false") { + generatedRouter.tls = {}; + if (labels[`${routerPrefix}tls.certresolver`]) { + generatedRouter.tls.certResolver = labels[`${routerPrefix}tls.certresolver`]; + } + if (labels[`${routerPrefix}tls.options`]) { + generatedRouter.tls.options = qualifyReference(labels[`${routerPrefix}tls.options`], "docker"); + } + } + policyRouters[`xiteng-static-${stableName(policyKey, baseRouter)}`] = generatedRouter; + } + if (!discoveredHosts.size) { + throw new Error("no exact Host() value found; set .hosts explicitly"); + } + middlewares[middlewareName] = {headers: {customResponseHeaders: responseHeaders}}; + Object.assign(routers, policyRouters); + cloudflarePolicies.push({ + id: policyKey, + hosts: [...discoveredHosts].sort(), + paths + }); + } catch (error) { + errors.push(`${policyKey}: ${error.message}`); + } + } + } + + cloudflarePolicies.sort((left, right) => left.id.localeCompare(right.id)); + return { + traefik: {http: {routers, middlewares}}, + cloudflare: { + version: 1, + zone: zoneName, + description: managedRuleDescription, + expression: cloudflareExpression(cloudflarePolicies), + policies: cloudflarePolicies + }, + errors, + policyCount: cloudflarePolicies.length, + routerCount: Object.keys(routers).length + }; +} + +export function cloudflareRuleFromSpec(spec) { + return { + action: "set_cache_settings", + action_parameters: { + cache: true, + edge_ttl: { + mode: "respect_origin", + status_code_ttl: [ + {status_code_range: {to: 199}, value: 0}, + {status_code_range: {from: 300, to: 499}, value: 0}, + {status_code_range: {from: 500}, value: -1} + ] + }, + browser_ttl: {mode: "respect_origin"}, + cache_key: {cache_deception_armor: true}, + serve_stale: {disable_stale_while_updating: false} + }, + expression: spec.expression, + description: managedRuleDescription, + enabled: Array.isArray(spec.policies) && spec.policies.length > 0 + }; +} + +async function cloudflareRequest(path, {token, method = "GET", body} = {}) { + const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(15000) + }); + const payload = await response.json(); + if (!response.ok || !payload.success) { + const detail = (payload.errors || []).map((error) => `${error.code}: ${error.message}`).join("; "); + const error = new Error(`Cloudflare API ${response.status}${detail ? ` (${detail})` : ""}`); + error.status = response.status; + throw error; + } + return payload.result; +} + +function canonicalizeJson(value) { + if (Array.isArray(value)) { + return value.map(canonicalizeJson); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalizeJson(value[key])]) + ); + } + return value; +} + +function comparableRule(rule) { + return JSON.stringify(canonicalizeJson({ + action: rule.action, + action_parameters: rule.action_parameters, + expression: rule.expression, + description: rule.description, + enabled: rule.enabled !== false + })); +} + +export async function syncCloudflareRule(spec, {zoneId, token, dryRun = false} = {}) { + if (!zoneId || !token) { + throw new Error("Cloudflare zone ID and API token are required"); + } + const desired = cloudflareRuleFromSpec(spec); + let ruleset; + try { + ruleset = await cloudflareRequest(`/zones/${zoneId}/rulesets/phases/http_request_cache_settings/entrypoint`, {token}); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + const current = ruleset?.rules?.find((rule) => rule.description === managedRuleDescription); + if (current && comparableRule(current) === comparableRule(desired)) { + return {changed: false, action: "unchanged", ruleId: current.id}; + } + if (dryRun) { + return {changed: true, action: current ? "update" : "create", desired}; + } + if (!ruleset) { + const created = await cloudflareRequest(`/zones/${zoneId}/rulesets`, { + token, + method: "POST", + body: { + name: "Xiteng label-managed cache rules", + description: managedRuleDescription, + kind: "zone", + phase: "http_request_cache_settings", + rules: [desired] + } + }); + return {changed: true, action: "create-ruleset", ruleId: created.rules?.[0]?.id}; + } + if (!current) { + const created = await cloudflareRequest(`/zones/${zoneId}/rulesets/${ruleset.id}/rules`, { + token, + method: "POST", + body: desired + }); + return {changed: true, action: "create", ruleId: created.id}; + } + const updated = await cloudflareRequest(`/zones/${zoneId}/rulesets/${ruleset.id}/rules/${current.id}`, { + token, + method: "PATCH", + body: desired + }); + return {changed: true, action: "update", ruleId: updated.id}; +} + +function writeIfChanged(path, value) { + let current = ""; + try { + current = readFileSync(path, "utf8"); + } catch { + // The first discovery creates the file. + } + if (current === value) { + return false; + } + const temporary = `${path}.tmp`; + writeFileSync(temporary, value, {mode: 0o644}); + renameSync(temporary, path); + return true; +} + +function dockerGet(socketPath, pathname) { + return new Promise((resolve, reject) => { + const request = http.request({socketPath, path: pathname, method: "GET", headers: {Accept: "application/json"}}, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + if ((response.statusCode || 500) >= 400) { + reject(new Error(`Docker API ${response.statusCode}`)); + return; + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch (error) { + reject(error); + } + }); + }); + request.setTimeout(5000, () => request.destroy(new Error("Docker API timeout"))); + request.on("error", reject); + request.end(); + }); +} + +function readToken(path) { + try { + return readFileSync(path, "utf8").trim(); + } catch { + return ""; + } +} + +export async function runController() { + const port = boundedInteger(process.env.PORT, 8094, 1, 65535); + const socketPath = process.env.DOCKER_SOCKET || "/var/run/docker.sock"; + const outputDirectory = process.env.OUTPUT_DIRECTORY || "/dynamic"; + const stateDirectory = process.env.STATE_DIRECTORY || "/state"; + const intervalMs = boundedInteger(process.env.DISCOVERY_INTERVAL_MS, 5000, 2000, 60000); + const cloudflareIntervalMs = boundedInteger(process.env.CLOUDFLARE_SYNC_INTERVAL_MS, 60000, 10000, 3600000); + const zoneName = process.env.CLOUDFLARE_ZONE_NAME || "xiteng.site"; + const zoneId = process.env.CLOUDFLARE_ZONE_ID || ""; + const tokenFile = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token"; + const traefikPath = `${outputDirectory}/xiteng-static-cache.yml`; + const cloudflarePath = `${stateDirectory}/cloudflare-cache-rules.json`; + const state = { + ready: false, + lastDiscoveryAt: null, + policies: 0, + routers: 0, + errors: [], + cloudflare: {status: "waiting-for-token", lastSyncAt: null, error: null} + }; + let discoveryRunning = false; + let lastCloudflareAttempt = 0; + let lastCloudflareSpec = ""; + mkdirSync(outputDirectory, {recursive: true}); + mkdirSync(stateDirectory, {recursive: true}); + + async function discover() { + if (discoveryRunning) { + return; + } + discoveryRunning = true; + try { + const containers = await dockerGet(socketPath, "/containers/json?all=0"); + const built = buildConfiguration(containers, {zoneName}); + const traefikValue = `${JSON.stringify(built.traefik, null, 2)}\n`; + const cloudflareValue = `${JSON.stringify(built.cloudflare, null, 2)}\n`; + const changed = writeIfChanged(traefikPath, traefikValue); + writeIfChanged(cloudflarePath, cloudflareValue); + state.ready = true; + state.lastDiscoveryAt = new Date().toISOString(); + state.policies = built.policyCount; + state.routers = built.routerCount; + state.errors = built.errors; + if (changed || built.errors.length) { + console.log(JSON.stringify({event: "cache-config", changed, policies: built.policyCount, routers: built.routerCount, errors: built.errors})); + } + + const token = readToken(tokenFile); + const now = Date.now(); + if (!token) { + state.cloudflare.status = "waiting-for-token"; + state.cloudflare.error = null; + } else if (cloudflareValue !== lastCloudflareSpec || now - lastCloudflareAttempt >= cloudflareIntervalMs) { + lastCloudflareAttempt = now; + try { + const result = await syncCloudflareRule(built.cloudflare, {zoneId, token}); + lastCloudflareSpec = cloudflareValue; + state.cloudflare = {status: result.action, lastSyncAt: new Date().toISOString(), error: null}; + if (result.changed) { + console.log(JSON.stringify({event: "cloudflare-cache-rule", action: result.action, policies: built.policyCount})); + } + } catch (error) { + state.cloudflare = {status: "error", lastSyncAt: new Date().toISOString(), error: error.message}; + console.error(JSON.stringify({event: "cloudflare-cache-rule-error", error: error.message})); + } + } + } catch (error) { + state.errors = [error.message]; + console.error(JSON.stringify({event: "cache-controller-error", error: error.message})); + } finally { + discoveryRunning = false; + } + } + + const server = http.createServer((request, response) => { + if (request.url === "/healthz") { + const healthy = state.ready && state.errors.length === 0 && state.cloudflare.status !== "error"; + response.writeHead(healthy ? 200 : 503, {"Content-Type": "application/json", "Cache-Control": "no-store"}); + response.end(JSON.stringify(state)); + return; + } + response.writeHead(404, {"Content-Type": "text/plain", "Cache-Control": "no-store"}); + response.end("Not found"); + }); + server.listen(port, "0.0.0.0"); + await discover(); + setInterval(discover, intervalMs).unref(); +} + +if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) { + runController().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/edge-cache/controller.test.mjs b/edge-cache/controller.test.mjs new file mode 100644 index 0000000..0d3bd93 --- /dev/null +++ b/edge-cache/controller.test.mjs @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {buildConfiguration, cloudflareRuleFromSpec, definitionsFromLabels, syncCloudflareRule} from "./controller.mjs"; + +const labels = { + "com.docker.compose.project": "authentik", + "com.docker.compose.service": "authentik", + "traefik.enable": "true", + "traefik.http.routers.authentik.rule": "Host(`auth.xiteng.site`)", + "traefik.http.routers.authentik.entrypoints": "websecure", + "traefik.http.routers.authentik.service": "authentik", + "traefik.http.routers.authentik.middlewares": "force-https-headers", + "traefik.http.routers.authentik.tls": "true", + "traefik.http.routers.authentik.tls.certresolver": "cfresolver", + "traefik.http.routers.authentik-http.rule": "Host(`auth.xiteng.site`)", + "traefik.http.routers.authentik-http.entrypoints": "web", + "traefik.http.routers.authentik-http.service": "authentik", + "xiteng.site.cache.frontend.enabled": "true", + "xiteng.site.cache.frontend.routers": "authentik,authentik-http", + "xiteng.site.cache.frontend.paths": "/static/dist/,/assets/", + "xiteng.site.cache.frontend.edge-ttl": "604800", + "xiteng.site.cache.frontend.browser-ttl": "3600" +}; + +function reverseObjectKeys(value) { + if (Array.isArray(value)) { + return value.map(reverseObjectKeys); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).reverse().map(([key, nested]) => [key, reverseObjectKeys(nested)]) + ); + } + return value; +} + +test("parses cache definitions without exposing unrelated labels", () => { + const definitions = definitionsFromLabels(labels); + assert.equal(definitions.size, 1); + assert.equal(definitions.get("frontend").paths, "/static/dist/,/assets/"); + assert.equal(definitions.get("frontend").routers, "authentik,authentik-http"); +}); + +test("builds higher-priority GET/HEAD routers and preserves base middleware", () => { + const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"}); + assert.deepEqual(built.errors, []); + assert.equal(built.policyCount, 1); + assert.equal(built.routerCount, 2); + const generated = Object.values(built.traefik.http.routers); + assert.ok(generated.every((router) => router.rule.includes("Method(`GET`)"))); + assert.ok(generated.every((router) => router.rule.includes("PathPrefix(`/static/dist/`)"))); + assert.ok(generated.some((router) => router.middlewares.includes("force-https-headers@docker"))); + assert.ok(generated.every((router) => router.middlewares[0].startsWith("xiteng-static-"))); + assert.ok(generated.some((router) => router.tls?.certResolver === "cfresolver")); + const middleware = Object.values(built.traefik.http.middlewares)[0]; + assert.equal(middleware.headers.customResponseHeaders["Cache-Control"], "public, max-age=3600, stale-while-revalidate=86400"); + assert.match(middleware.headers.customResponseHeaders["Cloudflare-CDN-Cache-Control"], /s-maxage=604800/); +}); + +test("creates one Cloudflare expression from exact hosts and safe path prefixes", () => { + const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"}); + assert.match(built.cloudflare.expression, /http\.host eq "auth\.xiteng\.site"/); + assert.match(built.cloudflare.expression, /starts_with\(http\.request\.uri\.path, "\/assets\/"\)/); + const rule = cloudflareRuleFromSpec(built.cloudflare); + assert.equal(rule.action_parameters.cache, true); + assert.equal(rule.action_parameters.edge_ttl.status_code_ttl[1].value, 0); + assert.equal(rule.enabled, true); +}); + +test("does not update a semantically identical Cloudflare rule when API reorders fields", async () => { + const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"}); + const current = {id: "managed-rule", ...reverseObjectKeys(cloudflareRuleFromSpec(built.cloudflare))}; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify({ + success: true, + result: {id: "cache-ruleset", rules: [current]} + }), {status: 200, headers: {"Content-Type": "application/json"}}); + try { + const result = await syncCloudflareRule(built.cloudflare, {zoneId: "zone", token: "token", dryRun: true}); + assert.deepEqual(result, {changed: false, action: "unchanged", ruleId: "managed-rule"}); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("does not override browser Cache-Control unless browser-ttl is explicit", () => { + const edgeOnly = {...labels}; + delete edgeOnly["xiteng.site.cache.frontend.browser-ttl"]; + const built = buildConfiguration([{Id: "edge-only", Labels: edgeOnly}], {zoneName: "xiteng.site"}); + const headers = Object.values(built.traefik.http.middlewares)[0].headers.customResponseHeaders; + assert.equal(headers["Cache-Control"], undefined); + assert.match(headers["Cloudflare-CDN-Cache-Control"], /s-maxage=604800/); +}); + +test("rejects root and traversal-like cache paths", () => { + for (const paths of ["/", "/static/../admin/", "/static//nested/"]) { + const broken = {...labels, "xiteng.site.cache.frontend.paths": paths}; + const built = buildConfiguration([{Id: paths, Labels: broken}], {zoneName: "xiteng.site"}); + assert.equal(built.policyCount, 0); + assert.equal(built.errors.length, 1); + assert.equal(Object.keys(built.traefik.http.routers).length, 0); + assert.equal(Object.keys(built.traefik.http.middlewares).length, 0); + } +}); + +test("does not let labels cache a hostname outside the managed zone", () => { + const broken = { + ...labels, + "traefik.http.routers.authentik.rule": "Host(`example.com`)", + "traefik.http.routers.authentik-http.rule": "Host(`example.com`)" + }; + const built = buildConfiguration([{Id: "external", Labels: broken}], {zoneName: "xiteng.site"}); + assert.equal(built.policyCount, 0); + assert.match(built.errors[0], /outside xiteng\.site/); +}); diff --git a/edge-cache/dynamic/.gitkeep b/edge-cache/dynamic/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/edge-cache/dynamic/.gitkeep @@ -0,0 +1 @@ + diff --git a/edge-cache/secrets/.gitkeep b/edge-cache/secrets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/edge-cache/secrets/.gitkeep @@ -0,0 +1 @@ + diff --git a/edge-cache/state/.gitkeep b/edge-cache/state/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/edge-cache/state/.gitkeep @@ -0,0 +1 @@ + diff --git a/frpc/docker-compose.yml b/frpc/docker-compose.yml index bd8dc66..c7420ec 100644 --- a/frpc/docker-compose.yml +++ b/frpc/docker-compose.yml @@ -5,6 +5,18 @@ services: restart: unless-stopped volumes: - ./frpc.toml:/etc/frp/frpc.toml:ro + labels: + - "xiteng.site.component.frpc.enabled=true" + - "xiteng.site.component.frpc.name=FRP Client" + - "xiteng.site.component.frpc.description=通过阿里云 VPS 中转 Gitea SSH 流量。" + - "xiteng.site.component.frpc.section=infrastructure" + - "xiteng.site.component.frpc.category=边缘与网络" + - "xiteng.site.component.frpc.endpoint=git.xiteng.site:22" + - "xiteng.site.component.frpc.access=ssh-key" + - "xiteng.site.component.frpc.access-label=需要 SSH Key" + - "xiteng.site.component.frpc.icon=FRP" + - "xiteng.site.component.frpc.accent=blue" + - "xiteng.site.component.frpc.order=120" networks: - homelab_net diff --git a/gitea/compose.yml b/gitea/compose.yml index 7b86487..9531666 100644 --- a/gitea/compose.yml +++ b/gitea/compose.yml @@ -1,8 +1,20 @@ services: db: - image: postgres:15 + image: postgres:15@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425 restart: always env_file: ../.env + labels: + - "xiteng.site.component.gitea-db.enabled=true" + - "xiteng.site.component.gitea-db.name=Gitea PostgreSQL" + - "xiteng.site.component.gitea-db.description=保存仓库平台的账户、Issue、PR 与系统元数据。" + - "xiteng.site.component.gitea-db.section=infrastructure" + - "xiteng.site.component.gitea-db.category=数据层" + - "xiteng.site.component.gitea-db.access=internal" + - "xiteng.site.component.gitea-db.access-label=仅容器网络" + - "xiteng.site.component.gitea-db.icon=PG" + - "xiteng.site.component.gitea-db.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg" + - "xiteng.site.component.gitea-db.accent=blue" + - "xiteng.site.component.gitea-db.order=530" volumes: - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d - ./db_data:/var/lib/postgresql/data @@ -10,7 +22,7 @@ services: - homelab_net gitea: - image: gitea/gitea:latest + image: gitea/gitea:1.27.1 container_name: gitea restart: unless-stopped env_file: ../.env @@ -26,6 +38,7 @@ services: GITEA__server__SSH_DOMAIN: git.xiteng.site volumes: - ./data:/data + - ./custom/extra_links.tmpl:/data/gitea/templates/custom/extra_links.tmpl:ro # 保留 SSH 端口以便通过 SSH 推送代码(可选),移除 HTTP 宿主端口,由 Traefik 暴露 ports: - "3004:3000" @@ -42,23 +55,33 @@ services: # --- 开启 TLS 并指定解析器 --- - "traefik.http.routers.gitea.tls=true" - "traefik.http.routers.gitea.tls.certresolver=cfresolver" + - "xiteng.site.cache.gitea.enabled=true" + - "xiteng.site.cache.gitea.routers=gitea,gitea-http" + - "xiteng.site.cache.gitea.paths=/assets/,/css/,/js/,/images/" + - "xiteng.site.cache.gitea.edge-ttl=604800" + - "xiteng.site.cache.gitea.stale-while-revalidate=86400" # TCP Config (SSH) - "traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)" # SNI must be * for SSH - "traefik.tcp.routers.gitea-ssh.entrypoints=ssh" - "traefik.tcp.services.gitea-ssh.loadbalancer.server.port=22" - # --- 2. Homepage 自动发现设置 --- - - "homepage.group=我的服务" # 分组名称 - - "homepage.name=Gitea" # 显示名称 - - "homepage.icon=gitea" # 图标 (支持 si, mdi 等前缀) - - "homepage.href=https://gitea.xiteng.site" # 点击跳转的链接 - - "homepage.description=代码托管平台" # 副标题描述 - # --- 3. AutoKuma 自动发现设置 --- - # 格式: kuma.<自定义ID>.<监控类型>.<属性> - - "kuma.gitea.http.name=Gitea" # 监控项名称 - - "kuma.gitea.http.url=http://gitea:3000" # 内网直连 - # (可选) 每 60 秒检查一次,重试 3 次 - - "kuma.gitea.http.interval=60" - - "kuma.gitea.http.max_retries=3" + # --- Xiteng Site dynamic catalog --- + - "xiteng.site.component.gitea.enabled=true" + - "xiteng.site.component.gitea.name=Gitea" + - "xiteng.site.component.gitea.description=自托管代码、Issue、Pull Request 与包管理平台。" + - "xiteng.site.component.gitea.section=services" + - "xiteng.site.component.gitea.category=开发与协作" + - "xiteng.site.component.gitea.url=https://gitea.xiteng.site/liooil" + - "xiteng.site.component.gitea.endpoint=git.xiteng.site:22" + - "xiteng.site.component.gitea.access=mixed" + - "xiteng.site.component.gitea.access-label=公开浏览 · 写操作需登录" + - "xiteng.site.component.gitea.icon=GT" + - "xiteng.site.component.gitea.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg" + - "xiteng.site.component.gitea.accent=green" + - "xiteng.site.component.gitea.order=100" + - "xiteng.site.component.gitea.navigation=new-tab" + - "xiteng.site.component.gitea.portal-link=native" + - "xiteng.site.component.gitea.monitor.enabled=true" + - "xiteng.site.component.gitea.monitor.url=http://gitea:3000" networks: - homelab_net diff --git a/gitea/custom/extra_links.tmpl b/gitea/custom/extra_links.tmpl new file mode 100644 index 0000000..0f9e78f --- /dev/null +++ b/gitea/custom/extra_links.tmpl @@ -0,0 +1 @@ +← xiteng.site diff --git a/hedgedoc/compose.yml b/hedgedoc/compose.yml index 4b51696..ed73533 100644 --- a/hedgedoc/compose.yml +++ b/hedgedoc/compose.yml @@ -1,19 +1,31 @@ services: hedgedoc-db: - image: postgres:16-alpine + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 environment: - POSTGRES_USER=$POSTGRES_USER - POSTGRES_PASSWORD=$POSTGRES_PASSWORD - POSTGRES_DB=$POSTGRES_DB container_name: hedgedoc_db restart: unless-stopped + labels: + - "xiteng.site.component.hedgedoc-db.enabled=true" + - "xiteng.site.component.hedgedoc-db.name=HedgeDoc PostgreSQL" + - "xiteng.site.component.hedgedoc-db.description=保存笔记、修订记录、会话与权限元数据。" + - "xiteng.site.component.hedgedoc-db.section=infrastructure" + - "xiteng.site.component.hedgedoc-db.category=数据层" + - "xiteng.site.component.hedgedoc-db.access=internal" + - "xiteng.site.component.hedgedoc-db.access-label=仅容器网络" + - "xiteng.site.component.hedgedoc-db.icon=PG" + - "xiteng.site.component.hedgedoc-db.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg" + - "xiteng.site.component.hedgedoc-db.accent=blue" + - "xiteng.site.component.hedgedoc-db.order=540" volumes: - ./data:/var/lib/postgresql/data networks: - homelab_net hedgedoc: - image: quay.io/hedgedoc/hedgedoc:latest + image: quay.io/hedgedoc/hedgedoc:1.11.1 container_name: hedgedoc restart: unless-stopped environment: @@ -40,6 +52,7 @@ services: - CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR=email volumes: - ./uploads:/hedgedoc/public/uploads + - ./custom/index-body.ejs:/hedgedoc/public/views/index/body.ejs:ro depends_on: - hedgedoc-db ports: @@ -62,19 +75,28 @@ services: # --- 新增:开启 TLS 并指定解析器 --- - "traefik.http.routers.hedgedoc.tls=true" - "traefik.http.routers.hedgedoc.tls.certresolver=cfresolver" - # --- 2. Homepage 自动发现设置 --- - - "homepage.group=我的服务" # 分组名称 - - "homepage.name=HedgeDoc" # 显示名称 - - "homepage.icon=si-hedgedoc" # 图标 (支持 si, mdi 等前缀) - - "homepage.href=https://notes.xiteng.site" # 点击跳转的链接 - - "homepage.description=实时 Markdown 协作" # 副标题描述 - # --- 3. AutoKuma 自动发现设置 --- - # 格式: kuma.<自定义ID>.<监控类型>.<属性> - - "kuma.hedgedoc.http.name=HedgeDoc" # 监控项名称 - - "kuma.hedgedoc.http.url=http://hedgedoc:3000" # 内网直连 - # (可选) 每 60 秒检查一次,重试 3 次 - - "kuma.hedgedoc.http.interval=60" - - "kuma.hedgedoc.http.max_retries=3" + - "xiteng.site.cache.hedgedoc.enabled=true" + - "xiteng.site.cache.hedgedoc.routers=hedgedoc,hedgedoc-http" + - "xiteng.site.cache.hedgedoc.paths=/assets/,/static/" + - "xiteng.site.cache.hedgedoc.edge-ttl=604800" + - "xiteng.site.cache.hedgedoc.stale-while-revalidate=86400" + # --- Xiteng Site dynamic catalog --- + - "xiteng.site.component.hedgedoc.enabled=true" + - "xiteng.site.component.hedgedoc.name=HedgeDoc" + - "xiteng.site.component.hedgedoc.description=实时 Markdown 协作、分享与技术笔记。" + - "xiteng.site.component.hedgedoc.section=services" + - "xiteng.site.component.hedgedoc.category=开发与协作" + - "xiteng.site.component.hedgedoc.url=https://notes.xiteng.site" + - "xiteng.site.component.hedgedoc.access=mixed" + - "xiteng.site.component.hedgedoc.access-label=公开入口 · 内容按笔记授权" + - "xiteng.site.component.hedgedoc.icon=HD" + - "xiteng.site.component.hedgedoc.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg" + - "xiteng.site.component.hedgedoc.accent=yellow" + - "xiteng.site.component.hedgedoc.order=110" + - "xiteng.site.component.hedgedoc.navigation=new-tab" + - "xiteng.site.component.hedgedoc.portal-link=native" + - "xiteng.site.component.hedgedoc.monitor.enabled=true" + - "xiteng.site.component.hedgedoc.monitor.url=http://hedgedoc:3000" networks: - homelab_net diff --git a/hedgedoc/custom/index-body.ejs b/hedgedoc/custom/index-body.ejs new file mode 100644 index 0000000..19d483c --- /dev/null +++ b/hedgedoc/custom/index-body.ejs @@ -0,0 +1,182 @@ +
+
+
+ +
+
+

+ +
+
+ +
style="display:none;"<% } %>> +
+

HedgeDoc logo

+

+ Ideas grow better together +

+ <% if (infoMessage && infoMessage.length > 0) { %> +
<%= infoMessage %>
+ <% } %> + <% if (errorMessage && errorMessage.length > 0) { %> +
<%= errorMessage %>
+ <% } %> + <% if (authProviders.facebook || authProviders.twitter || authProviders.github || authProviders.gitlab || authProviders.mattermost || authProviders.dropbox || authProviders.google || authProviders.ldap || authProviders.saml || authProviders.oauth2 || authProviders.email) { %> + + <%= __('or') %> + <% } %> + + +
+
+ +
style="display:none;"<% } %>> + +
+
+
+ +
+
+ +
+ + <%= __('Title') %> + + + <%= __('Time') %> + + + +
+ + +
    +
+
    +
    + +
    +
    + +

    + <%- __('Powered by %s', 'HedgeDoc') %> | <%= __('Releases') %> | <%= __('Source Code') %><% if(imprint) { %> | <%= __('Imprint') %><% } %><% if(privacyStatement) { %> | <%= __('Privacy') %><% } %><% if(termsOfUse) { %> | <%= __('Terms of Use') %><% } %> +

    + +
    +
    +
    +
    +
    + + + + +<%- include('../shared/signin-modal') %> diff --git a/hedgedoc2/compose.yml b/hedgedoc2/compose.yml new file mode 100644 index 0000000..73d21e1 --- /dev/null +++ b/hedgedoc2/compose.yml @@ -0,0 +1,103 @@ +services: + hedgedoc2: + build: + context: /home/xiteng/src/hedgedoc + dockerfile: Dockerfile + image: hedgedoc2:ai-preview + container_name: hedgedoc2 + restart: unless-stopped + environment: + - HD_BACKEND_PORT=3000 + - HD_BACKEND_BIND_IP=0.0.0.0 + - HD_FRONTEND_PORT=3001 + - PORT=3001 + - HD_DATABASE_TYPE=sqlite + - HD_DATABASE_NAME=/data/hedgedoc.sqlite + - HD_BASE_URL=https://notes2.xiteng.site + - HD_RENDERER_BASE_URL=https://notes2.xiteng.site + - HD_INTERNAL_API_URL=http://localhost:3000/ + # Auth — Authentik OIDC + - HD_AUTH_SESSION_SECRET=hedgedoc2-experimental-session-secret-min-32-chars!! + - HD_AUTH_OIDC_SERVERS=AUTHENTIK + - HD_AUTH_OIDC_AUTHENTIK_ISSUER=https://auth.xiteng.site/application/o/hedgedoc2/ + - HD_AUTH_OIDC_AUTHENTIK_CLIENT_ID=hedgedoc2 + - HD_AUTH_OIDC_AUTHENTIK_CLIENT_SECRET=hedgedoc2-secret-9 + - HD_AUTH_OIDC_AUTHENTIK_PROVIDER_NAME=Authentik + - HD_AUTH_OIDC_AUTHENTIK_AUTHORIZE_URL=https://auth.xiteng.site/application/o/authorize/ + - HD_AUTH_OIDC_AUTHENTIK_TOKEN_URL=https://auth.xiteng.site/application/o/token/ + - HD_AUTH_OIDC_AUTHENTIK_USERINFO_URL=https://auth.xiteng.site/application/o/userinfo/ + - HD_AUTH_OIDC_AUTHENTIK_END_SESSION_URL=https://auth.xiteng.site/application/o/hedgedoc2/end-session/ + - HD_AUTH_LOCAL_ENABLE_LOGIN=true + # Permissions + - HD_NOTE_PERMISSIONS_DEFAULT_EVERYONE=write + # Media + - HD_MEDIA_BACKEND_TYPE=filesystem + - HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH=/data/uploads + # AI assistant config + - HD_AI_ENABLED=true + - HD_AI_TIMEOUT_MS=60000 + - HD_AI_MAX_INPUT_CHARS=32000 + - HD_AI_KEY_VAULT_URL=http://ai-gateway:8093 + - HD_AI_KEY_VAULT_TOKEN_FILE=/run/secrets/key_vault_service_token + - HD_AI_KEY_VAULT_ISSUER=https://auth.xiteng.site + - HD_AI_KEY_VAULT_OIDC_IDENTIFIER=authentik + volumes: + - ./data:/data + - ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/key_vault_service_token:ro + networks: + - homelab_net + labels: + # Traefik routing + - "traefik.enable=true" + # Backend routes: API, realtime websocket, uploads, and media + - "traefik.http.routers.hedgedoc2-api.rule=Host(`notes2.xiteng.site`) && (PathPrefix(`/api`) || PathPrefix(`/realtime`) || PathPrefix(`/uploads`) || PathPrefix(`/media`))" + - "traefik.http.routers.hedgedoc2-api.entrypoints=websecure" + - "traefik.http.routers.hedgedoc2-api.tls=true" + - "traefik.http.routers.hedgedoc2-api.tls.certresolver=cfresolver" + - "traefik.http.routers.hedgedoc2-api.service=hedgedoc2-api" + - "traefik.http.services.hedgedoc2-api.loadbalancer.server.port=3000" + # Backend HTTP entrypoint + - "traefik.http.routers.hedgedoc2-api-http.rule=Host(`notes2.xiteng.site`) && (PathPrefix(`/api`) || PathPrefix(`/realtime`) || PathPrefix(`/uploads`) || PathPrefix(`/media`))" + - "traefik.http.routers.hedgedoc2-api-http.entrypoints=web" + - "traefik.http.routers.hedgedoc2-api-http.service=hedgedoc2-api" + - "traefik.http.routers.hedgedoc2-api-http.middlewares=force-https" + - "traefik.http.routers.hedgedoc2-api.middlewares=force-https" + # Frontend → frontend (3001) + - "traefik.http.routers.hedgedoc2.rule=Host(`notes2.xiteng.site`)" + - "traefik.http.routers.hedgedoc2.entrypoints=websecure" + - "traefik.http.routers.hedgedoc2.tls=true" + - "traefik.http.routers.hedgedoc2.tls.certresolver=cfresolver" + - "traefik.http.routers.hedgedoc2.service=hedgedoc2" + - "traefik.http.services.hedgedoc2.loadbalancer.server.port=3001" + # HTTP entrypoint (Cloudflare Tunnel) + - "traefik.http.routers.hedgedoc2-http.rule=Host(`notes2.xiteng.site`)" + - "traefik.http.routers.hedgedoc2-http.entrypoints=web" + - "traefik.http.routers.hedgedoc2-http.service=hedgedoc2" + - "xiteng.site.cache.hedgedoc2.enabled=true" + - "xiteng.site.cache.hedgedoc2.routers=hedgedoc2,hedgedoc2-http" + - "xiteng.site.cache.hedgedoc2.paths=/assets/,/static/" + - "xiteng.site.cache.hedgedoc2.edge-ttl=604800" + - "xiteng.site.cache.hedgedoc2.stale-while-revalidate=86400" + # Force X-Forwarded-Proto + - "traefik.http.routers.hedgedoc2-http.middlewares=force-https" + - "traefik.http.routers.hedgedoc2.middlewares=force-https" + - "xiteng.site.component.hedgedoc2.enabled=true" + - "xiteng.site.component.hedgedoc2.name=HedgeDoc 2 (AI)" + - "xiteng.site.component.hedgedoc2.description=带本地模型写作助手的 HedgeDoc 2 实验环境。" + - "xiteng.site.component.hedgedoc2.section=services" + - "xiteng.site.component.hedgedoc2.category=开发与协作" + - "xiteng.site.component.hedgedoc2.url=https://notes2.xiteng.site" + - "xiteng.site.component.hedgedoc2.access=sso" + - "xiteng.site.component.hedgedoc2.access-label=需要登录" + - "xiteng.site.component.hedgedoc2.icon=H2" + - "xiteng.site.component.hedgedoc2.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg" + - "xiteng.site.component.hedgedoc2.accent=blue" + - "xiteng.site.component.hedgedoc2.order=120" + - "xiteng.site.component.hedgedoc2.navigation=new-tab" + - "xiteng.site.component.hedgedoc2.portal-link=native" + - "xiteng.site.component.hedgedoc2.monitor.enabled=true" + - "xiteng.site.component.hedgedoc2.monitor.url=http://hedgedoc2:3001" + +networks: + homelab_net: + external: true diff --git a/homelab-emergency b/homelab-emergency new file mode 100755 index 0000000..62507d9 --- /dev/null +++ b/homelab-emergency @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +AUTHENTIK_COMPOSE="$SCRIPT_DIR/authentik/compose.yml" +VAULT_COMPOSE="$SCRIPT_DIR/ai-gateway/compose.yml" + +usage() { + cat <<'EOF' +Usage: ./homelab-emergency [arguments] + +Bootstrap and configuration: + init-secrets + identity-bootstrap + +Recovery: + status + identity-recovery [username] default: liooil + identity-set-password [username] default: liooil + identity-reset-2fa [username] delete all TOTP devices; default: liooil + identity-reset-passkeys [username] delete all Passkeys; default: liooil + vault-list [owner-sub] + vault-audit [limit] + vault-verify + vault-delete + vault-reassign + vault-backup + vault-rotate-key + +The script never prints stored provider credentials. +EOF +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + echo "Required command not found: $1" >&2 + exit 1 + } +} + +ensure_parent() { + mkdir -p -- "$(dirname -- "$1")" +} + +generate_base64_key() { + local destination=$1 + if [[ -e "$destination" ]]; then + echo "Keeping existing secret: $destination" + return + fi + ensure_parent "$destination" + chmod 700 -- "$(dirname -- "$destination")" + openssl rand 32 | base64 -w 0 >"$destination" + chmod 600 -- "$destination" + echo "Created: $destination" +} + + +vault_exec() { + if docker ps --format '{{.Names}}' | grep -Fxq ai-gateway; then + docker compose -f "$VAULT_COMPOSE" exec -T ai-gateway node /app/cli.mjs "$@" + else + docker compose -f "$VAULT_COMPOSE" run --rm --no-deps ai-gateway node /app/cli.mjs "$@" + fi +} + +identity_reset_authenticators() { + local username=$1 + local model_module=$2 + local model_name=$3 + docker compose -f "$AUTHENTIK_COMPOSE" exec -T -e TARGET_USERNAME="$username" authentik \ + ak shell -c "import os; from authentik.core.models import User; from $model_module import $model_name; user=User.objects.get(username=os.environ['TARGET_USERNAME']); print($model_name.objects.filter(user=user).delete()[0])" +} + +identity_audit() { + local action=$1 + local username=$2 + local encoded + encoded=$(printf '%s' "$username" | base64 -w 0) + mkdir -p -- "$SCRIPT_DIR/site/data" + printf '{"createdAt":"%s","actor":"homelab-emergency","action":"%s","target":"base64:%s","detail":"{}"}\n' \ + "$(date --iso-8601=seconds)" "$action" "$encoded" >>"$SCRIPT_DIR/site/data/identity-audit.jsonl" + chmod 600 -- "$SCRIPT_DIR/site/data/identity-audit.jsonl" +} + + +command_name=${1:-} +shift || true + +case "$command_name" in + init-secrets) + require_command openssl + require_command base64 + umask 077 + generate_base64_key "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key" + generate_base64_key "$SCRIPT_DIR/ai-gateway/secrets/portal_gateway_hmac" + ;; + identity-bootstrap) + docker compose -f "$AUTHENTIK_COMPOSE" exec -T authentik \ + ak shell -c "exec(open('/bootstrap/portal_identity.py').read())" + docker compose -f "$SCRIPT_DIR/site/compose.yml" up -d --force-recreate xiteng-site + ;; + identity-recovery) + username=${1:-liooil} + docker compose -f "$AUTHENTIK_COMPOSE" exec -T authentik ak create_recovery_key 60 "$username" -v 0 + ;; + identity-set-password) + username=${1:-liooil} + docker compose -f "$AUTHENTIK_COMPOSE" exec authentik ak changepassword "$username" + ;; + identity-reset-2fa) + username=${1:-liooil} + read -r -p "Delete every TOTP device for $username? [y/N] " confirm + [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 + identity_reset_authenticators "$username" "authentik.stages.authenticator_totp.models" "TOTPDevice" + identity_audit "emergency.totp.reset" "$username" + ;; + identity-reset-passkeys) + username=${1:-liooil} + read -r -p "Delete every Passkey for $username? [y/N] " confirm + [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 + identity_reset_authenticators "$username" "authentik.stages.authenticator_webauthn.models" "WebAuthnDevice" + identity_audit "emergency.passkeys.reset" "$username" + ;; + status) + docker compose -f "$AUTHENTIK_COMPOSE" ps authentik worker postgres redis + docker compose -f "$VAULT_COMPOSE" ps + ;; + vault-list) + vault_exec list "$@" + ;; + vault-audit) + vault_exec audit "$@" + ;; + vault-verify) + vault_exec verify "$@" + ;; + vault-delete) + credential_id=${1:-} + if [[ -z "$credential_id" ]]; then + echo "Credential ID is required" >&2 + exit 1 + fi + read -r -p "Permanently delete credential $credential_id? [y/N] " confirm + [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 + vault_exec delete "$credential_id" + ;; + vault-reassign) + if [[ $# -lt 4 ]]; then + echo "credential-id, issuer, sub and username are required" >&2 + exit 1 + fi + vault_exec reassign "$@" + ;; + vault-backup) + destination=${1:-} + if [[ -z "$destination" ]]; then + echo "Destination DB path is required" >&2 + exit 1 + fi + absolute_destination=$(realpath -m -- "$destination") + case "$absolute_destination" in + "$SCRIPT_DIR/ai-gateway/data/"*) ;; + *) + echo "Vault CLI backup target must be under ai-gateway/data so the container can write it." >&2 + echo "Suggested: ai-gateway/data/backups/vault-$(date +%Y%m%d-%H%M%S).db" >&2 + exit 1 + ;; + esac + relative_destination=/data/${absolute_destination#"$SCRIPT_DIR/ai-gateway/data/"} + ensure_parent "$absolute_destination" + vault_exec backup "$relative_destination" + chmod 600 -- "$absolute_destination" + echo "Vault backup written to $absolute_destination" + ;; + vault-rotate-key) + new_key_file=${1:-} + if [[ -z "$new_key_file" || ! -f "$new_key_file" ]]; then + echo "A readable new key file is required" >&2 + exit 1 + fi + read -r -p "Rewrap every Vault data key and then replace vault_master_key? [y/N] " confirm + [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 + temporary_key="$SCRIPT_DIR/ai-gateway/secrets/vault_master_key.next" + cp -- "$new_key_file" "$temporary_key" + chmod 600 -- "$temporary_key" + vault_exec rotate-master "/run/secrets/vault_master_key.next" + mv -- "$temporary_key" "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key" + chmod 600 -- "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key" + docker compose -f "$VAULT_COMPOSE" restart ai-gateway + echo "Vault master key rotated and service restarted. Back up the new key now." + ;; + ""|-h|--help|help) + usage + ;; + *) + echo "Unknown command: $command_name" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/homepage/compose.yml b/homepage/compose.yml deleted file mode 100644 index c69a33e..0000000 --- a/homepage/compose.yml +++ /dev/null @@ -1,56 +0,0 @@ -services: - homepage: - image: ghcr.io/gethomepage/homepage:latest - container_name: homepage - restart: unless-stopped - volumes: - - ./config:/app/config - - ./config/icons:/app/public/icons:ro - - /var/run/docker.sock:/var/run/docker.sock:ro - ports: - - "3003:3000" - environment: - - HOMEPAGE_ALLOWED_HOSTS=home.xiteng.site,xiteng.site - - HOMEPAGE_AUTH_PROVIDER=oidc - - HOMEPAGE_AUTH_OIDC_ISSUER=https://auth.xiteng.site/application/o/homepage/ - - HOMEPAGE_AUTH_OIDC_CLIENT_ID=homepage-64bfd7a7 - - HOMEPAGE_AUTH_OIDC_CLIENT_SECRET=b5d710941dba4a4694a92fe7bc381f3c853a248b5114467d9b82b0953928326b - - HOMEPAGE_AUTH_OIDC_SCOPE=openid profile email - labels: - # --- 1. Traefik 路由设置 --- - - "traefik.enable=true" - - "traefik.http.routers.homepage.rule=Host(`home.xiteng.site`)" - - "traefik.http.services.homepage.loadbalancer.server.port=3000" - - "traefik.http.routers.homepage.entrypoints=websecure" - # --- 根域名 xiteng.site --- - - "traefik.http.routers.homepage-root.rule=Host(`xiteng.site`)" - - "traefik.http.routers.homepage-root.service=homepage" - - "traefik.http.routers.homepage-root.entrypoints=websecure" - # --- HTTP 入口(来自 cloudflared tunnel)--- - - "traefik.http.routers.homepage-root-http.rule=Host(`xiteng.site`)" - - "traefik.http.routers.homepage-root-http.service=homepage" - - "traefik.http.routers.homepage-root-http.entrypoints=web" - - "traefik.http.routers.homepage-root.tls=true" - - "traefik.http.routers.homepage-root.tls.certresolver=cfresolver" - # --- 新增:开启 TLS 并指定解析器 --- - - "traefik.http.routers.homepage.tls=true" - - "traefik.http.routers.homepage.tls.certresolver=cfresolver" - # --- 3. Homepage 自动发现设置 --- - - "homepage.group=我的服务" - - "homepage.name=HomePage" - - "homepage.icon=homepage" - - "homepage.href=https://home.xiteng.site" - - "homepage.description=导航主页" - # --- 4. AutoKuma 自动发现设置 --- - # 格式: kuma.<自定义ID>.<监控类型>.<属性> - - "kuma.homepage.http.name=HomePage" # 监控项名称 - - "kuma.homepage.http.url=http://homepage:3000" # 内网直连 - # (可选) 每 60 秒检查一次,重试 3 次 - - "kuma.homepage.http.interval=60" - - "kuma.homepage.http.max_retries=3" - networks: - - homelab_net - -networks: - homelab_net: - external: true diff --git a/invokeai/compose.yml b/invokeai/compose.yml new file mode 100644 index 0000000..a411aae --- /dev/null +++ b/invokeai/compose.yml @@ -0,0 +1,62 @@ +services: + invokeai: + image: ghcr.io/invoke-ai/invokeai:6.13.7-cuda + container_name: invokeai + restart: unless-stopped + environment: + INVOKEAI_ROOT: /invokeai + INVOKEAI_HOST: 0.0.0.0 + INVOKEAI_PORT: 9090 + volumes: + - ./data:/invokeai:rw + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + networks: + - homelab_net + labels: + # Traefik + - "traefik.enable=true" + - "traefik.http.routers.invokeai.rule=Host(`invoke.xiteng.site`)" + - "traefik.http.routers.invokeai.entrypoints=websecure" + - "traefik.http.routers.invokeai.tls=true" + - "traefik.http.routers.invokeai.tls.certresolver=cfresolver" + - "traefik.http.routers.invokeai.service=invokeai" + - "traefik.http.routers.invokeai.middlewares=invokeai-scheme,invokeai-auth" + - "traefik.http.services.invokeai.loadbalancer.server.port=9090" + - "traefik.http.routers.invokeai-http.rule=Host(`invoke.xiteng.site`)" + - "traefik.http.routers.invokeai-http.entrypoints=web" + - "traefik.http.routers.invokeai-http.service=invokeai" + - "traefik.http.routers.invokeai-http.middlewares=invokeai-scheme,invokeai-auth" + - "xiteng.site.cache.invokeai.enabled=true" + - "xiteng.site.cache.invokeai.routers=invokeai,invokeai-http" + - "xiteng.site.cache.invokeai.paths=/assets/,/static/,/css/,/js/" + - "xiteng.site.cache.invokeai.edge-ttl=604800" + - "xiteng.site.cache.invokeai.stale-while-revalidate=86400" + # Authentik ForwardAuth + - "traefik.http.middlewares.invokeai-scheme.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.invokeai-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.invokeai-auth.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.invokeai-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name" + - "xiteng.site.component.invokeai.enabled=true" + - "xiteng.site.component.invokeai.name=InvokeAI" + - "xiteng.site.component.invokeai.description=图像生成、画布编辑、图库和模型管理工作台。" + - "xiteng.site.component.invokeai.section=services" + - "xiteng.site.component.invokeai.category=AI" + - "xiteng.site.component.invokeai.url=https://invoke.xiteng.site" + - "xiteng.site.component.invokeai.access=sso" + - "xiteng.site.component.invokeai.access-label=需要 Authentik" + - "xiteng.site.component.invokeai.icon=IA" + - "xiteng.site.component.invokeai.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg" + - "xiteng.site.component.invokeai.accent=purple" + - "xiteng.site.component.invokeai.order=220" + - "xiteng.site.component.invokeai.monitor.enabled=true" + - "xiteng.site.component.invokeai.monitor.url=http://invokeai:9090" + +networks: + homelab_net: + external: true diff --git a/openwebui/compose.yml b/openwebui/compose.yml deleted file mode 100644 index 481b80e..0000000 --- a/openwebui/compose.yml +++ /dev/null @@ -1,58 +0,0 @@ -services: - openwebui: - image: ghcr.io/open-webui/open-webui:v0.9.5 - container_name: openwebui - restart: unless-stopped - volumes: - - ./data:/app/backend/data - environment: - # --- OIDC 认证 (Authentik) --- - - WEBUI_AUTH=true - - ENABLE_OAUTH_SIGNUP=true - - OAUTH_CLIENT_ID=openwebui-63f6865e - - OAUTH_CLIENT_SECRET=65cecaf8cc974c7fad55c749c65e50ee06bae0a9c8614252b4a44707a700c188 - - OPENID_PROVIDER_URL=https://auth.xiteng.site/application/o/openwebui/.well-known/openid-configuration - - OAUTH_SCOPES=openid email profile - - OAUTH_PROVIDER_NAME=Authentik - - WEBUI_URL=https://ai.xiteng.site - # --- Session --- - - WEBUI_SECRET_KEY=${OPENWEBUI_SECRET_KEY} - # --- 网络 (解决国内 IPv6/ghcr 访问问题) --- - - HF_ENDPOINT=https://hf-mirror.com - dns: - - 223.5.5.5 - - 114.114.114.114 - labels: - # ========== Traefik ========== - - "traefik.enable=true" - # HTTPS router (cert management) - - "traefik.http.routers.openwebui.rule=Host(`ai.xiteng.site`)" - - "traefik.http.routers.openwebui.entrypoints=websecure" - - "traefik.http.routers.openwebui.tls=true" - - "traefik.http.routers.openwebui.tls.certresolver=cfresolver" - - "traefik.http.services.openwebui.loadbalancer.server.port=8080" - # HTTP router (Cloudflare Tunnel ingress) - - "traefik.http.routers.openwebui-http.rule=Host(`ai.xiteng.site`)" - - "traefik.http.routers.openwebui-http.service=openwebui" - - "traefik.http.routers.openwebui-http.entrypoints=web" - # X-Forwarded-Proto middleware - - "traefik.http.middlewares.force-https.headers.customrequestheaders.X-Forwarded-Proto=https" - - "traefik.http.routers.openwebui.middlewares=force-https" - - "traefik.http.routers.openwebui-http.middlewares=force-https" - # ========== Homepage ========== - - "homepage.group=我的服务" - - "homepage.name=OpenWebUI" - - "homepage.icon=si-openwebui" - - "homepage.href=https://ai.xiteng.site" - - "homepage.description=AI Chat 界面" - # ========== AutoKuma ========== - - "kuma.openwebui.http.name=OpenWebUI" - - "kuma.openwebui.http.url=http://openwebui:8080" - - "kuma.openwebui.http.interval=60" - - "kuma.openwebui.http.max_retries=3" - networks: - - homelab_net - -networks: - homelab_net: - external: true diff --git a/outpost-seaweedfs/compose.yml b/outpost-seaweedfs/compose.yml index c465f73..6347b1d 100644 --- a/outpost-seaweedfs/compose.yml +++ b/outpost-seaweedfs/compose.yml @@ -1,12 +1,25 @@ services: outpost: - image: ghcr.io/goauthentik/proxy:2026.5.0 + image: ghcr.io/goauthentik/proxy:2026.5.6 container_name: authentik-outpost-seaweedfs restart: unless-stopped environment: AUTHENTIK_HOST: https://auth.xiteng.site AUTHENTIK_TOKEN: ${AUTHENTIK_OUTPOST_SEAWEEDFS_TOKEN} AUTHENTIK_INSECURE: "true" + labels: + - "traefik.enable=false" + - "xiteng.site.component.seaweedfs-outpost.enabled=true" + - "xiteng.site.component.seaweedfs-outpost.name=SeaweedFS Auth Outpost" + - "xiteng.site.component.seaweedfs-outpost.description=为 SeaweedFS Web 入口提供独立 ForwardAuth 会话。" + - "xiteng.site.component.seaweedfs-outpost.section=infrastructure" + - "xiteng.site.component.seaweedfs-outpost.category=身份与访问" + - "xiteng.site.component.seaweedfs-outpost.access=internal" + - "xiteng.site.component.seaweedfs-outpost.access-label=认证回调组件" + - "xiteng.site.component.seaweedfs-outpost.icon=OP" + - "xiteng.site.component.seaweedfs-outpost.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg" + - "xiteng.site.component.seaweedfs-outpost.accent=blue" + - "xiteng.site.component.seaweedfs-outpost.order=220" ports: - "9003:9000" networks: diff --git a/outpost/compose.yml b/outpost/compose.yml index eacd506..176c6f2 100644 --- a/outpost/compose.yml +++ b/outpost/compose.yml @@ -1,6 +1,6 @@ services: outpost: - image: ghcr.io/goauthentik/proxy:2026.5.0 + image: ghcr.io/goauthentik/proxy:2026.5.6 container_name: authentik-outpost restart: unless-stopped environment: @@ -21,6 +21,73 @@ services: - "traefik.http.routers.outpost-http.rule=Host(`outpost.xiteng.site`)" - "traefik.http.routers.outpost-http.service=outpost" - "traefik.http.routers.outpost-http.entrypoints=web" + # --- ComfyUI ForwardAuth callback --- + - "traefik.http.routers.comfyui-outpost.rule=Host(`comfy.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.comfyui-outpost.entrypoints=websecure" + - "traefik.http.routers.comfyui-outpost.service=outpost" + - "traefik.http.routers.comfyui-outpost.priority=100" + - "traefik.http.routers.comfyui-outpost.tls=true" + - "traefik.http.routers.comfyui-outpost.tls.certresolver=cfresolver" + - "traefik.http.routers.comfyui-outpost-http.rule=Host(`comfy.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.comfyui-outpost-http.entrypoints=web" + - "traefik.http.routers.comfyui-outpost-http.service=outpost" + - "traefik.http.routers.comfyui-outpost-http.priority=100" + # --- InvokeAI ForwardAuth callback --- + - "traefik.http.routers.invokeai-outpost.rule=Host(`invoke.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.invokeai-outpost.entrypoints=websecure" + - "traefik.http.routers.invokeai-outpost.service=outpost" + - "traefik.http.routers.invokeai-outpost.priority=100" + - "traefik.http.routers.invokeai-outpost.tls=true" + - "traefik.http.routers.invokeai-outpost.tls.certresolver=cfresolver" + - "traefik.http.routers.invokeai-outpost-http.rule=Host(`invoke.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.invokeai-outpost-http.entrypoints=web" + - "traefik.http.routers.invokeai-outpost-http.service=outpost" + - "traefik.http.routers.invokeai-outpost-http.priority=100" + # --- code-server ForwardAuth callback --- + - "traefik.http.routers.code-server-outpost.rule=Host(`code.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.code-server-outpost.entrypoints=websecure" + - "traefik.http.routers.code-server-outpost.service=outpost" + - "traefik.http.routers.code-server-outpost.priority=100" + - "traefik.http.routers.code-server-outpost.tls=true" + - "traefik.http.routers.code-server-outpost.tls.certresolver=cfresolver" + - "traefik.http.routers.code-server-outpost-http.rule=Host(`code.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.code-server-outpost-http.entrypoints=web" + - "traefik.http.routers.code-server-outpost-http.service=outpost" + - "traefik.http.routers.code-server-outpost-http.priority=100" + # --- Xiteng Chat ForwardAuth callback --- + - "traefik.http.routers.xiteng-chat-outpost.rule=Host(`chat.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.xiteng-chat-outpost.entrypoints=websecure" + - "traefik.http.routers.xiteng-chat-outpost.service=outpost" + - "traefik.http.routers.xiteng-chat-outpost.priority=100" + - "traefik.http.routers.xiteng-chat-outpost.tls=true" + - "traefik.http.routers.xiteng-chat-outpost.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-chat-outpost-http.rule=Host(`chat.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.xiteng-chat-outpost-http.entrypoints=web" + - "traefik.http.routers.xiteng-chat-outpost-http.service=outpost" + - "traefik.http.routers.xiteng-chat-outpost-http.priority=100" + # --- Xiteng Portal administration ForwardAuth callback --- + - "traefik.http.routers.xiteng-outpost.rule=Host(`xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.xiteng-outpost.entrypoints=websecure" + - "traefik.http.routers.xiteng-outpost.service=outpost" + - "traefik.http.routers.xiteng-outpost.priority=300" + - "traefik.http.routers.xiteng-outpost.tls=true" + - "traefik.http.routers.xiteng-outpost.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-outpost-http.rule=Host(`xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)" + - "traefik.http.routers.xiteng-outpost-http.entrypoints=web" + - "traefik.http.routers.xiteng-outpost-http.service=outpost" + - "traefik.http.routers.xiteng-outpost-http.priority=300" + # --- Xiteng Site dynamic catalog --- + - "xiteng.site.component.authentik-outpost.enabled=true" + - "xiteng.site.component.authentik-outpost.name=Authentik Proxy Outpost" + - "xiteng.site.component.authentik-outpost.description=为受保护 Web 服务执行 ForwardAuth 流程。" + - "xiteng.site.component.authentik-outpost.section=infrastructure" + - "xiteng.site.component.authentik-outpost.category=身份与访问" + - "xiteng.site.component.authentik-outpost.access=internal" + - "xiteng.site.component.authentik-outpost.access-label=认证回调组件" + - "xiteng.site.component.authentik-outpost.icon=OP" + - "xiteng.site.component.authentik-outpost.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg" + - "xiteng.site.component.authentik-outpost.accent=red" + - "xiteng.site.component.authentik-outpost.order=210" networks: - homelab_net diff --git a/remark42/compose.yml b/remark42/compose.yml new file mode 100644 index 0000000..e29be0b --- /dev/null +++ b/remark42/compose.yml @@ -0,0 +1,57 @@ +services: + remark42: + image: ghcr.io/umputun/remark42:v1.16.4 + container_name: remark42 + restart: unless-stopped + environment: + - REMARK_URL=https://remark.xiteng.site + - SECRET=60719f2fbf0c4fff4fffb3c3fa9a385953402601caf4324d734985c0afd9f7a9 + - SITE=remark + - TRUSTED_PROXY=172.18.0.0/16 + - AUTH_ANON=false + - AUTH_CUSTOM_NAME=Authentik + - AUTH_CUSTOM_CID=remark42-acd7c6f8 + - AUTH_CUSTOM_CSEC=cb2163478f1959c7d900e98a9f0f89ef6ce9345989dc5043cd7442c33f56edb5 + - AUTH_CUSTOM_AUTH_URL=https://auth.xiteng.site/application/o/authorize/ + - AUTH_CUSTOM_TOKEN_URL=https://auth.xiteng.site/application/o/token/ + - AUTH_CUSTOM_INFO_URL=https://auth.xiteng.site/application/o/userinfo/ + - AUTH_CUSTOM_SCOPES=openid,profile,email + volumes: + - ./var:/srv/var + labels: + # ========== Traefik ========== + - "traefik.enable=true" + # HTTPS + - "traefik.http.routers.remark42.rule=Host(`remark.xiteng.site`)" + - "traefik.http.routers.remark42.entrypoints=websecure" + - "traefik.http.routers.remark42.tls=true" + - "traefik.http.routers.remark42.tls.certresolver=cfresolver" + - "traefik.http.services.remark42.loadbalancer.server.port=8080" + # HTTP (Cloudflare Tunnel) + - "traefik.http.routers.remark42-http.rule=Host(`remark.xiteng.site`)" + - "traefik.http.routers.remark42-http.service=remark42" + - "traefik.http.routers.remark42-http.entrypoints=web" + - "xiteng.site.cache.remark42.enabled=true" + - "xiteng.site.cache.remark42.routers=remark42,remark42-http" + - "xiteng.site.cache.remark42.paths=/assets/,/static/,/js/,/css/" + - "xiteng.site.cache.remark42.edge-ttl=604800" + - "xiteng.site.cache.remark42.stale-while-revalidate=86400" + - "xiteng.site.component.remark42.enabled=true" + - "xiteng.site.component.remark42.name=Remark42" + - "xiteng.site.component.remark42.description=可嵌入页面的评论服务;匿名发表评论已关闭。" + - "xiteng.site.component.remark42.section=services" + - "xiteng.site.component.remark42.category=内容与互动" + - "xiteng.site.component.remark42.endpoint=remark.xiteng.site" + - "xiteng.site.component.remark42.access=sso" + - "xiteng.site.component.remark42.access-label=发表评论需 Authentik" + - "xiteng.site.component.remark42.icon=R42" + - "xiteng.site.component.remark42.accent=green" + - "xiteng.site.component.remark42.order=320" + - "xiteng.site.component.remark42.monitor.enabled=true" + - "xiteng.site.component.remark42.monitor.url=http://remark42:8080/ping" + networks: + - homelab_net + +networks: + homelab_net: + external: true diff --git a/seaweedfs/compose.yml b/seaweedfs/compose.yml index debdc72..6e93d02 100644 --- a/seaweedfs/compose.yml +++ b/seaweedfs/compose.yml @@ -1,6 +1,6 @@ services: seaweedfs: - image: chrislusf/seaweedfs:latest + image: chrislusf/seaweedfs:4.41 container_name: seaweedfs restart: unless-stopped command: "server -s3 -s3.port=8333 -master.port=9333 -volume.port=8080 -filer -filer.port=8888 -dir=/data -s3.domainName=file.xiteng.site" @@ -22,6 +22,7 @@ services: - "traefik.http.routers.minio.entrypoints=websecure" - "traefik.http.routers.minio.tls=true" - "traefik.http.routers.minio.tls.certresolver=cfresolver" + - "traefik.http.routers.minio.service=minio" - "traefik.http.routers.minio-http.rule=Host(`file.xiteng.site`)" - "traefik.http.routers.minio-http.service=minio" - "traefik.http.routers.minio-http.entrypoints=web" @@ -33,25 +34,45 @@ services: - "traefik.http.routers.minio.middlewares=minio-scheme,minio-auth" - "traefik.http.routers.minio-http.middlewares=minio-scheme,minio-auth" # --- Traefik: S3 API (8333) --- - - "traefik.http.routers.minio-api.rule=Host(`minio-api.xiteng.site`)" - - "traefik.http.services.minio-api.loadbalancer.server.port=8333" - - "traefik.http.routers.minio-api.entrypoints=websecure" - - "traefik.http.routers.minio-api.tls=true" - - "traefik.http.routers.minio-api.tls.certresolver=cfresolver" - - "traefik.http.routers.minio-api-http.rule=Host(`minio-api.xiteng.site`)" - - "traefik.http.routers.minio-api-http.service=minio-api" - - "traefik.http.routers.minio-api-http.entrypoints=web" - # --- Homepage --- - - "homepage.group=我的服务" - - "homepage.name=SeaweedFS" - - "homepage.icon=/icons/seaweedfs-local.png" - - "homepage.href=https://file.xiteng.site" - - "homepage.description=对象存储" - # --- AutoKuma --- - - "kuma.minio.http.name=SeaweedFS" - - "kuma.minio.http.url=http://seaweedfs:8888" - - "kuma.minio.http.interval=60" - - "kuma.minio.http.max_retries=3" + - "traefik.http.routers.s3.rule=Host(`s3.xiteng.site`)" + - "traefik.http.services.s3.loadbalancer.server.port=8333" + - "traefik.http.routers.s3.entrypoints=websecure" + - "traefik.http.routers.s3.tls=true" + - "traefik.http.routers.s3.tls.certresolver=cfresolver" + - "traefik.http.routers.s3-http.rule=Host(`s3.xiteng.site`)" + - "traefik.http.routers.s3-http.service=s3" + - "traefik.http.routers.s3-http.entrypoints=web" + - "xiteng.site.cache.seaweedfs-minio.enabled=true" + - "xiteng.site.cache.seaweedfs-minio.routers=minio,minio-http" + - "xiteng.site.cache.seaweedfs-minio.paths=/assets/,/static/,/ui/" + - "xiteng.site.cache.seaweedfs-minio.edge-ttl=604800" + - "xiteng.site.cache.seaweedfs-minio.stale-while-revalidate=86400" + # --- Xiteng Site dynamic catalog: one container, two public components --- + - "xiteng.site.component.seaweedfs.enabled=true" + - "xiteng.site.component.seaweedfs.name=SeaweedFS" + - "xiteng.site.component.seaweedfs.description=分布式文件、Filer Web 与对象存储核心。" + - "xiteng.site.component.seaweedfs.section=services" + - "xiteng.site.component.seaweedfs.category=存储" + - "xiteng.site.component.seaweedfs.url=https://file.xiteng.site" + - "xiteng.site.component.seaweedfs.access=sso" + - "xiteng.site.component.seaweedfs.access-label=需要 Authentik" + - "xiteng.site.component.seaweedfs.icon=SW" + - "xiteng.site.component.seaweedfs.accent=green" + - "xiteng.site.component.seaweedfs.order=500" + - "xiteng.site.component.seaweedfs.monitor.enabled=true" + - "xiteng.site.component.seaweedfs.monitor.url=http://seaweedfs:8888" + - "xiteng.site.component.s3-api.enabled=true" + - "xiteng.site.component.s3-api.name=SeaweedFS S3 API" + - "xiteng.site.component.s3-api.description=S3 兼容对象存储协议入口。" + - "xiteng.site.component.s3-api.section=services" + - "xiteng.site.component.s3-api.category=存储" + - "xiteng.site.component.s3-api.url=https://s3.xiteng.site" + - "xiteng.site.component.s3-api.endpoint=s3.xiteng.site:443" + - "xiteng.site.component.s3-api.access=access-key" + - "xiteng.site.component.s3-api.access-label=需要 Access Key" + - "xiteng.site.component.s3-api.icon=S3" + - "xiteng.site.component.s3-api.accent=blue" + - "xiteng.site.component.s3-api.order=510" networks: - homelab_net diff --git a/seaweedfs/security.toml b/seaweedfs/security.toml index b63e565..4e3a47e 100644 --- a/seaweedfs/security.toml +++ b/seaweedfs/security.toml @@ -1,3 +1,7 @@ # Homelab Security Configuration -# All JWT sections are commented out — filer UI auth is handled by Authentik ForwardAuth. -# See README.md for architecture details. +# JWT filer signing enabled — required for S3 API IAM auth. +# This enables JWT on filer writes (web UI uploads need JWT token), +# but the S3 API uses this key to validate request signatures. +# Filer web UI is still protected by Authentik ForwardAuth. +[jwt.filer_signing] +key = "xiteng-s3-signing-key-2026" diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..0259db7 --- /dev/null +++ b/site/README.md @@ -0,0 +1,142 @@ +# Xiteng Site component labels + +`xiteng.site` does not contain a hardcoded service list. A Docker container opts one or more +components into the public catalog with labels under this namespace: + +```yaml +labels: + - "xiteng.site.component.example.enabled=true" + - "xiteng.site.component.example.name=Example" + - "xiteng.site.component.example.description=What this component does." + - "xiteng.site.component.example.section=services" + - "xiteng.site.component.example.category=开发与协作" + - "xiteng.site.component.example.url=https://example.xiteng.site" + - "xiteng.site.component.example.endpoint=example.xiteng.site:443" + - "xiteng.site.component.example.access=sso" + - "xiteng.site.component.example.access-label=需要 Authentik" + - "xiteng.site.component.example.icon=EX" + - "xiteng.site.component.example.icon-url=https://example.xiteng.site/favicon.svg" + - "xiteng.site.component.example.accent=blue" + - "xiteng.site.component.example.order=100" + - "xiteng.site.component.example.navigation=new-tab" + - "xiteng.site.component.example.portal-link=native" + - "xiteng.site.component.example.monitor.enabled=true" + - "xiteng.site.component.example.monitor.url=http://example:8080/healthz" +``` + +## Schema + +| Field | Required | Values / behavior | +|---|---:|---| +| `enabled` | yes | Only the exact value `true` publishes the component. | +| `name` | yes | Public display name. | +| `description` | recommended | Public description; never put secrets here. | +| `section` | yes | `services` or `infrastructure`. | +| `category` | recommended | Dynamic group heading. | +| `url` | no | Only HTTP(S) URLs are accepted. No URL renders a non-clickable card. | +| `endpoint` | no | Public protocol endpoint or connection hint. | +| `access` | yes | Machine-readable mode such as `public`, `sso`, `mixed`, `access-key`, `ssh-key`, `local`, or `internal`. | +| `access-label` | recommended | Human-readable access boundary shown on the card. | +| `icon` | no | Final fallback text or emoji, limited to eight characters. | +| `icon-url` | no | Preferred HTTP(S) icon URL. Without it, the UI tries `/favicon.svg`, `/favicon.ico`, then `/favicon.png` on the component origin before showing `icon`. | +| `accent` | no | `red`, `green`, `yellow`, `blue`, or `ink`. | +| `order` | no | Numeric order inside a section; defaults to `999`. | +| `navigation` | no | `new-tab` (default for URLs), `same-tab`, or `endpoint` (default without a URL). | +| `portal-link` | no | `embedded`, `native`, or `none`; documents how the service returns to the Portal. | +| `monitor.enabled` | no | `true` enables the built-in HTTP GET probe. | +| `monitor.url` | when enabled | Internal HTTP(S) target. It is never returned by the public API. | +| `monitor.interval` | no | Check interval in seconds, default `60`. | +| `monitor.failures` | no | Consecutive failures before `down`, default `3`. | +| `monitor.timeout` | no | Request timeout in seconds, default `10`. | +| `monitor.accept` | no | Accepted HTTP codes, default `200-299`; comma-separated values and ranges are supported. | + +The component id (`example` above) must be globally stable. One container may publish multiple +components by using multiple ids, which is useful for services such as SeaweedFS Web and its S3 API. + +The registry reads container state and image names from Docker. It returns only the public fields above plus +Compose project/service names, runtime status, sanitized monitor state, response time, last check time, and +24-hour availability. Internal monitor URLs, errors, environment variables, mounts, raw labels, Docker +configuration, and secret values are never returned to the public site container. + +## Navigation contract + +Components with a public HTTP(S) URL open in a new tab by default with `noopener noreferrer`, leaving the +Portal available in the original tab. Owned or officially customizable applications link back to +`https://xiteng.site/?focus=#services`; the Portal clears incompatible filters, scrolls to the +component card, briefly highlights it, and then removes the `focus` query parameter. Protocol endpoints and +internal-only components remain non-clickable. Do not inject navigation into third-party HTML at the proxy. + +## Monitoring and lifecycle + +The Registry is the only discovery and lifecycle control plane. It uses the stable component id as the +database primary key, so a component can never create multiple monitors. It performs bounded-concurrency +HTTP GET probes and stores state in `site/data/registry.db`: + +```text +active → missing → archived → purged +``` + +- `active`: the component Label is currently present on a Docker container; +- `missing`: the container or Label disappeared, but catalog and monitor history remain for 30 days; +- `archived`: monitoring is paused and the component is hidden from the default public catalog; +- `purged`: an administrator explicitly removes the component and all of its monitoring data. + +A component that returns with the same id before purge reuses its existing history. Raw checks are retained +for 30 days; hourly and daily aggregates are retained for 365 days. Response bodies are never stored. + +`https://xiteng.site/admin` and `/api/admin/*` are protected by Authentik ForwardAuth and additionally +require the exact username `liooil`; this check is repeated in the site backend and Key Vault. All other +users use `/account` and `/api/account/*`. The account page is the canonical self-service surface for +profile name/email, avatar resolution, password recovery, TOTP, Passkeys, Authentik sessions, and Provider +configuration constrained by the session's `(issuer, sub)` pair. Provider definition, model list, and an +optional credential are saved from one form. Backend credentials are encrypted by Key Vault; Frontend +credentials remain in the current browser's IndexedDB. Either mode can be saved and connectivity-tested in +the same action. The public homepage remains unauthenticated. + +The admin page is the canonical control plane for human users, ordinary groups, password setup/recovery, +session revocation, authenticator status/reset, and a group-only application access matrix. The native +Authentik admin UI is hidden. TOTP and Passkey enrollment launches dedicated Authentik setup flows and +returns to `/account`; list, rename, delete, reset, and ownership checks remain in the Portal API. The account +page hashes the normalized email with SHA-256 in the browser, then loads Gravatar and Libravatar directly with +no referrer; a deterministic initials image remains visible while loading and on failure. Avatar bytes never +pass through the Portal backend. The Portal accesses Authentik with a server-only API token and +records every identity mutation in a local JSONL audit; password values are never logged. `liuhome` is +protected and currently contains `liooil` and `ziyue`; all managed non-public applications are restricted +to that group. + +The default Authentik identification stage enables WebAuthn conditional UI. A discoverable Passkey can +authenticate directly on `auth.xiteng.site`; Authentik's default flow policies then skip both password and +the later MFA stage. Username/password plus TOTP remains available as a fallback, and new Passkeys are +enrolled with the default `resident_key_requirement=preferred` setup stage. + +## Host metrics + +The internal `metrics` service publishes the sanitized `/api/metrics` payload used by the device status +cards. It reads host CPU and memory counters from a read-only `/proc` mount, root filesystem capacity +through a read-only bind on the same filesystem, and NVIDIA GPU telemetry through the `utility` driver +capability. The public payload is limited to: + +- CPU usage, model, logical core count, and load averages; +- memory and root filesystem used/available/total values; +- GPU model, utilization, VRAM, temperature, and power; +- hostname and collection timestamp. + +The metrics container has no Docker socket and no public router. The site server proxies its fixed +internal endpoint as `/api/metrics`; arbitrary host files and commands are not exposed. + +## PWA and icons + +`favicon.svg` is the source artwork for browser and install icons. The PNG and ICO derivatives live in +`icons/` and at `favicon.ico`. `manifest.webmanifest` enables standalone installation and shortcuts to the +service and infrastructure catalogs. `sw.js` caches only the public page shell and static artwork. Runtime +component/metrics APIs, Authentik paths, and all administration requests always use the network and are +never written to the PWA cache. + +Setting `enabled=false`, removing the labels, or removing the container moves the component to `missing` +without changing `index.html` or `app.js`. The old `homepage.*` and `kuma.*` namespaces are no longer read; +new and existing components use only the `xiteng.site.component.*` schema. + +Static edge caching is a separate, security-sensitive declaration and is intentionally not part of +the public component schema. A service can opt public, user-independent asset directories into the +shared cache with `xiteng.site.cache..*` labels. See `edge-cache/README.md` for the schema; +never apply it to API, admin, callback, tokenized download, HTML, or user-content paths. diff --git a/site/account.html b/site/account.html new file mode 100644 index 0000000..5ceb269 --- /dev/null +++ b/site/account.html @@ -0,0 +1,162 @@ + + + + + + + + + 我的账户 / xiteng.site + + + + + +
    + +
    + +
    +
    +

    PROFILE / SECURITY / KEY VAULT

    +

    我的账户

    +

    管理个人资料、密码、双因素认证、Passkey、登录会话和 AI Provider 凭据。

    +

    正在验证会话…

    +
    + + + +
    +
    +

    SECURITY

    登录与安全

    + +
    + +
    + +
    +
    +

    BUILT-IN + CUSTOM

    Provider Registry

    + +
    +
    只需填写 URL 与 API Key 即可先行探测;测试不会保存 Provider 或凭据。Backend Key 保存到 Key Vault;Frontend Key 必须跳转到 Chat 后保存在 chat.xiteng.site 的浏览器存储中。
    +
    +
    + + + +
    +
    + 进阶设置 +
    + + + + + + + + + + + +
    + Proxy 凭据 + + +
    +
    +
    +
    + +
    +
    +
    +
    正在读取 Provider…
    +

    SAVED BACKEND SECRETS

    已保存的 Backend 凭据

    +
    正在读取凭据元数据…
    +
    + +
    +

    AUDIT

    我的审计记录

    +
    +
    +
    + + diff --git a/site/account.js b/site/account.js new file mode 100644 index 0000000..715f274 --- /dev/null +++ b/site/account.js @@ -0,0 +1,789 @@ +const state = {session: null, identity: null, providers: [], credentials: [], audit: [], busy: new Set()}; + + +function element(tagName, className, text) { + const node = document.createElement(tagName); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function formatTime(value) { + if (!value) return "—"; + return new Date(value).toLocaleString("zh-CN", {month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"}); +} + +function avatarInitials(profile) { + const source = String(profile.name || profile.username || "U").trim() || "U"; + const parts = source.split(/\s+/).filter(Boolean); + return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)[0]}` : [...source].slice(0, 2).join("")).toUpperCase(); +} + +function avatarPlaceholder(profile) { + const initials = avatarInitials(profile); + const source = String(profile.username || profile.name || initials); + let hash = 0; + for (const character of source) hash = ((hash << 5) - hash + character.codePointAt(0)) | 0; + const hue = Math.abs(hash) % 360; + const escaped = initials.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + const svg = `${escaped}`; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +async function avatarHash(email) { + const input = new TextEncoder().encode(String(email || "").trim().toLowerCase()); + const digest = await crypto.subtle.digest("SHA-256", input); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function loadAccountAvatar(profile, refresh = false) { + const avatar = document.getElementById("account-avatar"); + const requestId = String(Number(avatar.dataset.requestId || "0") + 1); + avatar.dataset.requestId = requestId; + avatar.dataset.loading = "true"; + avatar.src = avatarPlaceholder(profile); + if (!String(profile.email || "").trim()) { + avatar.dataset.loading = "false"; + return; + } + + const hash = await avatarHash(profile.email); + if (avatar.dataset.requestId !== requestId) return; + const cacheBuster = refresh ? `&v=${Date.now()}` : ""; + const sources = [ + `https://www.gravatar.com/avatar/${hash}?d=404&s=256${cacheBuster}`, + `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256${cacheBuster}` + ]; + const trySource = (sourceIndex) => { + if (avatar.dataset.requestId !== requestId) return; + if (sourceIndex >= sources.length) { + avatar.dataset.loading = "false"; + avatar.avatarLoader = null; + return; + } + const image = new Image(); + image.referrerPolicy = "no-referrer"; + avatar.avatarLoader = image; + const timeout = window.setTimeout(() => { + image.onload = null; + image.onerror = null; + trySource(sourceIndex + 1); + }, 5000); + image.onload = () => { + window.clearTimeout(timeout); + if (avatar.dataset.requestId !== requestId) return; + avatar.src = image.src; + avatar.dataset.loading = "false"; + if (avatar.avatarLoader === image) avatar.avatarLoader = null; + }; + image.onerror = () => { + window.clearTimeout(timeout); + trySource(sourceIndex + 1); + }; + image.src = sources[sourceIndex]; + }; + trySource(0); +} + +async function request(path, options = {}) { + const response = await fetch(path, { + cache: "no-store", + ...options, + headers: {"Accept": "application/json", ...(options.body ? {"Content-Type": "application/json"} : {})} + }); + const text = await response.text(); + let payload = {}; + try { payload = text ? JSON.parse(text) : {}; } catch { payload = {error: text}; } + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + return payload; +} + +function actionButton(label, handler, dangerous = false) { + const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label); + button.type = "button"; + button.addEventListener("click", handler); + return button; +} + +function pill(label, tone = "") { + return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label); +} + +async function withBusy(key, operation) { + if (state.busy.has(key)) return; + state.busy.add(key); + try { await operation(); } catch (error) { window.alert(`操作失败:${error.message}`); } finally { state.busy.delete(key); } +} + +async function loadSession() { + const payload = await request("/api/account/session"); + state.session = payload.identity; + document.getElementById("account-session").textContent = `${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`; + document.getElementById("account-admin-link").hidden = !payload.identity.admin; +} + +function securityRecord(title, detail, actions = []) { + const record = element("article", "admin-record account-security-record"); + const heading = element("div", "admin-record-heading"); + heading.append(element("strong", "", title)); + const buttons = element("div", "admin-actions"); + actions.forEach((action) => buttons.append(action)); + heading.append(buttons); + record.append(heading, element("p", "admin-help", detail)); + return record; +} + +async function mutateAccount(path, body = {}) { + return request(path, {method: "POST", body: JSON.stringify(body)}); +} + +function renderSecurityList(id, entries, emptyText, renderer) { + const container = document.getElementById(id); + container.replaceChildren(); + if (!entries.length) container.append(element("div", "empty-state", emptyText)); + entries.forEach((entry) => container.append(renderer(entry))); +} + +function renderAccount() { + const {profile, security} = state.identity; + const form = document.getElementById("account-profile-form"); + form.elements.namedItem("name").value = profile.name || profile.username; + form.elements.namedItem("email").value = profile.email || ""; + document.getElementById("account-username").value = profile.username; + document.getElementById("account-avatar-name").textContent = profile.name || profile.username; + document.getElementById("account-avatar-source").textContent = "浏览器直连 Gravatar → Libravatar → 本地字母"; + loadAccountAvatar(profile); + + renderSecurityList("account-totp", security.totp, "尚未配置 TOTP。", (device) => securityRecord( + device.name, + "Authenticator TOTP", + [ + actionButton("重命名", async () => { + const name = window.prompt("TOTP 设备名称", device.name); + if (name) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/rename`, {name}); await loadAccount(); }); + }), + actionButton("删除", async () => { + if (window.confirm(`删除 TOTP 设备 ${device.name}?`)) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/delete`); await loadAccount(); }); + }, true) + ] + )); + + renderSecurityList("account-passkeys", security.passkeys, "尚未注册 Passkey。", (device) => securityRecord( + device.name, + `${device.deviceType || "WebAuthn"} · 注册 ${formatTime(device.createdAt)} · ${device.aaguid || "AAGUID 未知"}`, + [ + actionButton("重命名", async () => { + const name = window.prompt("Passkey 名称", device.name); + if (name) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/rename`, {name}); await loadAccount(); }); + }), + actionButton("删除", async () => { + if (window.confirm(`删除 Passkey ${device.name}?请确认仍有密码或其他 Passkey 可用。`)) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/delete`); await loadAccount(); }); + }, true) + ] + )); + + renderSecurityList("account-sessions", security.sessions, "当前没有可管理的登录会话。", (session) => securityRecord( + session.current ? "当前会话" : (session.userAgent || "登录会话"), + `${session.ip || "IP 未知"} · 最近使用 ${formatTime(session.lastUsed)} · 到期 ${formatTime(session.expires)}`, + [actionButton("注销", async () => { + if (!window.confirm(session.current ? "注销当前会话?" : "注销此会话?")) return; + await withBusy(`session-${session.id}`, async () => { + await mutateAccount(`/api/account/security/sessions/${session.id}/delete`); + if (session.current) window.location.reload(); else await loadAccount(); + }); + }, true)] + )); +} + +async function loadAccount() { + state.identity = await request("/api/account/identity"); + renderAccount(); +} + +const featuredProviderGroups = [ + {title: "SOTA", ids: ["openai", "anthropic"]}, + {title: "Proxy", ids: ["openrouter", "rust.cat"]}, + {title: "本地模型", ids: ["llama.cpp", "ollama"]}, + {title: "其他常用", ids: ["deepseek", "minimax", "moonshot", "zai"]} +]; + +function fillProviderForm(provider) { + const form = document.getElementById("account-provider-form"); + const fields = form.elements; + form.dataset.providerId = provider.id; + fields.namedItem("id").value = provider.id; + fields.namedItem("name").value = provider.name; + fields.namedItem("api").value = provider.api; + fields.namedItem("connectionType").value = provider.connection.type; + fields.namedItem("baseUrl").value = provider.connection.baseUrl; + fields.namedItem("authType").value = provider.auth.type; + fields.namedItem("authHeader").value = provider.auth.header || ""; + fields.namedItem("proxyType").value = provider.connection.proxy?.type || ""; + fields.namedItem("proxyUrl").value = provider.connection.proxy?.url || ""; + fields.namedItem("defaultModel").value = provider.defaultModel || ""; + fields.namedItem("discoveryType").value = provider.discovery?.type || ""; + fields.namedItem("discoveryUrl").value = provider.discovery?.url || ""; + fields.namedItem("apiKey").value = ""; + fields.namedItem("proxyUsername").value = ""; + fields.namedItem("proxyPassword").value = ""; + const hasCredential = provider.connection.type === "backend" + && provider.credentials.some((credential) => credential.name === "default"); + fields.namedItem("apiKey").placeholder = provider.connection.type === "frontend" + ? "仅用于探测;Frontend Key 需在 Chat 中保存" + : hasCredential ? "已保存;留空保持原凭据" : "可选"; + syncProviderConnectionFields(); + form.querySelector(".provider-advanced").open = true; + form.scrollIntoView({behavior: "smooth", block: "start"}); + fields.namedItem("name").focus({preventScroll: true}); +} + +function providerGroup(title, providers) { + const section = element("section", "provider-group"); + section.append(element("h3", "provider-group-title", title)); + const list = element("div", "admin-list"); + providers.forEach((provider) => list.append(renderProvider(provider))); + section.append(list); + return section; +} + +function providerHasCredential(provider, name = "default") { + return provider.connection.type === "backend" + && provider.credentials.some((credential) => credential.name === name); +} + +function providerTestEndpoint(provider) { + return provider.discovery.url; +} + +function inferredProviderSettings(baseUrl, api = "", authType = "", authHeader = "") { + const url = new URL(baseUrl); + const hostname = url.hostname.toLowerCase(); + const inferredApi = api || (hostname.includes("anthropic") + ? "anthropic-messages" + : hostname.includes("googleapis") ? "google-generative-ai" : "openai-completions"); + const inferredAuth = authType || (inferredApi === "anthropic-messages" || inferredApi === "google-generative-ai" ? "header" : "bearer"); + const inferredHeader = inferredAuth === "header" + ? authHeader || (inferredApi === "google-generative-ai" ? "x-goog-api-key" : "x-api-key") + : ""; + const inferredId = hostname.replace(/^api\./, "").split(".")[0].replace(/[^a-z0-9._-]+/g, "-") || "custom-provider"; + return {api: inferredApi, authType: inferredAuth, authHeader: inferredHeader, id: inferredId, name: hostname}; +} + +function inferredProviderDiscovery(baseUrl, api, type = "", discoveryUrl = "") { + const discoveryType = type || (api === "anthropic-messages" + ? "anthropic-models-list" + : api === "google-generative-ai" ? "google-models-list" : "openai-models-list"); + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + const url = discoveryUrl || (discoveryType === "anthropic-models-list" + ? `${normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`}/models?limit=200` + : discoveryType === "google-models-list" ? `${normalizedBaseUrl}/models?pageSize=200` : `${normalizedBaseUrl}/models`); + return {type: discoveryType, url}; +} + +function providerAccountEndpoints(provider) { + const baseUrl = provider.connection.baseUrl.replace(/\/+$/, ""); + const url = new URL(baseUrl); + if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"]; + if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`]; + if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`]; + if (url.hostname === "api.openai.com" || ["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`]; + return []; +} + +function providerHeaders(provider, secret) { + const headers = new Headers({"Accept": "application/json"}); + Object.entries(provider.headers || {}).forEach(([name, value]) => headers.set(name, value)); + Object.entries(secret.provider?.headers || {}).forEach(([name, value]) => headers.set(name, value)); + const apiKey = secret.provider?.apiKey || ""; + if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`); + if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey); + if (provider.api === "anthropic-messages" && !headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01"); + return headers; +} + +function modelResults(payload) { + const source = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : []; + return source.slice(0, 300).map((model) => { + const rawId = model?.id || model?.name || model?.model; + if (typeof rawId !== "string" || !rawId.trim()) return null; + const id = rawId.replace(/^models\//, ""); + const pricing = Object.fromEntries(Object.entries(model).filter(([key]) => /price|pricing|cost|rate|token/i.test(key))); + return {id, name: model.displayName || model.name?.replace?.(/^models\//, "") || id, ...(Object.keys(pricing).length ? {pricing} : {})}; + }).filter(Boolean); +} + +function providerAccountMetadata(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const entries = Object.entries(payload).filter(([key]) => /balance|credit|quota|usage|limit|billing|currency/i.test(key)); + return entries.length ? Object.fromEntries(entries) : null; +} + +function providerRateLimits(response) { + const entries = [...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)); + return entries.length ? Object.fromEntries(entries) : null; +} + +function renderProviderTestResult(result) { + const target = document.getElementById("account-provider-test-result"); + target.replaceChildren(); + const heading = element("div", "provider-test-heading"); + heading.append( + element("strong", "", `连接成功 · HTTP ${result.status} · ${result.latencyMs}ms`), + element("code", "", result.endpoint) + ); + target.append(heading); + const models = Array.isArray(result.models) ? result.models : []; + const modelSection = element("section", "provider-test-section"); + modelSection.append(element("h4", "", `发现模型(${result.modelCount ?? models.length})`)); + if (!models.length) { + modelSection.append(element("p", "admin-help", "Provider 未返回可识别的模型列表。")); + } else { + const modelRow = (model) => { + const row = element("div", "provider-test-model"); + const identity = element("div"); + identity.append(element("strong", "", model.name || model.id), element("code", "", model.id)); + if (model.pricing) identity.append(element("small", "", `费率:${JSON.stringify(model.pricing)}`)); + row.append(identity, actionButton("设为默认", () => { + document.getElementById("account-provider-form").elements.namedItem("defaultModel").value = model.id; + })); + return row; + }; + const displayedModels = models.slice(0, 100); + const initialModels = displayedModels.slice(0, window.matchMedia("(max-width: 680px)").matches ? 5 : 12); + const list = element("div", "provider-test-models"); + initialModels.forEach((model) => list.append(modelRow(model))); + modelSection.append(list); + if (displayedModels.length > initialModels.length) { + const more = element("details", "provider-test-more"); + more.append(element("summary", "admin-action", `展开其余 ${displayedModels.length - initialModels.length} 个模型`)); + const moreList = element("div", "provider-test-models"); + displayedModels.slice(initialModels.length).forEach((model) => moreList.append(modelRow(model))); + more.append(moreList); + modelSection.append(more); + } + if (models.length > 100) modelSection.append(element("p", "admin-help", `仅展示前 100 个模型;共发现 ${models.length} 个。`)); + } + target.append(modelSection); + const metadata = [ + ["账户 / 余额", result.account], + ["账户探测", result.accountProbe], + ["限流信息", result.rateLimits] + ].filter((entry) => entry[1]); + if (metadata.length) { + const details = element("section", "provider-test-section"); + details.append(element("h4", "", "账户与费率信息")); + metadata.forEach(([label, value]) => { + const block = element("div", "provider-test-metadata"); + block.append(element("strong", "", label), element("pre", "", JSON.stringify(value, null, 2))); + details.append(block); + }); + target.append(details); + } + target.className = "provider-test-result success"; +} + +function renderProviderTestError(error) { + const target = document.getElementById("account-provider-test-result"); + target.className = "provider-test-result error-state"; + target.textContent = `测试失败:${error.message}`; +} + +function frontendProviderFetch(provider, secret) { + if (!provider.connection.proxy) return fetch; + if (provider.connection.proxy.type !== "relay") throw new Error(`不支持的 Frontend Proxy:${provider.connection.proxy.type}`); + return (url, options = {}) => fetch(provider.connection.proxy.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(secret.proxy?.token ? {"Authorization": `Bearer ${secret.proxy.token}`} : {}) + }, + body: JSON.stringify({ + url: String(url), + method: options.method || "GET", + headers: Object.fromEntries(new Headers(options.headers).entries()), + body: typeof options.body === "string" ? options.body : null + }), + signal: options.signal + }); +} + +async function testProviderConnection(provider, credentialName = "default", secretOverride = null) { + if (provider.connection.type === "backend") { + return request("/api/account/provider-test", { + method: "POST", + body: JSON.stringify({providerId: provider.id, credentialName}) + }); + } + const secret = secretOverride || {}; + if (provider.auth.type !== "none" && !secretOverride) throw new Error("请在 Chat 中配置此 Frontend Provider 的本地 Key"); + const endpoint = providerTestEndpoint(provider); + const providerFetch = frontendProviderFetch(provider, secret); + const headers = providerHeaders(provider, secret); + const startedAt = performance.now(); + const response = await providerFetch(endpoint, { + method: "GET", + headers, + signal: AbortSignal.timeout(15000) + }); + const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); + const text = await response.text(); + if (!response.ok) throw new Error(text.slice(0, 1000) || `Provider HTTP ${response.status}`); + let payload = {}; + try { payload = text ? JSON.parse(text) : {}; } catch { payload = {preview: text.slice(0, 1000)}; } + const models = modelResults(payload); + let accountProbe = null; + for (const accountEndpoint of providerAccountEndpoints(provider)) { + try { + const accountResponse = await providerFetch(accountEndpoint, {method: "GET", headers, signal: AbortSignal.timeout(5000)}); + if (!accountResponse.ok) continue; + accountProbe = {endpoint: accountEndpoint, status: accountResponse.status, data: await accountResponse.json()}; + break; + } catch { + // Account metadata is optional and must not fail a successful model probe. + } + } + return { + ok: true, + status: response.status, + latencyMs, + endpoint, + modelCount: models.length, + models, + account: providerAccountMetadata(payload), + accountProbe, + rateLimits: providerRateLimits(response) + }; +} +function renderProvider(provider) { + const record = element("article", "admin-record"); + const heading = element("div", "admin-record-heading"); + const identity = element("div"); + identity.append(element("h3", "", provider.name), element("code", "", provider.id)); + const badges = element("div", "admin-component-status"); + const credentialLabel = provider.connection.type === "frontend" + ? provider.auth.type === "none" ? "无需 Key" : "由 Chat 管理 Key" + : providerHasCredential(provider) ? "凭据已设置" : "无凭据"; + badges.append( + pill(provider.builtin ? "内置" : "Custom", provider.builtin ? "" : "monitor-up"), + pill(provider.connection.type === "frontend" ? "Frontend" : "Backend"), + pill(credentialLabel, provider.connection.type === "frontend" || providerHasCredential(provider) ? "monitor-up" : ""), + pill(provider.api) + ); + heading.append(identity, badges); + const proxy = provider.connection.proxy ? `${provider.connection.proxy.type} · ${provider.connection.proxy.url}` : "无"; + const details = element("p", "admin-help", `${provider.connection.baseUrl} · Proxy ${proxy} · 默认模型 ${provider.defaultModel || "未设置"}`); + const actions = element("div", "admin-actions"); + actions.append(actionButton(provider.builtin ? "复制并配置" : "编辑 Provider 与凭据", () => fillProviderForm(provider))); + actions.append(actionButton("测试连通性", () => withBusy(`provider-test-${provider.id}`, async () => { + try { + renderProviderTestResult(await testProviderConnection(provider)); + document.getElementById("account-provider-test-result").scrollIntoView({behavior: "smooth", block: "nearest"}); + } catch (error) { + renderProviderTestError(error); + throw error; + } + }))); + if (provider.connection.type === "frontend") { + actions.append(actionButton(provider.auth.type === "none" ? "在 Chat 中配置端点" : "在 Chat 中配置本地 Key(需 CORS / Relay)", () => { + window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(provider.id)}`; + })); + } + if (!provider.builtin) { + actions.append(actionButton("删除 Custom", async () => { + if (!window.confirm(`删除 Custom Provider ${provider.id}?`)) return; + await withBusy(`provider-${provider.id}`, async () => { + await request(`/api/account/vault/providers/${provider.id}/delete`, {method: "POST", body: "{}"}); + await loadVault(); + }); + }, true)); + } + record.append(heading, details, actions); + return record; +} + +function renderCredential(credential) { + const record = element("article", "admin-record"); + const heading = element("div", "admin-record-heading"); + const identity = element("div"); + identity.append(element("h3", "", credential.providerId), element("code", "", credential.id)); + const badges = element("div", "admin-component-status"); + badges.append(pill(`sha256:${credential.fingerprint}`)); + heading.append(identity, badges); + const meta = element("p", "admin-help", `创建 ${formatTime(credential.createdAt)} · 更新 ${formatTime(credential.updatedAt)} · 最近读取 ${formatTime(credential.lastAccessedAt)}`); + const actions = element("div", "admin-actions"); + actions.append(actionButton("校验加密完整性", () => runCredentialAction(credential, "verify"))); + actions.append(actionButton("编辑 Provider 与凭据", () => { + const provider = state.providers.find((item) => item.id === credential.providerId); + if (provider) fillProviderForm(provider); + })); + actions.append(actionButton("删除", async () => { + if (window.confirm(`永久删除 ${credential.providerId} 的凭据?`)) await runCredentialAction(credential, "delete"); + }, true)); + record.append(heading, meta, actions); + return record; +} + +function renderVault() { + const providers = document.getElementById("account-providers"); + providers.replaceChildren(); + const customProviders = state.providers.filter((provider) => !provider.builtin); + if (customProviders.length) providers.append(providerGroup("我的 Custom Provider", customProviders)); + const featuredIds = new Set(featuredProviderGroups.flatMap((group) => group.ids)); + for (const group of featuredProviderGroups) { + const entries = group.ids.map((id) => state.providers.find((provider) => provider.builtin && provider.id === id)).filter(Boolean); + if (entries.length) providers.append(providerGroup(group.title, entries)); + } + const moreProviders = state.providers.filter((provider) => provider.builtin && !featuredIds.has(provider.id)); + if (moreProviders.length) { + const more = element("details", "provider-more"); + more.append(element("summary", "admin-action", `更多内置 Provider(${moreProviders.length})`)); + const list = element("div", "admin-list provider-more-list"); + moreProviders.forEach((provider) => list.append(renderProvider(provider))); + more.append(list); + providers.append(more); + } + + const credentials = document.getElementById("account-credentials"); + credentials.replaceChildren(); + if (state.credentials.length) state.credentials.forEach((credential) => credentials.append(renderCredential(credential))); + else credentials.append(element("div", "empty-state", "尚未保存 Backend Credential。")); + + const audit = document.getElementById("account-audit"); + audit.replaceChildren(); + if (!state.audit.length) audit.append(element("div", "empty-state", "暂无审计事件。")); + for (const event of state.audit) { + const row = element("div", "audit-row"); + row.append(element("time", "", formatTime(event.createdAt)), element("code", "", event.actorUsername), element("strong", "", event.action), element("span", "", event.detail || event.targetId || "—"), pill(event.result, event.result === "success" ? "monitor-up" : "monitor-down")); + audit.append(row); + } +} + +async function loadVault() { + try { + const [providers, credentials, audit] = await Promise.all([ + request("/api/account/vault/providers"), + request("/api/account/vault/credentials"), + request("/api/account/vault/audit") + ]); + state.providers = providers.providers || []; + state.credentials = credentials.credentials || []; + state.audit = audit.events || []; + renderVault(); + } catch (error) { + document.getElementById("account-providers").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`)); + } +} + +function buildSecret(apiKey, proxyUsername, proxyPassword, connectionType = "backend") { + return { + provider: {apiKey}, + ...(proxyUsername || proxyPassword ? { + proxy: connectionType === "frontend" + ? {token: proxyPassword} + : {username: proxyUsername, password: proxyPassword} + } : {}) + }; +} + +function providerFormValue(form) { + const data = new FormData(form); + const connectionType = String(data.get("connectionType") || "backend"); + const proxyType = String(data.get("proxyType") || ""); + const baseUrl = String(data.get("baseUrl") || "").trim(); + const inferred = inferredProviderSettings(baseUrl, String(data.get("api") || ""), String(data.get("authType") || ""), String(data.get("authHeader") || "")); + const discovery = inferredProviderDiscovery(baseUrl, inferred.api, String(data.get("discoveryType") || ""), String(data.get("discoveryUrl") || "")); + const providerId = String(data.get("id") || inferred.id).trim().toLowerCase(); + const apiKey = String(data.get("apiKey") || ""); + const proxyUsername = proxyType ? String(data.get("proxyUsername") || "") : ""; + const proxyPassword = proxyType ? String(data.get("proxyPassword") || "") : ""; + return { + providerId, + connectionType, + apiKey, + proxyUsername, + proxyPassword, + hasSecretInput: Boolean(apiKey || proxyUsername || proxyPassword), + secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType), + provider: { + id: providerId, + name: String(data.get("name") || inferred.name).trim(), + api: inferred.api, + connection: { + type: connectionType, + baseUrl, + proxy: proxyType ? {type: proxyType, url: data.get("proxyUrl")} : null + }, + auth: { + type: inferred.authType, + ...(inferred.authType === "header" ? {header: inferred.authHeader} : {}) + }, + headers: {}, + defaultModel: String(data.get("defaultModel") || "probe-model"), + discovery, + builtin: false, + credentials: [], + credentialState: connectionType === "frontend" ? "local" : "missing" + } + }; +} + +function applyProviderDetection(result) { + const form = document.getElementById("account-provider-form"); + const fields = form.elements; + const detected = result.detected || {}; + if (!fields.namedItem("id").value) fields.namedItem("id").value = detected.id || ""; + if (!fields.namedItem("name").value) fields.namedItem("name").value = detected.name || ""; + if (!fields.namedItem("api").value) fields.namedItem("api").value = detected.api || ""; + if (!fields.namedItem("authType").value) fields.namedItem("authType").value = detected.auth?.type || ""; + if (!fields.namedItem("authHeader").value) fields.namedItem("authHeader").value = detected.auth?.header || ""; + if (!fields.namedItem("discoveryType").value) fields.namedItem("discoveryType").value = detected.discovery?.type || ""; + if (!fields.namedItem("discoveryUrl").value) fields.namedItem("discoveryUrl").value = detected.discovery?.url || ""; + if (!fields.namedItem("defaultModel").value && result.models?.[0]?.id) fields.namedItem("defaultModel").value = result.models[0].id; + syncProviderConnectionFields(); +} + +document.getElementById("account-provider-test").addEventListener("click", async () => { + const form = document.getElementById("account-provider-form"); + const baseUrl = form.elements.namedItem("baseUrl"); + if (!baseUrl.reportValidity()) return; + const value = providerFormValue(form); + await withBusy("provider-draft-test", async () => { + try { + const result = value.connectionType === "backend" + ? await request("/api/account/provider-test", { + method: "POST", + body: JSON.stringify({provider: value.provider, secret: value.secret}) + }) + : await testProviderConnection(value.provider, "default", value.hasSecretInput ? value.secret : null); + result.detected ||= { + id: value.provider.id, + name: value.provider.name, + api: value.provider.api, + auth: value.provider.auth, + connection: value.provider.connection, + discovery: value.provider.discovery + }; + applyProviderDetection(result); + renderProviderTestResult(result); + } catch (error) { + renderProviderTestError(error); + throw error; + } + }); +}); + +async function runCredentialAction(credential, action, body = {}) { + await withBusy(`credential-${credential.id}`, async () => { + const payload = await request(`/api/account/vault/credentials/${credential.id}/${action}`, {method: "POST", body: JSON.stringify(body)}); + if (action === "verify") window.alert(payload.verified ? "密文完整。" : "密文校验失败,请立即替换或删除。"); + await loadVault(); + }); +} + +document.getElementById("account-refresh").addEventListener("click", loadVault); + +document.getElementById("account-provider-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const value = providerFormValue(form); + const { + providerId, + connectionType, + apiKey, + proxyUsername, + proxyPassword, + hasSecretInput, + provider: payload + } = value; + await withBusy("provider-save", async () => { + await request("/api/account/vault/providers", {method: "POST", body: JSON.stringify(payload)}); + if (hasSecretInput && connectionType === "backend") { + const existing = state.credentials.find((credential) => credential.providerId === providerId && credential.name === "default") + || state.credentials.find((credential) => credential.providerId === providerId); + if (existing) { + await request(`/api/account/vault/credentials/${existing.id}/replace`, { + method: "POST", + body: JSON.stringify({secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)}) + }); + } else { + await request("/api/account/vault/credentials", { + method: "POST", + body: JSON.stringify({providerId, name: "default", secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)}) + }); + } + } else if (connectionType === "frontend") { + window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(providerId)}`; + return; + } + await loadVault(); + form.reset(); + form.querySelector(".provider-advanced").open = false; + syncProviderConnectionFields(); + delete form.dataset.providerId; + }); +}); + +function syncProviderConnectionFields() { + const form = document.getElementById("account-provider-form"); + const fields = form.elements; + const frontend = fields.namedItem("connectionType").value === "frontend"; + const proxyType = fields.namedItem("proxyType"); + for (const option of proxyType.options) { + option.hidden = Boolean(option.value) && (frontend ? option.value !== "relay" : option.value === "relay"); + } + if (proxyType.selectedOptions[0]?.hidden) proxyType.value = ""; + const proxyConfigured = Boolean(proxyType.value); + fields.namedItem("proxyUrl").required = proxyConfigured; + fields.namedItem("proxyUrl").disabled = !proxyConfigured; + fields.namedItem("proxyUsername").disabled = !proxyConfigured || frontend; + const headerAuth = fields.namedItem("authType").value === "header"; + fields.namedItem("authHeader").disabled = !headerAuth; + fields.namedItem("authHeader").required = headerAuth; + fields.namedItem("proxyPassword").disabled = !proxyConfigured; + fields.namedItem("proxyPassword").placeholder = frontend ? "Relay Token(可选)" : "Proxy 密码(可选)"; +} + +const providerForm = document.getElementById("account-provider-form"); +providerForm.elements.namedItem("connectionType").addEventListener("change", syncProviderConnectionFields); +providerForm.elements.namedItem("proxyType").addEventListener("change", syncProviderConnectionFields); +providerForm.elements.namedItem("authType").addEventListener("change", syncProviderConnectionFields); +syncProviderConnectionFields(); + +document.getElementById("account-security-refresh").addEventListener("click", () => withBusy("account-refresh", loadAccount)); + +document.getElementById("account-profile-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + await withBusy("profile", async () => { + await mutateAccount("/api/account/profile", {name: data.get("name"), email: data.get("email")}); + await loadAccount(); + }); +}); + +document.getElementById("account-avatar-refresh").addEventListener("click", () => { + loadAccountAvatar(state.identity.profile, true); +}); + +document.getElementById("account-password").addEventListener("click", () => withBusy("password", async () => { + const payload = await mutateAccount("/api/account/security/password"); + window.prompt("15 分钟内有效的密码设置链接", payload.result.link); +})); + +document.getElementById("account-add-totp").addEventListener("click", () => window.location.assign(state.identity.security.totpSetupUrl)); +document.getElementById("account-add-passkey").addEventListener("click", () => window.location.assign(state.identity.security.passkeySetupUrl)); + +document.getElementById("account-logout-all").addEventListener("click", async () => { + if (!window.confirm("注销全部 Authentik 会话?当前页面也会退出。")) return; + await withBusy("sessions-all", async () => { + await mutateAccount("/api/account/security/sessions/all"); + window.location.reload(); + }); +}); + +async function initialize() { + try { await loadSession(); await Promise.all([loadAccount(), loadVault()]); } catch (error) { document.getElementById("account-session").textContent = `会话不可用:${error.message}`; } +} + +document.getElementById("account-avatar").src = avatarPlaceholder({name: "U", username: "user"}); + +initialize(); diff --git a/site/admin.html b/site/admin.html new file mode 100644 index 0000000..9f8ea84 --- /dev/null +++ b/site/admin.html @@ -0,0 +1,144 @@ + + + + + + + + + Portal 管理 / xiteng.site + + + + + + + + +
    + +
    + +
    +
    +

    ADMIN CONTROL PLANE

    +

    身份、凭据与组件

    +

    + Authentik 是唯一身份系统。只有用户名严格等于 liooil 的会话可以访问本页; + Key Vault 独立负责 Backend Credential,管理页不会保存或回显密钥明文。 +

    +

    正在验证管理员会话…

    +
    + +
    +
    家庭用户
    +
    Vault 凭据
    +
    动态组件
    +
    不可用组件
    +
    + +
    +
    +
    +

    AUTHENTIK / IDENTITY

    +

    用户与访问策略

    +
    + +
    +
    正在连接 Authentik…
    + +
    + + +
    + +
    +
    +

    新建普通用户

    + + + + +
    +
    +

    新建用户组

    + + +

    liuhome 是家庭基础组,不能重命名或删除。

    +
    +
    + +
    +

    用户

    +
    正在读取用户…
    +
    +
    +

    用户组

    +
    正在读取用户组…
    +
    +
    +

    非开放服务权限矩阵

    +

    每个服务只允许所选用户组;当前统一使用 liuhome

    +
    正在读取应用…
    +
    +
    +

    身份操作审计

    +
    +
    +
    + +
    +
    +
    +

    KEY VAULT

    +

    Backend Provider 凭据

    +
    + +
    +
    + 管理员只能管理 Backend Credential;Frontend Credential 始终保留在用户浏览器中。 +
    + +
    + + + + + +
    + +
    +
    正在读取凭据元数据…
    +
    + +
    +

    审计

    +
    +
    +
    + +
    +
    +
    +

    REGISTRY / MONITORING

    +

    组件生命周期

    +
    + +
    +

    正在读取 Registry…

    +
    +
    正在读取组件生命周期…
    +
    +
    +
    + + diff --git a/site/admin.js b/site/admin.js new file mode 100644 index 0000000..bed6217 --- /dev/null +++ b/site/admin.js @@ -0,0 +1,583 @@ +const state = { + session: null, + identity: null, + identitySearch: "", + credentials: [], + providers: [], + audit: [], + components: [], + busy: new Set() +}; + +const lifecycleLabels = { + active: "当前存在", + missing: "已经消失", + archived: "已经归档" +}; + +const monitorLabels = { + up: "可用", + down: "不可用", + degraded: "等待重试", + pending: "等待检查", + paused: "已暂停", + unmonitored: "未配置" +}; +const knownComponentIcons = { + gitea: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg", + hedgedoc: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg", + hedgedoc2: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg", + "code-server": "https://cdn.simpleicons.org/coder", + chat: "https://xiteng.site/icons/services/chat.svg", + comfyui: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg", + invokeai: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg", + authentik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "authentik-worker": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "authentik-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "seaweedfs-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "authentik-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "gitea-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "hedgedoc-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "authentik-redis": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg", + "cloudflare-tunnel": "https://cdn.simpleicons.org/cloudflare", + traefik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg", + portal: "https://xiteng.site/favicon.svg" +}; + +function element(tagName, className, text) { + const node = document.createElement(tagName); + if (className) { + node.className = className; + } + if (text !== undefined) { + node.textContent = text; + } + return node; +} + +function componentIconSources(component) { + const sources = []; + try { + if (component.iconUrl) sources.push(new URL(component.iconUrl).href); + } catch {} + if (knownComponentIcons[component.id]) sources.push(knownComponentIcons[component.id]); + try { + const origin = new URL(component.url).origin; + sources.push(`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`); + } catch {} + return [...new Set(sources)]; +} + +function componentIcon(component) { + const icon = element("span", "component-icon"); + icon.append(element("span", "component-icon-fallback", component.icon || component.id.slice(0, 2).toUpperCase())); + const sources = componentIconSources(component); + const trySource = (index) => { + if (index >= sources.length) return; + const image = document.createElement("img"); + image.alt = ""; + image.decoding = "async"; + image.referrerPolicy = "no-referrer"; + let finished = false; + const timeout = window.setTimeout(() => { + finished = true; + trySource(index + 1); + }, 4000); + image.addEventListener("load", () => { + if (finished) return; + finished = true; + window.clearTimeout(timeout); + icon.querySelector("img")?.remove(); + icon.append(image); + icon.classList.add("has-image"); + }, {once: true}); + image.addEventListener("error", () => { + if (finished) return; + finished = true; + window.clearTimeout(timeout); + trySource(index + 1); + }, {once: true}); + image.src = sources[index]; + }; + trySource(0); + return icon; +} + +function formatTime(value) { + if (!value) { + return "—"; + } + return new Date(value).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +} + +async function request(path, options = {}) { + const response = await fetch(path, { + cache: "no-store", + ...options, + headers: { + "Accept": "application/json", + ...(options.body ? {"Content-Type": "application/json"} : {}), + ...(options.headers || {}) + } + }); + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = {error: text}; + } + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + return payload; +} + +function actionButton(label, handler, {dangerous = false, disabled = false} = {}) { + const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label); + button.type = "button"; + button.disabled = disabled; + button.addEventListener("click", handler); + return button; +} + +function pill(label, tone = "") { + return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label); +} + +async function loadSession() { + const payload = await request("/api/admin/session"); + state.session = payload.identity; + document.getElementById("admin-session").textContent = + `${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`; +} + +function groupChecks(selectedIds) { + const wrapper = element("div", "admin-checks"); + for (const group of state.identity?.groups || []) { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = group.id; + input.checked = selectedIds.includes(group.id); + label.append(input, document.createTextNode(group.name)); + wrapper.append(label); + } + return wrapper; +} + +function renderIdentityUser(user) { + const record = element("article", "admin-record"); + const heading = element("div", "admin-record-heading"); + const title = element("div"); + title.append(element("h3", "", user.name || user.username), element("code", "", `${user.username} · ${user.uuid}`)); + const badges = element("div", "admin-component-status"); + badges.append( + pill(user.active ? "可登录" : "已停用", user.active ? "monitor-up" : "monitor-down"), + pill(`TOTP ${user.totpCount}`), + pill(`Passkey ${user.passkeyCount}`), + ...(user.administrator ? [pill("管理员", "monitor-up")] : []) + ); + heading.append(title, badges); + const meta = element("p", "admin-help", `${user.email || "未设置邮箱"} · 最近登录 ${formatTime(user.lastLogin)} · 创建 ${formatTime(user.createdAt)}`); + const groups = element("p", "admin-help", `用户组:${user.groups.map((group) => group.name).join("、") || "无"}`); + const actions = element("div", "admin-actions"); + actions.append(actionButton("编辑", async () => { + const name = window.prompt("显示名", user.name || user.username); + if (name === null) return; + const email = window.prompt("邮箱,可留空", user.email || ""); + if (email === null) return; + await mutateIdentity(`/api/admin/identity/users/${user.id}/update`, {name, email}); + })); + if (user.username !== "liooil") { + actions.append(actionButton(user.active ? "停用" : "启用", () => mutateIdentity( + `/api/admin/identity/users/${user.id}/${user.active ? "disable" : "enable"}`, + {} + ), {dangerous: user.active})); + } + actions.append(actionButton("设置临时密码", async () => { + const password = window.prompt("输入至少 12 个字符的临时密码"); + if (password) await mutateIdentity(`/api/admin/identity/users/${user.id}/password`, {password}); + })); + actions.append(actionButton("生成恢复链接", async () => { + const payload = await mutateIdentity(`/api/admin/identity/users/${user.id}/recovery`, {}, false); + window.prompt("一小时内有效的密码设置链接", payload.result.link); + })); + actions.append(actionButton("注销全部会话", () => mutateIdentity(`/api/admin/identity/users/${user.id}/sessions`, {}), {dangerous: true})); + if (user.totpCount) actions.append(actionButton("重置 TOTP", async () => { + if (window.confirm(`删除 ${user.username} 的全部 ${user.totpCount} 个 TOTP 设备?`)) await mutateIdentity(`/api/admin/identity/users/${user.id}/reset-totp`, {}); + }, {dangerous: true})); + if (user.passkeyCount) actions.append(actionButton("重置 Passkey", async () => { + if (window.confirm(`删除 ${user.username} 的全部 ${user.passkeyCount} 个 Passkey?`)) await mutateIdentity(`/api/admin/identity/users/${user.id}/reset-passkeys`, {}); + }, {dangerous: true})); + record.append(heading, meta, groups, actions); + return record; +} + +function renderIdentityGroup(group) { + const record = element("article", "admin-record"); + const heading = element("div", "admin-record-heading"); + const title = element("div"); + title.append(element("h3", "", group.name), element("code", "", group.id)); + heading.append(title, pill(`${group.users.length} 个用户`)); + const checks = element("div", "admin-checks"); + for (const user of state.identity.users) { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = String(user.id); + input.checked = group.userIds.includes(user.id); + label.append(input, document.createTextNode(user.username)); + checks.append(label); + } + const actions = element("div", "admin-actions"); + actions.append(actionButton("保存成员", async () => { + const desired = new Set([...checks.querySelectorAll("input:checked")].map((input) => Number(input.value))); + for (const user of state.identity.users) { + const current = group.userIds.includes(user.id); + if (current !== desired.has(user.id)) { + await request(`/api/admin/identity/groups/${group.id}/members`, { + method: "POST", + body: JSON.stringify({userId: user.id, member: desired.has(user.id)}) + }); + } + } + await loadIdentity(); + })); + if (group.name !== "liuhome") { + actions.append(actionButton("重命名", async () => { + const name = window.prompt("用户组名称", group.name); + if (name) await mutateIdentity(`/api/admin/identity/groups/${group.id}/update`, {name}); + })); + actions.append(actionButton("删除", async () => { + if (window.confirm(`删除用户组 ${group.name}?`)) await mutateIdentity(`/api/admin/identity/groups/${group.id}/delete`, {}); + }, {dangerous: true})); + } + record.append(heading, checks, actions); + return record; +} + +function renderIdentityApplication(application) { + const record = element("article", "admin-record"); + const heading = element("div", "admin-record-heading"); + const title = element("div"); + title.append(element("h3", "", application.name), element("code", "", application.slug)); + heading.append(title, pill(application.available ? (application.allowedGroups.join("、") || "所有已登录用户") : "未配置", application.available ? "monitor-up" : "monitor-down")); + const checks = groupChecks(application.allowedGroupIds); + const actions = element("div", "admin-actions"); + actions.append(actionButton("保存允许组", async () => { + const groupIds = [...checks.querySelectorAll("input:checked")].map((input) => input.value); + await mutateIdentity(`/api/admin/identity/applications/${application.slug}/groups`, {groupIds}); + }, {disabled: !application.available})); + record.append(heading, checks, actions); + return record; +} + +function renderIdentity() { + const status = document.getElementById("identity-status"); + status.className = `admin-notice ${state.identity.healthy ? "success" : "warning"}`; + status.textContent = state.identity.healthy + ? `Authentik 已连接;基础访问组为 ${state.identity.protectedGroup}。` + : "Authentik 健康检查失败。"; + document.getElementById("admin-users").textContent = String(state.identity.users.length); + const users = document.getElementById("identity-users"); + users.replaceChildren(...state.identity.users.map(renderIdentityUser)); + const groups = document.getElementById("identity-groups"); + groups.replaceChildren(...state.identity.groups.map(renderIdentityGroup)); + const applications = document.getElementById("identity-applications"); + applications.replaceChildren(...state.identity.applications.map(renderIdentityApplication)); + const audit = document.getElementById("identity-audit"); + audit.replaceChildren(...state.identity.audit.map((event) => { + const row = element("div", "audit-row"); + row.append(element("time", "", formatTime(event.createdAt)), element("code", "", event.actor), element("strong", "", event.action), element("span", "", event.target)); + return row; + })); + if (!state.identity.audit.length) audit.append(element("div", "empty-state", "暂无身份修改记录。")); +} + +async function loadIdentity() { + const query = state.identitySearch ? `?search=${encodeURIComponent(state.identitySearch)}` : ""; + state.identity = await request(`/api/admin/identity${query}`); + renderIdentity(); +} + +async function mutateIdentity(path, body, reload = true) { + const payload = await request(path, {method: "POST", body: JSON.stringify(body)}); + if (reload) await loadIdentity(); + return payload; +} + + +function credentialActions(credential) { + const actions = element("div", "admin-actions"); + actions.append(actionButton("校验密文", () => runCredentialAction(credential, "verify"))); + actions.append(actionButton("替换", async () => { + const apiKey = window.prompt(`输入 ${credential.providerId} / ${credential.name} 的新 API Key:`); + if (!apiKey) return; + await runCredentialAction(credential, "replace", {secret: {provider: {apiKey}}}); + })); + actions.append(actionButton("永久删除", async () => { + if (window.confirm(`永久删除 ${credential.providerId} / ${credential.name}?`)) { + await runCredentialAction(credential, "delete"); + } + }, {dangerous: true})); + return actions; +} + +function renderCredential(credential) { + const record = element("article", "admin-record"); + const heading = element("div", "admin-record-heading"); + const identity = element("div"); + identity.append(element("h3", "", `${credential.providerId} / ${credential.name}`)); + identity.append(element("code", "", credential.id)); + const badges = element("div", "admin-component-status"); + badges.append(pill(`sha256:${credential.fingerprint}`)); + heading.append(identity, badges); + const details = element("dl", "admin-details vault-details"); + const values = [ + ["所有者", `${credential.owner.username} · ${credential.owner.sub}`], + ["身份源", credential.owner.issuer], + ["创建", formatTime(credential.createdAt)], + ["更新", formatTime(credential.updatedAt)] + ]; + for (const [name, value] of values) { + const item = element("div"); + item.append(element("dt", "", name), element("dd", "", value)); + details.append(item); + } + record.append(heading, details, credentialActions(credential)); + return record; +} + + +function renderAuditEvent(event) { + const row = element("div", "audit-row"); + row.append( + element("time", "", formatTime(event.createdAt)), + element("code", "", event.actorUsername), + element("strong", "", event.action), + element("span", "", event.detail || event.targetId || "—"), + pill(event.result, event.result === "success" ? "monitor-up" : "monitor-down") + ); + return row; +} + +function renderVault() { + const credentials = document.getElementById("vault-credentials"); + credentials.replaceChildren(); + if (state.credentials.length) { + state.credentials.forEach((credential) => credentials.append(renderCredential(credential))); + } else { + credentials.append(element("div", "empty-state", "Vault 中没有 Backend Credential。")); + } + document.getElementById("admin-credentials").textContent = String(state.credentials.length); + + const providerSelect = document.getElementById("admin-credential-provider"); + providerSelect.replaceChildren(); + state.providers.filter((provider) => provider.connection.type === "backend").forEach((provider) => { + providerSelect.append(new Option(`${provider.name} (${provider.id})`, provider.id)); + }); + + const audit = document.getElementById("vault-audit"); + audit.replaceChildren(); + if (state.audit.length) state.audit.forEach((event) => audit.append(renderAuditEvent(event))); + else audit.append(element("div", "empty-state", "暂无 Vault 审计事件。")); +} + +async function loadVault() { + try { + const [providers, credentials, audit] = await Promise.all([ + request("/api/admin/vault/providers"), + request("/api/admin/vault/credentials"), + request("/api/admin/vault/audit") + ]); + state.providers = providers.providers || []; + state.credentials = credentials.credentials || []; + state.audit = audit.events || []; + renderVault(); + } catch (error) { + document.getElementById("vault-credentials").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`)); + } +} + +async function runCredentialAction(credential, action, body = {}) { + await withBusy(`credential-${credential.id}`, async () => { + const payload = await request(`/api/admin/vault/credentials/${credential.id}/${action}`, { + method: "POST", + body: JSON.stringify(body) + }); + if (action === "verify") { + window.alert(payload.verified ? "密文完整,可以使用。" : "密文校验失败,请立即替换或吊销。" ); + } + await loadVault(); + }); +} + +function componentActionButton(component, action, label, dangerous = false) { + return actionButton(label, () => runComponentAction(component, action), { + dangerous, + disabled: state.busy.has(`component-${component.id}`) + }); +} + +function componentActions(component) { + const actions = element("div", "admin-actions"); + actions.append(component.lifecycle === "archived" + ? componentActionButton(component, "restore", "恢复") + : componentActionButton(component, "archive", "归档")); + if (component.monitor.enabled) { + actions.append(component.monitor.status === "paused" + ? componentActionButton(component, "resume", "恢复探测") + : componentActionButton(component, "pause", "暂停探测")); + } + if (["missing", "archived"].includes(component.lifecycle)) { + actions.append(componentActionButton(component, "purge", "永久清理", true)); + } + return actions; +} + +function componentRow(component) { + const row = element("article", "admin-component"); + const identity = element("div", "admin-component-identity"); + identity.append(componentIcon(component)); + const title = element("div"); + title.append(element("h2", "", component.name), element("code", "", component.id)); + identity.append(title); + const status = element("div", "admin-component-status"); + status.append( + pill(lifecycleLabels[component.lifecycle] || component.lifecycle, `lifecycle-${component.lifecycle}`), + pill(monitorLabels[component.monitor.status] || component.monitor.status, `monitor-${component.monitor.status}`) + ); + const details = element("dl", "admin-details"); + const values = [ + ["最后发现", formatTime(component.lastSeen)], + ["最后检查", formatTime(component.monitor.checkedAt)], + ["响应时间", Number.isFinite(component.monitor.responseTimeMs) ? `${component.monitor.responseTimeMs} ms` : "—"], + ["24h 可用率", Number.isFinite(component.monitor.uptime24h) ? `${component.monitor.uptime24h.toFixed(2)}%` : "—"] + ]; + for (const [name, value] of values) { + const item = element("div"); + item.append(element("dt", "", name), element("dd", "", value)); + details.append(item); + } + if (component.monitor.error) { + details.append(element("p", "admin-error", `最近错误:${component.monitor.error}`)); + } + row.append(identity, status, details, componentActions(component)); + return row; +} + +function renderComponents() { + const target = document.getElementById("admin-components"); + target.replaceChildren(); + document.getElementById("admin-total").textContent = String(state.components.length); + document.getElementById("admin-down").textContent = String( + state.components.filter((component) => component.monitor.status === "down").length + ); + if (!state.components.length) { + target.append(element("div", "empty-state", "Registry 中没有组件。")); + return; + } + state.components.forEach((component) => target.append(componentRow(component))); +} + +async function loadComponents() { + try { + const payload = await request("/api/admin/components"); + state.components = Array.isArray(payload.components) ? payload.components : []; + renderComponents(); + document.getElementById("admin-updated").textContent = `更新于 ${formatTime(payload.generatedAt)}`; + } catch (error) { + document.getElementById("admin-components").replaceChildren( + element("div", "error-state", `管理数据暂时不可用:${error.message}`) + ); + document.getElementById("admin-updated").textContent = "Registry 管理接口不可用"; + } +} + +async function runComponentAction(component, action) { + if (action === "purge" && !window.confirm(`永久清理 ${component.name} 及其全部监控历史?此操作不可撤销。`)) { + return; + } + await withBusy(`component-${component.id}`, async () => { + await request(`/api/admin/components/${component.id}/${action}`, {method: "POST", body: "{}"}); + await loadComponents(); + }); +} + +async function withBusy(key, operation) { + if (state.busy.has(key)) { + return; + } + state.busy.add(key); + try { + await operation(); + } catch (error) { + window.alert(`操作失败:${error.message}`); + } finally { + state.busy.delete(key); + } +} + +document.getElementById("vault-refresh").addEventListener("click", loadVault); +document.getElementById("admin-refresh").addEventListener("click", loadComponents); +document.getElementById("identity-refresh").addEventListener("click", loadIdentity); + +document.getElementById("identity-search-form").addEventListener("submit", async (event) => { + event.preventDefault(); + state.identitySearch = String(new FormData(event.currentTarget).get("search") || "").trim(); + await loadIdentity(); +}); + +document.getElementById("user-create-form").addEventListener("submit", async (event) => { + event.preventDefault(); + await withBusy("user-create", async () => { + await mutateIdentity("/api/admin/identity/users", Object.fromEntries(new FormData(event.currentTarget).entries())); + event.currentTarget.reset(); + }); +}); + +document.getElementById("group-create-form").addEventListener("submit", async (event) => { + event.preventDefault(); + await withBusy("group-create", async () => { + await mutateIdentity("/api/admin/identity/groups", Object.fromEntries(new FormData(event.currentTarget).entries())); + event.currentTarget.reset(); + }); +}); + + +document.getElementById("credential-create-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const payload = { + providerId: data.get("providerId"), + name: data.get("name"), + secret: {provider: {apiKey: data.get("apiKey")}} + }; + await withBusy("credential-create", async () => { + await request("/api/admin/vault/credentials", { + method: "POST", + body: JSON.stringify(payload) + }); + event.currentTarget.reset(); + await loadVault(); + }); +}); + + +async function initialize() { + try { + await loadSession(); + await Promise.all([loadIdentity(), loadVault(), loadComponents()]); + } catch (error) { + document.getElementById("admin-session").textContent = `管理员会话不可用:${error.message}`; + } +} + +initialize(); diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..c71693c --- /dev/null +++ b/site/app.js @@ -0,0 +1,456 @@ +const state = { + components: [], + filter: "all", + generatedAt: null +}; + +const publicAccessModes = new Set(["public"]); +const onlineStates = new Set(["up"]); +const knownComponentIcons = { + gitea: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg", + hedgedoc: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg", + hedgedoc2: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg", + "code-server": "https://cdn.simpleicons.org/coder", + chat: "https://xiteng.site/icons/services/chat.svg", + comfyui: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg", + invokeai: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg", + authentik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "authentik-worker": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "authentik-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "seaweedfs-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "authentik-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "gitea-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "hedgedoc-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "authentik-redis": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg", + "cloudflare-tunnel": "https://cdn.simpleicons.org/cloudflare", + traefik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg", + portal: "https://xiteng.site/favicon.svg" +}; +const requestedFocusId = new URLSearchParams(window.location.search).get("focus") || ""; +const focusComponentId = /^[a-z0-9][a-z0-9-]*$/.test(requestedFocusId) ? requestedFocusId : ""; +let focusHandled = false; + +const statusLabels = { + running: "运行中", + restarting: "重启中", + unhealthy: "异常", + degraded: "部分异常", + paused: "已暂停", + exited: "已停止", + dead: "不可用", + created: "待启动", + missing: "已消失", + archived: "已归档", + unknown: "未知" +}; + +const monitorLabels = { + up: "服务可用", + down: "服务不可用", + degraded: "等待重试", + pending: "等待检查", + paused: "探测暂停", + unmonitored: "未配置探测" +}; + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) { + return "—"; + } + + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const digits = value >= 100 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(digits)} ${units[unitIndex]}`; +} + +function normalizedPercent(value) { + return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0; +} + +function updateMetric(name, percent, detail, secondary) { + const value = normalizedPercent(percent); + document.getElementById(`${name}-value`).textContent = Number.isFinite(percent) ? `${value.toFixed(1)}%` : "N/A"; + const progress = document.getElementById(`${name}-progress`); + progress.value = value; + progress.textContent = `${value.toFixed(1)}%`; + document.getElementById(`${name}-detail`).textContent = detail; + document.getElementById(`${name}-secondary`).textContent = secondary; +} + +function renderMetrics(payload) { + document.getElementById("device-hostname").textContent = payload.hostname || "HOMELAB"; + + updateMetric( + "cpu", + payload.cpu?.usagePercent, + `${payload.cpu?.logicalCores || "—"} 线程 · ${payload.cpu?.model || "Unknown CPU"}`, + `LOAD ${payload.cpu?.load1?.toFixed(2) ?? "—"} / ${payload.cpu?.load5?.toFixed(2) ?? "—"} / ${payload.cpu?.load15?.toFixed(2) ?? "—"}` + ); + + updateMetric( + "memory", + payload.memory?.usagePercent, + `${formatBytes(payload.memory?.usedBytes)} / ${formatBytes(payload.memory?.totalBytes)}`, + `AVAILABLE ${formatBytes(payload.memory?.availableBytes)}` + ); + + updateMetric( + "disk", + payload.disk?.usagePercent, + `${formatBytes(payload.disk?.usedBytes)} / ${formatBytes(payload.disk?.totalBytes)}`, + `${payload.disk?.device || "ROOT"} · AVAILABLE ${formatBytes(payload.disk?.availableBytes)}` + ); + + const gpu = payload.gpu?.devices?.[0]; + if (payload.gpu?.available && gpu) { + updateMetric( + "gpu", + gpu.utilizationPercent, + gpu.name, + `VRAM ${gpu.memoryUsedMiB?.toFixed(0) ?? "—"} / ${gpu.memoryTotalMiB?.toFixed(0) ?? "—"} MiB · ${gpu.temperatureCelsius?.toFixed(0) ?? "—"}°C · ${gpu.powerDrawWatts?.toFixed(0) ?? "—"} W` + ); + } else { + updateMetric("gpu", null, "未发现 NVIDIA GPU", "VRAM —"); + } + + const updated = new Date(payload.generatedAt); + document.getElementById("metrics-updated").textContent = `实时公开指标 · ${updated.toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit"})}`; +} + +function renderMetricsError() { + for (const name of ["cpu", "memory", "disk", "gpu"]) { + updateMetric(name, null, "指标暂时不可用", "等待采集器恢复"); + } + document.getElementById("metrics-updated").textContent = "主机指标暂时不可用"; +} + +async function loadMetrics() { + try { + const response = await fetch("/api/metrics", { + headers: {"Accept": "application/json"}, + cache: "no-store" + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + renderMetrics(await response.json()); + } catch (error) { + console.error("Failed to load host metrics", error); + renderMetricsError(); + } +} + +function createElement(tagName, className, text) { + const element = document.createElement(tagName); + if (className) { + element.className = className; + } + if (text !== undefined) { + element.textContent = text; + } + return element; +} + +function componentIconSources(component) { + const sources = []; + const explicit = safeHref(component.iconUrl); + if (explicit) sources.push(explicit); + const known = safeHref(knownComponentIcons[component.id]); + if (known) sources.push(known); + const service = safeHref(component.url); + if (service) { + const origin = new URL(service).origin; + sources.push(`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`); + } + return [...new Set(sources)]; +} + +function componentIcon(component) { + const icon = createElement("span", "component-icon"); + icon.append(createElement("span", "component-icon-fallback", component.icon || component.name.slice(0, 2).toUpperCase())); + const sources = componentIconSources(component); + const trySource = (index) => { + if (index >= sources.length) return; + const image = document.createElement("img"); + image.alt = ""; + image.decoding = "async"; + image.referrerPolicy = "no-referrer"; + let finished = false; + const timeout = window.setTimeout(() => { + finished = true; + trySource(index + 1); + }, 4000); + image.addEventListener("load", () => { + if (finished) return; + finished = true; + window.clearTimeout(timeout); + icon.querySelector("img")?.remove(); + icon.append(image); + icon.classList.add("has-image"); + }, {once: true}); + image.addEventListener("error", () => { + if (finished) return; + finished = true; + window.clearTimeout(timeout); + trySource(index + 1); + }, {once: true}); + image.src = sources[index]; + }; + trySource(0); + return icon; +} + +function isPublic(component) { + return publicAccessModes.has(component.access); +} + +function matchesFilter(component) { + if (state.filter === "public") { + return isPublic(component); + } + if (state.filter === "restricted") { + return !isPublic(component); + } + if (state.filter === "online") { + return onlineStates.has(component.monitor?.status); + } + return true; +} + +function safeHref(value) { + if (!value) { + return null; + } + + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch { + return null; + } +} + +function componentCard(component) { + const href = safeHref(component.url); + const card = createElement(href ? "a" : "article", `component-card accent-${component.accent || "ink"}`); + card.dataset.componentId = component.id; + + if (href) { + card.href = href; + if (component.navigation !== "same-tab") { + card.target = "_blank"; + card.rel = "noopener noreferrer"; + } + card.setAttribute("aria-label", component.navigation === "same-tab" ? `打开 ${component.name}` : `在新标签打开 ${component.name}`); + } + + const head = createElement("div", "component-head"); + head.append(componentIcon(component)); + + const states = createElement("div", "component-state-stack"); + const status = createElement( + "span", + `component-status status-${component.status || "unknown"}`, + `容器 · ${statusLabels[component.status] || statusLabels.unknown}` + ); + states.append(status); + if (component.monitor?.enabled) { + states.append(createElement( + "span", + `component-status component-monitor-status monitor-${component.monitor.status || "pending"}`, + monitorLabels[component.monitor.status] || "等待检查" + )); + } + head.append(states); + card.append(head); + + card.append(createElement("p", "component-kicker", component.category || "组件")); + card.append(createElement("h3", "", component.name)); + card.append(createElement("p", "component-description", component.description || "未提供说明")); + + const foot = createElement("div", "component-foot"); + const tags = createElement("div", "component-tags"); + tags.append(createElement( + "span", + `tag ${isPublic(component) ? "access-public" : "access-restricted"}`, + component.accessLabel || (isPublic(component) ? "无需登录" : "受控访问") + )); + + if (component.endpoint) { + tags.append(createElement("span", "tag", component.endpoint)); + } + + if (component.instanceCount > 1) { + tags.append(createElement("span", "tag", `${component.instanceCount} 实例`)); + } + + if (component.monitor?.enabled && Number.isFinite(component.monitor.uptime24h)) { + tags.append(createElement("span", "tag", `24H ${component.monitor.uptime24h.toFixed(2)}%`)); + } + + if (component.monitor?.enabled && Number.isFinite(component.monitor.responseTimeMs)) { + tags.append(createElement("span", "tag", `${component.monitor.responseTimeMs} ms`)); + } + + foot.append(tags); + + const meta = createElement("div", "component-meta"); + meta.append(createElement("span", "", component.image || component.service || "Docker service")); + if (href) { + meta.append(createElement("span", "component-open", component.navigation === "same-tab" ? "打开 →" : "新标签打开 ↗")); + } else { + meta.append(createElement("span", "", component.service || "internal")); + } + foot.append(meta); + card.append(foot); + + return card; +} + +function renderCatalog(targetId, section) { + const target = document.getElementById(targetId); + const visible = state.components.filter((component) => component.section === section && matchesFilter(component)); + target.replaceChildren(); + + if (!visible.length) { + target.append(createElement("div", "empty-state", "当前筛选条件下没有组件。")); + return; + } + + const groups = new Map(); + for (const component of visible) { + const category = component.category || "其他"; + if (!groups.has(category)) { + groups.set(category, []); + } + groups.get(category).push(component); + } + + for (const [category, components] of groups) { + const group = createElement("section", "category-group"); + group.append(createElement("h3", "category-title", `${category} · ${components.length}`)); + const grid = createElement("div", "component-grid"); + for (const component of components) { + grid.append(componentCard(component)); + } + group.append(grid); + target.append(group); + } +} + +function renderSummary() { + const total = state.components.length; + const running = state.components.filter((component) => component.monitor?.status === "up").length; + const restricted = state.components.filter((component) => !isPublic(component)).length; + + document.getElementById("component-count").textContent = String(total); + document.getElementById("running-count").textContent = String(running); + document.getElementById("restricted-count").textContent = String(restricted); + + const updated = document.getElementById("catalog-updated"); + if (state.generatedAt) { + const date = new Date(state.generatedAt); + updated.textContent = `统一发现与健康检查 · 更新于 ${date.toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit"})}`; + } +} + +function focusReturnedComponent() { + if (focusHandled || !focusComponentId) return; + const card = document.querySelector(`[data-component-id="${CSS.escape(focusComponentId)}"]`); + if (!card) return; + focusHandled = true; + window.requestAnimationFrame(() => { + card.scrollIntoView({behavior: "smooth", block: "center"}); + card.classList.add("return-focus"); + window.setTimeout(() => card.classList.remove("return-focus"), 1800); + }); + const url = new URL(window.location.href); + url.searchParams.delete("focus"); + window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); +} + +function render() { + renderSummary(); + renderCatalog("services-catalog", "services"); + renderCatalog("infrastructure-catalog", "infrastructure"); + focusReturnedComponent(); +} + +function renderError(message) { + for (const targetId of ["services-catalog", "infrastructure-catalog"]) { + const target = document.getElementById(targetId); + target.replaceChildren(createElement("div", "error-state", message)); + } + document.getElementById("catalog-updated").textContent = "组件目录暂时不可用"; +} + +async function loadComponents() { + try { + const response = await fetch("/api/components", { + headers: {"Accept": "application/json"}, + cache: "no-store" + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = await response.json(); + if (!Array.isArray(payload.components)) { + throw new Error("invalid registry response"); + } + + state.components = payload.components; + if (focusComponentId && state.components.some((component) => component.id === focusComponentId)) { + state.filter = "all"; + for (const button of document.querySelectorAll(".filter")) { + button.classList.toggle("active", button.dataset.filter === "all"); + } + } + state.generatedAt = payload.generatedAt; + render(); + } catch (error) { + console.error("Failed to load component catalog", error); + renderError("无法读取动态组件目录,请稍后刷新。"); + } +} + +for (const button of document.querySelectorAll(".filter")) { + button.addEventListener("click", () => { + state.filter = button.dataset.filter || "all"; + for (const item of document.querySelectorAll(".filter")) { + item.classList.toggle("active", item === button); + } + render(); + }); +} + +document.getElementById("year").textContent = String(new Date().getFullYear()); +loadComponents(); +loadMetrics(); + +setInterval(() => { + if (document.visibilityState === "visible") { + loadComponents(); + } +}, 30000); + +setInterval(() => { + if (document.visibilityState === "visible") { + loadMetrics(); + } +}, 5000); + +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch((error) => { + console.error("Failed to register service worker", error); + }); + }); +} diff --git a/site/authentik.mjs b/site/authentik.mjs new file mode 100644 index 0000000..ff10769 --- /dev/null +++ b/site/authentik.mjs @@ -0,0 +1,337 @@ +import {appendFileSync, readFileSync} from "node:fs"; + +const managedApplications = new Map([ + ["xiteng-portal-admin", "Portal"], + ["xiteng-portal", "Portal Home"], + ["xiteng-chat", "Xiteng Chat"], + ["code-server", "Code Server"], + ["comfyui", "ComfyUI"], + ["invokeai", "InvokeAI"], + ["gitea", "Gitea"], + ["hedgedoc", "HedgeDoc"], + ["hedgedoc2", "HedgeDoc 2"], + ["minio", "SeaweedFS Console"], + ["seaweedfs", "SeaweedFS"], + ["remark42", "Remark42"] +]); + + +function text(value, maximum = 160) { + return typeof value === "string" ? value.trim().slice(0, maximum) : ""; +} + +function readAudit(path) { + try { + return readFileSync(path, "utf8").trim().split("\n").filter(Boolean).slice(-100).reverse().map(JSON.parse); + } catch { + return []; + } +} + +function reject(message, statusCode = 400) { + const error = new Error(message); + error.statusCode = statusCode; + throw error; +} + + +function writeAudit(path, actor, action, target, detail = "") { + appendFileSync(path, `${JSON.stringify({createdAt: new Date().toISOString(), actor, action, target, detail})}\n`, {mode: 0o600}); +} + +export function createAuthentikAdmin({baseUrl, token, auditPath, adminUsername = "liooil", protectedGroup = "liuhome"}) { + async function request(pathname, {method = "GET", body} = {}) { + if (!token) { + const error = new Error("Authentik API token is not configured"); + error.statusCode = 503; + throw error; + } + const response = await fetch(new URL(`/api/v3${pathname}`, baseUrl), { + method, + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + ...(body === undefined ? {} : {"Content-Type": "application/json"}) + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(10000) + }); + const payload = response.status === 204 ? null : await response.json().catch(() => null); + if (!response.ok) { + const detail = payload && typeof payload === "object" + ? Object.entries(payload).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`).join("; ") + : `HTTP ${response.status}`; + const error = new Error(detail || `Authentik HTTP ${response.status}`); + error.statusCode = response.status; + throw error; + } + return payload; + } + + async function page(pathname) { + const payload = await request(pathname); + return Array.isArray(payload) ? payload : payload?.results || []; + } + + async function groupByName(name) { + const groups = await page(`/core/groups/?name=${encodeURIComponent(name)}&include_users=true&page_size=100`); + return groups.find((group) => group.name === name) || null; + } + + async function userByUsername(username) { + const users = await page(`/core/users/?username=${encodeURIComponent(username)}&include_groups=true&page_size=10`); + const user = users.find((item) => item.username === username && item.type === "internal"); + if (!user) reject("Authentik 用户不存在", 404); + return user; + } + + async function securityForUser(user) { + const [totpDevices, passkeys, sessions] = await Promise.all([ + page("/authenticators/admin/totp/?page_size=200"), + page("/authenticators/admin/webauthn/?page_size=200"), + page(`/core/authenticated_sessions/?user__username=${encodeURIComponent(user.username)}&page_size=100`) + ]); + return { + totp: totpDevices.filter((device) => device.user?.pk === user.pk).map((device) => ({id: device.pk, name: device.name})), + passkeys: passkeys.filter((device) => device.user?.pk === user.pk).map((device) => ({ + id: device.pk, + name: device.name, + createdAt: device.created_on, + deviceType: device.device_type, + aaguid: device.aaguid + })), + sessions: sessions.filter((session) => session.user === user.pk).map((session) => ({ + id: session.uuid, + current: session.current, + ip: session.last_ip, + userAgent: session.last_user_agent, + lastUsed: session.last_used, + expires: session.expires + })) + }; + } + + async function accountSummary(username) { + const user = await userByUsername(username); + const security = await securityForUser(user); + const next = encodeURIComponent("https://xiteng.site/account"); + return { + profile: { + id: user.pk, + uuid: user.uuid, + username: user.username, + name: user.name, + email: user.email, + groups: (user.groups_obj || []).map((group) => group.name), + }, + security: { + ...security, + totpSetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-totp-setup/?next=${next}`, + passkeySetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-webauthn-setup/?next=${next}` + } + }; + } + + async function accountMutate(pathname, body, username) { + const user = await userByUsername(username); + let result; + let action; + if (pathname === "/api/account/profile") { + result = await request(`/core/users/${user.pk}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}}); + action = "profile.update"; + } else if (pathname === "/api/account/security/password") { + result = await request(`/core/users/${user.pk}/recovery/`, {method: "POST", body: {token_duration: "minutes=15"}}); + action = "password.recovery"; + } else { + const deviceAction = pathname.match(/^\/api\/account\/security\/(totp|passkeys)\/(\d+)\/(rename|delete)$/); + const sessionAction = pathname.match(/^\/api\/account\/security\/sessions\/([0-9a-f-]+)\/delete$/); + if (deviceAction) { + const [, kind, id, operation] = deviceAction; + const endpoint = kind === "totp" ? "totp" : "webauthn"; + const device = await request(`/authenticators/admin/${endpoint}/${id}/`); + if (device.user?.pk !== user.pk) reject("认证设备不属于当前用户", 404); + result = operation === "rename" + ? await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "PATCH", body: {name: text(body.name, 200)}}) + : await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "DELETE"}); + action = `${kind}.${operation}`; + } else if (sessionAction) { + const session = await request(`/core/authenticated_sessions/${sessionAction[1]}/`); + if (session.user !== user.pk) reject("会话不属于当前用户", 404); + result = await request(`/core/authenticated_sessions/${sessionAction[1]}/`, {method: "DELETE"}); + action = "session.delete"; + } else if (pathname === "/api/account/security/sessions/all") { + result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${user.pk}`, {method: "DELETE"}); + action = "session.delete_all"; + } else { + reject("Account action not found", 404); + } + } + writeAudit(auditPath, username, action, username, "{}"); + return result; + } + + + async function summary(search = "") { + const query = new URLSearchParams({type: "internal", include_groups: "true", page_size: "100"}); + if (search) query.set("search", search); + const [users, groups, applications, bindings, totpDevices, passkeys, health] = await Promise.all([ + page(`/core/users/?${query}`), + page("/core/groups/?include_users=true&page_size=100"), + page("/core/applications/?superuser_full_list=true&page_size=100"), + page("/policies/bindings/?page_size=200"), + page("/authenticators/admin/totp/?page_size=200"), + page("/authenticators/admin/webauthn/?page_size=200"), + fetch(new URL("/-/health/ready/", baseUrl), {signal: AbortSignal.timeout(3000)}).then((response) => response.ok).catch(() => false) + ]); + const humanUsers = users.filter((user) => user.type === "internal" && user.path === "users" && user.username !== "AnonymousUser" && !user.username.startsWith("ak-")); + const ordinaryGroups = groups.filter((group) => !group.is_superuser && !group.name.startsWith("authentik ")); + const groupNames = new Map(groups.map((group) => [group.pk, group.name])); + const applicationBySlug = new Map(applications.map((application) => [application.slug, application])); + return { + configured: Boolean(token), + healthy: health, + protectedGroup, + users: humanUsers.map((user) => ({ + id: user.pk, + uuid: user.uuid, + username: user.username, + name: user.name, + email: user.email, + active: user.is_active, + administrator: user.is_superuser, + lastLogin: user.last_login, + createdAt: user.date_joined, + groups: (user.groups_obj || []).map((group) => ({id: group.pk, name: group.name})), + totpCount: totpDevices.filter((device) => device.user?.pk === user.pk).length, + passkeyCount: passkeys.filter((device) => device.user?.pk === user.pk).length + })), + groups: ordinaryGroups.map((group) => ({ + id: group.pk, + name: group.name, + userIds: group.users || [], + users: (group.users_obj || []).map((user) => ({id: user.pk, username: user.username, name: user.name})) + })), + applications: [...managedApplications].map(([slug, fallbackName]) => { + const application = applicationBySlug.get(slug); + const allowedGroupIds = application + ? bindings.filter((binding) => binding.target === application.pk && binding.enabled !== false && binding.group).map((binding) => binding.group) + : []; + return { + slug, + name: application?.name || fallbackName, + available: Boolean(application), + allowedGroupIds, + allowedGroups: allowedGroupIds.map((id) => groupNames.get(id) || id) + }; + }), + audit: readAudit(auditPath) + }; + } + + async function mutate(pathname, body, actor) { + let result; + let action; + let target; + + if (pathname === "/api/admin/identity/users") { + const username = text(body.username, 150); + if (!/^[A-Za-z0-9@._-]+$/.test(username)) reject("用户名只能包含字母、数字和 @._-"); + const defaultGroup = await groupByName(protectedGroup); + const groups = Array.isArray(body.groupIds) && body.groupIds.length ? body.groupIds : defaultGroup ? [defaultGroup.pk] : []; + result = await request("/core/users/", {method: "POST", body: { + username, + name: text(body.name) || username, + email: text(body.email, 254), + is_active: true, + path: "users", + type: "internal", + groups + }}); + action = "user.create"; + target = username; + } else { + const userAction = pathname.match(/^\/api\/admin\/identity\/users\/(\d+)\/(update|enable|disable|password|recovery|sessions|reset-totp|reset-passkeys)$/); + const groupAction = pathname.match(/^\/api\/admin\/identity\/groups\/([0-9a-f-]+)\/(update|delete|members)$/); + const appAction = pathname.match(/^\/api\/admin\/identity\/applications\/([a-z0-9-]+)\/groups$/); + if (userAction) { + const [, id, operation] = userAction; + const user = await request(`/core/users/${id}/`); + if (user.username === adminUsername && operation === "disable") reject(`不能停用管理员 ${adminUsername}`, 409); + if (operation === "update") { + result = await request(`/core/users/${id}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}}); + } else if (["enable", "disable"].includes(operation)) { + result = await request(`/core/users/${id}/`, {method: "PATCH", body: {is_active: operation === "enable"}}); + } else if (operation === "password") { + const password = text(body.password, 1024); + if (password.length < 12) reject("临时密码至少需要 12 个字符"); + await request(`/core/users/${id}/set_password/`, {method: "POST", body: {password}}); + result = {changed: true}; + } else if (operation === "recovery") { + result = await request(`/core/users/${id}/recovery/`, {method: "POST", body: {token_duration: "hours=1"}}); + } else if (["reset-totp", "reset-passkeys"].includes(operation)) { + const endpoint = operation === "reset-totp" ? "totp" : "webauthn"; + const devices = (await page(`/authenticators/admin/${endpoint}/?page_size=200`)).filter((device) => device.user?.pk === user.pk); + await Promise.all(devices.map((device) => request(`/authenticators/admin/${endpoint}/${device.pk}/`, {method: "DELETE"}))); + result = {deleted: devices.length}; + } else { + result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${id}`, {method: "DELETE"}); + } + action = `user.${operation}`; + target = user.username; + } else if (pathname === "/api/admin/identity/groups") { + const name = text(body.name); + if (!name) reject("用户组名称不能为空"); + result = await request("/core/groups/", {method: "POST", body: {name, is_superuser: false}}); + action = "group.create"; + target = name; + } else if (groupAction) { + const [, id, operation] = groupAction; + const group = await request(`/core/groups/${id}/?include_users=true`); + if (group.name === protectedGroup && ["update", "delete"].includes(operation)) reject(`不能修改或删除基础用户组 ${protectedGroup}`, 409); + if (operation === "update") { + result = await request(`/core/groups/${id}/`, {method: "PATCH", body: {name: text(body.name)}}); + } else if (operation === "delete") { + result = await request(`/core/groups/${id}/`, {method: "DELETE"}); + } else { + const userId = Number.parseInt(body.userId, 10); + const user = await request(`/core/users/${userId}/`); + if (group.name === protectedGroup && [adminUsername, "ziyue"].includes(user.username) && body.member === false) { + reject(`${user.username} 必须保留在 ${protectedGroup}`, 409); + } + await request(`/core/groups/${id}/${body.member === false ? "remove_user" : "add_user"}/`, {method: "POST", body: {pk: userId}}); + result = {changed: true}; + } + action = `group.${operation}`; + target = group.name; + } else if (appAction) { + const slug = appAction[1]; + if (!managedApplications.has(slug)) reject("应用不在 Portal 管理范围内", 404); + const application = await request(`/core/applications/${slug}/`); + const current = await page(`/policies/bindings/?target=${application.pk}&page_size=100`); + await Promise.all(current.map((binding) => request(`/policies/bindings/${binding.pk}/`, {method: "DELETE"}))); + const groupIds = [...new Set(Array.isArray(body.groupIds) ? body.groupIds : [])]; + await Promise.all(groupIds.map((group) => request("/policies/bindings/", {method: "POST", body: { + target: application.pk, + group, + order: 0, + enabled: true, + negate: false, + failure_result: false + }}))); + result = {allowedGroupIds: groupIds}; + action = "application.groups"; + target = slug; + } else { + const error = new Error("Identity action not found"); + error.statusCode = 404; + throw error; + } + } + + const auditDetail = action === "user.password" ? "{}" : JSON.stringify(body); + writeAudit(auditPath, actor, action, target, auditDetail); + return result; + } + + return {summary, mutate, accountSummary, accountMutate}; +} diff --git a/site/authentik.test.mjs b/site/authentik.test.mjs new file mode 100644 index 0000000..89437d6 --- /dev/null +++ b/site/authentik.test.mjs @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {mkdtempSync, readFileSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {createAuthentikAdmin} from "./authentik.mjs"; + +const user = { + pk: 3, + uuid: "user-uuid", + username: "liooil", + name: "西腾", + email: "liooil@example.com", + is_active: true, + is_superuser: true, + type: "internal", + path: "users", + groups_obj: [{pk: "group-id", name: "liuhome"}], + last_login: null, + date_joined: "2026-01-01T00:00:00Z" +}; +const group = {pk: "group-id", name: "liuhome", is_superuser: false, users: [3], users_obj: [{pk: 3, username: "liooil", name: "西腾"}]}; +const application = {pk: "app-id", slug: "xiteng-chat", name: "Xiteng Chat"}; +const binding = {pk: "binding-id", target: "app-id", group: "group-id", enabled: true}; + +function response(payload, status = 200) { + return new Response(payload === null ? null : JSON.stringify(payload), {status, headers: {"Content-Type": "application/json"}}); +} + +test("summarizes only human users and liuhome application access", async () => { + const directory = mkdtempSync(join(tmpdir(), "authentik-admin-test-")); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const path = new URL(url).pathname; + if (path === "/-/health/ready/") return response({status: "ok"}); + if (path === "/api/v3/core/users/") return response({results: [user, {...user, pk: 9, username: "ak-outpost", path: "goauthentik.io/outposts"}]}); + if (path === "/api/v3/core/groups/") return response({results: [group]}); + if (path === "/api/v3/core/applications/") return response({results: [application]}); + if (path === "/api/v3/policies/bindings/") return response({results: [binding]}); + if (path === "/api/v3/authenticators/admin/totp/") return response({results: [{pk: 1, name: "Phone", user: {pk: 3}}]}); + if (path === "/api/v3/authenticators/admin/webauthn/") return response({results: [{pk: 2, name: "Laptop", user: {pk: 3}}]}); + throw new Error(`Unexpected URL ${url}`); + }; + try { + const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: join(directory, "audit.jsonl")}); + const summary = await admin.summary(); + assert.deepEqual(summary.users.map((item) => item.username), ["liooil"]); + assert.deepEqual(summary.groups.map((item) => item.name), ["liuhome"]); + assert.deepEqual(summary.applications.find((item) => item.slug === "xiteng-chat").allowedGroups, ["liuhome"]); + assert.equal(summary.users[0].totpCount, 1); + assert.equal(summary.users[0].passkeyCount, 1); + } finally { + globalThis.fetch = originalFetch; + rmSync(directory, {recursive: true, force: true}); + } +}); + +test("refuses to disable liooil", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (new URL(url).pathname === "/api/v3/core/users/3/") return response(user); + throw new Error(`Unexpected URL ${url}`); + }; + try { + const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"}); + await assert.rejects(() => admin.mutate("/api/admin/identity/users/3/disable", {}, "liooil"), (error) => { + assert.equal(error.statusCode, 409); + return /不能停用/.test(error.message); + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("does not write temporary passwords to the identity audit", async () => { + const directory = mkdtempSync(join(tmpdir(), "authentik-audit-test-")); + const auditPath = join(directory, "audit.jsonl"); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options = {}) => { + const path = new URL(url).pathname; + if (path === "/api/v3/core/users/3/") return response(user); + if (path === "/api/v3/core/users/3/set_password/" && options.method === "POST") return response(null, 204); + throw new Error(`Unexpected URL ${url}`); + }; + try { + const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath}); + await admin.mutate("/api/admin/identity/users/3/password", {password: "Temporary-secret-2026"}, "liooil"); + const audit = readFileSync(auditPath, "utf8"); + assert.doesNotMatch(audit, /Temporary-secret-2026/); + assert.match(audit, /user\.password/); + } finally { + globalThis.fetch = originalFetch; + rmSync(directory, {recursive: true, force: true}); + } +}); + +test("returns only the current user's authenticators and sessions", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const parsed = new URL(url); + const path = parsed.pathname; + if (path === "/api/v3/core/users/") return response({results: [user]}); + if (path === "/api/v3/authenticators/admin/totp/") return response({results: [ + {pk: 1, name: "Phone", user: {pk: 3}}, + {pk: 9, name: "Other", user: {pk: 99}} + ]}); + if (path === "/api/v3/authenticators/admin/webauthn/") return response({results: [ + {pk: 2, name: "Laptop", created_on: "2026-01-02T00:00:00Z", device_type: "single_device", aaguid: "aaguid", user: {pk: 3}} + ]}); + if (path === "/api/v3/core/authenticated_sessions/") return response({results: [ + {uuid: "session-id", user: 3, current: true, last_ip: "127.0.0.1", last_user_agent: "Browser", last_used: "2026-01-03T00:00:00Z", expires: "2026-01-04T00:00:00Z"}, + {uuid: "other-session", user: 99} + ]}); + throw new Error(`Unexpected URL ${url}`); + }; + try { + const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"}); + const account = await admin.accountSummary("liooil"); + assert.equal(account.profile.username, "liooil"); + assert.deepEqual(account.security.totp.map((device) => device.name), ["Phone"]); + assert.deepEqual(account.security.passkeys.map((device) => device.name), ["Laptop"]); + assert.deepEqual(account.security.sessions.map((session) => session.id), ["session-id"]); + assert.match(account.security.totpSetupUrl, /default-authenticator-totp-setup/); + assert.match(account.security.passkeySetupUrl, /default-authenticator-webauthn-setup/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("refuses to mutate another user's authenticator", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const path = new URL(url).pathname; + if (path === "/api/v3/core/users/") return response({results: [user]}); + if (path === "/api/v3/authenticators/admin/webauthn/9/") return response({pk: 9, name: "Other", user: {pk: 99}}); + throw new Error(`Unexpected URL ${url}`); + }; + try { + const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"}); + await assert.rejects(() => admin.accountMutate("/api/account/security/passkeys/9/delete", {}, "liooil"), (error) => error.statusCode === 404); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/site/compose.yml b/site/compose.yml new file mode 100644 index 0000000..36ecf54 --- /dev/null +++ b/site/compose.yml @@ -0,0 +1,211 @@ +services: + metrics: + image: python:3-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc + container_name: xiteng-site-metrics + restart: unless-stopped + command: ["python3", "/app/metrics.py"] + environment: + - PORT=8092 + - HOST_PROC=/host/proc + - DISK_PATH=/host/disk + - CACHE_TTL_SECONDS=2 + - NVIDIA_VISIBLE_DEVICES=all + - NVIDIA_DRIVER_CAPABILITIES=utility + volumes: + - ./metrics.py:/app/metrics.py:ro + - /proc:/host/proc:ro + - /etc/hostname:/host/hostname:ro + - ./:/host/disk:ro + read_only: true + security_opt: + - no-new-privileges:true + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8092/healthz', timeout=2)"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - homelab_net + labels: + - "traefik.enable=false" + - "xiteng.site.component.host-metrics.enabled=true" + - "xiteng.site.component.host-metrics.name=Host Metrics" + - "xiteng.site.component.host-metrics.description=采集并公开 CPU、内存、磁盘与 GPU 的实时汇总指标。" + - "xiteng.site.component.host-metrics.section=infrastructure" + - "xiteng.site.component.host-metrics.category=可观测性" + - "xiteng.site.component.host-metrics.access=public" + - "xiteng.site.component.host-metrics.access-label=公开只读指标" + - "xiteng.site.component.host-metrics.icon=HW" + - "xiteng.site.component.host-metrics.accent=green" + - "xiteng.site.component.host-metrics.order=590" + + registry: + image: node:24-alpine + container_name: xiteng-site-registry + restart: unless-stopped + command: ["node", "/app/registry.mjs"] + environment: + - PORT=8091 + - DOCKER_SOCKET=/var/run/docker.sock + - DATABASE_PATH=/data/registry.db + - DISCOVERY_INTERVAL_MS=5000 + - MISSING_RETENTION_DAYS=30 + - RAW_RETENTION_DAYS=30 + - AGGREGATE_RETENTION_DAYS=365 + - MONITOR_CONCURRENCY=4 + volumes: + - ./registry.mjs:/app/registry.mjs:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./data:/data + read_only: true + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8091/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - homelab_net + labels: + - "traefik.enable=false" + - "xiteng.site.component.site-registry.enabled=true" + - "xiteng.site.component.site-registry.name=Component Registry" + - "xiteng.site.component.site-registry.description=只读发现 Docker Label,并向门户输出白名单组件元数据。" + - "xiteng.site.component.site-registry.section=infrastructure" + - "xiteng.site.component.site-registry.category=门户" + - "xiteng.site.component.site-registry.access=internal" + - "xiteng.site.component.site-registry.access-label=仅容器网络" + - "xiteng.site.component.site-registry.icon=API" + - "xiteng.site.component.site-registry.accent=blue" + - "xiteng.site.component.site-registry.order=12" + + xiteng-site: + image: node:24-alpine + container_name: xiteng-site + restart: unless-stopped + command: ["node", "/app/server.mjs"] + environment: + - PORT=8080 + - REGISTRY_URL=http://xiteng-site-registry:8091/components + - REGISTRY_ADMIN_URL=http://xiteng-site-registry:8091/admin + - METRICS_URL=http://xiteng-site-metrics:8092/metrics + - KEY_VAULT_URL=http://ai-gateway:8093 + - PROVIDER_TEST_URL=http://xiteng-chat:3000/api/provider-test + - KEY_VAULT_TOKEN_FILE=/run/secrets/portal_gateway_hmac + - ADMIN_USERNAME=liooil + - AUTHENTIK_ISSUER=https://auth.xiteng.site + - AUTHENTIK_URL=https://auth.xiteng.site + - AUTHENTIK_TOKEN_FILE=/run/authentik-secrets/portal_api_token + - IDENTITY_AUDIT_PATH=/data/identity-audit.jsonl + volumes: + - ./index.html:/app/index.html:ro + - ./styles.css:/app/styles.css:ro + - ./app.js:/app/app.js:ro + - ./sw.js:/app/sw.js:ro + - ./manifest.webmanifest:/app/manifest.webmanifest:ro + - ./favicon.svg:/app/favicon.svg:ro + - ./favicon.ico:/app/favicon.ico:ro + - ./icons:/app/icons:ro + - ./admin.html:/app/admin.html:ro + - ./admin.js:/app/admin.js:ro + - ./account.html:/app/account.html:ro + - ./account.js:/app/account.js:ro + - ./authentik.mjs:/app/authentik.mjs:ro + - ./server.mjs:/app/server.mjs:ro + - ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro + - ../authentik/secrets/portal_api_token:/run/authentik-secrets/portal_api_token:ro + - ./data:/data + read_only: true + security_opt: + - no-new-privileges:true + depends_on: + registry: + condition: service_healthy + metrics: + condition: service_healthy + networks: + - homelab_net + labels: + # Traefik + - "traefik.enable=true" + - "traefik.http.routers.xiteng-site.rule=Host(`xiteng.site`)" + - "traefik.http.routers.xiteng-site.entrypoints=websecure" + - "traefik.http.routers.xiteng-site.tls=true" + - "traefik.http.routers.xiteng-site.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-site.service=xiteng-site" + - "traefik.http.routers.xiteng-site.middlewares=xiteng-site-nocache" + - "traefik.http.services.xiteng-site.loadbalancer.server.port=8080" + - "traefik.http.routers.xiteng-site-http.rule=Host(`xiteng.site`)" + - "traefik.http.routers.xiteng-site-http.service=xiteng-site" + - "traefik.http.routers.xiteng-site-http.entrypoints=web" + - "traefik.http.routers.xiteng-site-http.middlewares=xiteng-site-nocache" + - "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Cache-Control=no-store, no-cache, must-revalidate" + - "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Pragma=no-cache" + - "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Expires=0" + # Authentik ForwardAuth protects all Portal administration and account routes. + - "traefik.http.middlewares.xiteng-admin-scheme.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.xiteng-admin-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.xiteng-admin-auth.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.xiteng-admin-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid" + - "traefik.http.middlewares.xiteng-admin-context.headers.customrequestheaders.X-Portal-Authenticated=1" + - "traefik.http.routers.xiteng-site-admin.rule=Host(`xiteng.site`) && (PathPrefix(`/admin`) || PathPrefix(`/account`) || PathPrefix(`/api/admin`) || PathPrefix(`/api/account`))" + - "traefik.http.routers.xiteng-site-admin.entrypoints=websecure" + - "traefik.http.routers.xiteng-site-admin.tls=true" + - "traefik.http.routers.xiteng-site-admin.tls.certresolver=cfresolver" + - "traefik.http.routers.xiteng-site-admin.service=xiteng-site" + - "traefik.http.routers.xiteng-site-admin.middlewares=xiteng-admin-scheme,xiteng-admin-auth,xiteng-admin-context,xiteng-site-nocache" + - "traefik.http.routers.xiteng-site-admin.priority=200" + - "traefik.http.routers.xiteng-site-admin-http.rule=Host(`xiteng.site`) && (PathPrefix(`/admin`) || PathPrefix(`/account`) || PathPrefix(`/api/admin`) || PathPrefix(`/api/account`))" + - "traefik.http.routers.xiteng-site-admin-http.entrypoints=web" + - "traefik.http.routers.xiteng-site-admin-http.service=xiteng-site" + - "traefik.http.routers.xiteng-site-admin-http.middlewares=xiteng-admin-scheme,xiteng-admin-auth,xiteng-admin-context,xiteng-site-nocache" + - "traefik.http.routers.xiteng-site-admin-http.priority=200" + # The native Authentik admin UI is hidden; login, OAuth/OIDC and API endpoints remain available. + - "traefik.http.routers.authentik-admin-hidden.rule=Host(`auth.xiteng.site`) && PathPrefix(`/if/admin`)" + - "traefik.http.routers.authentik-admin-hidden.entrypoints=websecure" + - "traefik.http.routers.authentik-admin-hidden.tls=true" + - "traefik.http.routers.authentik-admin-hidden.tls.certresolver=cfresolver" + - "traefik.http.routers.authentik-admin-hidden.service=xiteng-site" + - "traefik.http.routers.authentik-admin-hidden.priority=5000" + - "traefik.http.routers.authentik-admin-hidden-http.rule=Host(`auth.xiteng.site`) && PathPrefix(`/if/admin`)" + - "traefik.http.routers.authentik-admin-hidden-http.entrypoints=web" + - "traefik.http.routers.authentik-admin-hidden-http.service=xiteng-site" + - "traefik.http.routers.authentik-admin-hidden-http.priority=5000" + - "xiteng.site.cache.portal-static.enabled=true" + - "xiteng.site.cache.portal-static.routers=xiteng-site,xiteng-site-http" + - "xiteng.site.cache.portal-static.paths=/icons/" + - "xiteng.site.cache.portal-static.edge-ttl=604800" + - "xiteng.site.cache.portal-static.stale-while-revalidate=86400" + # Dynamic catalog + - "xiteng.site.component.portal.enabled=true" + - "xiteng.site.component.portal.name=Xiteng Portal" + - "xiteng.site.component.portal.description=唯一公开入口、个人主页与 Authentik 保护的 Homelab 管理控制面。" + - "xiteng.site.component.portal.section=infrastructure" + - "xiteng.site.component.portal.category=门户" + - "xiteng.site.component.portal.url=https://xiteng.site" + - "xiteng.site.component.portal.access=public" + - "xiteng.site.component.portal.access-label=公开目录" + - "xiteng.site.component.portal.icon=XT" + - "xiteng.site.component.portal.icon-url=https://xiteng.site/favicon.svg" + - "xiteng.site.component.portal.accent=red" + - "xiteng.site.component.portal.order=10" + - "xiteng.site.component.portal.monitor.enabled=true" + - "xiteng.site.component.portal.monitor.url=http://xiteng-site:8080/healthz" + - "xiteng.site.component.portal.monitor.interval=60" + - "xiteng.site.component.portal.monitor.failures=3" + - "xiteng.site.component.portal.monitor.timeout=10" + - "xiteng.site.component.portal.monitor.accept=200-299" + +networks: + homelab_net: + external: true diff --git a/site/favicon.ico b/site/favicon.ico new file mode 100644 index 0000000..657ba88 Binary files /dev/null and b/site/favicon.ico differ diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..2ffa937 --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,7 @@ + + Xiteng Portal + A red geometric X on a cream tile with a yellow background. + + + + diff --git a/site/icons/apple-touch-icon.png b/site/icons/apple-touch-icon.png new file mode 100644 index 0000000..f29be3f Binary files /dev/null and b/site/icons/apple-touch-icon.png differ diff --git a/site/icons/favicon-32.png b/site/icons/favicon-32.png new file mode 100644 index 0000000..3bfc816 Binary files /dev/null and b/site/icons/favicon-32.png differ diff --git a/site/icons/icon-192.png b/site/icons/icon-192.png new file mode 100644 index 0000000..7348741 Binary files /dev/null and b/site/icons/icon-192.png differ diff --git a/site/icons/icon-512.png b/site/icons/icon-512.png new file mode 100644 index 0000000..d99f4c4 Binary files /dev/null and b/site/icons/icon-512.png differ diff --git a/site/icons/icon-maskable-512.png b/site/icons/icon-maskable-512.png new file mode 100644 index 0000000..d99f4c4 Binary files /dev/null and b/site/icons/icon-maskable-512.png differ diff --git a/site/icons/services/chat.svg b/site/icons/services/chat.svg new file mode 100644 index 0000000..365e132 --- /dev/null +++ b/site/icons/services/chat.svg @@ -0,0 +1 @@ + diff --git a/site/import-kuma.mjs b/site/import-kuma.mjs new file mode 100644 index 0000000..ab1fee1 --- /dev/null +++ b/site/import-kuma.mjs @@ -0,0 +1,142 @@ +import {DatabaseSync} from "node:sqlite"; + +const sourcePath = process.env.KUMA_DATABASE || "/migration/kuma.db"; +const targetPath = process.env.REGISTRY_DATABASE || "/data/registry.db"; +const rawRetentionDays = Number.parseInt(process.env.RAW_RETENTION_DAYS || "30", 10); +const aggregateRetentionDays = Number.parseInt(process.env.AGGREGATE_RETENTION_DAYS || "365", 10); + +const source = new DatabaseSync(sourcePath, {readOnly: true}); +const target = new DatabaseSync(targetPath); +target.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); + +const components = target.prepare(` + SELECT id, monitor_json FROM registry_component WHERE monitor_json IS NOT NULL +`).all(); +const sourceMonitors = source.prepare(` + SELECT m.id, m.name, m.url, COUNT(h.id) AS checks + FROM monitor m + LEFT JOIN heartbeat h ON h.monitor_id = m.id + GROUP BY m.id + ORDER BY checks DESC, m.id ASC +`).all(); +const heartbeats = source.prepare(` + SELECT status, time, ping, msg + FROM heartbeat + WHERE monitor_id = ? AND time >= ? + ORDER BY time ASC +`); +const dailyStats = source.prepare(` + SELECT timestamp, ping, up, down + FROM stat_daily + WHERE monitor_id = ? AND timestamp >= ? + ORDER BY timestamp ASC +`); + +const insertCheck = target.prepare(` + INSERT OR IGNORE INTO monitor_check ( + component_id, checked_at, ok, status_code, latency_ms, error + ) VALUES (?, ?, ?, ?, ?, ?) +`); +const upsertHourly = target.prepare(` + INSERT INTO monitor_hourly (component_id, bucket, total, successful, latency_sum) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(component_id, bucket) DO UPDATE SET + total = total + 1, + successful = successful + excluded.successful, + latency_sum = latency_sum + excluded.latency_sum +`); +const upsertDaily = target.prepare(` + INSERT INTO monitor_daily (component_id, bucket, total, successful, latency_sum) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(component_id, bucket) DO NOTHING +`); + +function isoFromKuma(value) { + return new Date(value.endsWith("Z") ? value : `${value}Z`).toISOString(); +} + +function statusCode(message) { + const match = typeof message === "string" ? message.match(/\b(\d{3})\b/) : null; + return match ? Number.parseInt(match[1], 10) : null; +} + +function safeError(message, ok) { + if (ok || typeof message !== "string") { + return null; + } + return message.replace(/[\r\n\t]+/g, " ").slice(0, 300); +} + +function normalizedUrl(value) { + try { + const url = new URL(value); + url.pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + return url.href.replace(/\/$/, ""); + } catch { + return value; + } +} + +const rawCutoff = new Date(Date.now() - rawRetentionDays * 86400000); +const kumaRawCutoff = rawCutoff.toISOString().replace("T", " ").replace("Z", ""); +const aggregateCutoff = Math.floor((Date.now() - aggregateRetentionDays * 86400000) / 1000); +const imported = []; + +target.exec("BEGIN IMMEDIATE"); +try { + for (const component of components) { + const monitor = JSON.parse(component.monitor_json); + const candidates = sourceMonitors.filter((candidate) => normalizedUrl(candidate.url) === normalizedUrl(monitor.url)); + if (!candidates.length) { + continue; + } + + const canonical = candidates[0]; + let rawChecks = 0; + for (const heartbeat of heartbeats.all(canonical.id, kumaRawCutoff)) { + const checkedAt = isoFromKuma(heartbeat.time); + const ok = heartbeat.status === 1; + const result = insertCheck.run( + component.id, + checkedAt, + ok ? 1 : 0, + statusCode(heartbeat.msg), + Number.isFinite(heartbeat.ping) ? Math.max(0, Math.round(heartbeat.ping)) : null, + safeError(heartbeat.msg, ok) + ); + if (result.changes > 0) { + const latency = Number.isFinite(heartbeat.ping) ? Math.max(0, Math.round(heartbeat.ping)) : 0; + upsertHourly.run(component.id, `${checkedAt.slice(0, 13)}:00:00.000Z`, ok ? 1 : 0, latency); + rawChecks += 1; + } + } + + let dailyRows = 0; + for (const daily of dailyStats.all(canonical.id, aggregateCutoff)) { + const total = daily.up + daily.down; + const bucket = new Date(daily.timestamp * 1000).toISOString().slice(0, 10) + "T00:00:00.000Z"; + const result = upsertDaily.run( + component.id, + bucket, + total, + daily.up, + Math.max(0, Math.round((daily.ping || 0) * total)) + ); + dailyRows += result.changes; + } + + imported.push({ + component: component.id, + monitorId: canonical.id, + monitorName: canonical.name, + rawChecks, + dailyRows + }); + } + target.exec("COMMIT"); +} catch (error) { + target.exec("ROLLBACK"); + throw error; +} + +console.log(JSON.stringify({imported}, null, 2)); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..c73c4da --- /dev/null +++ b/site/index.html @@ -0,0 +1,209 @@ + + + + + + + + + + + + liooil / xiteng.site + + + + + + + + +
    + +
    + +
    +
    +
    +

    PERSONAL INDEX / HOMELAB CATALOG

    +

    一个入口,
    完整呈现。

    +

    + 服务是否存在与谁能使用它是两件事。这里公开展示全部应用与基础设施; + 登录、密钥和内网边界仍由各组件自己负责。 +

    + +
    + +
    +
    + LIVE CATALOG + +
    +
    +
    +
    +
    已声明组件
    +
    +
    +
    +
    服务可用
    +
    +
    +
    +
    受控入口
    +
    +
    +

    正在读取 Docker Label…

    +
    +
    + +
    +
    +
    +
    +

    DEVICE STATUS / HOMELAB

    +

    设备状态

    +
    +

    正在读取主机指标…

    +
    + +
    +
    +
    CPUPROCESSOR
    + + 0% +

    正在读取处理器状态…

    +

    LOAD —

    +
    + +
    +
    MEMMEMORY
    + + 0% +

    正在读取内存状态…

    +

    AVAILABLE —

    +
    + +
    +
    DISKROOT FS
    + + 0% +

    正在读取磁盘状态…

    +

    /

    +
    + +
    +
    GPUNVIDIA
    + + 0% +

    正在读取显卡状态…

    +

    VRAM —

    +
    +
    +
    +
    + +
    +
    +
    + 01 + 存在是公开信息 +

    组件名称、用途、技术栈、入口和运行状态对所有访客可见。

    +
    +
    + 02 + 授权保护数据与操作 +

    SSO、Access Key、SSH Key 或内网边界在进入组件时生效。

    +
    +
    + 03 + Label 决定目录内容 +

    容器自行声明展示信息;页面不维护硬编码服务清单。

    +
    +
    +
    + +
    +
    +
    +

    APPLICATIONS / ENDPOINTS

    +

    服务目录

    +
    +

    所有可使用的应用和协议入口。需要认证的服务会在卡片上明确标注。

    +
    + +
    + + + + +
    + +
    +
    正在发现服务…
    +
    +
    + +
    +
    +
    +
    +

    EDGE / IDENTITY / DATA / OPS

    +

    基础设施

    +
    +

    公开系统的组成与职责;不公开密码、Token、私钥和其他秘密值。

    +
    +
    +
    正在发现基础设施…
    +
    +
    +
    + +
    +
    +
    +

    OPERATING MODEL

    +

    Now

    +
    +
    +
    +
    + DISCOVERY +

    统一入口

    +

    xiteng.site 是唯一目录;组件由 Docker Label 自动加入或移除。

    +
    +
    + ACCESS +

    按服务授权

    +

    门户保持公开,受控服务在点击后交给 Authentik 或对应凭据系统。

    +
    +
    + TRANSPARENCY +

    架构默认公开

    +

    公开组件、镜像与状态;环境变量、挂载、原始 Label 和秘密值不进入目录 API。

    +
    +
    +
    +
    + + + + diff --git a/site/manifest.webmanifest b/site/manifest.webmanifest new file mode 100644 index 0000000..f33a33b --- /dev/null +++ b/site/manifest.webmanifest @@ -0,0 +1,48 @@ +{ + "id": "/", + "name": "Xiteng Portal", + "short_name": "Xiteng", + "description": "动态 Homelab 服务目录、设备状态与健康监控。", + "lang": "zh-CN", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#f7efe0", + "theme_color": "#f0c94b", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "服务目录", + "short_name": "服务", + "url": "/#services", + "icons": [{"src": "/icons/icon-192.png", "sizes": "192x192"}] + }, + { + "name": "基础设施", + "short_name": "设施", + "url": "/#infrastructure", + "icons": [{"src": "/icons/icon-192.png", "sizes": "192x192"}] + } + ] +} diff --git a/site/metrics.py b/site/metrics.py new file mode 100644 index 0000000..432a514 --- /dev/null +++ b/site/metrics.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Expose a small, sanitized snapshot of host CPU, memory, disk, and NVIDIA GPU usage.""" + +import json +import os +import subprocess +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +PORT = int(os.environ.get("PORT", "8092")) +HOST_PROC = Path(os.environ.get("HOST_PROC", "/host/proc")) +DISK_PATH = Path(os.environ.get("DISK_PATH", "/host/disk")) +CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "2")) + +cache_lock = threading.Lock() +cached_snapshot = None +cached_at = 0.0 +previous_cpu = None + + +def read_text(path): + return path.read_text(encoding="utf-8", errors="replace") + + +def read_cpu_times(): + first_line = read_text(HOST_PROC / "stat").splitlines()[0] + values = [int(value) for value in first_line.split()[1:]] + idle = values[3] + (values[4] if len(values) > 4 else 0) + return sum(values), idle + + +def cpu_usage_percent(): + global previous_cpu + + current = read_cpu_times() + if previous_cpu is None: + previous_cpu = current + time.sleep(0.12) + current = read_cpu_times() + + total_delta = current[0] - previous_cpu[0] + idle_delta = current[1] - previous_cpu[1] + previous_cpu = current + if total_delta <= 0: + return 0.0 + return round(max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100)), 1) + + +def cpu_info(): + cpuinfo = read_text(HOST_PROC / "cpuinfo") + model = "Unknown CPU" + logical_cores = 0 + for line in cpuinfo.splitlines(): + if line.startswith("processor"): + logical_cores += 1 + elif line.startswith("model name") and model == "Unknown CPU": + model = line.split(":", 1)[1].strip() + + load_values = read_text(HOST_PROC / "loadavg").split() + return { + "usagePercent": cpu_usage_percent(), + "logicalCores": logical_cores, + "model": model, + "load1": float(load_values[0]), + "load5": float(load_values[1]), + "load15": float(load_values[2]), + } + + +def memory_info(): + values = {} + for line in read_text(HOST_PROC / "meminfo").splitlines(): + key, raw_value = line.split(":", 1) + values[key] = int(raw_value.strip().split()[0]) * 1024 + + total = values["MemTotal"] + available = values["MemAvailable"] + used = total - available + return { + "usedBytes": used, + "availableBytes": available, + "totalBytes": total, + "usagePercent": round(used / total * 100, 1), + } + + +def root_device(): + for line in read_text(HOST_PROC / "1" / "mountinfo").splitlines(): + fields = line.split() + if len(fields) < 10 or fields[4] != "/" or "-" not in fields: + continue + separator = fields.index("-") + if len(fields) > separator + 2: + return fields[separator + 2] + return "root filesystem" + + +def disk_info(): + stats = os.statvfs(DISK_PATH) + total = stats.f_blocks * stats.f_frsize + free = stats.f_bfree * stats.f_frsize + available = stats.f_bavail * stats.f_frsize + used = total - free + return { + "device": root_device(), + "mount": "/", + "usedBytes": used, + "availableBytes": available, + "totalBytes": total, + "usagePercent": round(used / total * 100, 1), + } + + +def optional_float(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + +def gpu_info(): + query = ",".join([ + "index", + "name", + "utilization.gpu", + "memory.used", + "memory.total", + "temperature.gpu", + "power.draw", + "power.limit", + ]) + + try: + result = subprocess.run( + [ + "nvidia-smi", + f"--query-gpu={query}", + "--format=csv,noheader,nounits", + ], + capture_output=True, + check=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return {"available": False, "devices": []} + + devices = [] + for line in result.stdout.splitlines(): + fields = [field.strip() for field in line.split(",")] + if len(fields) != 8: + continue + devices.append({ + "index": int(fields[0]), + "name": fields[1], + "utilizationPercent": optional_float(fields[2]), + "memoryUsedMiB": optional_float(fields[3]), + "memoryTotalMiB": optional_float(fields[4]), + "temperatureCelsius": optional_float(fields[5]), + "powerDrawWatts": optional_float(fields[6]), + "powerLimitWatts": optional_float(fields[7]), + }) + + return {"available": bool(devices), "devices": devices} + + +def host_name(): + hostname_path = Path("/host/hostname") + if hostname_path.exists(): + return read_text(hostname_path).strip() + return "homelab" + + +def collect_snapshot(): + global cached_snapshot, cached_at + + now = time.monotonic() + with cache_lock: + if cached_snapshot is not None and now - cached_at < CACHE_TTL_SECONDS: + return cached_snapshot + + cached_snapshot = { + "generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "hostname": host_name(), + "cpu": cpu_info(), + "memory": memory_info(), + "disk": disk_info(), + "gpu": gpu_info(), + } + cached_at = time.monotonic() + return cached_snapshot + + +class MetricsHandler(BaseHTTPRequestHandler): + def send_json(self, status_code, payload): + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode() + self.send_response(status_code) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/healthz": + self.send_json(200, {"status": "ok"}) + return + if self.path != "/metrics": + self.send_json(404, {"error": "Not found"}) + return + + try: + self.send_json(200, collect_snapshot()) + except Exception as error: + print(f"metrics collection failed: {error}", flush=True) + self.send_json(503, {"error": "Metrics unavailable"}) + + def log_message(self, message_format, *args): + return + + +ThreadingHTTPServer(("0.0.0.0", PORT), MetricsHandler).serve_forever() diff --git a/site/registry.mjs b/site/registry.mjs new file mode 100644 index 0000000..8f356af --- /dev/null +++ b/site/registry.mjs @@ -0,0 +1,852 @@ +import http from "node:http"; +import {mkdirSync} from "node:fs"; +import {DatabaseSync} from "node:sqlite"; + +const port = Number.parseInt(process.env.PORT || "8091", 10); +const dockerSocket = process.env.DOCKER_SOCKET || "/var/run/docker.sock"; +const databasePath = process.env.DATABASE_PATH || "/data/registry.db"; +const discoveryIntervalMs = boundedInteger(process.env.DISCOVERY_INTERVAL_MS, 5000, 2000, 60000); +const missingRetentionDays = boundedInteger(process.env.MISSING_RETENTION_DAYS, 30, 1, 3650); +const rawRetentionDays = boundedInteger(process.env.RAW_RETENTION_DAYS, 30, 1, 3650); +const aggregateRetentionDays = boundedInteger(process.env.AGGREGATE_RETENTION_DAYS, 365, 30, 3650); +const monitorConcurrency = boundedInteger(process.env.MONITOR_CONCURRENCY, 4, 1, 32); +const adminProxyHeader = process.env.ADMIN_PROXY_HEADER || "X-Portal-Admin"; +const labelPrefix = "xiteng.site.component."; + +const allowedFields = new Set([ + "enabled", + "name", + "description", + "section", + "category", + "url", + "endpoint", + "access", + "access-label", + "icon", + "icon-url", + "navigation", + "portal-link", + "accent", + "order", + "monitor.enabled", + "monitor.url", + "monitor.interval", + "monitor.failures", + "monitor.timeout", + "monitor.accept" +]); + +const allowedSections = new Set(["services", "infrastructure"]); +const allowedAccents = new Set(["red", "green", "yellow", "blue", "ink"]); +const activeChecks = new Set(); +let discoveryRunning = false; +let lastDiscoveryAt = null; + +mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true}); +const database = new DatabaseSync(databasePath); +database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; + + CREATE TABLE IF NOT EXISTS registry_component ( + id TEXT PRIMARY KEY, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + lifecycle TEXT NOT NULL DEFAULT 'active', + missing_since TEXT, + archived_at TEXT, + archive_reason TEXT, + metadata_json TEXT NOT NULL, + monitor_json TEXT, + monitor_state TEXT NOT NULL DEFAULT 'unmonitored', + monitor_paused INTEGER NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_checked_at TEXT, + last_status_code INTEGER, + last_latency_ms INTEGER, + last_error TEXT + ); + + CREATE TABLE IF NOT EXISTS monitor_check ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE, + checked_at TEXT NOT NULL, + ok INTEGER NOT NULL, + status_code INTEGER, + latency_ms INTEGER, + error TEXT + ); + + CREATE INDEX IF NOT EXISTS monitor_check_component_time + ON monitor_check(component_id, checked_at); + + CREATE UNIQUE INDEX IF NOT EXISTS monitor_check_unique + ON monitor_check(component_id, checked_at); + + CREATE TABLE IF NOT EXISTS monitor_incident ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE, + started_at TEXT NOT NULL, + ended_at TEXT, + cause TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS monitor_incident_open + ON monitor_incident(component_id) WHERE ended_at IS NULL; + + CREATE TABLE IF NOT EXISTS monitor_hourly ( + component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE, + bucket TEXT NOT NULL, + total INTEGER NOT NULL DEFAULT 0, + successful INTEGER NOT NULL DEFAULT 0, + latency_sum INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (component_id, bucket) + ); + + CREATE TABLE IF NOT EXISTS monitor_daily ( + component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE, + bucket TEXT NOT NULL, + total INTEGER NOT NULL DEFAULT 0, + successful INTEGER NOT NULL DEFAULT 0, + latency_sum INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (component_id, bucket) + ); +`); + +const selectComponent = database.prepare("SELECT * FROM registry_component WHERE id = ?"); +const selectComponents = database.prepare("SELECT * FROM registry_component ORDER BY id"); +const insertComponent = database.prepare(` + INSERT INTO registry_component ( + id, first_seen, last_seen, lifecycle, metadata_json, monitor_json, monitor_state + ) VALUES (?, ?, ?, 'active', ?, ?, ?) +`); +const updateDiscoveredComponent = database.prepare(` + UPDATE registry_component + SET last_seen = ?, + lifecycle = ?, + missing_since = NULL, + archived_at = CASE WHEN archive_reason = 'manual' THEN archived_at ELSE NULL END, + archive_reason = CASE WHEN archive_reason = 'manual' THEN archive_reason ELSE NULL END, + metadata_json = ?, + monitor_json = ?, + monitor_state = ?, + monitor_paused = ? + WHERE id = ? +`); +const markMissing = database.prepare(` + UPDATE registry_component + SET lifecycle = 'missing', missing_since = ? + WHERE id = ? AND lifecycle = 'active' +`); +const markArchivedByRetention = database.prepare(` + UPDATE registry_component + SET lifecycle = 'archived', archived_at = ?, archive_reason = 'retention', + monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'paused' END, + monitor_paused = CASE WHEN monitor_json IS NULL THEN 0 ELSE 1 END + WHERE id = ? AND lifecycle = 'missing' +`); +const insertCheck = database.prepare(` + INSERT INTO monitor_check (component_id, checked_at, ok, status_code, latency_ms, error) + VALUES (?, ?, ?, ?, ?, ?) +`); +const upsertHourly = database.prepare(` + INSERT INTO monitor_hourly (component_id, bucket, total, successful, latency_sum) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(component_id, bucket) DO UPDATE SET + total = total + 1, + successful = successful + excluded.successful, + latency_sum = latency_sum + excluded.latency_sum +`); +const upsertDaily = database.prepare(` + INSERT INTO monitor_daily (component_id, bucket, total, successful, latency_sum) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(component_id, bucket) DO UPDATE SET + total = total + 1, + successful = successful + excluded.successful, + latency_sum = latency_sum + excluded.latency_sum +`); +const uptimeSince = database.prepare(` + SELECT COUNT(*) AS total, COALESCE(SUM(ok), 0) AS successful + FROM monitor_check + WHERE component_id = ? AND checked_at >= ? +`); +const openIncident = database.prepare(` + SELECT started_at FROM monitor_incident + WHERE component_id = ? AND ended_at IS NULL +`); + +function boundedInteger(value, fallback, minimum, maximum) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(minimum, Math.min(maximum, parsed)); +} + +function dockerGet(pathname) { + return new Promise((resolve, reject) => { + const request = http.request({ + socketPath: dockerSocket, + path: pathname, + method: "GET", + headers: {"Accept": "application/json"} + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + if ((response.statusCode || 500) >= 400) { + reject(new Error(`Docker API ${response.statusCode}`)); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); + + request.setTimeout(3000, () => request.destroy(new Error("Docker API timeout"))); + request.on("error", reject); + request.end(); + }); +} + +function limitedString(value, fallback = "", limit = 500) { + return typeof value === "string" ? value.trim().slice(0, limit) : fallback; +} + +function safeUrl(value) { + const candidate = limitedString(value); + if (!candidate) { + return ""; + } + + try { + const url = new URL(candidate); + if (url.username || url.password) { + return ""; + } + return ["http:", "https:"].includes(url.protocol) ? url.href : ""; + } catch { + return ""; + } +} + +function healthFromStatus(status) { + if (status.includes("(unhealthy)")) { + return "unhealthy"; + } + if (status.includes("(healthy)")) { + return "healthy"; + } + if (status.includes("(health: starting)")) { + return "starting"; + } + return "none"; +} + +function instanceState(container) { + const state = limitedString(container.State, "unknown").toLowerCase(); + const health = healthFromStatus(limitedString(container.Status)); + return health === "unhealthy" ? "unhealthy" : state; +} + +function aggregateStatus(instances) { + const states = instances.map((instance) => instance.state); + if (states.length > 0 && states.every((state) => state === "running")) { + return "running"; + } + if (states.includes("unhealthy")) { + return "unhealthy"; + } + if (states.includes("restarting")) { + return "restarting"; + } + if (states.includes("running")) { + return "degraded"; + } + for (const state of ["dead", "exited", "paused", "created"]) { + if (states.includes(state)) { + return state; + } + } + return "unknown"; +} + +function definitionsFromLabels(labels) { + const definitions = new Map(); + + for (const [key, value] of Object.entries(labels || {})) { + if (!key.startsWith(labelPrefix)) { + continue; + } + + const remainder = key.slice(labelPrefix.length); + const separator = remainder.indexOf("."); + if (separator <= 0) { + continue; + } + + const id = remainder.slice(0, separator); + const field = remainder.slice(separator + 1); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || !allowedFields.has(field)) { + continue; + } + + if (!definitions.has(id)) { + definitions.set(id, {}); + } + definitions.get(id)[field] = limitedString(value); + } + + return definitions; +} + +function publicDefinition(id, definition) { + const order = Number.parseInt(definition.order || "999", 10); + return { + id, + name: limitedString(definition.name, id), + description: limitedString(definition.description), + section: allowedSections.has(definition.section) ? definition.section : "infrastructure", + category: limitedString(definition.category, "其他"), + url: safeUrl(definition.url), + endpoint: limitedString(definition.endpoint, "", 160), + access: limitedString(definition.access, "internal", 40), + accessLabel: limitedString(definition["access-label"], "受控访问", 100), + icon: limitedString(definition.icon, id.slice(0, 2).toUpperCase(), 8), + iconUrl: safeUrl(definition["icon-url"]), + navigation: ["new-tab", "same-tab", "endpoint"].includes(definition.navigation) ? definition.navigation : (definition.url ? "new-tab" : "endpoint"), + portalLink: ["embedded", "native", "none"].includes(definition["portal-link"]) ? definition["portal-link"] : "none", + accent: allowedAccents.has(definition.accent) ? definition.accent : "ink", + order: Number.isFinite(order) ? order : 999 + }; +} + +function monitorDefinition(definition) { + if (definition["monitor.enabled"]?.toLowerCase() !== "true") { + return null; + } + + const url = safeUrl(definition["monitor.url"]); + if (!url) { + return null; + } + + return { + type: "http", + url, + interval: boundedInteger(definition["monitor.interval"], 60, 10, 86400), + failures: boundedInteger(definition["monitor.failures"], 3, 1, 20), + timeout: boundedInteger(definition["monitor.timeout"], 10, 1, 60), + accept: limitedString(definition["monitor.accept"], "200-299", 80) + }; +} + +function parseJson(value, fallback) { + try { + return value ? JSON.parse(value) : fallback; + } catch { + return fallback; + } +} + +async function discoverDockerComponents() { + const containers = await dockerGet("/containers/json?all=true"); + const components = new Map(); + + for (const container of containers) { + const labels = container.Labels || {}; + for (const [id, definition] of definitionsFromLabels(labels)) { + if (definition.enabled?.toLowerCase() !== "true") { + continue; + } + + const metadata = publicDefinition(id, definition); + const instance = { + container: limitedString(container.Names?.[0] || "", "", 160).replace(/^\//, ""), + service: limitedString(labels["com.docker.compose.service"], "docker", 160), + project: limitedString(labels["com.docker.compose.project"], "", 160), + image: limitedString(container.Image, "", 300), + state: instanceState(container), + health: healthFromStatus(limitedString(container.Status)) + }; + + if (!components.has(id)) { + components.set(id, {...metadata, monitor: monitorDefinition(definition), instances: []}); + } + components.get(id).instances.push(instance); + } + } + + for (const component of components.values()) { + const primary = component.instances[0]; + component.status = aggregateStatus(component.instances); + component.instanceCount = component.instances.length; + component.container = primary.container; + component.service = primary.service; + component.project = primary.project; + component.image = primary.image; + } + + return components; +} + +function reconcileComponent(id, discovered, now) { + const existing = selectComponent.get(id); + const metadata = {...discovered}; + delete metadata.monitor; + delete metadata.instances; + const metadataJson = JSON.stringify(metadata); + const monitorJson = discovered.monitor ? JSON.stringify(discovered.monitor) : null; + + if (!existing) { + insertComponent.run( + id, + now, + now, + metadataJson, + monitorJson, + monitorJson ? "pending" : "unmonitored" + ); + return; + } + + const manuallyArchived = existing.lifecycle === "archived" && existing.archive_reason === "manual"; + const monitorChanged = existing.monitor_json !== monitorJson; + let monitorState = existing.monitor_state; + let monitorPaused = existing.monitor_paused; + + if (!monitorJson) { + monitorState = "unmonitored"; + monitorPaused = 0; + } else if (manuallyArchived) { + monitorState = "paused"; + monitorPaused = 1; + } else if (monitorChanged || existing.lifecycle === "archived") { + monitorState = "pending"; + monitorPaused = 0; + } + + updateDiscoveredComponent.run( + now, + manuallyArchived ? "archived" : "active", + metadataJson, + monitorJson, + monitorState, + monitorPaused, + id + ); + + if (monitorChanged) { + database.prepare(` + UPDATE registry_component + SET consecutive_failures = 0, last_checked_at = NULL, last_status_code = NULL, + last_latency_ms = NULL, last_error = NULL + WHERE id = ? + `).run(id); + database.prepare(` + UPDATE monitor_incident SET ended_at = ? + WHERE component_id = ? AND ended_at IS NULL + `).run(now, id); + } +} + +async function reconcileDiscovery() { + if (discoveryRunning) { + return; + } + discoveryRunning = true; + + try { + const now = new Date().toISOString(); + const discovered = await discoverDockerComponents(); + + database.exec("BEGIN IMMEDIATE"); + try { + for (const [id, component] of discovered) { + reconcileComponent(id, component, now); + } + + for (const existing of selectComponents.all()) { + if (discovered.has(existing.id) || existing.lifecycle === "archived") { + continue; + } + + if (existing.lifecycle === "active") { + markMissing.run(now, existing.id); + continue; + } + + if (existing.lifecycle === "missing" && existing.missing_since) { + const missingForMs = Date.now() - new Date(existing.missing_since).getTime(); + if (missingForMs >= missingRetentionDays * 86400000) { + markArchivedByRetention.run(now, existing.id); + } + } + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + + lastDiscoveryAt = now; + } catch (error) { + console.error("Docker discovery failed", error.message); + } finally { + discoveryRunning = false; + } +} + +function statusAccepted(statusCode, expression) { + for (const part of expression.split(",")) { + const match = part.trim().match(/^(\d{3})(?:-(\d{3}))?$/); + if (!match) { + continue; + } + const minimum = Number.parseInt(match[1], 10); + const maximum = Number.parseInt(match[2] || match[1], 10); + if (statusCode >= minimum && statusCode <= maximum) { + return true; + } + } + return false; +} + +function safeError(error) { + const message = error instanceof Error ? error.message : String(error); + return limitedString(message.replace(/[\r\n\t]+/g, " "), "probe failed", 300); +} + +function updateAggregate(componentId, checkedAt, ok, latencyMs) { + const hourBucket = `${checkedAt.slice(0, 13)}:00:00.000Z`; + const dayBucket = `${checkedAt.slice(0, 10)}T00:00:00.000Z`; + upsertHourly.run(componentId, hourBucket, ok ? 1 : 0, latencyMs || 0); + upsertDaily.run(componentId, dayBucket, ok ? 1 : 0, latencyMs || 0); +} + +function recordCheck(componentId, monitor, result) { + const existing = selectComponent.get(componentId); + if (!existing) { + return; + } + + const nextFailures = result.ok ? 0 : existing.consecutive_failures + 1; + const nextState = result.ok ? "up" : nextFailures >= monitor.failures ? "down" : "degraded"; + + database.exec("BEGIN IMMEDIATE"); + try { + insertCheck.run( + componentId, + result.checkedAt, + result.ok ? 1 : 0, + result.statusCode, + result.latencyMs, + result.error + ); + updateAggregate(componentId, result.checkedAt, result.ok, result.latencyMs); + + database.prepare(` + UPDATE registry_component + SET monitor_state = ?, consecutive_failures = ?, last_checked_at = ?, + last_status_code = ?, last_latency_ms = ?, last_error = ? + WHERE id = ? + `).run( + nextState, + nextFailures, + result.checkedAt, + result.statusCode, + result.latencyMs, + result.error, + componentId + ); + + if (nextState === "down" && existing.monitor_state !== "down") { + database.prepare(` + INSERT OR IGNORE INTO monitor_incident (component_id, started_at, cause) + VALUES (?, ?, ?) + `).run(componentId, result.checkedAt, result.error || `HTTP ${result.statusCode || "error"}`); + } + + if (result.ok) { + database.prepare(` + UPDATE monitor_incident SET ended_at = ? + WHERE component_id = ? AND ended_at IS NULL + `).run(result.checkedAt, componentId); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } +} + +async function checkComponent(row, monitor) { + const checkedAt = new Date().toISOString(); + const startedAt = performance.now(); + let statusCode = null; + let ok = false; + let errorMessage = null; + + try { + const response = await fetch(monitor.url, { + method: "GET", + redirect: "follow", + headers: { + "Accept": "text/html,application/json;q=0.9,*/*;q=0.1", + "User-Agent": "Xiteng-Portal-Monitor/1.0" + }, + signal: AbortSignal.timeout(monitor.timeout * 1000) + }); + statusCode = response.status; + ok = statusAccepted(statusCode, monitor.accept); + if (!ok) { + errorMessage = `HTTP ${statusCode}`; + } + if (response.body) { + await response.body.cancel(); + } + } catch (error) { + errorMessage = safeError(error); + } + + const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); + recordCheck(row.id, monitor, { + checkedAt, + ok, + statusCode, + latencyMs, + error: errorMessage + }); +} + +function scheduleDueChecks() { + if (activeChecks.size >= monitorConcurrency) { + return; + } + + const now = Date.now(); + const candidates = selectComponents.all().filter((row) => { + if (!row.monitor_json || row.monitor_paused || !["active", "missing"].includes(row.lifecycle)) { + return false; + } + const monitor = parseJson(row.monitor_json, null); + if (!monitor) { + return false; + } + const lastChecked = row.last_checked_at ? new Date(row.last_checked_at).getTime() : 0; + return !activeChecks.has(row.id) && now - lastChecked >= monitor.interval * 1000; + }); + + for (const row of candidates.slice(0, monitorConcurrency - activeChecks.size)) { + const monitor = parseJson(row.monitor_json, null); + activeChecks.add(row.id); + checkComponent(row, monitor) + .catch((error) => console.error(`Monitor ${row.id} failed`, error.message)) + .finally(() => activeChecks.delete(row.id)); + } +} + +function availability(componentId, hours) { + const since = new Date(Date.now() - hours * 3600000).toISOString(); + const result = uptimeSince.get(componentId, since); + if (!result || result.total === 0) { + return null; + } + return Math.round((result.successful / result.total) * 10000) / 100; +} + +function publicComponent(row, includeAdmin = false) { + const metadata = parseJson(row.metadata_json, {id: row.id, name: row.id}); + const monitor = parseJson(row.monitor_json, null); + const incident = openIncident.get(row.id); + const component = { + ...metadata, + lifecycle: row.lifecycle, + status: row.lifecycle === "active" ? metadata.status : row.lifecycle, + monitor: { + enabled: Boolean(monitor), + status: monitor + ? row.monitor_paused ? "paused" : row.monitor_state + : "unmonitored", + checkedAt: row.last_checked_at, + responseTimeMs: row.last_latency_ms, + statusCode: row.last_status_code, + consecutiveFailures: row.consecutive_failures, + uptime24h: monitor ? availability(row.id, 24) : null, + incidentSince: incident?.started_at || null + } + }; + + if (includeAdmin) { + component.firstSeen = row.first_seen; + component.lastSeen = row.last_seen; + component.missingSince = row.missing_since; + component.archivedAt = row.archived_at; + component.archiveReason = row.archive_reason; + component.monitor.paused = Boolean(row.monitor_paused); + component.monitor.error = row.last_error; + } + + return component; +} + +function componentPayload(includeArchived = false, includeAdmin = false) { + const components = selectComponents.all() + .filter((row) => includeArchived || row.lifecycle !== "archived") + .map((row) => publicComponent(row, includeAdmin)) + .sort((left, right) => { + if (left.section !== right.section) { + return left.section.localeCompare(right.section); + } + if (left.order !== right.order) { + return left.order - right.order; + } + return left.name.localeCompare(right.name, "zh-CN"); + }); + + return { + generatedAt: new Date().toISOString(), + discoveredAt: lastDiscoveryAt, + components + }; +} + +function pruneHistory() { + const rawCutoff = new Date(Date.now() - rawRetentionDays * 86400000).toISOString(); + const aggregateCutoff = new Date(Date.now() - aggregateRetentionDays * 86400000).toISOString(); + database.prepare("DELETE FROM monitor_check WHERE checked_at < ?").run(rawCutoff); + database.prepare("DELETE FROM monitor_hourly WHERE bucket < ?").run(aggregateCutoff); + database.prepare("DELETE FROM monitor_daily WHERE bucket < ?").run(aggregateCutoff); +} + +function isAdminProxy(request) { + return request.headers[adminProxyHeader.toLowerCase()] === "1"; +} + +function adminAction(id, action) { + const row = selectComponent.get(id); + if (!row) { + return {statusCode: 404, payload: {error: "Component not found"}}; + } + + const now = new Date().toISOString(); + if (action === "archive") { + database.prepare(` + UPDATE registry_component + SET lifecycle = 'archived', archived_at = ?, archive_reason = 'manual', + monitor_paused = CASE WHEN monitor_json IS NULL THEN 0 ELSE 1 END, + monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'paused' END + WHERE id = ? + `).run(now, id); + } else if (action === "restore") { + const recentlySeen = Date.now() - new Date(row.last_seen).getTime() < discoveryIntervalMs * 3; + database.prepare(` + UPDATE registry_component + SET lifecycle = ?, missing_since = ?, archived_at = NULL, archive_reason = NULL, + monitor_paused = 0, + monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'pending' END + WHERE id = ? + `).run(recentlySeen ? "active" : "missing", recentlySeen ? null : now, id); + } else if (action === "pause") { + if (!row.monitor_json) { + return {statusCode: 409, payload: {error: "Monitoring is not configured"}}; + } + database.prepare(` + UPDATE registry_component SET monitor_paused = 1, monitor_state = 'paused' WHERE id = ? + `).run(id); + } else if (action === "resume") { + if (!row.monitor_json) { + return {statusCode: 409, payload: {error: "Monitoring is not configured"}}; + } + database.prepare(` + UPDATE registry_component + SET monitor_paused = 0, monitor_state = 'pending', consecutive_failures = 0 + WHERE id = ? + `).run(id); + } else if (action === "purge") { + const recentlySeen = Date.now() - new Date(row.last_seen).getTime() < discoveryIntervalMs * 3; + if (row.lifecycle !== "missing" && recentlySeen) { + return {statusCode: 409, payload: {error: "Remove or disable the component Label before purging"}}; + } + database.prepare("DELETE FROM registry_component WHERE id = ?").run(id); + return {statusCode: 200, payload: {status: "purged", id}}; + } else { + return {statusCode: 404, payload: {error: "Unknown action"}}; + } + + return { + statusCode: 200, + payload: {status: "ok", component: publicComponent(selectComponent.get(id), true)} + }; +} + +function sendJson(response, statusCode, payload, extraHeaders = {}) { + const body = JSON.stringify(payload); + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "X-Content-Type-Options": "nosniff", + ...extraHeaders + }); + response.end(body); +} + +const server = http.createServer(async (request, response) => { + const pathname = new URL(request.url || "/", "http://localhost").pathname; + + if (request.method === "GET" && pathname === "/healthz") { + sendJson(response, 200, { + status: "ok", + discovery: lastDiscoveryAt ? "ready" : "starting", + activeChecks: activeChecks.size + }); + return; + } + + if (request.method === "GET" && pathname === "/components") { + sendJson(response, 200, componentPayload(false, false)); + return; + } + + if (pathname.startsWith("/admin/") && !isAdminProxy(request)) { + sendJson(response, 403, {error: "Forbidden"}); + return; + } + + if (request.method === "GET" && pathname === "/admin/components") { + sendJson(response, 200, componentPayload(true, true)); + return; + } + + const actionMatch = pathname.match(/^\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/); + if (request.method === "POST" && actionMatch) { + const result = adminAction(actionMatch[1], actionMatch[2]); + sendJson(response, result.statusCode, result.payload); + return; + } + + if (!["GET", "POST"].includes(request.method || "")) { + sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, POST"}); + return; + } + + sendJson(response, 404, {error: "Not found"}); +}); + +await reconcileDiscovery(); +pruneHistory(); +setInterval(reconcileDiscovery, discoveryIntervalMs); +setInterval(scheduleDueChecks, 2000); +setInterval(pruneHistory, 6 * 3600000); + +server.listen(port, "0.0.0.0", () => { + console.log(`component registry and monitor listening on :${port}`); +}); diff --git a/site/server.mjs b/site/server.mjs new file mode 100644 index 0000000..ea20964 --- /dev/null +++ b/site/server.mjs @@ -0,0 +1,394 @@ +import {createAuthentikAdmin} from "./authentik.mjs"; +import {createReadStream, readFileSync} from "node:fs"; +import {stat} from "node:fs/promises"; +import http from "node:http"; + +const port = Number.parseInt(process.env.PORT || "8080", 10); +const registryUrl = process.env.REGISTRY_URL || "http://xiteng-site-registry:8091/components"; +const registryAdminUrl = process.env.REGISTRY_ADMIN_URL || "http://xiteng-site-registry:8091/admin"; +const metricsUrl = process.env.METRICS_URL || "http://xiteng-site-metrics:8092/metrics"; +const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093"; +const providerTestUrl = process.env.PROVIDER_TEST_URL || "http://xiteng-chat:3000/api/provider-test"; +const keyVaultTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac"; +const adminUsername = process.env.ADMIN_USERNAME || "liooil"; +const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site"; +const authentikUrl = process.env.AUTHENTIK_URL || "https://auth.xiteng.site"; +const authentikTokenFile = process.env.AUTHENTIK_TOKEN_FILE || "/run/authentik-secrets/portal_api_token"; +const identityAuditPath = process.env.IDENTITY_AUDIT_PATH || "/data/identity-audit.jsonl"; + +const staticFiles = new Map([ + ["/", {path: "/app/index.html", type: "text/html; charset=utf-8"}], + ["/index.html", {path: "/app/index.html", type: "text/html; charset=utf-8"}], + ["/styles.css", {path: "/app/styles.css", type: "text/css; charset=utf-8"}], + ["/app.js", {path: "/app/app.js", type: "text/javascript; charset=utf-8"}], + ["/sw.js", {path: "/app/sw.js", type: "text/javascript; charset=utf-8", cacheControl: "no-cache"}], + ["/manifest.webmanifest", {path: "/app/manifest.webmanifest", type: "application/manifest+json; charset=utf-8"}], + ["/favicon.svg", {path: "/app/favicon.svg", type: "image/svg+xml"}], + ["/favicon.ico", {path: "/app/favicon.ico", type: "image/x-icon"}], + ["/icons/favicon-32.png", {path: "/app/icons/favicon-32.png", type: "image/png"}], + ["/icons/apple-touch-icon.png", {path: "/app/icons/apple-touch-icon.png", type: "image/png"}], + ["/icons/icon-192.png", {path: "/app/icons/icon-192.png", type: "image/png"}], + ["/icons/icon-512.png", {path: "/app/icons/icon-512.png", type: "image/png"}], + ["/icons/icon-maskable-512.png", {path: "/app/icons/icon-maskable-512.png", type: "image/png"}], + ["/icons/services/chat.svg", {path: "/app/icons/services/chat.svg", type: "image/svg+xml"}], + ["/admin", {path: "/app/admin.html", type: "text/html; charset=utf-8"}], + ["/admin/", {path: "/app/admin.html", type: "text/html; charset=utf-8"}], + ["/admin.js", {path: "/app/admin.js", type: "text/javascript; charset=utf-8"}], + ["/account", {path: "/app/account.html", type: "text/html; charset=utf-8"}], + ["/account/", {path: "/app/account.html", type: "text/html; charset=utf-8"}], + ["/account.js", {path: "/app/account.js", type: "text/javascript; charset=utf-8"}] +]); + +const securityHeaders = { + "Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data: https://www.gravatar.com https://seccdn.libravatar.org https://cdn.jsdelivr.net https://cdn.simpleicons.org; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY" +}; + +function sendJson(response, statusCode, payload, extraHeaders = {}) { + const body = JSON.stringify(payload); + response.writeHead(statusCode, { + ...securityHeaders, + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + ...extraHeaders + }); + response.end(body); +} + + +function readOptionalSecret(path) { + try { + return readFileSync(path, "utf8").trim(); + } catch { + return null; + } +} + +const keyVaultToken = readOptionalSecret(keyVaultTokenFile); +const authentikAdmin = createAuthentikAdmin({ + baseUrl: authentikUrl, + token: readOptionalSecret(authentikTokenFile), + auditPath: identityAuditPath, + adminUsername +}); + +async function readBody(request, maximum = 65536) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maximum) { + const error = new Error("Request body too large"); + error.statusCode = 413; + throw error; + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function parseJson(body) { + try { + return body.length ? JSON.parse(body.toString("utf8")) : {}; + } catch { + const error = new Error("Invalid JSON body"); + error.statusCode = 400; + throw error; + } +} + +async function proxyJson(request, response, upstreamUrl, unavailableMessage, headers = {}, timeoutMs = 5000) { + try { + const method = request.method === "POST" ? "POST" : "GET"; + const body = method === "POST" ? await readBody(request) : undefined; + const upstream = await fetch(upstreamUrl, { + method, + body: body?.length ? body : undefined, + headers: { + "Accept": "application/json", + ...(body?.length ? {"Content-Type": "application/json"} : {}), + ...headers + }, + signal: AbortSignal.timeout(timeoutMs) + }); + const upstreamBody = await upstream.text(); + + response.writeHead(upstream.status, { + ...securityHeaders, + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(upstreamBody) + }); + response.end(upstreamBody); + } catch (error) { + console.error(unavailableMessage, error.message); + sendJson(response, 503, {error: unavailableMessage}); + } +} + +async function serveStatic(request, response, file) { + try { + const metadata = await stat(file.path); + response.writeHead(200, { + ...securityHeaders, + "Cache-Control": file.cacheControl || "no-cache", + "Content-Type": file.type, + "Content-Length": metadata.size + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + createReadStream(file.path).pipe(response); + } catch { + sendJson(response, 404, {error: "Not found"}); + } +} + +function header(request, name) { + const value = request.headers[name]; + return typeof value === "string" ? value.trim() : ""; +} + +function identityFromRequest(request) { + const username = header(request, "x-authentik-username"); + return { + issuer: authentikIssuer, + sub: header(request, "x-authentik-uid") || username, + username, + email: header(request, "x-authentik-email"), + admin: username === adminUsername, + provider: "authentik" + }; +} + +function isAuthenticated(request) { + return header(request, "x-portal-authenticated") === "1" && Boolean(identityFromRequest(request).username); +} + +function validOrigin(request) { + return request.headers.origin === "https://xiteng.site"; +} + +function requireMutationOrigin(request, response) { + if (!validOrigin(request)) { + sendJson(response, 403, {error: "Invalid request origin"}); + return false; + } + return true; +} + +async function proxyKeyVault(request, response, upstreamPath) { + if (!keyVaultToken) { + sendJson(response, 503, {error: "Key Vault is not initialized"}); + return; + } + try { + const body = request.method === "POST" ? await readBody(request, 1048576) : Buffer.alloc(0); + const actor = identityFromRequest(request); + const upstream = await fetch(new URL(upstreamPath, keyVaultUrl), { + method: request.method, + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${keyVaultToken}`, + ...(body.length ? {"Content-Type": "application/json"} : {}), + "X-Key-Vault-Actor-Issuer": actor.issuer, + "X-Key-Vault-Actor-Sub": actor.sub, + "X-Key-Vault-Actor-Username": actor.username + }, + body: body.length ? body : undefined, + signal: AbortSignal.timeout(10000) + }); + const upstreamBody = await upstream.text(); + response.writeHead(upstream.status, { + ...securityHeaders, + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(upstreamBody) + }); + response.end(upstreamBody); + } catch (error) { + console.error("Key Vault unavailable", error.message); + sendJson(response, 503, {error: "Key Vault unavailable"}); + } +} + + +function vaultUpstreamPath(pathname, admin, searchParams) { + const prefix = admin ? "/api/admin/vault" : "/api/account/vault"; + const suffix = pathname.slice(prefix.length); + if (suffix === "/providers") return "/v1/providers"; + if (/^\/providers\/[a-z0-9._-]+\/delete$/.test(suffix)) return `/v1${suffix}`; + if (suffix === "/credentials") { + return `/v1/credentials${admin && searchParams.get("scope") !== "own" ? "?scope=all" : ""}`; + } + if (suffix === "/audit") return `/v1/audit${admin ? "?scope=all&limit=200" : "?limit=100"}`; + if (/^\/credentials\/[0-9a-f-]+\/(replace|verify|delete)$/.test(suffix)) return `/v1${suffix}`; + return null; +} + +const server = http.createServer(async (request, response) => { + try { + if (!request.url) { + sendJson(response, 400, {error: "Bad request"}); + return; + } + + const url = new URL(request.url, "http://localhost"); + const pathname = url.pathname; + + if (request.method === "GET" && pathname === "/healthz") { + sendJson(response, 200, {status: "ok"}); + return; + } + + if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/components") { + await proxyJson(request, response, registryUrl, "Component registry unavailable"); + return; + } + + if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/metrics") { + await proxyJson(request, response, metricsUrl, "Host metrics unavailable"); + return; + } + + + const isAdminPath = pathname === "/admin" + || pathname === "/admin/" + || pathname === "/admin.js" + || pathname.startsWith("/api/admin/"); + const isAccountPath = pathname === "/account" + || pathname === "/account/" + || pathname === "/account.js" + || pathname.startsWith("/api/account/"); + + if ((isAdminPath || isAccountPath) && !isAuthenticated(request)) { + sendJson(response, 403, {error: "Authenticated user required"}); + return; + } + if (isAdminPath && !identityFromRequest(request).admin) { + sendJson(response, 403, {error: `Administrator ${adminUsername} required`}); + return; + } + + if (request.method === "GET" && ["/api/admin/session", "/api/account/session"].includes(pathname)) { + sendJson(response, 200, {identity: identityFromRequest(request), administrator: adminUsername}); + return; + } + + if (pathname === "/api/admin/identity" && request.method === "GET") { + sendJson(response, 200, await authentikAdmin.summary(url.searchParams.get("search") || "")); + return; + } + + if (pathname.startsWith("/api/admin/identity/") && request.method === "POST") { + if (!requireMutationOrigin(request, response)) { + return; + } + const result = await authentikAdmin.mutate( + pathname, + parseJson(await readBody(request)), + identityFromRequest(request).username + ); + sendJson(response, 200, {result}); + return; + } + + if (pathname === "/api/account/identity" && request.method === "GET") { + sendJson(response, 200, await authentikAdmin.accountSummary(identityFromRequest(request).username)); + return; + } + + if (pathname === "/api/account/provider-test" && request.method === "POST") { + if (!requireMutationOrigin(request, response)) return; + const identity = identityFromRequest(request); + await proxyJson(request, response, providerTestUrl, "Provider connectivity test unavailable", { + "X-Authentik-Username": identity.username, + "X-Authentik-Uid": identity.sub, + "X-Authentik-Email": identity.email + }, 20000); + return; + } + + if (pathname.startsWith("/api/account/") && request.method === "POST" && !pathname.startsWith("/api/account/vault")) { + if (!requireMutationOrigin(request, response)) return; + const result = await authentikAdmin.accountMutate( + pathname, + parseJson(await readBody(request)), + identityFromRequest(request).username + ); + sendJson(response, 200, {result}); + return; + } + + if (pathname.startsWith("/api/admin/vault") || pathname.startsWith("/api/account/vault")) { + const admin = pathname.startsWith("/api/admin/vault"); + const upstreamPath = vaultUpstreamPath(pathname, admin, url.searchParams); + if (!upstreamPath) { + sendJson(response, 404, {error: "Vault action not found"}); + return; + } + if (request.method === "POST" && !requireMutationOrigin(request, response)) { + return; + } + if (!["GET", "POST"].includes(request.method || "")) { + sendJson(response, 405, {error: "Method not allowed"}); + return; + } + await proxyKeyVault(request, response, upstreamPath); + return; + } + + + if (pathname === "/api/admin/components" && request.method === "GET") { + await proxyJson( + request, + response, + `${registryAdminUrl}/components`, + "Registry administration unavailable", + {"X-Portal-Admin": "1"} + ); + return; + } + + const adminAction = pathname.match(/^\/api\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/); + if (adminAction && request.method === "POST") { + if (!requireMutationOrigin(request, response)) { + return; + } + await proxyJson( + request, + response, + `${registryAdminUrl}/components/${adminAction[1]}/${adminAction[2]}`, + "Registry administration unavailable", + {"X-Portal-Admin": "1"} + ); + return; + } + + const file = staticFiles.get(pathname); + if (file && ["GET", "HEAD"].includes(request.method || "")) { + await serveStatic(request, response, file); + return; + } + + if (!["GET", "HEAD", "POST"].includes(request.method || "")) { + sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, HEAD, POST"}); + return; + } + + sendJson(response, 404, {error: "Not found"}); + } catch (error) { + console.error("Portal request failed", error.message); + sendJson(response, error.statusCode || 500, {error: error.message || "Request failed"}); + } +}); + +server.listen(port, "0.0.0.0", () => { + console.log(`xiteng.site listening on :${port}; administrator=${adminUsername}`); +}); diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..b1b2d66 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,1798 @@ +:root { + --paper: #f7efe0; + --paper-strong: #fffaf0; + --ink: #1c1712; + --muted: #675f55; + --red: #d83b21; + --green: #177a48; + --yellow: #f0c94b; + --blue: #93c9d7; + --shadow: 4px 4px 0 var(--ink); + --max: 1180px; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--paper); + color: var(--ink); + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 16px; + line-height: 1.6; + background: + linear-gradient(90deg, rgba(28, 23, 18, 0.065) 1px, transparent 1px), + linear-gradient(rgba(28, 23, 18, 0.055) 1px, transparent 1px), + var(--paper); + background-size: 44px 44px; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +.shell { + width: min(var(--max), calc(100% - 40px)); + margin-inline: auto; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + border-bottom: 2px solid var(--ink); + background: rgba(247, 239, 224, 0.94); + backdrop-filter: blur(10px); +} + +.nav { + min-height: 62px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.brand { + font-weight: 950; + letter-spacing: -0.02em; +} + +.nav-links, +.actions, +.filters, +.component-tags, +.component-meta, +.footer-inner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.nav-links { + justify-content: flex-end; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 800; +} + +.nav-links a, +.button, +.filter { + border: 2px solid var(--ink); + border-radius: 6px; + background: var(--paper-strong); + box-shadow: 3px 3px 0 var(--ink); + font-weight: 850; + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +.nav-links a { + padding: 6px 10px; +} + +.nav-links a:hover, +.button:hover, +.filter:hover, +.component-card[href]:hover { + transform: translate(-2px, -2px); + box-shadow: 6px 6px 0 var(--ink); +} + +.hero { + min-height: calc(88vh - 62px); + padding-block: 70px; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(310px, 0.65fr); + align-items: center; + gap: clamp(36px, 7vw, 96px); +} + +.eyebrow, +.section-kicker, +.component-kicker, +.component-tags, +.component-meta, +.catalog-updated, +.principle-number, +.now-card > span, +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.eyebrow, +.section-kicker { + margin: 0 0 16px; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.eyebrow { + display: inline-block; + padding: 5px 9px; + border: 2px solid var(--ink); + border-radius: 6px; + background: var(--yellow); + box-shadow: 3px 3px 0 var(--ink); +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 26px; + max-width: 800px; + font-size: clamp(54px, 8vw, 96px); + line-height: 0.95; + letter-spacing: -0.065em; +} + +.intro { + max-width: 710px; + margin-bottom: 0; + color: var(--muted); + font-size: clamp(18px, 2vw, 21px); +} + +.actions { + margin-top: 32px; + gap: 14px; +} + +.button { + min-height: 46px; + padding: 10px 16px; +} + +.button.primary { + color: var(--paper-strong); + background: var(--red); +} + +.hero-panel { + border: 3px solid var(--ink); + border-radius: 9px; + background: var(--paper-strong); + box-shadow: 10px 10px 0 var(--ink); + transform: rotate(1.5deg); +} + +.hero-panel-head { + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 3px solid var(--ink); + background: var(--blue); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 900; +} + +.live-dot { + width: 11px; + height: 11px; + border: 2px solid var(--ink); + border-radius: 50%; + background: var(--green); + box-shadow: 2px 2px 0 var(--ink); +} + +.hero-stats { + margin: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.hero-stats div { + min-width: 0; + padding: 22px 12px; + text-align: center; + border-right: 2px solid var(--ink); +} + +.hero-stats div:last-child { + border-right: 0; +} + +.hero-stats dt { + font-size: 34px; + font-weight: 950; + line-height: 1; +} + +.hero-stats dd { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; +} + +.catalog-updated { + margin: 0; + padding: 12px 16px; + border-top: 2px solid var(--ink); + color: var(--muted); + font-size: 11px; +} + +.principles { + border-block: 2px solid var(--ink); + background: rgba(255, 250, 240, 0.74); +} + +.device-status { + padding-block: 54px; + border-block: 2px solid var(--ink); + background: var(--ink); + color: var(--paper-strong); +} + +.device-heading-row { + margin-bottom: 24px; + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; +} + +.device-heading-row h2 { + margin-bottom: 0; + font-size: clamp(34px, 5vw, 50px); + line-height: 1; + letter-spacing: -0.04em; +} + +.device-heading-row .section-kicker { + color: var(--yellow); +} + +.metrics-updated { + margin: 0 0 4px; + color: #cfc6b7; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.metric-card { + min-width: 0; + padding: 18px; + border: 2px solid var(--paper-strong); + border-radius: 8px; + background: #27211b; + box-shadow: 4px 4px 0 var(--paper-strong); +} + +.metric-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #cfc6b7; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 900; +} + +.metric-card-head span:first-child { + color: var(--paper-strong); + font-size: 13px; +} + +.metric-value { + display: block; + margin-block: 15px 11px; + font-size: 34px; + line-height: 1; +} + +.metric-card progress { + width: 100%; + height: 12px; + display: block; + overflow: hidden; + border: 2px solid var(--paper-strong); + border-radius: 999px; + background: transparent; + appearance: none; +} + +.metric-card progress::-webkit-progress-bar { + background: #4a4037; +} + +.metric-card progress::-webkit-progress-value { + background: var(--metric-color, var(--yellow)); +} + +.metric-card progress::-moz-progress-bar { + background: var(--metric-color, var(--yellow)); +} + +.metric-cpu { + --metric-color: var(--red); +} + +.metric-memory { + --metric-color: var(--blue); +} + +.metric-disk { + --metric-color: var(--yellow); +} + +.metric-gpu { + --metric-color: #49c47a; +} + +.metric-detail, +.metric-secondary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-detail { + margin: 14px 0 4px; + color: var(--paper-strong); + font-size: 12px; +} + +.metric-secondary { + margin: 0; + color: #aca397; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; +} + +.principle-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.principle-grid article { + min-height: 178px; + padding: 28px; + border-right: 2px solid var(--ink); +} + +.principle-grid article:last-child { + border-right: 0; +} + +.principle-number { + display: block; + margin-bottom: 14px; + color: var(--red); + font-size: 12px; + font-weight: 900; +} + +.principle-grid strong { + display: block; + margin-bottom: 8px; + font-size: 19px; +} + +.principle-grid p { + margin-bottom: 0; + color: var(--muted); + font-size: 14px; +} + +.catalog-section, +.now-section { + padding-block: 88px; + scroll-margin-top: 62px; +} + +.infrastructure-section { + width: 100%; + border-block: 2px solid var(--ink); + background: rgba(147, 201, 215, 0.16); +} + +.section-heading { + margin-bottom: 28px; + display: flex; + align-items: end; + justify-content: space-between; + gap: 30px; +} + +.section-heading h2 { + margin-bottom: 0; + font-size: clamp(40px, 6vw, 64px); + line-height: 1; + letter-spacing: -0.045em; +} + +.section-heading > p { + max-width: 510px; + margin-bottom: 4px; + color: var(--muted); +} + +.filters { + margin-bottom: 32px; +} + +.filter { + cursor: pointer; + padding: 8px 12px; + color: var(--ink); + font: inherit; + font-size: 13px; +} + +.filter.active { + background: var(--yellow); +} + +.catalog { + min-width: 0; + display: grid; + gap: 34px; +} + +.category-group { + min-width: 0; + display: grid; + gap: 15px; +} + +.category-title { + margin: 0; + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.category-title::after { + content: ""; + height: 2px; + flex: 1; + background: var(--ink); + opacity: 0.2; +} + +.component-grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.component-card, +.loading-card, +.empty-state, +.error-state { + border: 2px solid var(--ink); + border-radius: 8px; + background: var(--paper-strong); + box-shadow: var(--shadow); +} + +.component-card { + min-width: 0; + min-height: 250px; + padding: 19px; + display: flex; + flex-direction: column; + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.component-card.return-focus { + animation: return-focus 1.8s ease; +} + +@keyframes return-focus { + 0%, 100% { outline: 0 solid transparent; } + 20%, 70% { outline: 7px solid rgba(240, 201, 75, 0.7); outline-offset: 5px; } +} + +.component-card.accent-red { + border-top: 10px solid var(--red); +} + +.component-card.accent-green { + border-top: 10px solid var(--green); +} + +.component-card.accent-yellow { + border-top: 10px solid var(--yellow); +} + +.component-card.accent-blue { + border-top: 10px solid var(--blue); +} + +.component-card.accent-ink { + border-top: 10px solid var(--ink); +} + +.component-head { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.component-state-stack { + min-width: 0; + max-width: calc(100% - 64px); + display: grid; + justify-items: end; + gap: 6px; +} + +.component-icon { + width: 48px; + height: 48px; + flex: 0 0 48px; + position: relative; + overflow: hidden; + display: grid; + place-items: center; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--yellow); + box-shadow: 3px 3px 0 var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 15px; + font-weight: 950; +} + +.component-icon img { + width: 34px; + height: 34px; + display: block; + object-fit: contain; +} + +.component-icon.has-image { + background: #fff; +} + +.component-icon.has-image .component-icon-fallback { + display: none; +} + +.component-status { + max-width: 100%; + display: inline-flex; + align-items: center; + gap: 6px; + overflow: hidden; + padding: 4px 8px; + border: 2px solid var(--ink); + border-radius: 999px; + background: #fff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 900; + white-space: nowrap; +} + +.component-status::before { + content: ""; + width: 7px; + height: 7px; + border: 1px solid var(--ink); + border-radius: 50%; + background: var(--muted); +} + +.component-status.status-running::before { + background: #49c47a; +} + +.component-status.status-restarting::before, +.component-status.status-degraded::before { + background: var(--yellow); +} + +.component-status.status-unhealthy::before, +.component-status.status-exited::before, +.component-status.status-dead::before, +.component-status.status-missing::before, +.component-monitor-status.monitor-down::before { + background: var(--red); +} + +.component-monitor-status.monitor-up::before { + background: #49c47a; +} + +.component-monitor-status.monitor-degraded::before, +.component-monitor-status.monitor-pending::before { + background: var(--yellow); +} + +.component-monitor-status.monitor-paused::before { + background: var(--blue); +} + +.component-monitor-status { + background: var(--ink); + color: var(--paper-strong); +} + +.admin-main { + padding-block: 64px 100px; +} + +.admin-heading { + max-width: 850px; + margin-bottom: 40px; +} + +.admin-heading h1 { + margin-bottom: 22px; +} + +.admin-heading > p:last-child { + max-width: 720px; + color: var(--muted); + font-size: 18px; +} + +.admin-summary { + margin-bottom: 30px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 3px solid var(--ink); + border-radius: 8px; + background: var(--paper-strong); + box-shadow: var(--shadow); +} + +.admin-summary div { + padding: 22px; + display: grid; + gap: 5px; + border-right: 2px solid var(--ink); +} + +.admin-summary div:last-child { + border-right: 0; +} + +.admin-summary strong { + font-size: 34px; + line-height: 1; +} + +.admin-summary span, +.admin-toolbar, +.admin-pill, +.admin-details, +.admin-action { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.admin-summary span { + color: var(--muted); + font-size: 11px; +} + +.admin-toolbar { + margin-bottom: 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.admin-toolbar p { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.admin-components { + display: grid; + gap: 14px; +} + +.admin-component { + padding: 18px; + display: grid; + grid-template-columns: minmax(210px, 1fr) minmax(150px, auto) minmax(360px, 1.6fr) auto; + align-items: center; + gap: 20px; + border: 2px solid var(--ink); + border-radius: 8px; + background: var(--paper-strong); + box-shadow: 3px 3px 0 var(--ink); +} + +.admin-component-identity { + display: flex; + align-items: center; + gap: 15px; + min-width: 0; +} + +.admin-component-identity h2 { + margin: 0 0 3px; + overflow: hidden; + font-size: 18px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-component-identity code { + color: var(--muted); + font-size: 10px; +} + +.admin-component-status { + display: grid; + justify-items: start; + gap: 6px; +} + +.admin-pill { + padding: 4px 8px; + border: 2px solid var(--ink); + border-radius: 999px; + background: #fff; + font-size: 10px; + font-weight: 900; +} + +.admin-pill.lifecycle-missing, +.admin-pill.monitor-down { + background: #ffd8d1; +} + +.admin-pill.lifecycle-archived, +.admin-pill.monitor-paused { + background: #d7ebf0; +} + +.admin-pill.monitor-up { + background: #d7f3e3; +} + +.admin-pill.monitor-degraded, +.admin-pill.monitor-pending { + background: #fff0b5; +} + +.admin-details { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.admin-details div { + min-width: 0; +} + +.admin-details dt { + color: var(--muted); + font-size: 9px; +} + +.admin-details dd { + margin: 2px 0 0; + overflow: hidden; + font-size: 10px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-error { + grid-column: 1 / -1; + margin: 0; + color: var(--red); + font-size: 10px; +} + +.admin-actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 7px; +} + +.admin-action { + cursor: pointer; + padding: 6px 8px; + border: 2px solid var(--ink); + border-radius: 5px; + background: var(--paper); + box-shadow: 2px 2px 0 var(--ink); + font-size: 10px; + font-weight: 900; +} + +.admin-action.dangerous { + color: #fff; + background: var(--red); +} + +.admin-action:disabled { + cursor: wait; + opacity: 0.45; +} + +.admin-session { + margin: 20px 0 0; + color: var(--green); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.admin-panel { + min-width: 0; + margin-top: 34px; + padding: clamp(18px, 3vw, 30px); + border: 3px solid var(--ink); + border-radius: 9px; + background: rgba(255, 250, 240, 0.88); + box-shadow: var(--shadow); +} + +.admin-panel-heading, +.admin-record-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.admin-panel-heading { + margin-bottom: 20px; +} + +.admin-panel-heading .section-kicker { + margin-bottom: 7px; +} + +.admin-panel-heading h2 { + margin: 0; + font-size: clamp(28px, 4vw, 42px); + line-height: 1; + letter-spacing: -0.04em; +} + +.account-profile-layout { + display: grid; + grid-template-columns: minmax(180px, 0.7fr) minmax(280px, 1.3fr); + gap: 18px; +} + +.account-avatar-card, +.account-security-card { + min-width: 0; + padding: 16px; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--paper); +} + +.account-avatar-card { + display: grid; + justify-items: center; + align-content: center; + gap: 10px; + text-align: center; +} + +.account-avatar { + width: 128px; + height: 128px; + border: 3px solid var(--ink); + border-radius: 18px; + background: var(--paper-strong); + object-fit: cover; + box-shadow: 4px 4px 0 var(--ink); +} + +.account-security-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.account-security-card { + display: grid; + align-content: start; + gap: 14px; +} + +.account-security-card h3 { + margin: 0; +} + +.account-security-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.account-sessions-card { + grid-column: 1 / -1; +} + +.account-security-record .admin-help { + margin: 0; +} + +.admin-notice { + margin-bottom: 20px; + padding: 12px 14px; + border: 2px solid var(--ink); + border-radius: 6px; + background: #d7ebf0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + overflow-wrap: anywhere; +} + +.admin-notice.success { + background: #d7f3e3; +} + +.admin-notice.warning { + background: #fff0b5; +} + +.admin-form-grid { + margin-block: 20px 30px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.admin-form { + min-width: 0; + padding: 16px; + display: grid; + gap: 12px; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--paper); +} + +.admin-form h3 { + margin: 0; +} + +.admin-form label { + min-width: 0; + display: grid; + gap: 5px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 850; +} + +.admin-form input, +.admin-form select { + width: 100%; + min-width: 0; + min-height: 38px; + padding: 7px 9px; + border: 2px solid var(--ink); + border-radius: 5px; + color: var(--ink); + background: var(--paper-strong); + font: inherit; + font-size: 12px; +} + +.admin-form p, +.admin-help { + margin: 0; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.admin-form-inline { + margin-bottom: 20px; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + align-items: end; +} + +.admin-form-inline.compact { + grid-template-columns: minmax(180px, 1.4fr) minmax(140px, 1fr) minmax(140px, 1fr) auto; +} + +.provider-editor-form { + margin-bottom: 20px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: end; +} + +.provider-probe-fields { + min-width: 0; + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(240px, 1.4fr) minmax(220px, 1fr) auto; + align-items: end; + gap: 12px; +} + +.provider-advanced { + min-width: 0; + grid-column: 1 / -1; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--paper-strong); +} + +.provider-advanced > summary { + padding: 12px 14px; + font-weight: 850; + cursor: pointer; +} + +.provider-advanced-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 0 14px 14px; +} + +.provider-advanced-grid > label { + min-width: 0; +} + +.provider-credential-fields { + min-width: 0; + margin: 4px 0 0; + padding: 14px; + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + border: 2px dashed var(--ink); + border-radius: 7px; +} + +.provider-credential-fields legend { + padding-inline: 7px; + font-weight: 850; +} + +.provider-editor-actions { + grid-column: 1 / -1; +} + +.provider-test-result { + min-width: 0; + max-width: 100%; + overflow: hidden; + grid-column: 1 / -1; +} + +.provider-test-result:empty { + display: none; +} + +.provider-test-result.success { + display: grid; + gap: 14px; + padding: 16px; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--paper-strong); +} + +.provider-test-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.provider-test-heading code { + color: var(--muted); + font-size: 9px; + overflow-wrap: anywhere; +} + +.provider-test-section { + min-width: 0; + max-width: 100%; + display: grid; + gap: 8px; +} + +.provider-test-section h4 { + margin: 0; + font-size: 15px; +} + +.provider-test-models { + min-width: 0; + max-width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.provider-test-model { + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px; + border: 1px solid var(--ink); + border-radius: 6px; +} + +.provider-test-model > div { + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.provider-test-model strong, +.provider-test-model code, +.provider-test-model small { + display: block; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.provider-test-model code, +.provider-test-model small { + margin-top: 2px; + color: var(--muted); + font-size: 9px; +} + +.provider-test-model .admin-action { + flex: 0 0 auto; +} + +.provider-test-more { + min-width: 0; + max-width: 100%; +} + +.provider-test-more > summary { + display: inline-flex; + cursor: pointer; + list-style: none; +} + +.provider-test-more > summary::-webkit-details-marker { + display: none; +} + +.provider-test-more > .provider-test-models { + margin-top: 8px; +} + +.provider-test-more:not([open]) > .provider-test-models { + display: none; +} + +.provider-test-metadata pre { + max-height: 220px; + overflow: auto; + margin: 5px 0 0; + padding: 10px; + border-radius: 6px; + background: var(--paper); + font-size: 10px; + white-space: pre-wrap; +} + +.provider-credentials-heading { + margin-top: 28px; +} + +.provider-credentials-heading h3 { + margin: 0; + font-size: 22px; +} + +.admin-subsection { + min-width: 0; + margin-top: 30px; +} + +.admin-subsection > h3 { + margin-bottom: 10px; + font-size: 21px; +} + +.admin-subsection > .admin-help { + margin: -5px 0 13px; +} + +.admin-list { + min-width: 0; + display: grid; + gap: 10px; +} + +.provider-group { + min-width: 0; + display: grid; + gap: 10px; +} + +.provider-group + .provider-group, +.provider-more { + margin-top: 18px; +} + +.provider-group-title { + margin: 0; + font-size: 18px; +} + +.provider-more > summary { + width: fit-content; + list-style: none; + cursor: pointer; +} + +.provider-more > summary::-webkit-details-marker { + display: none; +} + +.provider-more[open] > summary { + margin-bottom: 12px; +} + +.provider-more-list { + padding-top: 2px; +} + +.admin-record { + min-width: 0; + padding: 15px; + display: grid; + gap: 13px; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--paper-strong); + box-shadow: 2px 2px 0 var(--ink); +} + +.admin-record-heading > div:first-child, +.admin-record-heading h3 { + min-width: 0; +} + +.admin-record h3 { + margin: 0 0 2px; + font-size: 17px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.admin-record code { + display: block; + max-width: 100%; + color: var(--muted); + font-size: 9px; + overflow-wrap: anywhere; +} + +.admin-record.compact-record { + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; +} + +.admin-checks { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.admin-checks label { + padding: 3px 7px; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--ink); + border-radius: 999px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; + font-weight: 800; +} + +.admin-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 850; +} + +.admin-wrap { + overflow-wrap: anywhere; +} + +.vault-details { + grid-template-columns: 1.1fr 1.5fr 0.7fr 0.7fr; +} + +.admin-audit { + min-width: 0; + display: grid; + border: 2px solid var(--ink); + border-radius: 7px; + background: var(--paper-strong); +} + +.audit-row { + min-width: 0; + padding: 9px 11px; + display: grid; + grid-template-columns: 120px 90px minmax(120px, 0.8fr) minmax(160px, 1.5fr) auto; + align-items: center; + gap: 10px; + border-bottom: 1px solid rgba(28, 23, 18, 0.3); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; +} + +.audit-row:last-child { + border-bottom: 0; +} + +.audit-row span:not(.admin-pill), +.audit-row code, +.audit-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.component-kicker { + margin: 22px 0 7px; + color: var(--muted); + font-size: 10px; + font-weight: 850; + text-transform: uppercase; +} + +.component-card h3 { + margin-bottom: 8px; + font-size: 23px; + line-height: 1.12; + overflow-wrap: anywhere; +} + +.component-description { + margin-bottom: 20px; + color: var(--muted); + font-size: 14px; + overflow-wrap: anywhere; +} + +.component-foot { + min-width: 0; + margin-top: auto; +} + +.component-tags { + min-width: 0; + margin-bottom: 13px; + align-items: stretch; +} + +.tag { + max-width: 100%; + padding: 4px 7px; + border: 1.5px solid var(--ink); + border-radius: 4px; + background: #fff; + font-size: 10px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.tag.access-public { + background: #d9f4df; +} + +.tag.access-restricted { + background: #ffe2a8; +} + +.component-meta { + min-width: 0; + align-items: flex-end; + justify-content: space-between; + flex-wrap: nowrap; + color: var(--muted); + font-size: 10px; +} + +.component-meta span:first-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.component-open { + flex: 0 0 auto; + color: var(--ink); + font-weight: 900; +} + +.loading-card, +.empty-state, +.error-state { + padding: 28px; + color: var(--muted); +} + +.error-state { + border-color: var(--red); + background: #fff0eb; + color: #7e2113; +} + +.now-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} + +.now-card { + min-height: 210px; + padding: 24px; + border: 2px solid var(--ink); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.now-card.accent-yellow { + background: var(--yellow); +} + +.now-card.accent-blue { + background: var(--blue); +} + +.now-card.accent-green { + color: #fff; + background: var(--green); +} + +.now-card > span { + font-size: 11px; + font-weight: 900; +} + +.now-card h3 { + margin: 28px 0 8px; + font-size: 25px; +} + +.now-card p { + margin-bottom: 0; +} + +code { + padding: 1px 4px; + border: 1px solid currentColor; + border-radius: 3px; + font-size: 0.86em; +} + +footer { + padding-block: 24px; + border-top: 2px solid var(--ink); + background: var(--ink); + color: var(--paper-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.footer-inner { + justify-content: space-between; +} + +@media (max-width: 940px) { + .hero { + grid-template-columns: 1fr; + min-height: auto; + } + + .hero-panel { + max-width: 560px; + transform: none; + } + + .component-grid, + .now-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .principle-grid { + grid-template-columns: 1fr; + } + + .principle-grid article { + min-height: auto; + border-right: 0; + border-bottom: 2px solid var(--ink); + } + + .principle-grid article:last-child { + border-bottom: 0; + } + + .admin-component { + grid-template-columns: 1fr 1fr; + } + + .admin-details { + grid-column: 1 / -1; + } + + .admin-form-inline, + .admin-form-inline.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .provider-editor-form, + .provider-advanced-grid, + .provider-credential-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .provider-probe-fields { + grid-template-columns: 1fr 1fr; + } + + .provider-probe-fields .admin-action { + grid-column: 1 / -1; + } + + .audit-row { + grid-template-columns: 105px 80px minmax(110px, 1fr) auto; + } + + .audit-row span:not(.admin-pill) { + grid-column: 1 / -1; + } +} + +@media (max-width: 680px) { + .provider-test-models { grid-template-columns: minmax(0, 1fr); } + .provider-test-heading { min-width: 0; flex-direction: column; } + .provider-test-heading code { width: 100%; word-break: break-all; } + .provider-test-model { align-items: stretch; flex-direction: column; } + .provider-test-model .admin-action { width: 100%; justify-content: center; } + .provider-test-metadata { min-width: 0; } + .provider-test-metadata pre { max-width: 100%; box-sizing: border-box; overflow-wrap: anywhere; word-break: break-word; } + .shell { + width: min(100% - 24px, var(--max)); + } + + .nav { + padding-block: 10px; + align-items: flex-start; + flex-direction: column; + } + + .nav-links { + width: 100%; + justify-content: flex-start; + } + + .nav-links a { + padding: 5px 8px; + } + + .hero { + padding-block: 50px; + } + + h1 { + font-size: clamp(48px, 17vw, 68px); + } + + .section-heading { + align-items: flex-start; + flex-direction: column; + } + + .device-heading-row { + align-items: flex-start; + flex-direction: column; + } + + .metric-grid { + grid-template-columns: 1fr; + } + + .component-grid, + .now-grid { + grid-template-columns: 1fr; + } + + .component-card { + min-height: 230px; + } + + .catalog-section, + .now-section { + padding-block: 64px; + } + + .footer-inner { + align-items: flex-start; + flex-direction: column; + } + + .admin-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-summary div:nth-child(2) { + border-right: 0; + } + + .admin-summary div:nth-child(-n + 2) { + border-bottom: 2px solid var(--ink); + } + + .admin-component { + grid-template-columns: 1fr; + } + + .admin-details { + grid-column: auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-actions { + justify-content: flex-start; + } + + .admin-panel-heading, + .admin-record-heading { + flex-direction: column; + } + + .admin-panel-heading .admin-actions, + .admin-record-heading .admin-component-status { + width: 100%; + } + + .admin-form-grid, + .admin-form-inline, + .admin-form-inline.compact, + .vault-details { + grid-template-columns: 1fr; + } + + .provider-editor-form, + .provider-probe-fields, + .provider-advanced-grid, + .provider-credential-fields { + grid-template-columns: 1fr; + } + + .provider-probe-fields .admin-action { + grid-column: auto; + } + + .account-profile-layout, + .account-security-grid { + grid-template-columns: 1fr; + } + + .account-sessions-card { + grid-column: auto; + } + + .account-security-heading { + align-items: flex-start; + flex-direction: column; + } + + .admin-record.compact-record { + grid-template-columns: 1fr; + justify-items: start; + } + + .audit-row { + grid-template-columns: 1fr auto; + } + + .audit-row code, + .audit-row strong, + .audit-row span:not(.admin-pill) { + grid-column: 1 / -1; + white-space: normal; + overflow-wrap: anywhere; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + } +} diff --git a/site/sw.js b/site/sw.js new file mode 100644 index 0000000..ccc2302 --- /dev/null +++ b/site/sw.js @@ -0,0 +1,97 @@ +const cacheName = "xiteng-portal-v8"; +const appShell = [ + "/", + "/styles.css", + "/app.js?v=7", + "/manifest.webmanifest", + "/favicon.svg", + "/favicon.ico", + "/icons/favicon-32.png", + "/icons/apple-touch-icon.png", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/icon-maskable-512.png" +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(cacheName) + .then((cache) => cache.addAll(appShell)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key !== cacheName).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + const url = new URL(request.url); + + if (request.method !== "GET" || url.origin !== self.location.origin) { + return; + } + + if (url.pathname.startsWith("/api/")) { + event.respondWith( + fetch(request).catch(() => new Response( + JSON.stringify({error: "Network unavailable"}), + { + status: 503, + headers: { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8" + } + } + )) + ); + return; + } + + if (url.pathname.startsWith("/admin") + || url.pathname.startsWith("/account") + || url.pathname === "/admin.js" + || url.pathname === "/account.js" + || url.pathname.startsWith("/oauth2/") + || url.pathname.startsWith("/outpost.goauthentik.io/")) { + event.respondWith(fetch(request)); + return; + } + + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok && ["/", "/index.html"].includes(url.pathname)) { + const copy = response.clone(); + caches.open(cacheName).then((cache) => cache.put("/", copy)); + } + return response; + }) + .catch(() => caches.match("/")) + ); + return; + } + + event.respondWith( + caches.match(request).then((cached) => { + const network = fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(cacheName).then((cache) => cache.put(request, copy)); + } + return response; + }); + if (cached) { + network.catch(() => undefined); + return cached; + } + return network; + }) + ); +}); diff --git a/traefik/compose.yml b/traefik/compose.yml index f40e737..1c8a3b2 100644 --- a/traefik/compose.yml +++ b/traefik/compose.yml @@ -1,12 +1,14 @@ services: traefik: - image: traefik:v3.7 + image: traefik:v3.7.10 container_name: traefik restart: unless-stopped env_file: ../.env command: - --api.insecure=true - --providers.docker=true + - --providers.file.directory=/dynamic + - --providers.file.watch=true - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --api.dashboard=true @@ -26,6 +28,20 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt + - ../edge-cache/dynamic:/dynamic:ro + labels: + - "xiteng.site.component.traefik.enabled=true" + - "xiteng.site.component.traefik.name=Traefik" + - "xiteng.site.component.traefik.description=动态反向代理、TLS 终止与服务路由中心。" + - "xiteng.site.component.traefik.section=infrastructure" + - "xiteng.site.component.traefik.category=边缘与网络" + - "xiteng.site.component.traefik.endpoint=HTTP :80 · HTTPS :443" + - "xiteng.site.component.traefik.access=internal" + - "xiteng.site.component.traefik.access-label=管理面仅内网" + - "xiteng.site.component.traefik.icon=TR" + - "xiteng.site.component.traefik.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg" + - "xiteng.site.component.traefik.accent=red" + - "xiteng.site.component.traefik.order=110" networks: - homelab_net diff --git a/uptime-kuma/compose.yml b/uptime-kuma/compose.yml deleted file mode 100644 index 153e24d..0000000 --- a/uptime-kuma/compose.yml +++ /dev/null @@ -1,56 +0,0 @@ -services: - uptime-kuma: - image: louislam/uptime-kuma:2 - container_name: uptime-kuma - restart: unless-stopped - healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001', (r) => {r.resume(); process.exit(0)}).on('error', () => process.exit(1))"] - interval: 10s - timeout: 5s - retries: 3 - volumes: - - ./data:/app/data - ports: - - "3001:3001" - labels: - - "traefik.enable=true" - - "traefik.http.routers.uptime.rule=Host(`uptime.xiteng.site`)" - - "traefik.http.services.uptime.loadbalancer.server.port=3001" - - "traefik.http.routers.uptime.entrypoints=websecure" - # ForwardAuth via Authentik Outpost - - "traefik.http.middlewares.authentik-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik" - - "traefik.http.middlewares.authentik-auth.forwardauth.trustForwardHeader=true" - - "traefik.http.middlewares.authentik-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name" - - "traefik.http.routers.uptime.middlewares=authentik-auth" - - "traefik.http.routers.uptime-http.middlewares=authentik-auth" - - "traefik.http.routers.uptime-http.rule=Host(`uptime.xiteng.site`)" - - "traefik.http.routers.uptime-http.service=uptime" - - "traefik.http.routers.uptime-http.entrypoints=web" - - "traefik.http.routers.uptime.tls=true" - - "traefik.http.routers.uptime.tls.certresolver=cfresolver" - - "homepage.group=我的服务" - - "homepage.name=Uptime Kuma" - - "homepage.icon=uptime-kuma" - - "homepage.href=https://uptime.xiteng.site" - - "homepage.description=网站监控工具" - networks: - - homelab_net - - autokuma: - image: ghcr.io/bigboot/autokuma:latest - restart: unless-stopped - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - env_file: ../.env - environment: - AUTOKUMA__KUMA__URL: http://uptime-kuma:3001 - AUTOKUMA__TAG: homelab - depends_on: - uptime-kuma: - condition: service_healthy - networks: - - homelab_net - -networks: - homelab_net: - external: true