formatting and comments

This commit is contained in:
2026-07-16 00:12:26 -07:00
parent edb6d2b5b4
commit 994854d104
45 changed files with 2514 additions and 628 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{ts,tsx,js,json,css,html,md,yaml,yml}]
indent_style = space
indent_size = 2
[*.{rs,toml}]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
+8
View File
@@ -0,0 +1,8 @@
.git/
**/dist/
**/node_modules/
**/target/
vendor/
config.toml
*.png
*.svg
+11
View File
@@ -0,0 +1,11 @@
{
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 100,
"proseWrap": "always",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}
+98 -24
View File
@@ -1,17 +1,36 @@
# 洛星瓷直播组件服务
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay`
弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 `vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在
`vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。
## 文档索引
- [总体架构与事件流](docs/architecture.md)
- [Rust 后端模块](apps/server-rust/README.md)
- [React 控制台、OBS 与 PWA](apps/overlay/README.md)
- [组件开发指南](docs/components/README.md)
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
- [WebSocket 实时协议](docs/protocol.md)
- [租户、Secret 与部署安全](docs/security.md)
- [完整配置注释](config.toml.example)
## 后端结构
`src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出:
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs` 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
- `components.rs` 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
- `realtime.rs` 按 component UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS WebSocket 是否在线。
- `auth.rs`、`repository.rs` 和 PostgreSQL RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs` 从服务端会话推导 owner,不接受客户端自报 owner ID。
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs`
的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
- `components.rs`
注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
- `realtime.rs` 按 component
UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS
WebSocket 是否在线。
- `auth.rs`、`repository.rs` 和 PostgreSQL
RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs`
从服务端会话推导 owner,不接受客户端自报 owner ID。
## 部署
@@ -25,13 +44,43 @@ openssl rand -hex 32 # 填入兼容字段 admin.session_secret
docker compose up --build -d
```
Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` 读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` 直接暴露到公网。
Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config`
读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719`
直接暴露到公网。
容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 `config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 `APP_UID` 与 `APP_GID`。
容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将
`config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置
`APP_UID` 与 `APP_GID`。
`[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。
`[database].url`
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
Secret 与每个用户的 CookieCloud
Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。
## 开发与代码质量
Rust 使用仓库内 `rustfmt.toml`,前端和文档使用固定版本 Prettier。推荐提交前执行:
```sh
cargo fmt --manifest-path apps/server-rust/Cargo.toml
cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
npm --prefix apps/overlay run format
npm --prefix apps/overlay run format:check
npm --prefix apps/overlay run docs:check
npm --prefix apps/overlay run build
docker compose config --quiet
git diff --check
```
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
ignore 会排除依赖、构建产物、第三方 vendor、PNG/SVG 和包含真实 Secret 的 `config.toml`。不要对
`vendor/blivedm` 做无关的批量风格改写,以便继续审查上游补丁。
## 首次初始化与登录
@@ -41,7 +90,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c
https://danmaku.luoxingci.com/control/setup
```
填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
填写系统管理员用户名和 `config.toml` 中的
`admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
@@ -50,9 +100,14 @@ https://danmaku.luoxingci.com/control/setup
- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。
- 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。
系统管理员可在 `/control/invitations` 创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili `room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
系统管理员可在 `/control/invitations`
创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili
`room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 `security.cookiecloud_allowed_hosts` 白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili `SESSDATA`。
每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的
`security.cookiecloud_allowed_hosts`
白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili
`SESSDATA`。
### 旧单用户配置迁移
@@ -67,7 +122,8 @@ https://danmaku.luoxingci.com/control/setup
## 必须使用 HTTPS
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持
`security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
```nginx
map $http_upgrade $connection_upgrade {
@@ -103,36 +159,54 @@ server {
}
```
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置
`secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
## 控制台 PWA
通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control` 会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 + TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和 `/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。
通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control`
会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 +
TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和
`/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。
PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*` 浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有 `/api/*` 响应始终在线直连且由服务端返回 `Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。
PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*`
浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有
`/api/*` 响应始终在线直连且由服务端返回
`Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。
部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。
## 组件与 OBS 地址
登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
登录 `/control/`
后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
```text
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
```
`publicId` 是组件 UUID。`#token=...` 位于 URL fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向 `/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
`publicId` 是组件 UUID。`#token=...` 位于 URL
fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从
`360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。
## 弹幕姬展示
每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。
花边 SVG 已本地打包,来源及公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
花边 SVG 已本地打包,来源及公版/CC0 许可记录在
`apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 `[gifts]` 调整。
后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过
`[gifts]` 调整。
普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 `SESSDATA` 刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 `[emoticons]` 调整。
普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的
`SESSDATA`
刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过
`[emoticons]` 调整。
+61
View File
@@ -0,0 +1,61 @@
# React 控制台与 OBS 前端
Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域托管。生产容器不包含 Node。
## 路由
| 路由 | 权限 | 作用 |
| ---------------------- | --------------- | ------------------------------------ |
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment |
| `/control/setup` | 首次部署 | 创建唯一 system admin |
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
`main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。
## 文件职责
| 文件 | 职责 |
| ------------------- | ---------------------------------------------------- |
| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer |
| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 |
| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 |
| `src/overlay.tsx` | WebSocket、消息队列、礼物/表情和 OBS 自适应渲染 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
## Secret 与状态
- TOTP Secret、恢复码、邀请码和新 OBS 地址只保存在当前 React state。
- API normalizer 不把未知对象直接传播到组件。
- API mutation 离线时立即失败,不进入 Background Sync。
- PWA 更新在 dirty form 或一次性 secret 可见时被阻止。
- OBS token 从 URL fragment 读取,只在 WebSocket 第一帧发送。
- OBS 收到坏 JSON、坏图片或未知事件时局部降级,不让浏览器源崩溃。
## PWA
manifest、start URL 与 Service Worker scope 均为 `/control/`。worker 使用 network-first
HTML 和 cache-first build assets;`/api/*`、`/obs/*`、WebSocket 和用户数据永不缓存。每次 Vite
build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后等待用户确认激活。
## 格式化与构建
```bash
npm run format
npm run format:check
npm run docs:check
npm run build
```
`npm run format`
使用仓库根目录的 Prettier 配置,同时格式化 TypeScript、TSX、CSS、HTML、JSON、Markdown、Compose
YAML 和项目文档。
组件协议与弹幕姬行为分别见:
- [实时协议](../../docs/protocol.md)
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
+17
View File
@@ -15,6 +15,7 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"typescript": "5.9.2",
"vite": "7.3.6"
}
@@ -1573,6 +1574,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
+19 -3
View File
@@ -2,7 +2,23 @@
"name": "lxc-stream-overlay",
"private": true,
"version": "0.1.0",
"scripts": { "build": "tsc -b && vite build", "dev": "vite" },
"dependencies": { "react": "19.1.1", "react-dom": "19.1.1" },
"devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" }
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"docs:check": "node scripts/check-doc-links.mjs ../..",
"format": "prettier --ignore-path ../../.prettierignore --write . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml",
"format:check": "prettier --ignore-path ../../.prettierignore --check . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml"
},
"dependencies": {
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"typescript": "5.9.2",
"vite": "7.3.6"
}
}
+4 -6
View File
@@ -7,8 +7,7 @@
- License: CC0 1.0 / public domain
- Retrieved: 2026-07-15
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
Paul Bürck”.
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899
@@ -16,8 +15,7 @@ Paul Bürck”.
- License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
Paul Bürck”.
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899
@@ -25,5 +23,5 @@ Paul Bürck”.
- License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15
The overlay uses these SVGs as recolorable CSS masks inside message cards and
bundles them locally, so OBS never needs to load the source websites.
The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
so OBS never needs to load the source websites.
+37 -19
View File
@@ -1,4 +1,11 @@
/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */
/**
* Network policy for the `/control/` application shell.
*
* `__PWA_BUILD_ID__` is replaced by Vite before emission. Navigations use a
* network-first strategy so fresh HTML can reference the newest hashed bundle;
* known static assets use cache-first. The worker never handles API, WebSocket
* or OBS routes because they are outside both this fetch policy and its scope.
*/
const buildId = '__PWA_BUILD_ID__'
const cacheName = `lxc-control-v2-shell-${buildId}`
const shellPath = '/control/'
@@ -24,8 +31,9 @@ async function precacheShell() {
const html = await response.clone().text()
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)]
.map(match => match[1])
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(
match => match[1],
)
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
}
@@ -34,13 +42,17 @@ self.addEventListener('install', event => {
})
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys()
await Promise.all(names
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
.map(name => caches.delete(name)))
await self.clients.claim()
})())
event.waitUntil(
(async () => {
const names = await caches.keys()
await Promise.all(
names
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
.map(name => caches.delete(name)),
)
await self.clients.claim()
})(),
)
})
self.addEventListener('message', event => {
@@ -52,9 +64,11 @@ function isControlShell(pathname) {
}
function isStaticAsset(pathname) {
return pathname.startsWith('/assets/')
|| pathname.startsWith('/pwa/')
|| pathname === '/control/manifest.webmanifest'
return (
pathname.startsWith('/assets/') ||
pathname.startsWith('/pwa/') ||
pathname === '/control/manifest.webmanifest'
)
}
async function navigationResponse(request) {
@@ -62,16 +76,20 @@ async function navigationResponse(request) {
const pathname = new URL(request.url).pathname
try {
const response = await fetch(request)
if (shellRoutes.includes(pathname)
&& response.ok
&& response.headers.get('content-type')?.includes('text/html')) {
if (
shellRoutes.includes(pathname) &&
response.ok &&
response.headers.get('content-type')?.includes('text/html')
) {
await cache.put(pathname, response.clone())
}
return response
} catch {
return (await cache.match(request, { ignoreSearch: true }))
|| (await cache.match(shellPath))
|| Response.error()
return (
(await cache.match(request, { ignoreSearch: true })) ||
(await cache.match(shellPath)) ||
Response.error()
)
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Verify repository-local Markdown links without making network requests.
*
* External URLs are intentionally skipped: CI should not fail because an
* upstream website is temporarily unavailable. Local documentation links are
* deterministic and catch renamed component guides or incorrect relative paths.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, extname, resolve } from 'node:path'
const root = resolve(process.argv[2] ?? new URL('../../..', import.meta.url).pathname)
const ignoredDirectories = new Set([
'.agents',
'.codex',
'.git',
'dist',
'node_modules',
'target',
'vendor',
])
const markdownFiles = []
function collect(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue
const path = resolve(directory, entry.name)
if (entry.isDirectory()) collect(path)
else if (extname(entry.name).toLowerCase() === '.md') markdownFiles.push(path)
}
}
collect(root)
const failures = []
const markdownLink = /\[[^\]]*\]\(([^)]+)\)/g
for (const file of markdownFiles) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(markdownLink)) {
const rawTarget = match[1].trim().replace(/^<|>$/g, '')
if (!rawTarget || rawTarget.startsWith('#') || /^[a-z][a-z+.-]*:/i.test(rawTarget)) continue
// The project does not currently use titled local links. Splitting here
// still handles the conventional `(path "title")` form if one is added.
const target = decodeURI(rawTarget.split(/\s+["']/)[0].split('#')[0])
if (!target) continue
const destination = resolve(dirname(file), target)
if (!existsSync(destination)) failures.push(`${file}: ${rawTarget}`)
}
}
if (failures.length > 0) {
console.error(`Broken local documentation links:\n${failures.join('\n')}`)
process.exitCode = 1
} else {
console.log(`Checked ${markdownFiles.length} Markdown files; local links are valid.`)
}
+120 -71
View File
@@ -1,3 +1,12 @@
/**
* Same-origin API client and defensive wire-format normalizers.
*
* The backend is authoritative and may evolve response wrappers independently
* from a deployed frontend. Normalizers accept those compatible wrappers while
* producing strict UI models. Mutations are refused while offline and are never
* queued for background replay, which avoids applying an old user's action
* after logout or tenant switching.
*/
import type {
AuthUser,
ComponentSummary,
@@ -13,7 +22,12 @@ export class ApiError extends Error {
readonly code?: string
readonly fieldErrors?: Record<string, string>
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
constructor(
status: number,
message: string,
code?: string,
fieldErrors?: Record<string, string>,
) {
super(message)
this.name = 'ApiError'
this.status = status
@@ -36,7 +50,8 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
}
const headers = new Headers(init.headers)
if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json')
if (init.body != null && !headers.has('content-type'))
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json')
const response = await fetch(path, {
@@ -49,13 +64,13 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
window.dispatchEvent(new Event('lxc:session-expired'))
}
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
throw new ApiError(
response.status,
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
typeof error.code === 'string' ? error.code : undefined,
error.fieldErrors && typeof error.fieldErrors === 'object'
? error.fieldErrors as Record<string, string>
? (error.fieldErrors as Record<string, string>)
: undefined,
)
}
@@ -71,24 +86,29 @@ export function json(method: string, body?: unknown): RequestInit {
function object(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
? (value as Record<string, unknown>)
: {}
}
export function normalizeSession(value: unknown): Session {
const root = object(value)
const candidate = root.user
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
const candidate =
root.user ??
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
? root
: null)
const raw = object(candidate)
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
const user: AuthUser | null = hasUser ? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
} : null
const user: AuthUser | null = hasUser
? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
}
: null
return { user, setupRequired: root.setupRequired === true }
}
@@ -98,19 +118,26 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
return {
enrollmentToken: String(enrollmentToken ?? ''),
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
qrDataUrl: typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
qrSvg:
typeof totp.qrSvg === 'string'
? totp.qrSvg
: typeof root.qrSvg === 'string'
? root.qrSvg
: undefined,
qrDataUrl:
typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
: undefined,
otpauthUri:
typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
otpauthUri: typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
}
@@ -119,24 +146,28 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
export function normalizeRecoveryCodes(value: unknown): string[] {
const root = object(value)
const codes = root.recoveryCodes ?? object(root.recovery).codes
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : []
return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === 'string')
: []
}
export function normalizeComponents(value: unknown): ComponentSummary[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
}).filter(item => item.id)
return list
.map(entry => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
})
.filter(item => item.id)
}
export function normalizeSettings(value: unknown): OverlaySettings {
@@ -147,50 +178,68 @@ export function normalizeSettings(value: unknown): OverlaySettings {
export function normalizeSource(value: unknown): CookieCloudSource {
const root = object(value)
const source = object(root.source ?? root)
const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud)
const cookieCloud = object(
source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud,
)
const status = object(source.status)
return {
roomId: String(source.roomId ?? root.roomId ?? ''),
cookieCloud: {
host: String(cookieCloud.host ?? ''),
key: '',
keyConfigured: cookieCloud.keyConfigured === true
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured: cookieCloud.passwordConfigured === true
|| cookieCloud.configured === true
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
keyConfigured:
cookieCloud.keyConfigured === true ||
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured:
cookieCloud.passwordConfigured === true ||
cookieCloud.configured === true ||
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
},
connected: typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail: typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
connected:
typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail:
typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
}
}
export function normalizeInvitations(value: unknown): Invitation[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined,
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined,
}
}).filter(item => item.id)
const list = Array.isArray(value)
? value
: Array.isArray(root.invitations)
? root.invitations
: []
return list
.map(entry => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt:
typeof item.consumedAt === 'string' || item.consumedAt === null
? item.consumedAt
: undefined,
revokedAt:
typeof item.revokedAt === 'string' || item.revokedAt === null
? item.revokedAt
: undefined,
}
})
.filter(item => item.id)
}
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
+113 -37
View File
@@ -1,3 +1,11 @@
/**
* Passwordless login and one-time TOTP enrollment screens.
*
* QR material, manual keys and recovery codes exist only in React memory and
* are dropped as soon as their enrollment phase completes. PWA activation is
* blocked while those values or partially completed forms are visible so an
* update cannot erase information that the server will not reveal again.
*/
import { useMemo, useState } from 'react'
import type { FormEvent, ReactNode } from 'react'
import {
@@ -11,7 +19,12 @@ import {
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
import type { TotpEnrollment } from './types'
function AuthShell({ eyebrow, title, children, footer }: {
function AuthShell({
eyebrow,
title,
children,
footer,
}: {
eyebrow: string
title: string
children: ReactNode
@@ -21,7 +34,9 @@ function AuthShell({ eyebrow, title, children, footer }: {
<main className="auth-page">
<section className="auth-card jade-panel">
<PwaControls />
<div className="auth-mark" aria-hidden="true">星</div>
<div className="auth-mark" aria-hidden="true">
星
</div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{children}
@@ -34,16 +49,19 @@ function AuthShell({ eyebrow, title, children, footer }: {
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
if (enrollment.qrSvg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
return undefined
}, [enrollment.qrDataUrl, enrollment.qrSvg])
return (
<div className="totp-enrollment">
<div className="totp-qr">
{source
? <img src={source} alt="TOTP 验证器绑定二维码" />
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
{source ? (
<img src={source} alt="TOTP 验证器绑定二维码" />
) : (
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
)}
</div>
<div className="totp-copy">
<h2>绑定动态验证器</h2>
@@ -57,7 +75,11 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
<div className="secret-row">
<code>{enrollment.manualKey || '未提供'}</code>
{enrollment.manualKey && (
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
<button
type="button"
className="text-button"
onClick={() => void copyToClipboard(enrollment.manualKey)}
>
复制
</button>
)}
@@ -72,12 +94,10 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
const [copied, setCopied] = useState(false)
const text = codes.join('\n')
const download = () => {
const blob = new Blob([
'洛星瓷直播组件恢复码\n',
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
text,
'\n',
], { type: 'text/plain;charset=utf-8' })
const blob = new Blob(
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
{ type: 'text/plain;charset=utf-8' },
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
@@ -88,26 +108,45 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
return (
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
{codes.length > 0
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
<p className="auth-lead">
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
</p>
{codes.length > 0 ? (
<div className="recovery-grid">
{codes.map(code => (
<code key={code}>{code}</code>
))}
</div>
) : (
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
)}
<div className="form-actions">
{codes.length > 0 && (
<>
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
<button
type="button"
className="secondary"
onClick={() => void copyToClipboard(text).then(setCopied)}
>
{copied ? '已复制' : '复制全部'}
</button>
<button type="button" className="secondary" onClick={download}>下载文本</button>
<button type="button" className="secondary" onClick={download}>
下载文本
</button>
</>
)}
<button type="button" onClick={onContinue}>我已妥善保存</button>
<button type="button" onClick={onContinue}>
我已妥善保存
</button>
</div>
</AuthShell>
)
}
export function LoginPage({ onAuthenticated, setupRequired }: {
export function LoginPage({
onAuthenticated,
setupRequired,
}: {
onAuthenticated: () => Promise<void>
setupRequired: boolean
}) {
@@ -142,13 +181,19 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
<AuthShell
eyebrow="洛星瓷直播组件"
title="回到你的云台"
footer={(
footer={
<p>
{setupRequired
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
{setupRequired ? (
<>
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
</>
) : (
<>
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
</>
)}
</p>
)}
}
>
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
<form className="stack-form" onSubmit={submit}>
@@ -173,9 +218,13 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
value={totpCode}
onChange={event => setTotpCode(useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6))}
onChange={event =>
setTotpCode(
useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6),
)
}
/>
</label>
<button
@@ -188,14 +237,21 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
>
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
</button>
{error && <div className="notice error" role="alert">{error}</div>}
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
</form>
</AuthShell>
)
}
export function EnrollmentPage({ mode, onAuthenticated }: {
export function EnrollmentPage({
mode,
onAuthenticated,
}: {
mode: 'setup' | 'register'
onAuthenticated: () => Promise<void>
}) {
@@ -214,7 +270,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
enrollment || recoveryCodes
? '完成 TOTP 绑定并保存一次性恢复码'
: '完成或清空正在填写的注册表单',
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
busy ||
Boolean(
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
),
)
const start = async (event: FormEvent) => {
@@ -276,7 +335,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
if (enrollment) {
return (
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
<AuthShell
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
title="强制绑定 TOTP"
>
<TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}>
<label>
@@ -294,8 +356,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</label>
{error && <div className="notice error" role="alert">{error}</div>}
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy || totpCode.length !== 6}>
{busy ? '正在确认…' : '确认绑定并创建账户'}
</button>
</form>
</AuthShell>
)
@@ -305,7 +373,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
<AuthShell
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
title={isSetup ? '创建系统管理员' : '创建你的账户'}
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
footer={
<p>
已有账户?<a href={authRoute('login')}>返回登录</a>
</p>
}
>
<p className="auth-lead">
{isSetup
@@ -350,7 +422,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
</label>
)}
{error && <div className="notice error" role="alert">{error}</div>}
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
</form>
</AuthShell>
+299 -179
View File
@@ -7,7 +7,9 @@ html,
body,
#root,
.copy {
font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif;
font-family:
'Noto Serif SC', 'Microsoft YaHei', 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji',
serif;
}
.wall {
@@ -19,19 +21,33 @@ body,
}
.card-decor {
--decor-primary-image: url("/assets/floral-divider.svg");
--decor-primary-image: url('/assets/floral-divider.svg');
--decor-primary-position: center 44%;
--decor-primary-size: 92% auto;
--decor-primary-transform: none;
--decor-primary-opacity: .11;
--decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%);
--decor-secondary-image: url("/assets/floral-cluster.svg");
--decor-primary-opacity: 0.11;
--decor-primary-color: linear-gradient(
90deg,
#e7d0f4 4%,
#75e6cc 35%,
#d5fff1 50%,
#6adcc6 68%,
#f4bfd4 96%
);
--decor-secondary-image: url('/assets/floral-cluster.svg');
--decor-secondary-position: right -10px bottom -22px;
--decor-secondary-size: 34% auto;
--decor-secondary-transform: none;
--decor-secondary-opacity: .065;
--decor-secondary-opacity: 0.065;
--decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc);
--decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04));
--decor-surface:
radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, 0.09), transparent 58%),
linear-gradient(
90deg,
rgba(230, 203, 241, 0.04),
transparent 18% 82%,
rgba(244, 190, 214, 0.04)
);
position: absolute;
inset: 0;
z-index: 0;
@@ -43,7 +59,7 @@ body,
.card-decor::before,
.card-decor::after {
content: "";
content: '';
position: absolute;
inset: 0;
z-index: 1;
@@ -59,7 +75,7 @@ body,
opacity: var(--decor-primary-opacity);
transform: var(--decor-primary-transform);
transform-origin: center;
filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28));
filter: drop-shadow(0 0 6px rgba(105, 255, 224, 0.28));
}
.card-decor::after {
@@ -78,12 +94,12 @@ body,
position: absolute;
inset: 2px;
z-index: 0;
border: 1px solid rgba(157, 255, 232, .14);
border: 1px solid rgba(157, 255, 232, 0.14);
border-radius: inherit;
background: var(--decor-surface);
box-shadow:
inset 0 1px rgba(225, 255, 247, .1),
inset 0 -1px rgba(82, 224, 198, .08);
inset 0 1px rgba(225, 255, 247, 0.1),
inset 0 -1px rgba(82, 224, 198, 0.08);
}
.decor-v1 {
@@ -94,67 +110,82 @@ body,
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 31% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .075;
--decor-secondary-opacity: 0.075;
--decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5);
--decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04));
--decor-surface:
radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, 0.08), transparent 56%),
linear-gradient(
90deg,
rgba(112, 232, 208, 0.045),
transparent 34% 76%,
rgba(236, 200, 243, 0.04)
);
}
.decor-v2 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: left -12px bottom -13px;
--decor-primary-size: 73% auto;
--decor-primary-opacity: .105;
--decor-primary-opacity: 0.105;
--decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-position: right -22px top -18px;
--decor-secondary-size: 62% auto;
--decor-secondary-opacity: .055;
--decor-secondary-opacity: 0.055;
--decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd);
--decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%);
--decor-surface:
radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, 0.09), transparent 54%),
linear-gradient(105deg, rgba(217, 198, 241, 0.045), transparent 58%);
}
.decor-v3 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: left -15px bottom -15px;
--decor-primary-size: 77% auto;
--decor-primary-transform: scaleX(-1);
--decor-primary-opacity: .115;
--decor-primary-opacity: 0.115;
--decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0);
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 32% auto;
--decor-secondary-opacity: .07;
--decor-secondary-opacity: 0.07;
--decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad);
--decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%);
--decor-surface:
radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, 0.075), transparent 54%),
linear-gradient(270deg, rgba(103, 228, 204, 0.05), transparent 64%);
}
.decor-v4 {
--decor-primary-image: url("/assets/floral-cluster.svg");
--decor-primary-image: url('/assets/floral-cluster.svg');
--decor-primary-position: left -18px bottom -28px;
--decor-primary-size: 45% auto;
--decor-primary-transform: rotate(-5deg);
--decor-primary-opacity: .09;
--decor-primary-opacity: 0.09;
--decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4);
--decor-secondary-position: left -15px bottom -25px;
--decor-secondary-size: 37% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .065;
--decor-secondary-opacity: 0.065;
--decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6);
--decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%);
--decor-surface:
radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, 0.085), transparent 47%),
radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, 0.06), transparent 44%);
}
.decor-v5 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: center top -17px;
--decor-primary-size: 90% auto;
--decor-primary-transform: scaleY(-1);
--decor-primary-opacity: .095;
--decor-primary-opacity: 0.095;
--decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-position: left 18% top -15px;
--decor-secondary-size: 55% auto;
--decor-secondary-opacity: .06;
--decor-secondary-opacity: 0.06;
--decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0);
--decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035));
--decor-surface:
radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, 0.065), transparent 56%),
linear-gradient(90deg, rgba(232, 199, 239, 0.04), transparent 52%, rgba(255, 229, 168, 0.035));
}
.card::before {
@@ -173,8 +204,8 @@ body,
position: absolute;
inset: 0;
z-index: 2;
opacity: .5;
transition: opacity .3s ease;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.decor-v1 .card-particle-layer,
@@ -208,7 +239,7 @@ body,
.card-particle.star {
background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9));
filter: drop-shadow(0 0 4px rgba(190, 255, 240, 0.9));
}
.card-particle.floret {
@@ -219,30 +250,99 @@ body,
radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72));
filter: drop-shadow(0 0 4px rgba(255, 202, 226, 0.72));
}
.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; }
.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; }
.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; }
.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; }
.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; }
.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; }
.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; }
.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; }
.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; }
.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; }
.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; }
.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; }
.card-particle:nth-child(1) {
left: 2%;
top: 14%;
animation-delay: -0.35s;
}
.card-particle:nth-child(2) {
right: 3%;
top: 16%;
--particle-size: 10px;
animation-delay: -1.15s;
}
.card-particle:nth-child(3) {
left: 4%;
bottom: 13%;
--particle-size: 7px;
animation-delay: -2.4s;
}
.card-particle:nth-child(4) {
right: 2%;
bottom: 15%;
--particle-size: 8px;
animation-delay: -0.75s;
}
.card-particle:nth-child(5) {
left: 22%;
top: 5%;
--particle-size: 9px;
animation-delay: -3.15s;
}
.card-particle:nth-child(6) {
right: 24%;
top: 8%;
--particle-size: 6px;
animation-delay: -1.65s;
}
.card-particle:nth-child(7) {
left: 47%;
bottom: 4%;
--particle-size: 8px;
animation-delay: -2.75s;
}
.card-particle:nth-child(8) {
right: 43%;
top: 4%;
--particle-size: 7px;
animation-delay: -0.15s;
}
.card-particle:nth-child(9) {
left: 34%;
top: 46%;
--particle-size: 6px;
animation-delay: -1.95s;
}
.card-particle:nth-child(10) {
right: 32%;
bottom: 30%;
--particle-size: 9px;
animation-delay: -3.55s;
}
.card-particle:nth-child(11) {
left: 12%;
top: 48%;
--particle-size: 7px;
animation-delay: -0.95s;
}
.card-particle:nth-child(12) {
right: 13%;
top: 52%;
--particle-size: 6px;
animation-delay: -2.2s;
}
@keyframes card-sparkle {
0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); }
38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); }
68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); }
0%,
100% {
opacity: 0.04;
transform: translate3d(0, 4px, 0) rotate(0) scale(0.45);
}
38% {
opacity: 0.82;
transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08);
}
68% {
opacity: 0.22;
transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(0.7);
}
}
.narrow .card-particle-layer > :nth-child(n+9),
.short .card-particle-layer > :nth-child(n+7) {
.narrow .card-particle-layer > :nth-child(n + 9),
.short .card-particle-layer > :nth-child(n + 7) {
display: none;
}
@@ -279,9 +379,9 @@ body,
width: auto;
height: 1.4em;
max-width: 6em;
margin-inline: .08em;
margin-inline: 0.08em;
object-fit: contain;
vertical-align: -.32em;
vertical-align: -0.32em;
opacity: 1;
filter: none;
}
@@ -292,7 +392,7 @@ body,
height: auto;
max-width: 100%;
max-height: 4.8em;
margin: .12em 0;
margin: 0.12em 0;
vertical-align: top;
}
@@ -300,8 +400,8 @@ body,
display: inline-block;
max-width: 5em;
max-height: 2.2em;
margin: 0 .1em;
vertical-align: -.55em;
margin: 0 0.1em;
vertical-align: -0.55em;
}
.copy b,
@@ -316,46 +416,52 @@ body,
.card.danmaku {
isolation: isolate;
transform-origin: center center;
transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease;
transition:
min-height 0.24s ease,
padding 0.24s ease,
border-radius 0.24s ease,
background 0.24s ease;
}
.card.danmaku.expanded {
min-height: 92px;
padding: 14px;
border-color: rgba(133, 255, 232, .58);
border-color: rgba(133, 255, 232, 0.58);
background:
linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)),
linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78));
linear-gradient(90deg, rgba(91, 224, 199, 0.13), transparent 14% 86%, rgba(91, 224, 199, 0.13)),
linear-gradient(115deg, rgba(4, 43, 59, 0.94), rgba(9, 82, 81, 0.78));
box-shadow:
inset 12px 0 18px -15px rgba(142, 255, 230, .95),
inset -12px 0 18px -15px rgba(142, 255, 230, .95),
0 10px 28px rgba(0, 15, 25, .32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
inset 12px 0 18px -15px rgba(142, 255, 230, 0.95),
inset -12px 0 18px -15px rgba(142, 255, 230, 0.95),
0 10px 28px rgba(0, 15, 25, 0.32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
}
.card.danmaku.expanded::after {
content: "";
content: '';
position: absolute;
inset-block: 4px;
inset-inline: -1px;
z-index: 2;
pointer-events: none;
border-inline: 4px solid rgba(123, 238, 214, .72);
border-inline: 4px solid rgba(123, 238, 214, 0.72);
border-radius: 11px;
background:
linear-gradient(90deg,
rgba(201, 255, 240, .32) 0,
rgba(63, 180, 165, .26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, .26) calc(100% - 5px),
rgba(201, 255, 240, .32) 100%);
background: linear-gradient(
90deg,
rgba(201, 255, 240, 0.32) 0,
rgba(63, 180, 165, 0.26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, 0.26) calc(100% - 5px),
rgba(201, 255, 240, 0.32) 100%
);
box-shadow:
inset 6px 0 7px -7px rgba(216, 255, 246, .72),
inset -6px 0 7px -7px rgba(216, 255, 246, .72),
-2px 0 0 rgba(7, 46, 53, .72),
2px 0 0 rgba(7, 46, 53, .72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
inset 6px 0 7px -7px rgba(216, 255, 246, 0.72),
inset -6px 0 7px -7px rgba(216, 255, 246, 0.72),
-2px 0 0 rgba(7, 46, 53, 0.72),
2px 0 0 rgba(7, 46, 53, 0.72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95)
both;
}
.card.danmaku.expanded .copy {
@@ -370,8 +476,8 @@ body,
.card.danmaku.expanded .copy b {
color: #f0fffb;
font-size: .78em;
letter-spacing: .06em;
font-size: 0.78em;
letter-spacing: 0.06em;
}
.card.danmaku.expanded .copy span {
@@ -386,7 +492,7 @@ body,
min-height: 38px;
padding: 5px 10px;
border-radius: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48));
background: linear-gradient(115deg, rgba(4, 34, 49, 0.72), rgba(8, 69, 72, 0.48));
}
.card.danmaku.compact .copy {
@@ -413,7 +519,7 @@ body,
white-space: normal;
}
.short .cards .card:nth-child(n+4) {
.short .cards .card:nth-child(n + 4) {
display: flex;
}
@@ -428,7 +534,7 @@ body,
@keyframes scroll-rails-open {
from {
transform: scaleX(.025);
transform: scaleX(0.025);
}
to {
transform: scaleX(1);
@@ -436,11 +542,17 @@ body,
}
@keyframes arrive {
from { transform: translateX(38px) scale(.96); }
to { transform: none; }
from {
transform: translateX(38px) scale(0.96);
}
to {
transform: none;
}
}
.overlay.narrow { padding: 6px; }
.overlay.narrow {
padding: 6px;
}
.narrow .card.danmaku.expanded {
min-height: 82px;
@@ -478,11 +590,11 @@ body,
.preset-buttons small {
font-weight: normal;
opacity: .72;
opacity: 0.72;
}
.preset-buttons .active {
box-shadow: 0 0 0 2px rgba(137, 255, 226, .28);
box-shadow: 0 0 0 2px rgba(137, 255, 226, 0.28);
}
.control .obs-address {
@@ -503,7 +615,7 @@ body,
max-height: 75vh;
overflow: auto;
margin-top: 14px;
border: 1px solid rgba(89, 183, 173, .25);
border: 1px solid rgba(89, 183, 173, 0.25);
background: #04131b;
}
@@ -515,7 +627,9 @@ body,
}
@media (max-width: 720px) {
.preview-viewport { max-height: 560px; }
.preview-viewport {
max-height: 560px;
}
}
/* Multi-user application shell ------------------------------------------------ */
@@ -523,10 +637,10 @@ body,
:root {
color-scheme: dark;
--app-bg: #041219;
--app-panel: rgba(7, 32, 42, .88);
--app-panel-strong: rgba(7, 39, 48, .96);
--app-line: rgba(110, 224, 205, .22);
--app-line-strong: rgba(117, 244, 218, .48);
--app-panel: rgba(7, 32, 42, 0.88);
--app-panel-strong: rgba(7, 39, 48, 0.96);
--app-line: rgba(110, 224, 205, 0.22);
--app-line-strong: rgba(117, 244, 218, 0.48);
--app-text: #dcfff7;
--app-muted: #83b8b0;
--app-accent: #67e6c5;
@@ -544,20 +658,20 @@ select {
position: relative;
border: 1px solid var(--app-line);
background:
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, .08), transparent 48%),
linear-gradient(135deg, rgba(8, 39, 50, .94), rgba(5, 25, 35, .91));
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, 0.08), transparent 48%),
linear-gradient(135deg, rgba(8, 39, 50, 0.94), rgba(5, 25, 35, 0.91));
box-shadow:
inset 0 1px rgba(210, 255, 245, .055),
0 18px 48px rgba(0, 8, 15, .22);
inset 0 1px rgba(210, 255, 245, 0.055),
0 18px 48px rgba(0, 8, 15, 0.22);
backdrop-filter: blur(18px);
}
.jade-panel::after {
content: "";
content: '';
position: absolute;
inset: 5px;
z-index: 0;
border: 1px solid rgba(122, 237, 216, .06);
border: 1px solid rgba(122, 237, 216, 0.06);
border-radius: inherit;
pointer-events: none;
}
@@ -573,7 +687,7 @@ select {
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .19em;
letter-spacing: 0.19em;
text-transform: uppercase;
}
@@ -585,15 +699,15 @@ select {
place-items: center;
overflow: auto;
background:
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, .2), transparent 30%),
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, .18), transparent 33%),
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, 0.2), transparent 30%),
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, 0.18), transparent 33%),
linear-gradient(145deg, #04131b, #082b31 55%, #041119);
color: var(--app-text);
}
.route-loading {
color: #93cfc4;
letter-spacing: .12em;
letter-spacing: 0.12em;
}
.auth-card {
@@ -607,13 +721,13 @@ select {
color: #dffff7;
font-size: clamp(28px, 5vw, 44px);
font-weight: 600;
letter-spacing: .04em;
letter-spacing: 0.04em;
}
.auth-card footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(109, 212, 196, .14);
border-top: 1px solid rgba(109, 212, 196, 0.14);
}
.auth-card footer p {
@@ -634,11 +748,11 @@ select {
height: 54px;
display: grid;
place-items: center;
border: 1px solid rgba(124, 239, 216, .32);
border: 1px solid rgba(124, 239, 216, 0.32);
border-radius: 50%;
color: rgba(190, 255, 241, .8);
background: radial-gradient(circle, rgba(62, 173, 155, .23), transparent 72%);
box-shadow: 0 0 30px rgba(80, 216, 191, .14);
color: rgba(190, 255, 241, 0.8);
background: radial-gradient(circle, rgba(62, 173, 155, 0.23), transparent 72%);
box-shadow: 0 0 30px rgba(80, 216, 191, 0.14);
}
.auth-lead {
@@ -676,12 +790,14 @@ select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid rgba(75, 157, 151, .55);
border: 1px solid rgba(75, 157, 151, 0.55);
border-radius: 10px;
outline: none;
color: #edfffb;
background: rgba(3, 18, 26, .82);
transition: border-color .18s ease, box-shadow .18s ease;
background: rgba(3, 18, 26, 0.82);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.stack-form input:focus,
@@ -690,14 +806,14 @@ select {
.secret-address input:focus,
.one-time-secret input:focus {
border-color: #6ee8ce;
box-shadow: 0 0 0 3px rgba(92, 225, 198, .12);
box-shadow: 0 0 0 3px rgba(92, 225, 198, 0.12);
}
.otp-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
font-size: 22px !important;
font-weight: 700;
letter-spacing: .35em;
letter-spacing: 0.35em;
text-align: center;
}
@@ -710,12 +826,15 @@ select {
border-radius: 9px;
color: #05241f;
background: linear-gradient(135deg, #86f2d8, #50cdb2);
box-shadow: 0 5px 18px rgba(40, 168, 145, .16);
box-shadow: 0 5px 18px rgba(40, 168, 145, 0.16);
font-weight: 700;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: filter .16s ease, transform .16s ease, border-color .16s ease;
transition:
filter 0.16s ease,
transform 0.16s ease,
border-color 0.16s ease;
}
.auth-page button:hover:not(:disabled),
@@ -728,23 +847,23 @@ select {
.auth-page button:disabled,
.dashboard-shell button:disabled {
cursor: wait;
filter: saturate(.45);
opacity: .65;
filter: saturate(0.45);
opacity: 0.65;
}
.auth-page button.secondary,
.dashboard-shell button.secondary,
.dashboard-shell .ghost-button {
border-color: rgba(91, 189, 176, .26);
border-color: rgba(91, 189, 176, 0.26);
color: #cffff4;
background: rgba(16, 67, 76, .62);
background: rgba(16, 67, 76, 0.62);
box-shadow: none;
}
.dashboard-shell button.danger {
border-color: rgba(255, 138, 156, .3);
border-color: rgba(255, 138, 156, 0.3);
color: #ffd7dd;
background: rgba(102, 32, 48, .58);
background: rgba(102, 32, 48, 0.58);
box-shadow: none;
}
@@ -773,32 +892,32 @@ select {
.recovery-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
letter-spacing: .08em;
letter-spacing: 0.08em;
}
.notice {
padding: 11px 13px;
border: 1px solid rgba(121, 222, 205, .24);
border: 1px solid rgba(121, 222, 205, 0.24);
border-radius: 10px;
color: #c9f8ee;
background: rgba(17, 73, 76, .42);
background: rgba(17, 73, 76, 0.42);
line-height: 1.45;
}
.notice.error {
border-color: rgba(255, 133, 151, .36);
border-color: rgba(255, 133, 151, 0.36);
color: #ffd6dc;
background: rgba(103, 31, 47, .45);
background: rgba(103, 31, 47, 0.45);
}
.notice.warning {
border-color: rgba(255, 213, 127, .34);
border-color: rgba(255, 213, 127, 0.34);
color: #ffe4aa;
background: rgba(93, 67, 24, .4);
background: rgba(93, 67, 24, 0.4);
}
.notice.success {
border-color: rgba(105, 239, 196, .34);
border-color: rgba(105, 239, 196, 0.34);
color: #baffea;
}
@@ -849,10 +968,10 @@ select {
min-width: 0;
padding: 8px 10px;
overflow-wrap: anywhere;
border: 1px solid rgba(106, 216, 199, .2);
border: 1px solid rgba(106, 216, 199, 0.2);
border-radius: 8px;
color: #e8fff9;
background: rgba(0, 12, 19, .66);
background: rgba(0, 12, 19, 0.66);
}
.compact-form {
@@ -869,12 +988,12 @@ select {
.recovery-grid code {
padding: 11px;
border: 1px solid rgba(104, 219, 200, .2);
border: 1px solid rgba(104, 219, 200, 0.2);
border-radius: 8px;
color: #e8fff9;
background: rgba(0, 13, 20, .7);
background: rgba(0, 13, 20, 0.7);
text-align: center;
letter-spacing: .08em;
letter-spacing: 0.08em;
}
.form-actions {
@@ -899,9 +1018,8 @@ select {
overflow: auto;
color: var(--app-text);
background:
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, .17), transparent 28%),
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, .12), transparent 32%),
var(--app-bg);
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, 0.17), transparent 28%),
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, 0.12), transparent 32%), var(--app-bg);
}
.dashboard-topbar {
@@ -914,8 +1032,8 @@ select {
grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr);
gap: 20px;
align-items: center;
border-bottom: 1px solid rgba(84, 178, 166, .16);
background: rgba(3, 18, 26, .84);
border-bottom: 1px solid rgba(84, 178, 166, 0.16);
background: rgba(3, 18, 26, 0.84);
backdrop-filter: blur(18px);
}
@@ -933,9 +1051,9 @@ select {
height: 38px;
display: grid;
place-items: center;
border: 1px solid rgba(111, 232, 210, .35);
border: 1px solid rgba(111, 232, 210, 0.35);
border-radius: 50%;
background: rgba(34, 116, 107, .22);
background: rgba(34, 116, 107, 0.22);
}
.brand div,
@@ -949,16 +1067,16 @@ select {
color: #689b94;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 9px;
letter-spacing: .12em;
letter-spacing: 0.12em;
}
.dashboard-topbar nav {
display: flex;
gap: 5px;
padding: 4px;
border: 1px solid rgba(89, 181, 169, .14);
border: 1px solid rgba(89, 181, 169, 0.14);
border-radius: 11px;
background: rgba(3, 20, 28, .54);
background: rgba(3, 20, 28, 0.54);
}
.dashboard-topbar nav a {
@@ -970,7 +1088,7 @@ select {
.dashboard-topbar nav a.active {
color: #e4fff9;
background: rgba(49, 132, 120, .35);
background: rgba(49, 132, 120, 0.35);
}
.account-menu {
@@ -999,9 +1117,9 @@ select {
.auth-page .pwa-action {
min-height: 34px;
padding: 6px 11px;
border-color: rgba(95, 215, 193, .28);
border-color: rgba(95, 215, 193, 0.28);
color: #cffff4;
background: rgba(16, 67, 76, .72);
background: rgba(16, 67, 76, 0.72);
box-shadow: none;
font-size: 12px;
}
@@ -1012,10 +1130,10 @@ select {
display: inline-flex;
gap: 6px;
align-items: center;
border: 1px solid rgba(255, 202, 112, .3);
border: 1px solid rgba(255, 202, 112, 0.3);
border-radius: 999px;
color: #ffe2a7;
background: rgba(83, 55, 20, .48);
background: rgba(83, 55, 20, 0.48);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
white-space: nowrap;
@@ -1026,29 +1144,31 @@ select {
height: 7px;
border-radius: 50%;
background: #ffca70;
box-shadow: 0 0 9px rgba(255, 202, 112, .68);
box-shadow: 0 0 9px rgba(255, 202, 112, 0.68);
}
.dashboard-shell .pwa-action {
min-height: 30px;
padding: 5px 10px;
border-color: rgba(95, 215, 193, .28);
border-color: rgba(95, 215, 193, 0.28);
color: #cffff4;
background: rgba(16, 67, 76, .72);
background: rgba(16, 67, 76, 0.72);
box-shadow: none;
font-size: 12px;
white-space: nowrap;
}
.dashboard-shell .pwa-action.update {
border-color: rgba(255, 222, 143, .4);
border-color: rgba(255, 222, 143, 0.4);
color: #ffe8b7;
background: rgba(91, 65, 25, .58);
background: rgba(91, 65, 25, 0.58);
animation: pwa-update-glow 2.2s ease-in-out infinite;
}
@keyframes pwa-update-glow {
50% { box-shadow: 0 0 15px rgba(255, 215, 123, .16); }
50% {
box-shadow: 0 0 15px rgba(255, 215, 123, 0.16);
}
}
@media (display-mode: standalone) {
@@ -1110,8 +1230,8 @@ select {
}
.component-list button.selected {
border-color: rgba(91, 211, 190, .23);
background: linear-gradient(110deg, rgba(38, 126, 114, .35), rgba(17, 69, 76, .3));
border-color: rgba(91, 211, 190, 0.23);
background: linear-gradient(110deg, rgba(38, 126, 114, 0.35), rgba(17, 69, 76, 0.3));
}
.component-list button > span:nth-child(2) {
@@ -1137,10 +1257,10 @@ select {
height: 38px;
display: grid;
place-items: center;
border: 1px solid rgba(106, 225, 203, .2);
border: 1px solid rgba(106, 225, 203, 0.2);
border-radius: 10px;
color: #a8f0df;
background: rgba(45, 135, 121, .2);
background: rgba(45, 135, 121, 0.2);
}
.component-list i {
@@ -1152,7 +1272,7 @@ select {
.component-list i.enabled {
background: #72ebc9;
box-shadow: 0 0 9px rgba(86, 232, 197, .7);
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
}
.future-components {
@@ -1160,7 +1280,7 @@ select {
padding-top: 15px;
display: grid;
gap: 4px;
border-top: 1px solid rgba(94, 187, 173, .12);
border-top: 1px solid rgba(94, 187, 173, 0.12);
color: #567d78;
}
@@ -1188,24 +1308,24 @@ select {
.status-chip {
width: max-content;
padding: 5px 9px;
border: 1px solid rgba(112, 197, 186, .2);
border: 1px solid rgba(112, 197, 186, 0.2);
border-radius: 99px;
color: #91bcb6;
background: rgba(17, 60, 66, .44);
background: rgba(17, 60, 66, 0.44);
font-size: 12px;
white-space: nowrap;
}
.status-chip.online {
border-color: rgba(99, 236, 199, .3);
border-color: rgba(99, 236, 199, 0.3);
color: #99f3d8;
background: rgba(31, 105, 87, .35);
background: rgba(31, 105, 87, 0.35);
}
.status-chip.offline {
border-color: rgba(240, 151, 159, .25);
border-color: rgba(240, 151, 159, 0.25);
color: #e9aeb4;
background: rgba(91, 39, 48, .34);
background: rgba(91, 39, 48, 0.34);
}
.dashboard-panel {
@@ -1251,7 +1371,7 @@ select {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.slider-grid input[type="range"] {
.slider-grid input[type='range'] {
width: 100%;
accent-color: #63ddc1;
}
@@ -1262,7 +1382,7 @@ select {
display: flex;
flex-wrap: wrap;
gap: 9px;
border: 1px solid rgba(89, 179, 167, .18);
border: 1px solid rgba(89, 179, 167, 0.18);
border-radius: 12px;
}
@@ -1381,9 +1501,9 @@ select {
padding: 17px;
display: grid;
gap: 11px;
border: 1px solid rgba(255, 215, 127, .28);
border: 1px solid rgba(255, 215, 127, 0.28);
border-radius: 12px;
background: rgba(86, 64, 24, .24);
background: rgba(86, 64, 24, 0.24);
}
.one-time-secret > b {
@@ -1408,7 +1528,7 @@ table {
th,
td {
padding: 12px 10px;
border-bottom: 1px solid rgba(85, 169, 158, .12);
border-bottom: 1px solid rgba(85, 169, 158, 0.12);
color: #a9d3cc;
text-align: left;
white-space: nowrap;
@@ -1426,10 +1546,10 @@ td:last-child {
.obs-configuration-error {
padding: 10px 12px;
border: 1px solid rgba(255, 143, 157, .42);
border: 1px solid rgba(255, 143, 157, 0.42);
border-radius: 10px;
color: #ffe1e5;
background: rgba(72, 24, 37, .82);
background: rgba(72, 24, 37, 0.82);
font-size: clamp(13px, 3vw, 17px);
}
File diff suppressed because it is too large Load Diff
+24 -5
View File
@@ -1,3 +1,11 @@
/**
* Browser entry point and intentionally small client-side router.
*
* `/obs/:publicId` is selected before the control application is initialized.
* That early split is a security and reliability boundary: OBS sources never
* register the control-console PWA or execute authenticated dashboard requests.
* All other routes share the session bootstrap and passwordless auth flow.
*/
import { useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
@@ -20,11 +28,15 @@ function NotFoundPage() {
return (
<main className="auth-page">
<section className="auth-card jade-panel not-found">
<div className="auth-mark" aria-hidden="true">云</div>
<div className="auth-mark" aria-hidden="true">
云
</div>
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
<h1>这里没有组件</h1>
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
<a className="button-link" href="/control/">返回控制台</a>
<a className="button-link" href="/control/">
返回控制台
</a>
</section>
</main>
)
@@ -85,9 +97,15 @@ function App() {
<PwaControls />
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
{offline && (
<p className="auth-lead">
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
</p>
)}
<div className="notice error">{loadError}</div>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
重新连接
</button>
</section>
</main>
)
@@ -124,7 +142,8 @@ function App() {
}
if (path === '/control/invitations') {
if (!session.user) return <Redirect to="/control/login" />
if (session.user.role !== 'system_admin') return <ForbiddenPage user={session.user} onLogout={logout} />
if (session.user.role !== 'system_admin')
return <ForbiddenPage user={session.user} onLogout={logout} />
return <InvitationsPage user={session.user} onLogout={logout} />
}
return <NotFoundPage />
+124 -47
View File
@@ -1,3 +1,12 @@
/**
* Transparent OBS renderer and component WebSocket client.
*
* The bearer token is read from the URL fragment, then sent as the first
* WebSocket frame; fragments never reach Nginx access logs or the initial HTTP
* request. Incoming events are treated as untrusted display data, bounded by
* component settings, and malformed frames are ignored without terminating a
* long-running browser source.
*/
import { useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { defaultOverlaySettings } from './types'
@@ -55,7 +64,20 @@ type OverlayProps = {
accessToken?: string
}
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
const cardParticles = [
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
] as const
const decorVariantCount = 6
function stableHash(value: string) {
@@ -95,21 +117,27 @@ function streamUrl(publicId: string) {
}
function enabled(type: string, settings: OverlaySettings) {
return (type === 'live.danmaku' && settings.showDanmaku)
|| (type === 'live.enter' && settings.showEnter)
|| (type.startsWith('live.gift') && settings.showGift)
|| (type === 'live.superchat' && settings.showSuperchat)
|| (type === 'live.guard.buy' && settings.showGuard)
|| (type === 'live.like' && settings.showLike)
|| (type === 'live.share' && settings.showShare)
return (
(type === 'live.danmaku' && settings.showDanmaku) ||
(type === 'live.enter' && settings.showEnter) ||
(type.startsWith('live.gift') && settings.showGift) ||
(type === 'live.superchat' && settings.showSuperchat) ||
(type === 'live.guard.buy' && settings.showGuard) ||
(type === 'live.like' && settings.showLike) ||
(type === 'live.share' && settings.showShare)
)
}
function parseSettings(value: unknown): OverlaySettings {
if (!value || typeof value !== 'object') return defaultOverlaySettings
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
return { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
}
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
function useEvents(
disabled: boolean,
publicId?: string,
accessToken?: string,
): {
settings: OverlaySettings
items: Item[]
setItems: Dispatch<SetStateAction<Item[]>>
@@ -117,7 +145,9 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
} {
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
const [items, setItems] = useState<Item[]>([])
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>(
'idle',
)
const settingsRef = useRef(settings)
useEffect(() => {
@@ -142,7 +172,7 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
retries = 0
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
}
socket.onclose = (event) => {
socket.onclose = event => {
if (dead) return
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
setConnection('denied')
@@ -166,7 +196,10 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
return
}
setConnection('connected')
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
if (
envelope.type === 'overlay.settings.snapshot' ||
envelope.type === 'overlay.settings.updated'
) {
setSettings(parseSettings(envelope.payload?.settings))
return
}
@@ -176,10 +209,13 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
const key = combo ? `combo:${combo}` : envelope.id
const existing = old.find(item => item.key === key)
const decorVariant = existing?.decorVariant
?? chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [{ ...envelope, key, received: Date.now(), decorVariant }, ...old.filter(item => item.key !== key)]
.slice(0, current.maxVisible)
const decorVariant =
existing?.decorVariant ??
chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [
{ ...envelope, key, received: Date.now(), decorVariant },
...old.filter(item => item.key !== key),
].slice(0, current.maxVisible)
})
} catch {
// A malformed upstream event must not break a long-running OBS source.
@@ -239,49 +275,75 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
}
function DanmakuBody({ payload }: { payload: LivePayload }) {
const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined
const segments = Array.isArray(payload.segments)
? (payload.segments as DanmakuSegment[])
: undefined
if (!segments?.length) return <>{payload.text || ''}</>
return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url
? <DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
: <span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</>
return (
<>
{segments.map((segment, index) =>
segment.type === 'emoticon' && segment.url ? (
<DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
) : (
<span className="danmaku-text" key={`text:${index}`}>
{segment.text}
</span>
),
)}
</>
)
}
function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) {
function Card({
item,
settings,
expanded,
}: {
item: Item
settings: OverlaySettings
expanded: boolean
}) {
const payload = item.payload || {}
const viewer = payload.viewer || {}
const gift = payload.gift
const isDanmaku = item.type === 'live.danmaku'
const tier = gift ? giftTier(item, settings) : ''
const body = gift
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
: isDanmaku
? <DanmakuBody payload={payload} />
: eventRenderers[item.type]?.(payload) || '送来了一份互动'
const body = gift ? (
`献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
) : isDanmaku ? (
<DanmakuBody payload={payload} />
) : (
eventRenderers[item.type]?.(payload) || '送来了一份互动'
)
return (
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}>
<article
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
>
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
{gift && (
<div className="gift-art">
{gift.animationUrl || gift.imageUrl
? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
else image.style.display = 'none'
}}
/>
)
: <span>✦</span>}
{gift.animationUrl || gift.imageUrl ? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
else image.style.display = 'none'
}}
/>
) : (
<span>✦</span>
)}
</div>
)}
<div className="copy">
<b>{viewer.name || '直播间观众'}</b>
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && <em>¥ {gift.priceCny.toFixed(2)}</em>}
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
<em>¥ {gift.priceCny.toFixed(2)}</em>
)}
</div>
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
</article>
@@ -327,7 +389,13 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
payload: {
viewer: { name: '星光旅人' },
quantity: 1,
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' },
gift: {
name: '甜蜜告白',
totalPrice: 12_000,
priceCny: 12,
imageUrl: '',
animationUrl: '',
},
},
},
])
@@ -343,7 +411,7 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
setExpandedKey(newest.key)
const densityFactor = shape === 'short' ? 0.6 : 1
const timer = window.setTimeout(
() => setExpandedKey(key => key === newest.key ? undefined : key),
() => setExpandedKey(key => (key === newest.key ? undefined : key)),
settings.collapseAfterSeconds * 1000 * densityFactor,
)
return () => window.clearTimeout(timer)
@@ -365,11 +433,20 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
}}
>
<section className="wall">
{missingAccess && <div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>}
{!missingAccess && events.connection === 'denied' && <div className="obs-configuration-error">OBS 访问令牌已失效。</div>}
{missingAccess && (
<div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>
)}
{!missingAccess && events.connection === 'denied' && (
<div className="obs-configuration-error">OBS 访问令牌已失效。</div>
)}
<div className="cards">
{items.map(item => (
<Card item={item} settings={settings} expanded={item.key === expandedKey} key={item.key} />
<Card
item={item}
settings={settings}
expanded={item.key === expandedKey}
key={item.key}
/>
))}
</div>
</section>
+64 -27
View File
@@ -1,3 +1,12 @@
/**
* Install, offline and explicit-update lifecycle for the control-console PWA.
*
* Registration is restricted to `/control/`; `/obs/*` is deliberately outside
* the scope. The worker caches only the public application shell and static
* assets. API responses, WebSockets and secrets remain network-only. A waiting
* worker activates only after user confirmation and after every registered
* one-time-secret or dirty-form blocker has cleared.
*/
import { useEffect, useSyncExternalStore } from 'react'
interface InstallChoice {
@@ -58,9 +67,10 @@ function addManifest() {
function observeRegistration(registration: ServiceWorkerRegistration) {
emit({
registration,
waitingWorker: registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
waitingWorker:
registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
})
registration.addEventListener('updatefound', () => {
@@ -76,18 +86,23 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
async function removeLegacyRootWorker() {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy = scopePath === '/'
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}))
await Promise.all(
registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy =
scopePath === '/' &&
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}),
)
const cacheNames = await caches.keys()
await Promise.all(cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)))
await Promise.all(
cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)),
)
}
export function cleanupLegacyPwa() {
@@ -121,18 +136,25 @@ export function initializePwa() {
const updateDisplayMode = () => emit({ standalone: isStandalone() })
window.addEventListener('online', updateConnection)
window.addEventListener('offline', updateConnection)
const modernListener = (displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}).addEventListener
const modernListener = (
displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}
).addEventListener
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
else (displayMode as unknown as { addListener?: (listener: () => void) => void })
.addListener?.call(displayMode, updateDisplayMode)
else
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
displayMode,
updateDisplayMode,
)
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault()
emit({ installPrompt: event as BeforeInstallPromptEvent })
})
window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true }))
window.addEventListener('appinstalled', () =>
emit({ installPrompt: undefined, standalone: true }),
)
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
@@ -151,13 +173,17 @@ export function initializePwa() {
void snapshot.registration?.update()
}
})
window.setInterval(() => {
if (navigator.onLine) void snapshot.registration?.update()
}, 60 * 60 * 1000)
window.setInterval(
() => {
if (navigator.onLine) void snapshot.registration?.update()
},
60 * 60 * 1000,
)
}
export function authRoute(name: 'login' | 'register' | 'setup'): string {
const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/')
const inControlScope =
location.pathname === '/control' || location.pathname.startsWith('/control/')
return inControlScope ? `/control/${name}` : `/${name}`
}
@@ -165,7 +191,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
useEffect(() => {
if (active) updateBlockers.set(key, reason)
else updateBlockers.delete(key)
return () => { updateBlockers.delete(key) }
return () => {
updateBlockers.delete(key)
}
}, [active, key, reason])
}
@@ -182,10 +210,14 @@ function applyUpdate() {
if (!worker) return
const reasons = [...new Set(updateBlockers.values())]
if (reasons.length > 0) {
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
window.alert(
`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`,
)
return
}
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
const confirmed = window.confirm(
'更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?',
)
if (!confirmed) return
reloadForUpdate = true
worker.postMessage({ type: 'SKIP_WAITING' })
@@ -198,7 +230,12 @@ export function PwaControls() {
return (
<div className="pwa-controls" aria-live="polite">
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
{!state.online && (
<span className="pwa-state offline">
<i aria-hidden="true" />
离线
</span>
)}
{state.waitingWorker && (
<button type="button" className="pwa-action update" onClick={applyUpdate}>
更新可用
+274 -1
View File
@@ -1 +1,274 @@
*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
font-family: 'Noto Serif SC', 'Microsoft YaHei', serif;
}
body {
background: transparent;
color: #dcfffa;
}
.overlay {
width: 100%;
height: 100%;
padding: clamp(8px, 2vw, 22px);
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
background: radial-gradient(ellipse at 100% 50%, rgba(21, 94, 96, 0.2), transparent 62%);
}
.wall {
width: min(100%, 480px);
display: flex;
flex-direction: column;
gap: 9px;
filter: drop-shadow(0 10px 28px rgba(0, 11, 19, 0.36));
}
.cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.card {
position: relative;
min-height: 58px;
padding: 11px 14px;
border: 1px solid rgba(101, 226, 211, 0.28);
border-radius: 14px;
overflow: hidden;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61));
backdrop-filter: blur(15px);
animation: arrive calc(0.35s + 0.35s * var(--motion)) cubic-bezier(0.19, 0.9, 0.3, 1) both;
}
.card:before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 25%, rgba(155, 255, 232, 0.14), transparent 65%);
transform: translateX(-120%);
animation: sheen calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s both;
}
.copy {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: clamp(12px, 2.7vw, 17px);
}
.copy b {
color: #e6fff9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy span {
color: #a9dbd4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy em {
font-style: normal;
color: #ffdc89;
font-size: 0.84em;
}
.gift-art {
z-index: 1;
width: 48px;
height: 48px;
flex: none;
border-radius: 12px;
background: radial-gradient(circle, #2e9f9e, transparent 66%);
display: grid;
place-items: center;
}
.gift-art img {
width: 100%;
height: 100%;
object-fit: contain;
}
.gift.high {
min-height: 72px;
border-color: rgba(102, 255, 229, 0.65);
background: linear-gradient(105deg, rgba(5, 59, 78, 0.92), rgba(14, 119, 103, 0.7));
}
.gift.featured {
min-height: 128px;
border-color: #b6ffdc;
background:
radial-gradient(circle at 20% 50%, rgba(69, 236, 190, 0.4), transparent 35%),
linear-gradient(118deg, rgba(7, 63, 84, 0.97), rgba(12, 105, 89, 0.85));
animation: featured calc(2.8s - 1.5s * var(--motion)) ease-in-out infinite;
}
.gift.featured .gift-art {
width: 92px;
height: 92px;
}
.particles {
position: absolute;
right: 9px;
top: 6px;
color: #ceffe4;
letter-spacing: 9px;
animation: float 2.2s ease-in-out infinite;
}
.narrow .overlay {
padding: 6px;
}
.narrow .wall {
gap: 5px;
}
.narrow .card {
min-height: 45px;
padding: 7px 9px;
border-radius: 10px;
}
.narrow .gift-art {
width: 34px;
height: 34px;
}
.narrow .gift.featured {
min-height: 90px;
align-items: flex-start;
}
.narrow .gift.featured .gift-art {
width: 58px;
height: 58px;
}
.short .cards .card:nth-child(n + 4) {
display: none;
}
.low-motion * {
animation: none !important;
}
.control {
min-height: 100%;
padding: clamp(20px, 5vw, 56px);
display: grid;
grid-template-columns: minmax(300px, 520px) minmax(280px, 1fr);
gap: 36px;
background: linear-gradient(135deg, #061923, #092c32 55%, #06151f);
color: #d9fff6;
}
.control h1 {
margin: 0;
color: #bffff0;
}
.control h2 {
margin: 0;
}
.control p {
color: #88bdb5;
}
.control label {
display: block;
margin: 14px 0;
color: #bcebe3;
}
.control input:not([type='checkbox']):not([type='range']) {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #357e79;
border-radius: 8px;
background: #071d28;
color: #e6fff9;
}
.control input[type='range'] {
margin-left: 10px;
accent-color: #5ae7c6;
}
.control output {
margin-left: 8px;
}
.control fieldset {
border: 1px solid #28645f;
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 3px 12px;
}
.control fieldset label {
margin: 8px 0;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.control button {
padding: 10px 14px;
border: 0;
border-radius: 8px;
background: #55dcb9;
color: #06211e;
font-weight: bold;
cursor: pointer;
}
.control button.secondary {
background: #173e4b;
color: #d4fff7;
}
.preview-frame {
height: 600px;
resize: both;
overflow: auto;
border: 1px dashed #4dafa4;
background: linear-gradient(135deg, rgba(71, 190, 172, 0.12), transparent);
}
.login {
display: grid;
place-content: center;
grid-template-columns: 360px;
background: #071923;
}
.login form {
display: flex;
flex-direction: column;
gap: 12px;
}
@keyframes arrive {
from {
opacity: 0;
transform: translateX(38px) scale(0.96);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheen {
to {
transform: translateX(135%);
}
}
@keyframes featured {
50% {
filter: brightness(1.18);
transform: scale(1.015);
}
}
@keyframes float {
50% {
transform: translateY(-8px);
opacity: 0.5;
}
}
@media (max-width: 720px) {
.control {
grid-template-columns: 1fr;
}
.preview-frame {
height: 480px;
}
}
+7
View File
@@ -1,3 +1,10 @@
/**
* Shared control-console view models.
*
* These types describe sanitized API responses and editable settings, not raw
* database rows or Bilibili packets. Secret fields are optional because the
* server normally returns only `*Configured` flags after initial submission.
*/
export type OverlaySettings = {
fontScale: number
showDanmaku: boolean
+16 -1
View File
@@ -1,4 +1,19 @@
{
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -1,3 +1,10 @@
/**
* Vite build configuration and PWA asset emission.
*
* The same per-build identifier is compiled into the browser bundle and the
* service worker. This makes an hourly/visibility update check discover a new
* deployment while still leaving activation under explicit user control.
*/
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { readFileSync } from 'node:fs'
+50
View File
@@ -0,0 +1,50 @@
# Rust 后端
这是多租户直播组件服务的可复用核心和唯一运行时进程。`src/main.rs` 只处理进程启动;主要行为由 library
crate 导出。
## 模块职责
| 模块 | 职责 |
| ------------- | -------------------------------------------------------- |
| `app` | 依赖组装、迁移、事件队列、源启动与旧配置导入 |
| `auth` | 邀请码、TOTP、恢复码、会话、secret 加密和组件 token |
| `components` | 组件定义、设置版本、订阅、projection 与 handler registry |
| `config` | TOML 解析、部署策略校验和旧单用户兼容字段 |
| `credentials` | CookieCloud URL 边界与 Bilibili Cookie 提取 |
| `db` | PostgreSQL pool、迁移和 RLS tenant context |
| `domain` | provider-independent event 与 WebSocket envelope |
| `http_api` | REST/WS、会话、权限、same-origin、静态资源与安全响应头 |
| `live` | provider trait、Bilibili adapter 与 source supervisor |
| `overlay` | 弹幕姬设置及礼物/表情目录 |
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
| `realtime` | source event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
## 重要不变量
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
- projection 无副作用;可靠业务动作必须使用幂等 handler。
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
## 本地质量检查
在仓库根目录执行:
```bash
cargo fmt --manifest-path apps/server-rust/Cargo.toml --check
cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
```
第三方 `vendor/blivedm` 不作为本项目风格重写目标;项目只维护保留原始 JSON 所需的小补丁。
更多设计说明:
- [总体架构](../../docs/architecture.md)
- [组件开发](../../docs/components/README.md)
- [实时协议](../../docs/protocol.md)
- [安全模型](../../docs/security.md)
+4
View File
@@ -0,0 +1,4 @@
edition = "2024"
max_width = 100
newline_style = "Unix"
use_small_heuristics = "Default"
+7
View File
@@ -1,3 +1,10 @@
//! Application composition root and process-wide dependencies.
//!
//! [`AppState::build`] wires the database, authentication service, component
//! registry, live-source supervisor and realtime router in dependency order.
//! Tenant-owned runtime state stays in PostgreSQL or the source supervisor;
//! this module only owns cloneable handles shared by Axum handlers.
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
+8
View File
@@ -1,3 +1,11 @@
//! Passwordless authentication, enrollment and secret-storage domain service.
//!
//! This module owns invite redemption, TOTP replay protection, recovery codes,
//! session issuance and tenant credential encryption. Raw sessions, recovery
//! codes and component tokens are returned only at creation time; persistent
//! rows contain hashes or authenticated ciphertext. HTTP-specific cookie and
//! origin policy deliberately live in `http_api`, not here.
use std::{fmt, sync::Arc, time::Duration};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
+7
View File
@@ -1,3 +1,10 @@
//! Extensible component registry and event-processing contracts.
//!
//! A component kind supplies a versioned settings definition, a pure browser
//! projection and optional durable handlers. The live provider and router know
//! only these traits, so adding a gift wall or song-request component does not
//! require branching on component kinds in the ingestion pipeline.
use std::{
collections::{BTreeSet, HashMap},
error::Error,
+7
View File
@@ -1,3 +1,10 @@
//! TOML configuration loading, validation and legacy bootstrap compatibility.
//!
//! Deployment-wide policy (bind address, encryption key, allowed CookieCloud
//! hosts and timeouts) remains in [`Config`]. Room IDs, credentials and component
//! settings become tenant-owned database records after enrollment; the legacy
//! TOML fields are import inputs and must not be treated as global live state.
use std::{env, fs, net::IpAddr, path::PathBuf};
use base64::{Engine, engine::general_purpose::STANDARD};
+7
View File
@@ -1,3 +1,10 @@
//! CookieCloud boundary and Bilibili cookie extraction.
//!
//! Tenant input reaches an outbound HTTP client only after canonical URL
//! validation and exact allow-list matching in the caller. Redirects are
//! disabled by the shared client, the synchronization key is encoded as one
//! path segment, and only the minimum Bilibili cookie fields leave this module.
use reqwest::{
Url,
header::{REFERER, USER_AGENT},
+7
View File
@@ -1,3 +1,10 @@
//! PostgreSQL pool, migrations and low-level tenant-scoped queries.
//!
//! Multi-tenant tables use PostgreSQL row-level security in addition to owner
//! columns and composite foreign keys. Every tenant query must execute inside a
//! transaction after [`Db::set_tenant`], which uses `SET LOCAL` so pooled
//! connections cannot retain the previous request's identity.
use std::{fmt, str::FromStr};
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
+7
View File
@@ -1,3 +1,10 @@
//! Provider-independent live events and the versioned component wire envelope.
//!
//! Provider adapters normalize platform packets into [`LiveEvent`]. Components
//! subscribe to stable [`LiveEventKind`] values and project them to
//! [`ComponentMessage`], keeping Bilibili command names and raw payloads out of
//! browser contracts and future provider implementations.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
+14 -6
View File
@@ -1,3 +1,11 @@
//! Axum HTTP/WebSocket adapter and public trust boundary.
//!
//! Handlers derive the tenant from a server-side session cookie; owner IDs from
//! request bodies are never trusted. This module also enforces same-origin
//! mutation checks, response cache policy, component-token WebSocket
//! authentication, bounded request bodies and security headers for the static
//! control console and OBS entry point.
use std::{sync::Arc, time::Duration};
use axum::{
@@ -326,7 +334,7 @@ async fn login(
Ok(result) => result,
Err(error) => {
state.login_limiter.failure(&body.username, &ip).await;
return Err(ApiError::from(error).as_generic_login());
return Err(ApiError::from(error).into_generic_login());
}
};
state.login_limiter.success(&body.username, &ip).await;
@@ -1146,7 +1154,7 @@ impl ApiError {
self
}
fn as_generic_login(mut self) -> Self {
fn into_generic_login(mut self) -> Self {
if self.status != StatusCode::TOO_MANY_REQUESTS {
self.status = StatusCode::UNAUTHORIZED;
self.code = "invalid_credentials";
@@ -1170,10 +1178,10 @@ impl IntoResponse for ApiError {
}),
)
.into_response();
if let Some(retry_after) = self.retry_after {
if let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string()) {
response.headers_mut().insert(header::RETRY_AFTER, value);
}
if let Some(retry_after) = self.retry_after
&& let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}
+30 -25
View File
@@ -1,3 +1,10 @@
//! Bilibili `blivedm_rs` adapter and raw-command normalization.
//!
//! The adapter authenticates with a CookieCloud-derived cookie, refreshes gift
//! and emoticon catalogs, and converts supported Bilibili commands into bounded
//! domain payloads. Unknown commands expose only sanitized metadata—never the
//! original unbounded packet or authentication material.
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
@@ -241,11 +248,11 @@ impl LiveProvider for BilibiliProvider {
});
}
while let Ok(message) = upstream_receiver.try_recv() {
if let Some(message) = normalize(message) {
if raw_sender.blocking_send(message).is_err() {
client.close();
return Ok(());
}
if let Some(message) = normalize(message)
&& raw_sender.blocking_send(message).is_err()
{
client.close();
return Ok(());
}
}
}
@@ -534,12 +541,12 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
json_object(object.get("extra")?)
});
if let Some(extra) = extra {
if let Some(emoticons) = extra.get("emots").and_then(json_object) {
if let Some(emoticons) = emoticons.as_object() {
for (token, metadata) in emoticons {
if let Some(hint) = emoticon_hint(metadata, token, false) {
hints.push(hint);
}
if let Some(emoticons) = extra.get("emots").and_then(json_object)
&& let Some(emoticons) = emoticons.as_object()
{
for (token, metadata) in emoticons {
if let Some(hint) = emoticon_hint(metadata, token, false) {
hints.push(hint);
}
}
}
@@ -547,22 +554,20 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
.get("emoticon_unique")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
if !hints
&& !hints
.iter()
.any(|hint| hint.unique.as_deref() == Some(unique))
{
hints.push(EmoticonHint {
text: text.to_owned(),
unique: Some(unique.to_owned()),
url: None,
width: None,
height: None,
is_dynamic: false,
bulge_display: value_is_truthy(extra.get("bulge_display")),
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
});
}
{
hints.push(EmoticonHint {
text: text.to_owned(),
unique: Some(unique.to_owned()),
url: None,
width: None,
height: None,
is_dynamic: false,
bulge_display: value_is_truthy(extra.get("bulge_display")),
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
});
}
}
hints
+7
View File
@@ -1,3 +1,10 @@
//! Live-provider abstraction and source lifecycle types.
//!
//! A provider receives trusted tenant/source context and emits canonical
//! [`LiveEvent`] values plus observable connection status. Cancellation and
//! restart ownership live in [`supervisor::SourceSupervisor`], keeping provider
//! implementations focused on one upstream connection.
pub mod bilibili;
pub mod supervisor;
+6
View File
@@ -1,3 +1,9 @@
//! Per-source task ownership, restart and cancellation.
//!
//! The supervisor guarantees at most one provider generation for a source ID.
//! Reconfiguration cancels the old task before a replacement starts, preventing
//! duplicate Bilibili listeners and duplicate downstream events.
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
+6
View File
@@ -1,3 +1,9 @@
//! Executable entry point for the livestream component host.
//!
//! Keep this file limited to process concerns: configuration, logging, socket
//! binding and graceful shutdown. All application behavior belongs in the
//! library crate so it can be exercised without starting a real HTTP server.
use lxc_stream_server::{app::AppState, config::Config, http_api};
use tracing::{info, warn};
+35 -21
View File
@@ -1,3 +1,10 @@
//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches.
//!
//! Settings are sanitized before persistence or projection. Catalog refreshes
//! replace the in-memory snapshot only after a complete valid response, so a
//! transient upstream failure preserves the last known gift images, prices and
//! emoticon URLs instead of breaking live rendering.
use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
@@ -123,16 +130,30 @@ pub struct EmoticonCatalog {
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
}
// Parser return aliases keep the atomic cache-replacement contract visible:
// both lookup maps and their source count are produced before either lock is
// updated, so readers never observe a half-refreshed catalog.
type GiftCatalogSnapshot = (HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
type EmoticonCatalogSnapshot = (
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
);
impl EmoticonCatalog {
pub async fn len(&self) -> usize {
self.by_unique.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
if let Some(unique) = unique.filter(|value| !value.is_empty()) {
if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() {
return Some(emoticon);
}
if let Some(unique) = unique.filter(|value| !value.is_empty())
&& let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
{
return Some(emoticon);
}
self.by_emoji.read().await.get(emoji).cloned()
}
@@ -184,11 +205,15 @@ impl GiftCatalog {
self.by_id.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
if let Some(id) = id {
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
return Some(gift);
}
if let Some(id) = id
&& let Some(gift) = self.by_id.read().await.get(&id).cloned()
{
return Some(gift);
}
self.by_name
.read()
@@ -231,9 +256,7 @@ impl GiftCatalog {
}
}
fn parse_catalog(
payload: &Value,
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
let list = payload
.pointer("/data/gift_config/base_config/list")
.and_then(Value::as_array)
@@ -282,16 +305,7 @@ fn string_field(value: &Value, name: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn parse_emoticon_catalog(
payload: &Value,
) -> Result<
(
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
),
String,
> {
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
let packages = payload
.pointer("/data/data")
.and_then(Value::as_array)
+6
View File
@@ -1,3 +1,9 @@
//! In-memory abuse limits for anonymous authentication and enrollment routes.
//!
//! Limits are evaluated across both account and network-derived keys while
//! returning one generic result to callers. This reduces brute-force attempts
//! without turning timing or error messages into a username-enumeration API.
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
+7
View File
@@ -1,3 +1,10 @@
//! Tenant-aware event routing and component-scoped realtime fanout.
//!
//! The router resolves enabled instances by owner and source, validates their
//! settings/subscriptions, runs durable handlers, then publishes passive
//! projections. Each component owns a separate broadcast channel; there is no
//! global receiver that could accidentally observe another tenant's events.
use std::{
collections::HashMap,
error::Error,
+7
View File
@@ -1,3 +1,10 @@
//! Repository facade for tenant components, settings and the routing cache.
//!
//! PostgreSQL is authoritative. Successful writes are reflected into the
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
//! source restarts hydrate that cache from tenant-scoped rows before events are
//! routed.
use std::{fmt, sync::Arc};
use chrono::{DateTime, Utc};
+2 -2
View File
@@ -3,7 +3,7 @@ services:
build: .
restart: unless-stopped
network_mode: host
user: "${APP_UID:-1000}:${APP_GID:-1000}"
user: '${APP_UID:-1000}:${APP_GID:-1000}'
read_only: true
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
@@ -15,7 +15,7 @@ services:
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
- ./config.toml:/app/config.toml:ro
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9719/health"]
test: ['CMD', 'curl', '--fail', '--silent', 'http://127.0.0.1:9719/health']
interval: 30s
timeout: 5s
retries: 5
+80
View File
@@ -0,0 +1,80 @@
# 系统架构
本文描述直播组件服务的运行边界、数据流和扩展点。实现代码分别位于 `apps/server-rust` 与
`apps/overlay`。
## 核心目标
- 每个账户固定绑定一个 Bilibili 直播间和一个独立直播源。
- 一个直播源只建立一条上游连接,但可以把事件投递给多个组件实例。
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
## 运行时数据流
```mermaid
flowchart LR
CC[CookieCloud] --> PF[ProviderFactory]
BL[Bilibili Live] <--> BP[BilibiliProvider]
PF --> BP
BP --> LE[LiveEvent]
LE --> SR[SourceEventRouter]
PG[(PostgreSQL)] --> CR[Component repository/cache]
CR --> SR
SR --> EH[Durable handlers]
SR --> PJ[Passive projection]
PJ --> HUB[Component-scoped EventHub]
HUB --> WS[Authenticated WebSocket]
WS --> OBS[OBS overlay]
```
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据获取 Cookie,并把原始消息转换成
`LiveEvent`。
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
## 状态所有权
| 状态 | 权威来源 | 内存副本 | 说明 |
| ---------------- | ------------ | ------------------------ | ----------------------------- |
| 用户、TOTP、会话 | PostgreSQL | 无 | Secret 加密,token 只保存摘要 |
| CookieCloud 凭据 | PostgreSQL | provider 构建期间解密 | 不返回浏览器 |
| 直播源和房间 | PostgreSQL | `SourceSupervisor` | 每用户固定一个房间 |
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
## 启动顺序
`AppState::build` 按以下顺序启动,避免事件进入未完成的运行时:
1. 创建 PostgreSQL 连接池并执行嵌入式迁移。
2. 构造加密和 passwordless 鉴权服务。
3. 注册内建组件定义。
4. 从 PostgreSQL hydrate 组件热路径缓存。
5. 创建有界源事件队列、路由器和每组件广播中心。
6. 为数据库中所有启用的直播源启动 provider。
7. 最后由 `main.rs` 绑定 HTTP 监听端口。
关机时先停止接收 HTTP,再取消所有 provider task,防止容器退出期间继续拉取上游事件。
## 进程与部署边界
- Node 只存在于 Docker 的前端构建阶段。
- 最终镜像仅运行 Rust 可执行文件,并从 `/app/web` 同域托管静态资源。
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
## 代码导航
- 后端模块职责:[`apps/server-rust/README.md`](../apps/server-rust/README.md)
- 前端路由和状态:[`apps/overlay/README.md`](../apps/overlay/README.md)
- 组件扩展指南:[`components/README.md`](components/README.md)
- WebSocket 协议:[`protocol.md`](protocol.md)
- 安全边界:[`security.md`](security.md)
+58
View File
@@ -0,0 +1,58 @@
# 组件开发指南
组件是“一个直播源上的独立功能实例”。当前内建
`danmaku_overlay`,未来礼物墙、点歌姬或统计组件都应使用同一套契约。
## 一个组件由什么组成
| 部分 | Rust 契约 | 职责 |
| -------- | --------------------- | ------------------------------------------------- |
| 定义 | `ComponentDefinition` | kind、设置版本、默认值、校验、迁移、订阅 |
| 投影 | `EventProjection` | 把 `LiveEvent` 转成浏览器消息,不执行持久化副作用 |
| Handler | `EventHandler` | 可选的数据库写入、点歌或外部动作 |
| 实例 | `ComponentInstance` | owner、source、kind、名称、设置和启用状态 |
| 实时通道 | `EventHub` | 按 component ID 隔离的有界广播 |
| 前端 | React renderer/editor | 管理设置、测试与 OBS 展示 |
## 新增组件步骤
1. 选择稳定、全小写的 kind,例如 `gift_wall` 或 `song_request`。
2. 为设置定义可序列化结构,提供安全默认值和 `sanitize`/校验逻辑。
3. 实现 `ComponentDefinition`:
- `settings_version` 从 `1` 开始。
- `migrate_settings` 必须能把已保存的旧版本升级到当前版本。
- `subscriptions` 只返回当前设置需要的 `LiveEventKind`。
4. 实现无副作用的 `EventProjection`。返回 `None` 表示该事件无需发送浏览器。
5. 若需要可靠业务动作,实现 `EventHandler`:
- 即使没有 OBS 客户端也会执行。
- 数据库操作必须包含 owner/source/component 条件。
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
## Projection 与 Handler 的边界
Projection 面向“现在打开的浏览器”。消息丢失或没有接收者都属于正常情况。它必须快速、确定、无副作用。
Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写入必须在这里完成,而不是等待前端收到 WebSocket。一个 handler 失败会记录在
`RouteReport`,但不会阻止其他 handler 或 OBS 投影。
## 设置版本规则
- 数据库同时保存 `settings` 与 `settings_version`。
- 每次读取或路由前,通过 registry 迁移并校验设置。
- 新字段应提供默认值,删除/改义字段必须增加版本并编写迁移。
- 客户端输入只能作为待校验 JSON;后端返回的 sanitized 设置才是权威值。
- UI slider 的范围不能代替后端范围检查。
## 租户与 token 规则
- 组件必须属于同一个 owner 与 source。
- 路由和数据库查询都要重复检查这一关系。
- 每个组件单独签发 access token,默认只有 `events:subscribe`。
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md)。
+66
View File
@@ -0,0 +1,66 @@
# `danmaku_overlay` 弹幕姬
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。
## 订阅事件
组件根据设置动态订阅:
- 弹幕、进房、礼物与礼物连击、醒目留言、舰长、点赞、分享。
- 关闭类别后,后端不为该组件投影对应事件,前端也会进行一次兼容性过滤。
- 未归一化的 `live.unknown` 默认不进入弹幕姬。
## 设置
| 字段 | 范围/单位 | 行为 |
| ------------------------ | ----------- | -------------------------- |
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
| `motionIntensity` | 0–100% | 卡片、流光与焦点动画强度 |
| `particleCount` | 0–12 | 每卡星花粒子数量 |
| `particleSpeed` | 25–300% | 粒子动画速度 |
| `lowPerformanceMode` | boolean | 关闭高成本动态效果 |
| `highValueThreshold` | 千分之一元 | 高价值礼物起点 |
| `featuredValueThreshold` | 千分之一元 | 焦点礼物起点 |
| `show*` | boolean | 控制各事件类别订阅 |
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range input 只改善交互,不能取代服务端校验。
## 卡片生命周期
1. 新事件插入队首,以卷轴动画横向展开。
2. 用户名与内容在展开态分行显示,长内容完整换行。
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
5. 超过 `maxVisible` 的最旧卡才会离开队列。
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。
## 礼物展示
- `live.gift` 创建礼物卡;`live.gift.combo` 使用 `comboId` 更新同一张卡。
- 元数据优先使用礼物目录中的静态图、GIF、币种和价格。
- GIF 加载失败时降级为静态图片;静态图失败时隐藏图片但保留名称、数量和用户。
- 普通、高价值和焦点礼物按 `totalPrice` 与两个阈值分级。
- `priceCny` 只用于人类可读展示;整数 `totalPrice` 用于精确分级。
## 弹幕表情
文字和表情按 `segments` 顺序混排。表情 URL 来自消息本身或用户级表情目录补全;图片使用
`no-referrer`,失败后显示原始表情文字。整条大表情可通过 `standalone` 使用更合适的尺寸。
## OBS 自适应
- 根背景完全透明,不存在 1920×1080 固定画布。
- `ResizeObserver` 根据浏览器源实际宽高选择 `narrow`、`short` 或 `standard`。
- 字号和间距使用 CSS custom properties 与 `clamp()`,OBS 自由缩放时不拉伸素材比例。
- 建议从 `360×600`、`440×760`、`600×1080` 或 `720×320` 开始测试。
- 低高度会减少视觉密度,但不会把仍在队列中的文字裁成省略号。
## 测试页面
控制台的事件测试直接构造 canonical
`LiveEvent`,使用当前账户、source 和 component 走同一套订阅、handler、projection 与 EventHub。测试事件标记为
`simulated=true`,不会发送到 Bilibili,也不会跨组件广播。
+103
View File
@@ -0,0 +1,103 @@
# 组件实时协议
当前协议版本为 `1`。后端的权威定义在 `domain.rs` 的
`ComponentMessage`,浏览器渲染器只依赖本文列出的稳定字段。
## 连接地址与认证
组件流地址:
```text
wss://danmaku.luoxingci.com/api/v1/components/<publicId>/stream
```
OBS 页面地址中的 token 位于 fragment:
```text
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
```
fragment 不会进入首次 HTTP 请求或 Nginx access
log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
```json
{
"type": "authenticate",
"token": "component-token"
}
```
服务端验证 token 摘要、组件归属和 `events:subscribe` scope。认证成功后返回:
```json
{
"version": 1,
"type": "authenticated",
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465"
}
```
之后立即发送
`overlay.settings.snapshot`,再开始发送实时事件。token 无效、被轮换或属于其他组件时,服务端以 policy
close 结束连接。
## 版本化事件信封
```json
{
"version": 1,
"id": "d56c1a28-a6f1-4ad9-b02f-4c22dc4d3c27",
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465",
"sourceId": "c8087bb0-0dde-40eb-a43d-a9a2574c2717",
"occurredAt": "2026-07-15T20:10:30.125Z",
"roomId": "123456",
"type": "live.danmaku",
"payload": {
"viewer": { "uid": "42", "name": "观众" },
"text": "晚上好",
"segments": [{ "type": "text", "text": "晚上好" }]
}
}
```
`ownerId` 永远不会序列化到浏览器。消费者应按 `version` 和 `type`
分派,并忽略不认识的 payload 字段,从而允许兼容地增加元数据。
## 事件类型
| `type` | 主要 payload | 说明 |
| --------------------------- | --------------------------------------------- | ---------------------- |
| `overlay.settings.snapshot` | `settings` | 认证后当前设置快照 |
| `overlay.settings.updated` | `settings` | 控制台保存后的实时设置 |
| `live.danmaku` | `viewer`, `text`, `segments` | 普通文字和表情分段 |
| `live.enter` | `viewer` | 进房事件 |
| `live.gift` | `viewer`, `gift`, `quantity`, `sourceEventId` | 一次可记账的礼物事件 |
| `live.gift.combo` | `viewer`, `gift`, `quantity`, `comboId` | 同一连击的视觉更新 |
| `live.superchat` | `viewer`, `message`, `price`, `sourceEventId` | 醒目留言 |
| `live.guard.buy` | `viewer`, `guardName`, `quantity`, `price` | 舰长购买 |
| `live.like` | `viewer` | 点赞 |
| `live.share` | `viewer` | 分享 |
| `live.unknown` | `command`, `metadata` | 有界且已清理的未知事件 |
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
是供展示使用的人民币数值。
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
`live.gift`;连击事件用于更新同一张视觉卡片。
## 表情分段
`live.danmaku.payload.segments` 是判别联合:
- `text`:包含可直接显示的文字。
- `emoticon`:包含回退文字、图片 URL、可选尺寸、动态标记和整条大表情标记。
图片失败时客户端必须回退到 `text`,不能让一张失效图片破坏整条弹幕。
## 背压与重连
- 每个组件使用有界 `tokio::broadcast` channel。
- 慢客户端发生 `Lagged` 时跳过已经丢失的旧帧,继续接收新事件;实时展示不保证历史重放。
- 浏览器对非鉴权关闭使用指数退避重连,上限 12 秒。
- 鉴权失败不会自动重试,避免对已撤销 token 形成无限请求。
- 需要可靠业务处理的功能必须实现 `EventHandler` 并写入数据库,不能把 WebSocket 当作消息队列。
+75
View File
@@ -0,0 +1,75 @@
# 安全模型
本服务同时处理 Bilibili 登录 Cookie、TOTP Secret、一次性恢复码、管理员邀请码和 OBS
token。以下规则是实现约束,而不是可选部署建议。
## 租户隔离
- HTTP handler 只从服务端会话解析 `owner_id`,不接受客户端声明的 owner。
- 组件查询同时限定 `owner_user_id` 与 `source_id`。
- 数据库使用 owner 复合外键、RLS 和 `FORCE ROW LEVEL SECURITY`。
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
`SET`。
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
- 路由器在投影发布前再次验证 owner、source 和 component ID。
## Secret 生命周期
| Secret | 浏览器可见性 | 数据库存储 | 轮换/消费 |
| -------------------- | ------------------- | ----------------------- | ---------------- |
| TOTP Secret | 注册时显示一次 | XChaCha20-Poly1305 密文 | 账户绑定 |
| 恢复码 | 注册完成时显示一次 | SHA-256 摘要 | 单次消费 |
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 |
`security.data_encryption_key`
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
## Passwordless 认证
- 第一个系统管理员只能通过数据库为空时的 bootstrap 流程创建。
- bootstrap proof 不成为账户密码,也不能用于日常登录。
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
## CookieCloud 与 SSRF
- 部署者通过 `cookiecloud_allowed_hosts` 指定精确的基础地址白名单。
- URL 会规范化,并拒绝 embedded credentials、query 和 fragment。
- Key 编码成单一路径段,不能注入额外路径。
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
- 日志不得把 `blivedm` 调到可能打印认证响应的详细级别。
## HTTP、WebSocket 与 OBS
- 公开部署必须在受信任反代终止 HTTPS,并保持 `secure_cookies = true`。
- 生产 session 使用 `__Host-` Cookie、HttpOnly、Secure 与 SameSite 策略。
- 写 API 执行 same-origin 检查并限制 body 大小。
- 所有 `/api/*` 响应使用 `Cache-Control: no-store`。
- OBS token 放在 URL fragment,并作为 WebSocket 第一帧发送。
- token 只具有 `events:subscribe` scope,且只能订阅其绑定的组件。
- WebSocket 首帧、frame size、认证时间和全局连接数均有上限。
## PWA 边界
- manifest、Service Worker 和 start URL 都限定在 `/control/`。
- `/obs/*` 不在 Service Worker scope 内。
- worker 只缓存静态应用壳层、图标和构建资源;不缓存 API、WebSocket 或直播事件。
- 离线 mutation 直接失败,不使用 Background Sync。
- 新 worker 仅在用户确认后激活。
- TOTP、恢复码、邀请码、新 OBS 地址或未保存设置可见时,更新会被阻止。
## 上线检查表
- [ ] 使用独立随机 `data_encryption_key`,并在安全位置备份。
- [ ] `config.toml` 权限为 `0600`,且已被 Git 和 Docker build context 排除。
- [ ] PostgreSQL runtime role 可以执行迁移,但 PUBLIC 无权执行 SECURITY DEFINER helper。
- [ ] `secure_cookies = true`,Nginx 正确传递 `X-Forwarded-Proto https`。
- [ ] 应用仅监听 loopback,9719 未直接暴露公网。
- [ ] CookieCloud 白名单只包含管理员批准的实例。
- [ ] 日志中没有 Cookie、TOTP、token、邀请码或上游认证响应。
- [ ] 轮换 OBS token 后,旧浏览器源立即断开且无法重连。