proper productionize project
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libasound2 libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libasound2 libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# The upstream client logs full upstream HTTP responses at INFO, which can include
|
||||
# account metadata and short-lived connection tokens. Keep application lifecycle
|
||||
|
||||
@@ -1,31 +1,138 @@
|
||||
# 洛星瓷直播弹幕姬
|
||||
# 洛星瓷直播组件服务
|
||||
|
||||
运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、设置数据库和访问密钥,然后执行:
|
||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||
|
||||
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 `vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。
|
||||
|
||||
## 后端结构
|
||||
|
||||
`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。
|
||||
|
||||
## 部署
|
||||
|
||||
先复制配置并生成独立密钥:
|
||||
|
||||
```sh
|
||||
cp config.toml.example config.toml
|
||||
openssl rand -base64 32 # 填入 security.data_encryption_key
|
||||
openssl rand -hex 32 # 可作为一次性 admin.password
|
||||
openssl rand -hex 32 # 填入兼容字段 admin.session_secret
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。
|
||||
Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` 读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` 直接暴露到公网。
|
||||
|
||||
应用容器使用 host 网络并监听 `9719`。它通过 `[database].url` 连接 PostgreSQL,仅用于持久化 OBS 弹幕姬设置。应用会自动创建所需表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。
|
||||
容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 `config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 `APP_UID` 与 `APP_GID`。
|
||||
|
||||
服务端已迁移为 Rust/Axum,并使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 发布的 `blivedm` crate 建立 Bilibili 认证弹幕连接;不再使用 Node 服务端或 `@laplace.live/ws`。为确保 UID、礼物价格和上游事件 ID 不会被库的简化消息结构丢弃,项目在 `vendor/blivedm` 固定了一个仅保留原始 JSON 的小补丁。应用从 CookieCloud 获取 Bilibili Cookie(必须包含 `SESSDATA`),用于获取认证 UID、直播间网关与 WebSocket 鉴权包。
|
||||
`[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。
|
||||
|
||||
## OBS 弹幕姬
|
||||
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。
|
||||
|
||||
镜像会在构建阶段编译 `apps/overlay`,最终由 Rust/Axum **同域托管**:
|
||||
## 首次初始化与登录
|
||||
|
||||
- `/obs?token=你的_OBS_访问令牌`:透明背景的 OBS 浏览器源。它没有固定画布,按浏览器源的实际宽高自动在窄侧栏、常规卡片和低高度模式之间切换。
|
||||
- `/control`:管理员控制台。使用 `admin.password` 登录后可改标题、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效、每卡粒子数量与速度、低性能模式和礼物高亮阈值;保存会实时广播给已打开的 OBS 源。
|
||||
首次部署且数据库中尚无账户时,打开:
|
||||
|
||||
控制台的“复制 OBS 地址”会生成含只读令牌的完整地址。该令牌仅能订阅 `/ws`,不能访问管理或写入接口。常用 OBS 浏览器源尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始;可按实际版面自由拖拽缩放,不会出现 1920×1080 的固定画布留白。
|
||||
```text
|
||||
https://danmaku.luoxingci.com/control/setup
|
||||
```
|
||||
|
||||
每张消息卡片都会从六套花纹组合中稳定选取一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景;连续的新卡会主动避开相同款式,礼物连击更新则保持原样式不跳动。透明消息墙本身不铺设全局装饰背景。粒子数量与速度可在控制台调整;窄尺寸会自动减少粒子,低性能模式会关闭动态粒子。花边 SVG 已本地打包,来源和公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
|
||||
填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
||||
|
||||
后端会在启动时、之后每十分钟调用 Bilibili 的无需 Cookie 礼物面板接口缓存礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失目录条目会安全降级为直播事件自带的名称与价格。
|
||||
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
|
||||
|
||||
直播弹幕中的普通混排表情和整条大表情会作为安全的文字/图片分段通过 WebSocket 推送。消息自带的图片地址优先;服务还会使用 CookieCloud 的 `SESSDATA` 刷新直播间表情目录,在消息只提供表情唯一标识时补齐图片。图片加载失败时前端会退回原始表情文字。
|
||||
- 它不会保存为账户密码,也不能用于日常登录。
|
||||
- 第一个账户创建后,bootstrap 接口永久拒绝再次初始化。
|
||||
- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。
|
||||
- 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。
|
||||
|
||||
刷新间隔和请求超时可通过 `[gifts]` 调整。`[overlay]` 与 `[overlay.events]` 是某个直播间第一次运行时的展示默认值;在 `/control` 保存后,数据库中的设置优先,因此更新 TOML 不会覆盖管理员已经调好的 OBS 样式。若希望重新采用 TOML 默认值,可删除该房间在 `overlay_settings` 表中的记录后重启。
|
||||
系统管理员可在 `/control/invitations` 创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili `room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
|
||||
|
||||
每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 `security.cookiecloud_allowed_hosts` 白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili `SESSDATA`。
|
||||
|
||||
### 旧单用户配置迁移
|
||||
|
||||
以下 TOML 段现在只用于第一次系统管理员初始化时的一次性兼容迁移:
|
||||
|
||||
- `[connection].room_id`:绑定首个系统管理员的房间。
|
||||
- `[cookiecloud]`:导入首个系统管理员的用户级加密凭据。
|
||||
- `[obs].access_token`:导入首个默认弹幕组件的旧 OBS 令牌。
|
||||
- `[overlay]` 与 `[overlay.events]`:迁移旧 `overlay_settings`,没有旧记录时作为首个组件的回退值。
|
||||
|
||||
迁移完成后,运行时以 PostgreSQL 中的用户、直播源、组件和凭据为准。以后修改这些旧段不会改变现有账户,也不会成为其他受邀用户的默认值;新用户使用邀请中绑定的房间,并在控制台填写自己的 CookieCloud。
|
||||
|
||||
## 必须使用 HTTPS
|
||||
|
||||
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name danmaku.luoxingci.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name danmaku.luoxingci.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/danmaku.luoxingci.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/danmaku.luoxingci.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9719;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
||||
|
||||
## 控制台 PWA
|
||||
|
||||
通过 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`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。
|
||||
|
||||
部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。
|
||||
|
||||
## 组件与 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` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
||||
|
||||
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
|
||||
|
||||
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。
|
||||
|
||||
## 弹幕姬展示
|
||||
|
||||
每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。
|
||||
|
||||
花边 SVG 已本地打包,来源及公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
|
||||
|
||||
后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 `[gifts]` 调整。
|
||||
|
||||
普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 `SESSDATA` 刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 `[emoticons]` 调整。
|
||||
|
||||
+21
-1
@@ -1 +1,21 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>洛星瓷弹幕猪</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0a302f" />
|
||||
<meta name="description" content="洛星瓷直播组件与 OBS 浏览器源控制台" />
|
||||
<meta name="application-name" content="洛星瓷直播云台" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="直播云台" />
|
||||
<link rel="icon" type="image/svg+xml" href="/pwa/icon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" />
|
||||
<title>洛星瓷直播云台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "/control",
|
||||
"name": "洛星瓷直播云台",
|
||||
"short_name": "直播云台",
|
||||
"description": "洛星瓷直播组件与 OBS 浏览器源控制台",
|
||||
"lang": "zh-CN",
|
||||
"dir": "auto",
|
||||
"start_url": "/control/?source=pwa",
|
||||
"scope": "/control/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#03121a",
|
||||
"theme_color": "#0a302f",
|
||||
"categories": ["utilities", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/pwa/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/pwa/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="70%" cy="20%" r="110%">
|
||||
<stop offset="0" stop-color="#195154"/>
|
||||
<stop offset=".52" stop-color="#082b34"/>
|
||||
<stop offset="1" stop-color="#020e18"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="jade" x1=".15" y1=".08" x2=".82" y2=".94">
|
||||
<stop stop-color="#d5fff7"/><stop offset=".45" stop-color="#5bdac3"/><stop offset="1" stop-color="#1b786f"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<path d="M0 386c92-8 137-49 165-111 20-44 49-69 91-76 61-10 106 19 132 67 20 36 48 55 124 59" fill="none" stroke="#76ddc7" stroke-opacity=".13" stroke-width="8"/>
|
||||
<circle cx="256" cy="256" r="132" fill="#092c33" stroke="#7be8d2" stroke-opacity=".4" stroke-width="5"/>
|
||||
<circle cx="256" cy="256" r="108" fill="#0a3539" stroke="#d5fff7" stroke-opacity=".17" stroke-width="2"/>
|
||||
<g fill="url(#jade)" filter="url(#glow)">
|
||||
<path d="M256 157l17 68 57-37-37 57 68 17-68 17 37 57-57-37-17 68-17-68-57 37 37-57-68-17 68-17-37-57 57 37z"/>
|
||||
<circle cx="256" cy="262" r="22" fill="#effffb"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="72%" cy="18%" r="105%">
|
||||
<stop offset="0" stop-color="#174e50"/>
|
||||
<stop offset=".5" stop-color="#082a33"/>
|
||||
<stop offset="1" stop-color="#020e18"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="jade" x1=".15" y1=".08" x2=".82" y2=".94">
|
||||
<stop stop-color="#b5fff1"/>
|
||||
<stop offset=".42" stop-color="#51d5bd"/>
|
||||
<stop offset="1" stop-color="#1b786f"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="13" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<path d="M62 358c70-8 102-50 128-108 16-35 37-54 68-61 43-10 83 10 105 47 17 29 35 47 87 51" fill="none" stroke="#76ddc7" stroke-opacity=".16" stroke-width="7"/>
|
||||
<path d="M63 143c56 8 84 31 105 74M449 366c-55-5-85-27-108-68" fill="none" stroke="#9ef3df" stroke-opacity=".13" stroke-linecap="round" stroke-width="6"/>
|
||||
<circle cx="256" cy="256" r="142" fill="#0a2930" stroke="#6ce1cb" stroke-opacity=".38" stroke-width="5"/>
|
||||
<circle cx="256" cy="256" r="118" fill="#0a3438" stroke="#b8fff0" stroke-opacity=".18" stroke-width="2"/>
|
||||
<g fill="url(#jade)" filter="url(#glow)">
|
||||
<path d="M256 142l19 78 65-43-43 65 78 19-78 19 43 65-65-43-19 78-19-78-65 43 43-65-78-19 78-19-43-65 65 43z"/>
|
||||
<circle cx="256" cy="261" r="25" fill="#e7fff9"/>
|
||||
</g>
|
||||
<g fill="#d8fff5">
|
||||
<circle cx="116" cy="116" r="7"/><circle cx="404" cy="129" r="5"/><circle cx="391" cy="393" r="7"/>
|
||||
</g>
|
||||
<g stroke="#b5fff1" stroke-linecap="round" stroke-width="5">
|
||||
<path d="M116 96v40M96 116h40M404 114v30M389 129h30M391 371v44M369 393h44"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,102 @@
|
||||
/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */
|
||||
const buildId = '__PWA_BUILD_ID__'
|
||||
const cacheName = `lxc-control-v2-shell-${buildId}`
|
||||
const shellPath = '/control/'
|
||||
const shellRoutes = [
|
||||
'/control/',
|
||||
'/control/invitations',
|
||||
'/control/login',
|
||||
'/control/register',
|
||||
'/control/setup',
|
||||
]
|
||||
const stableAssets = [
|
||||
'/control/manifest.webmanifest',
|
||||
'/pwa/icon-192.png',
|
||||
'/pwa/icon-512.png',
|
||||
'/pwa/icon-maskable-512.png',
|
||||
'/pwa/apple-touch-icon.png',
|
||||
]
|
||||
|
||||
async function precacheShell() {
|
||||
const cache = await caches.open(cacheName)
|
||||
const response = await fetch(shellPath, { cache: 'reload', credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error(`Cannot precache control shell: ${response.status}`)
|
||||
|
||||
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])
|
||||
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
|
||||
}
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(precacheShell())
|
||||
})
|
||||
|
||||
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()
|
||||
})())
|
||||
})
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'SKIP_WAITING') event.waitUntil(self.skipWaiting())
|
||||
})
|
||||
|
||||
function isControlShell(pathname) {
|
||||
return pathname.startsWith('/control/')
|
||||
}
|
||||
|
||||
function isStaticAsset(pathname) {
|
||||
return pathname.startsWith('/assets/')
|
||||
|| pathname.startsWith('/pwa/')
|
||||
|| pathname === '/control/manifest.webmanifest'
|
||||
}
|
||||
|
||||
async function navigationResponse(request) {
|
||||
const cache = await caches.open(cacheName)
|
||||
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')) {
|
||||
await cache.put(pathname, response.clone())
|
||||
}
|
||||
return response
|
||||
} catch {
|
||||
return (await cache.match(request, { ignoreSearch: true }))
|
||||
|| (await cache.match(shellPath))
|
||||
|| Response.error()
|
||||
}
|
||||
}
|
||||
|
||||
async function staticResponse(request) {
|
||||
const cached = await caches.match(request, { ignoreSearch: false })
|
||||
if (cached) return cached
|
||||
const response = await fetch(request)
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName)
|
||||
await cache.put(request, response.clone())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request
|
||||
if (request.method !== 'GET') return
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
|
||||
// Authentication, component data, WebSockets and OBS are outside this
|
||||
// worker's scope and are never cached. Keep the path check as defence in depth.
|
||||
if (request.mode === 'navigate' && isControlShell(url.pathname)) {
|
||||
event.respondWith(navigationResponse(request))
|
||||
return
|
||||
}
|
||||
if (isStaticAsset(url.pathname)) event.respondWith(staticResponse(request))
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
AuthUser,
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
Invitation,
|
||||
OverlaySettings,
|
||||
Session,
|
||||
TotpEnrollment,
|
||||
} from './types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code?: string
|
||||
readonly fieldErrors?: Record<string, string>
|
||||
|
||||
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.fieldErrors = fieldErrors
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return undefined
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) return response.json()
|
||||
const text = await response.text()
|
||||
return text ? { message: text } : undefined
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
|
||||
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')
|
||||
headers.set('accept', 'application/json')
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const payload = await parseResponse(response)
|
||||
if (!response.ok) {
|
||||
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> : {}
|
||||
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>
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export function json(method: string, body?: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||
? 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 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
|
||||
return { user, setupRequired: root.setupRequired === true }
|
||||
}
|
||||
|
||||
export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
||||
const root = object(value)
|
||||
const totp = object(root.totp ?? root.enrollment)
|
||||
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
|
||||
: 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,
|
||||
}
|
||||
}
|
||||
|
||||
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') : []
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function normalizeSettings(value: unknown): OverlaySettings {
|
||||
const root = object(value)
|
||||
return (root.settings ?? value) as 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 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),
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Fall through to the compatibility path used by OBS' embedded browser.
|
||||
}
|
||||
}
|
||||
const input = document.createElement('textarea')
|
||||
input.value = text
|
||||
input.readOnly = true
|
||||
input.style.position = 'fixed'
|
||||
input.style.left = '-9999px'
|
||||
input.style.opacity = '0'
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
input.select()
|
||||
let copied = false
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} finally {
|
||||
input.remove()
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
api,
|
||||
copyToClipboard,
|
||||
errorMessage,
|
||||
json,
|
||||
normalizeEnrollment,
|
||||
normalizeRecoveryCodes,
|
||||
} from './api'
|
||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||
import type { TotpEnrollment } from './types'
|
||||
|
||||
function AuthShell({ eyebrow, title, children, footer }: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<div className="auth-mark" aria-hidden="true">星</div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{children}
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
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)}`
|
||||
return undefined
|
||||
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
||||
|
||||
return (
|
||||
<div className="totp-enrollment">
|
||||
<div className="totp-qr">
|
||||
{source
|
||||
? <img src={source} alt="TOTP 验证器绑定二维码" />
|
||||
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
|
||||
</div>
|
||||
<div className="totp-copy">
|
||||
<h2>绑定动态验证器</h2>
|
||||
<ol>
|
||||
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li>
|
||||
<li>若无法扫码,选择“输入设置密钥”。</li>
|
||||
<li>输入应用中出现的 6 位动态码完成绑定。</li>
|
||||
</ol>
|
||||
<label>
|
||||
手工设置密钥
|
||||
<div className="secret-row">
|
||||
<code>{enrollment.manualKey || '未提供'}</code>
|
||||
{enrollment.manualKey && (
|
||||
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
||||
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 url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'luoxingci-recovery-codes.txt'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
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>}
|
||||
<div className="form-actions">
|
||||
{codes.length > 0 && (
|
||||
<>
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
|
||||
{copied ? '已复制' : '复制全部'}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={download}>下载文本</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onContinue}>我已妥善保存</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
onAuthenticated: () => Promise<void>
|
||||
setupRequired: boolean
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
usePwaUpdateBlocker(
|
||||
'login-form',
|
||||
'完成或清空正在填写的登录表单',
|
||||
busy || Boolean(username || totpCode),
|
||||
)
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api('/api/v1/auth/login', json('POST', { username: username.trim(), code: totpCode }))
|
||||
setTotpCode('')
|
||||
await onAuthenticated()
|
||||
location.assign('/control/')
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '用户名或动态验证码不正确'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow="洛星瓷直播组件"
|
||||
title="回到你的云台"
|
||||
footer={(
|
||||
<p>
|
||||
{setupRequired
|
||||
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
|
||||
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
||||
<form className="stack-form" onSubmit={submit}>
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'}
|
||||
<input
|
||||
required
|
||||
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
||||
inputMode={useRecoveryCode ? 'text' : 'numeric'}
|
||||
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
||||
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
||||
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))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-link"
|
||||
onClick={() => {
|
||||
setUseRecoveryCode(current => !current)
|
||||
setTotpCode('')
|
||||
}}
|
||||
>
|
||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
||||
</button>
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
mode: 'setup' | 'register'
|
||||
onAuthenticated: () => Promise<void>
|
||||
}) {
|
||||
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
||||
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
||||
const [username, setUsername] = useState('')
|
||||
const [bootstrapPassword, setBootstrapPassword] = useState('')
|
||||
const [enrollment, setEnrollment] = useState<TotpEnrollment>()
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>()
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const isSetup = mode === 'setup'
|
||||
usePwaUpdateBlocker(
|
||||
'totp-enrollment',
|
||||
enrollment || recoveryCodes
|
||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
||||
: '完成或清空正在填写的注册表单',
|
||||
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
|
||||
)
|
||||
|
||||
const start = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/start`,
|
||||
json('POST', {
|
||||
...(isSetup ? {} : { inviteCode: inviteCode.trim() }),
|
||||
username: username.trim(),
|
||||
...(isSetup ? { bootstrapPassword } : {}),
|
||||
}),
|
||||
)
|
||||
const next = normalizeEnrollment(payload)
|
||||
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识')
|
||||
setEnrollment(next)
|
||||
setBootstrapPassword('')
|
||||
if (!isSetup) {
|
||||
setInviteCode('')
|
||||
history.replaceState(null, '', authRoute('register'))
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '无法开始安全注册流程'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!enrollment) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
||||
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }),
|
||||
)
|
||||
setTotpCode('')
|
||||
// Drop QR/manual-key material from React state as soon as enrollment is
|
||||
// committed; only the one-time recovery codes remain on screen.
|
||||
setEnrollment(undefined)
|
||||
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '动态验证码无效或注册流程已过期'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
await onAuthenticated()
|
||||
location.assign('/control/')
|
||||
}
|
||||
|
||||
if (recoveryCodes) return <RecoveryCodes codes={recoveryCodes} onContinue={() => void finish()} />
|
||||
|
||||
if (enrollment) {
|
||||
return (
|
||||
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
|
||||
<TotpQr enrollment={enrollment} />
|
||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||
<label>
|
||||
验证器中的 6 位动态码
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
className="otp-input"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
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>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
||||
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
|
||||
>
|
||||
<p className="auth-lead">
|
||||
{isSetup
|
||||
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
|
||||
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
|
||||
</p>
|
||||
<form className="stack-form" onSubmit={start}>
|
||||
{!isSetup && (
|
||||
<label>
|
||||
邀请码
|
||||
<input
|
||||
required
|
||||
autoFocus={!inviteFromFragment}
|
||||
autoComplete="off"
|
||||
value={inviteCode}
|
||||
onChange={event => setInviteCode(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
required
|
||||
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
||||
autoComplete="username"
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isSetup && (
|
||||
<label>
|
||||
一次性初始化口令
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={bootstrapPassword}
|
||||
onChange={event => setBootstrapPassword(event.target.value)}
|
||||
/>
|
||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
||||
</label>
|
||||
)}
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
+1031
-16
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
copyToClipboard,
|
||||
errorMessage,
|
||||
json,
|
||||
normalizeComponents,
|
||||
normalizeInvitations,
|
||||
normalizeSettings,
|
||||
normalizeSource,
|
||||
} from './api'
|
||||
import { Overlay } from './overlay'
|
||||
import { PwaControls, usePwaUpdateBlocker } from './pwa'
|
||||
import { defaultOverlaySettings } from './types'
|
||||
import type {
|
||||
AuthUser,
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
Invitation,
|
||||
OverlaySettings,
|
||||
} from './types'
|
||||
|
||||
const previewPresets = [
|
||||
{ label: '窄侧栏', width: 360, height: 600 },
|
||||
{ label: '竖屏', width: 440, height: 760 },
|
||||
{ label: '高清竖栏', width: 600, height: 1080 },
|
||||
{ label: '横向条', width: 720, height: 320 },
|
||||
]
|
||||
|
||||
function isDanmakuKind(kind: string): boolean {
|
||||
return kind === 'danmaku_overlay' || kind === 'danmaku'
|
||||
}
|
||||
|
||||
type Flash = { kind: 'success' | 'error'; text: string } | undefined
|
||||
|
||||
function Panel({ title, description, aside, children, className = '' }: {
|
||||
title: string
|
||||
description?: string
|
||||
aside?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={`dashboard-panel jade-panel ${className}`}>
|
||||
<header className="panel-header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{aside}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FlashMessage({ flash }: { flash: Flash }) {
|
||||
if (!flash) return null
|
||||
return <div className={`notice ${flash.kind}`} role={flash.kind === 'error' ? 'alert' : 'status'}>{flash.text}</div>
|
||||
}
|
||||
|
||||
function ControlLayout({ user, active, onLogout, children }: {
|
||||
user: AuthUser
|
||||
active: 'components' | 'invitations'
|
||||
onLogout: () => Promise<void>
|
||||
children: ReactNode
|
||||
}) {
|
||||
const isAdmin = user.role === 'system_admin'
|
||||
return (
|
||||
<main className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<a className="brand" href="/control/" aria-label="返回组件控制台">
|
||||
<span aria-hidden="true">星</span>
|
||||
<div><b>洛星瓷直播云台</b><small>OBS COMPONENT STUDIO</small></div>
|
||||
</a>
|
||||
<nav aria-label="控制台导航">
|
||||
<a className={active === 'components' ? 'active' : ''} href="/control/">我的组件</a>
|
||||
{isAdmin && <a className={active === 'invitations' ? 'active' : ''} href="/control/invitations">邀请码</a>}
|
||||
</nav>
|
||||
<div className="account-menu">
|
||||
<PwaControls />
|
||||
<div><b>{user.displayName || user.username}</b><small>{isAdmin ? '系统管理员' : '用户'}</small></div>
|
||||
<button type="button" className="ghost-button" onClick={() => void onLogout()}>退出</button>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsEditor({ settings, onChange, onSave, saving }: {
|
||||
settings: OverlaySettings
|
||||
onChange: (settings: OverlaySettings) => void
|
||||
onSave: () => Promise<void>
|
||||
saving: boolean
|
||||
}) {
|
||||
const edit = <K extends keyof OverlaySettings>(key: K, value: OverlaySettings[K]) => {
|
||||
onChange({ ...settings, [key]: value })
|
||||
}
|
||||
const eventToggles: Array<[keyof OverlaySettings, string]> = [
|
||||
['showDanmaku', '弹幕'],
|
||||
['showEnter', '进房'],
|
||||
['showGift', '礼物'],
|
||||
['showSuperchat', '醒目留言'],
|
||||
['showGuard', '舰长'],
|
||||
['showLike', '点赞'],
|
||||
['showShare', '分享'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="settings-editor">
|
||||
<div className="slider-grid">
|
||||
<label>
|
||||
<span>字号 <output>{settings.fontScale}%</output></span>
|
||||
<input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={event => edit('fontScale', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>最大可见条数 <output>{settings.maxVisible}</output></span>
|
||||
<input type="range" min="1" max="12" value={settings.maxVisible} onChange={event => edit('maxVisible', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>自动收缩 <output>{settings.collapseAfterSeconds}s</output></span>
|
||||
<input type="range" min="2" max="120" value={settings.collapseAfterSeconds} onChange={event => edit('collapseAfterSeconds', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>卷轴展开时长 <output>{(settings.unfoldDurationMs / 1000).toFixed(1)}s</output></span>
|
||||
<input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={event => edit('unfoldDurationMs', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>动效强度 <output>{settings.motionIntensity}%</output></span>
|
||||
<input type="range" min="0" max="100" value={settings.motionIntensity} onChange={event => edit('motionIntensity', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>每卡粒子数量 <output>{settings.particleCount}</output></span>
|
||||
<input type="range" min="0" max="12" value={settings.particleCount} onChange={event => edit('particleCount', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>粒子动画速度 <output>{settings.particleSpeed}%</output></span>
|
||||
<input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={event => edit('particleSpeed', +event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="toggle-grid">
|
||||
<legend>显示事件</legend>
|
||||
{eventToggles.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(settings[key])}
|
||||
onChange={event => edit(key, event.target.checked as never)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.lowPerformanceMode} onChange={event => edit('lowPerformanceMode', event.target.checked)} />
|
||||
<span>低性能模式</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="field-grid two-columns">
|
||||
<label>
|
||||
高价值礼物阈值(厘)
|
||||
<input type="number" min="0" value={settings.highValueThreshold} onChange={event => edit('highValueThreshold', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
特别高价值阈值(厘)
|
||||
<input type="number" min="0" value={settings.featuredValueThreshold} onChange={event => edit('featuredValueThreshold', +event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions align-end">
|
||||
<button type="button" disabled={saving} onClick={() => void onSave()}>{saving ? '正在保存…' : '保存并实时同步'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayPreview({ settings }: { settings: OverlaySettings }) {
|
||||
const [preset, setPreset] = useState(previewPresets[1])
|
||||
return (
|
||||
<Panel title="自适应预览" description="预设只改变预览尺寸;OBS 浏览器源仍可使用任意宽高。" className="preview-panel">
|
||||
<div className="preset-buttons">
|
||||
{previewPresets.map(size => (
|
||||
<button
|
||||
type="button"
|
||||
className={size.label === preset.label ? 'active' : 'secondary'}
|
||||
onClick={() => setPreset(size)}
|
||||
key={size.label}
|
||||
>
|
||||
{size.label}<small>{size.width}×{size.height}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="preview-viewport">
|
||||
<div className="preview-frame" style={{ width: preset.width, height: preset.height }}>
|
||||
<Overlay preview previewSettings={settings} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceEditor({ source, onSaved }: {
|
||||
source: CookieCloudSource
|
||||
onSaved: (source: CookieCloudSource) => void
|
||||
}) {
|
||||
const [roomId, setRoomId] = useState(source.roomId)
|
||||
const [host, setHost] = useState(source.cookieCloud.host)
|
||||
const [key, setKey] = useState(source.cookieCloud.key)
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const sourceDirty = roomId !== source.roomId
|
||||
|| host.trim() !== source.cookieCloud.host
|
||||
|| key !== source.cookieCloud.key
|
||||
|| Boolean(password)
|
||||
usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty)
|
||||
|
||||
useEffect(() => {
|
||||
setRoomId(source.roomId)
|
||||
setHost(source.cookieCloud.host)
|
||||
setKey(source.cookieCloud.key)
|
||||
setPassword('')
|
||||
}, [source])
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>('/api/v1/source', json('PUT', {
|
||||
roomId: roomId.trim(),
|
||||
cookieCloud: {
|
||||
host: host.trim(),
|
||||
key: key.trim(),
|
||||
...(password ? { password } : {}),
|
||||
},
|
||||
}))
|
||||
const next = normalizeSource(payload)
|
||||
onSaved(next)
|
||||
setPassword('')
|
||||
setFlash({ kind: 'success', text: '直播源已保存,连接会使用新的隔离配置。' })
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Bilibili 直播源"
|
||||
description="该 CookieCloud 凭据仅属于当前账户,不会与其他用户共享。"
|
||||
aside={source.connected === undefined ? undefined : (
|
||||
<span className={`status-chip ${source.connected ? 'online' : 'offline'}`}>
|
||||
{source.connected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{source.detail && <p className="source-detail">{source.detail}</p>}
|
||||
<form className="field-grid two-columns" onSubmit={submit}>
|
||||
<label>
|
||||
邀请码绑定的直播间 ID
|
||||
<input required readOnly inputMode="numeric" value={roomId} onChange={event => setRoomId(event.target.value)} />
|
||||
<small>直播间由系统管理员签发邀请码时固定,用户不能自行切换。</small>
|
||||
</label>
|
||||
<label>
|
||||
CookieCloud 地址
|
||||
<input required type="url" placeholder="https://cookie.example.com" value={host} onChange={event => setHost(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
CookieCloud UUID / Key
|
||||
<input
|
||||
required={!source.cookieCloud.keyConfigured}
|
||||
autoComplete="off"
|
||||
placeholder={source.cookieCloud.keyConfigured ? '已设置;留空保持不变' : '请输入同步 UUID / Key'}
|
||||
value={key}
|
||||
onChange={event => setKey(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CookieCloud 密码
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={source.cookieCloud.passwordConfigured ? '已设置;留空保持不变' : '请输入同步密码'}
|
||||
required={!source.cookieCloud.passwordConfigured}
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<FlashMessage flash={flash} />
|
||||
<div className="form-actions align-end span-all">
|
||||
<button disabled={busy}>{busy ? '正在验证并保存…' : '保存直播源'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
type TokenState = {
|
||||
publicId: string
|
||||
configured: boolean
|
||||
updatedAt?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
function tokenState(value: unknown, fallbackPublicId: string): TokenState {
|
||||
const root = value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||
const token = typeof root.token === 'string' ? root.token : undefined
|
||||
const publicId = String(root.publicId ?? fallbackPublicId)
|
||||
let address = typeof root.url === 'string'
|
||||
? root.url
|
||||
: typeof root.path === 'string'
|
||||
? root.path
|
||||
: undefined
|
||||
if (address) address = new URL(address, location.origin).toString()
|
||||
if (!address && token) {
|
||||
const url = new URL(`/obs/${encodeURIComponent(publicId)}`, location.origin)
|
||||
url.hash = new URLSearchParams({ token }).toString()
|
||||
address = url.toString()
|
||||
}
|
||||
return {
|
||||
publicId,
|
||||
configured: root.configured === true || root.hasToken === true || root.tokenConfigured === true || Boolean(token || address),
|
||||
updatedAt: typeof root.updatedAt === 'string' ? root.updatedAt : undefined,
|
||||
address,
|
||||
}
|
||||
}
|
||||
|
||||
function ObsAccessPanel({ component }: { component: ComponentSummary }) {
|
||||
const [state, setState] = useState<TokenState>({ publicId: component.publicId, configured: false })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
usePwaUpdateBlocker(
|
||||
`obs-token:${component.id}`,
|
||||
'复制并妥善保存本次生成的 OBS 地址',
|
||||
busy || Boolean(state.address),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ publicId: component.publicId, configured: false })
|
||||
setFlash(undefined)
|
||||
api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/token`)
|
||||
.then(payload => { if (!cancelled) setState(tokenState(payload, component.publicId)) })
|
||||
.catch(reason => {
|
||||
if (cancelled) return
|
||||
setState({ publicId: component.publicId, configured: false })
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取当前组件的 OBS 令牌状态') })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [component.id, component.publicId])
|
||||
|
||||
const rotate = async () => {
|
||||
if (state.configured && !window.confirm('轮换后,所有使用旧地址的 OBS 浏览器源会立即失效。确定继续吗?')) return
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/token`, json('POST'))
|
||||
const next = tokenState(payload, component.publicId)
|
||||
setState(next)
|
||||
setFlash({ kind: 'success', text: '新 OBS 令牌已生成。请立即复制,离开本页后不会再次显示明文令牌。' })
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '无法轮换 OBS 令牌') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
if (!state.address) return
|
||||
const copied = await copyToClipboard(state.address)
|
||||
setFlash(copied
|
||||
? { kind: 'success', text: 'OBS 浏览器源地址已复制。' }
|
||||
: { kind: 'error', text: '浏览器阻止了复制,请手工选择下方地址。' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="OBS 只读访问" description="令牌仅能订阅这一组件;轮换不会影响账户登录或其他组件。">
|
||||
<div className="token-summary">
|
||||
<div>
|
||||
<small>访问状态</small>
|
||||
<b>{state.configured ? '已生成令牌' : '尚未生成'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<small>组件公开标识</small>
|
||||
<code>{state.publicId}</code>
|
||||
</div>
|
||||
{state.updatedAt && <div><small>最近轮换</small><b>{formatDate(state.updatedAt)}</b></div>}
|
||||
</div>
|
||||
<FlashMessage flash={flash} />
|
||||
{state.address && (
|
||||
<label className="secret-address">
|
||||
本次生成的 OBS 地址
|
||||
<input readOnly value={state.address} onFocus={event => event.currentTarget.select()} />
|
||||
</label>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="button" className={state.configured ? 'danger' : ''} disabled={busy} onClick={() => void rotate()}>
|
||||
{busy ? '正在生成…' : state.configured ? '轮换令牌' : '生成 OBS 地址'}
|
||||
</button>
|
||||
{state.address && <button type="button" className="secondary" onClick={() => void copy()}>复制地址</button>}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function TestEvents({ componentId }: { componentId: string }) {
|
||||
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift'>('danmaku')
|
||||
const [uid, setUid] = useState('test-viewer')
|
||||
const [name, setName] = useState('测试观众')
|
||||
const [text, setText] = useState('今天也要闪闪发光!')
|
||||
const [giftName, setGiftName] = useState('小花花')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [battery, setBattery] = useState(100)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
await api(`/api/v1/components/${encodeURIComponent(componentId)}/test-events`, json('POST', {
|
||||
kind,
|
||||
uid,
|
||||
name,
|
||||
...(kind === 'danmaku' ? { text } : {}),
|
||||
...(kind === 'gift' ? { giftName, quantity, battery } : {}),
|
||||
}))
|
||||
setFlash({ kind: 'success', text: '测试事件已发送到当前组件。' })
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '测试事件发送失败') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="事件测试" description="模拟事件只进入当前用户、当前组件,不会向 Bilibili 发送消息。">
|
||||
<form className="field-grid two-columns" onSubmit={submit}>
|
||||
<label>
|
||||
事件类型
|
||||
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
|
||||
<option value="danmaku">弹幕</option>
|
||||
<option value="enter">进入直播间</option>
|
||||
<option value="gift">礼物</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>测试 UID<input required value={uid} onChange={event => setUid(event.target.value)} /></label>
|
||||
<label>测试昵称<input required value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
{kind === 'danmaku' && <label>弹幕内容<input required value={text} onChange={event => setText(event.target.value)} /></label>}
|
||||
{kind === 'gift' && (
|
||||
<>
|
||||
<label>礼物名称<input required value={giftName} onChange={event => setGiftName(event.target.value)} /></label>
|
||||
<label>数量<input required type="number" min="1" value={quantity} onChange={event => setQuantity(+event.target.value)} /></label>
|
||||
<label>电池数<input required type="number" min="0" value={battery} onChange={event => setBattery(+event.target.value)} /></label>
|
||||
</>
|
||||
)}
|
||||
<FlashMessage flash={flash} />
|
||||
<div className="form-actions align-end span-all"><button disabled={busy}>{busy ? '正在发送…' : '触发测试事件'}</button></div>
|
||||
</form>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentList({ components, selectedId, onSelect }: {
|
||||
components: ComponentSummary[]
|
||||
selectedId?: string
|
||||
onSelect: (component: ComponentSummary) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="component-sidebar jade-panel">
|
||||
<div className="component-sidebar-heading">
|
||||
<p className="eyebrow">COMPONENTS</p>
|
||||
<h2>我的组件</h2>
|
||||
</div>
|
||||
{components.length === 0
|
||||
? <div className="empty-state"><b>还没有组件</b><p>账户初始化完成后,服务会为你创建默认弹幕姬。</p></div>
|
||||
: (
|
||||
<div className="component-list">
|
||||
{components.map(component => (
|
||||
<button
|
||||
type="button"
|
||||
className={component.id === selectedId ? 'selected' : ''}
|
||||
onClick={() => onSelect(component)}
|
||||
key={component.id}
|
||||
>
|
||||
<span className="component-icon" aria-hidden="true">{isDanmakuKind(component.kind) ? '弹' : '件'}</span>
|
||||
<span><b>{component.name}</b><small>{isDanmakuKind(component.kind) ? '直播弹幕姬' : component.kind}</small></span>
|
||||
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="future-components">
|
||||
<span>即将支持</span>
|
||||
<small>礼物展示 · 点歌姬</small>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
|
||||
const [components, setComponents] = useState<ComponentSummary[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [settings, setSettings] = useState<OverlaySettings>()
|
||||
const [source, setSource] = useState<CookieCloudSource>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const selectedIdRef = useRef<string | undefined>(undefined)
|
||||
const settingsRequestRef = useRef(0)
|
||||
const savedSettingsRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const selected = useMemo(() => components.find(component => component.id === selectedId), [components, selectedId])
|
||||
const settingsDirty = Boolean(settings)
|
||||
&& JSON.stringify(settings) !== savedSettingsRef.current
|
||||
usePwaUpdateBlocker('component-settings', '保存或还原当前组件设置', saving || settingsDirty)
|
||||
|
||||
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
|
||||
const requestId = ++settingsRequestRef.current
|
||||
setSaving(false)
|
||||
setSettings(undefined)
|
||||
savedSettingsRef.current = undefined
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/settings`)
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
|
||||
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
savedSettingsRef.current = JSON.stringify(next)
|
||||
setSettings(next)
|
||||
} catch (reason) {
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取组件设置') })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [componentPayload, sourcePayload] = await Promise.all([
|
||||
api<unknown>('/api/v1/components'),
|
||||
api<unknown>('/api/v1/source'),
|
||||
])
|
||||
if (cancelled) return
|
||||
const nextComponents = normalizeComponents(componentPayload)
|
||||
setComponents(nextComponents)
|
||||
setSource(normalizeSource(sourcePayload))
|
||||
const requested = new URLSearchParams(location.search).get('component')
|
||||
const first = nextComponents.find(component => component.id === requested)
|
||||
?? nextComponents.find(component => isDanmakuKind(component.kind))
|
||||
?? nextComponents[0]
|
||||
if (first) {
|
||||
selectedIdRef.current = first.id
|
||||
setSelectedId(first.id)
|
||||
await loadComponentSettings(first)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!cancelled) setFlash({ kind: 'error', text: errorMessage(reason, '控制台数据加载失败') })
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
settingsRequestRef.current += 1
|
||||
}
|
||||
}, [loadComponentSettings])
|
||||
|
||||
const choose = (component: ComponentSummary) => {
|
||||
selectedIdRef.current = component.id
|
||||
setSelectedId(component.id)
|
||||
const url = new URL(location.href)
|
||||
url.searchParams.set('component', component.id)
|
||||
history.replaceState(null, '', url)
|
||||
void loadComponentSettings(component)
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!selected || !settings) return
|
||||
const componentId = selected.id
|
||||
const requestId = settingsRequestRef.current
|
||||
setSaving(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/components/${encodeURIComponent(componentId)}/settings`,
|
||||
json('PUT', settings),
|
||||
)
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
|
||||
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
savedSettingsRef.current = JSON.stringify(next)
|
||||
setSettings(next)
|
||||
setFlash({ kind: 'success', text: '组件设置已保存,并实时同步到已连接的 OBS。' })
|
||||
} catch (reason) {
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '组件设置保存失败') })
|
||||
} finally {
|
||||
if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||
<div className="dashboard-grid">
|
||||
<ComponentList components={components} selectedId={selectedId} onSelect={choose} />
|
||||
<div className="dashboard-content">
|
||||
{loading && <div className="loading-panel jade-panel">正在展开云台…</div>}
|
||||
<FlashMessage flash={flash} />
|
||||
{!loading && selected && (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">{selected.kind.toUpperCase()}</p><h1>{selected.name}</h1></div>
|
||||
<span className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}>
|
||||
{selected.enabled === false ? '已停用' : '运行中'}
|
||||
</span>
|
||||
</div>
|
||||
{isDanmakuKind(selected.kind) && settings
|
||||
? (
|
||||
<>
|
||||
<Panel title="弹幕姬设置" description="每一项都独立保存在当前用户的组件下。">
|
||||
<SettingsEditor settings={settings} onChange={setSettings} onSave={saveSettings} saving={saving} />
|
||||
</Panel>
|
||||
<OverlayPreview settings={settings} />
|
||||
</>
|
||||
)
|
||||
: <Panel title="组件设置"><div className="empty-state">该组件类型的设置编辑器尚未安装。</div></Panel>}
|
||||
<ObsAccessPanel component={selected} key={selected.id} />
|
||||
<TestEvents componentId={selected.id} />
|
||||
</>
|
||||
)}
|
||||
{!loading && !selected && <Panel title="欢迎来到直播云台"><div className="empty-state">当前账户还没有可配置的组件。</div></Panel>}
|
||||
{source && <SourceEditor source={source} onSaved={setSource} />}
|
||||
</div>
|
||||
</div>
|
||||
</ControlLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '永久'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function invitationStatus(invitation: Invitation): { label: string; className: string } {
|
||||
if (invitation.revokedAt) return { label: '已撤销', className: 'offline' }
|
||||
if (invitation.expiresAt && new Date(invitation.expiresAt).getTime() <= Date.now()) return { label: '已过期', className: 'offline' }
|
||||
if (invitation.consumedAt) return { label: '已使用', className: 'offline' }
|
||||
return { label: '可使用', className: 'online' }
|
||||
}
|
||||
|
||||
export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||||
const [roomId, setRoomId] = useState('')
|
||||
const [expiresInHours, setExpiresInHours] = useState(24)
|
||||
const [newCode, setNewCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
usePwaUpdateBlocker('invitation-code', '复制并妥善保存本次生成的一次性邀请码', busy || Boolean(newCode))
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const payload = await api<unknown>('/api/v1/invitations')
|
||||
setInvitations(normalizeInvitations(payload))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch(reason => setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') }))
|
||||
}, [load])
|
||||
|
||||
const create = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
setNewCode('')
|
||||
try {
|
||||
const payload = await api<unknown>('/api/v1/invitations', json('POST', { roomId: roomId.trim(), expiresInHours }))
|
||||
const root = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
|
||||
const invitation = root.invitation && typeof root.invitation === 'object'
|
||||
? root.invitation as Record<string, unknown>
|
||||
: root
|
||||
const code = String(invitation.code ?? root.code ?? '')
|
||||
setNewCode(code)
|
||||
setFlash({ kind: 'success', text: '邀请码已创建。明文只显示这一次。' })
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '邀请码创建失败') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revoke = async (invitation: Invitation) => {
|
||||
if (!window.confirm('确定撤销这个邀请码吗?尚未完成的注册会立即失效。')) return
|
||||
setFlash(undefined)
|
||||
try {
|
||||
await api(`/api/v1/invitations/${encodeURIComponent(invitation.id)}`, json('DELETE'))
|
||||
setFlash({ kind: 'success', text: '邀请码已撤销。' })
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '邀请码撤销失败') })
|
||||
}
|
||||
}
|
||||
|
||||
const registerAddress = useMemo(() => {
|
||||
if (!newCode) return ''
|
||||
const url = new URL('/control/register', location.origin)
|
||||
url.hash = new URLSearchParams({ invite: newCode }).toString()
|
||||
return url.toString()
|
||||
}, [newCode])
|
||||
|
||||
return (
|
||||
<ControlLayout user={user} active="invitations" onLogout={onLogout}>
|
||||
<div className="admin-content">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">SYSTEM ADMIN</p><h1>邀请码管理</h1></div>
|
||||
<span className="status-chip online">仅系统管理员</span>
|
||||
</div>
|
||||
<FlashMessage flash={flash} />
|
||||
<Panel title="创建邀请码" description="默认单次使用、24 小时有效;邀请码只用于注册,不能用于日常登录。">
|
||||
<form className="field-grid two-columns" onSubmit={create}>
|
||||
<label>绑定的 Bilibili 直播间 ID<input required inputMode="numeric" value={roomId} onChange={event => setRoomId(event.target.value)} /></label>
|
||||
<label>有效小时数<input type="number" min="1" max="720" value={expiresInHours} onChange={event => setExpiresInHours(+event.target.value)} /></label>
|
||||
<div className="form-actions align-end span-all"><button disabled={busy}>{busy ? '正在创建…' : '创建邀请码'}</button></div>
|
||||
</form>
|
||||
{newCode && (
|
||||
<div className="one-time-secret">
|
||||
<b>仅显示一次</b>
|
||||
<code>{newCode}</code>
|
||||
<input readOnly value={registerAddress} onFocus={event => event.currentTarget.select()} />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(newCode)}>复制邀请码</button>
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(registerAddress)}>复制注册链接</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="历史邀请码" description="列表不包含邀请码明文,只显示可审计的状态和使用次数。">
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>直播间</th><th>邀请码前缀</th><th>创建时间</th><th>有效期</th><th>状态</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{invitations.map(invitation => {
|
||||
const status = invitationStatus(invitation)
|
||||
return (
|
||||
<tr key={invitation.id}>
|
||||
<td><code>{invitation.roomId}</code></td>
|
||||
<td><code>{invitation.codePrefix || '—'}</code></td>
|
||||
<td>{formatDate(invitation.createdAt)}</td>
|
||||
<td>{formatDate(invitation.expiresAt)}</td>
|
||||
<td><span className={`status-chip ${status.className}`}>{status.label}</span></td>
|
||||
<td>{status.className === 'online' && <button type="button" className="danger small" onClick={() => void revoke(invitation)}>撤销</button>}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{invitations.length === 0 && <tr><td colSpan={6}><div className="empty-state">尚未创建邀请码。</div></td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</ControlLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function ForbiddenPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
|
||||
return (
|
||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||
<div className="admin-content"><Panel title="没有访问权限"><p>邀请码管理仅对系统管理员开放。</p><a className="button-link" href="/control/">返回我的组件</a></Panel></div>
|
||||
</ControlLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401
|
||||
}
|
||||
+141
-60
@@ -1,68 +1,149 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
||||
import { EnrollmentPage, LoginPage } from './auth'
|
||||
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
|
||||
import { Overlay, tokenFromFragment } from './overlay'
|
||||
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
||||
import type { Session } from './types'
|
||||
import './style.css'
|
||||
import './control.css'
|
||||
|
||||
type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number }
|
||||
type Envelope = { id:string; type:string; payload:any }
|
||||
type Item = Envelope & { key:string; received:number; decorVariant:number }
|
||||
type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean }
|
||||
const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 }
|
||||
const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const
|
||||
const decorVariantCount=6
|
||||
function Redirect({ to }: { to: string }) {
|
||||
useEffect(() => {
|
||||
location.replace(to)
|
||||
}, [to])
|
||||
return <main className="route-loading">正在前往云台…</main>
|
||||
}
|
||||
|
||||
function stableHash(value:string) { let hash=2166136261; for(let index=0;index<value.length;index++){hash^=value.charCodeAt(index);hash=Math.imul(hash,16777619)} return hash>>>0 }
|
||||
function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount }
|
||||
function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return <div className={`card-decor decor-v${normalized}`} aria-hidden="true"><i className="card-decor-surface"/><div className="card-particle-layer">{cardParticles.slice(0,visible).map((kind,index)=><i className={`card-particle ${kind}`} key={`${kind}-${index}`}/>)}</div></div> }
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel not-found">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` }
|
||||
function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) }
|
||||
function useEvents(disabled=false) {
|
||||
const [settings,setSettings]=useState<Settings>(defaults); const [items,setItems]=useState<Item[]>([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings)
|
||||
useEffect(()=>{settingsRef.current=settings},[settings])
|
||||
useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0
|
||||
const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } }
|
||||
open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()}
|
||||
},[disabled])
|
||||
useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible])
|
||||
return {settings,items,connected,setItems}
|
||||
function App() {
|
||||
const [session, setSession] = useState<Session>()
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const path = location.pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
setLoadError('')
|
||||
try {
|
||||
const payload = await api<unknown>('/api/v1/auth/me')
|
||||
setSession(normalizeSession(payload))
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
return
|
||||
}
|
||||
setLoadError(errorMessage(error, '无法连接认证服务'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession()
|
||||
}, [refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
const wentOnline = () => {
|
||||
setOnline(true)
|
||||
if (loadError) void refreshSession()
|
||||
}
|
||||
const wentOffline = () => setOnline(false)
|
||||
window.addEventListener('online', wentOnline)
|
||||
window.addEventListener('offline', wentOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', wentOnline)
|
||||
window.removeEventListener('offline', wentOffline)
|
||||
}
|
||||
}, [loadError, refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
const expired = () => {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
if (location.pathname.startsWith('/control')) location.assign('/control/login')
|
||||
}
|
||||
window.addEventListener('lxc:session-expired', expired)
|
||||
return () => window.removeEventListener('lxc:session-expired', expired)
|
||||
}, [])
|
||||
|
||||
if (loadError) {
|
||||
const offline = !online
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
||||
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
||||
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
|
||||
<div className="notice error">{loadError}</div>
|
||||
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (!session) return <main className="route-loading">正在验证安全会话…</main>
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api('/api/v1/auth/logout', json('POST'))
|
||||
} finally {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
location.assign(authRoute('login'))
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/') return <Redirect to={session.user ? '/control/' : '/login'} />
|
||||
if (path === '/login' || path === '/control/login') {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
return <LoginPage onAuthenticated={refreshSession} setupRequired={session.setupRequired} />
|
||||
}
|
||||
if (path === '/setup' || path === '/control/setup') {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
if (!session.setupRequired) return <Redirect to={authRoute('login')} />
|
||||
return <EnrollmentPage mode="setup" onAuthenticated={refreshSession} />
|
||||
}
|
||||
if (path === '/register' || path === '/control/register') {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
|
||||
}
|
||||
if (path === '/control') {
|
||||
if (location.pathname === '/control') return <Redirect to="/control/" />
|
||||
if (!session.user) return <Redirect to="/control/login" />
|
||||
return <ComponentsPage user={session.user} onLogout={logout} />
|
||||
}
|
||||
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} />
|
||||
return <InvitationsPage user={session.user} onLogout={logout} />
|
||||
}
|
||||
return <NotFoundPage />
|
||||
}
|
||||
function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'}
|
||||
const eventRenderers:Record<string,(payload:any)=>string>={
|
||||
'live.enter':()=> '踏入了云台','live.superchat':p=>p.message,
|
||||
'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间'
|
||||
|
||||
const obsMatch = location.pathname.match(/^\/obs\/([^/]+)\/?$/)
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
if (obsMatch) {
|
||||
// A short-lived migration only: remove the root-scoped worker from early
|
||||
// development builds so it cannot keep controlling an OBS browser source.
|
||||
cleanupLegacyPwa()
|
||||
let publicId = ''
|
||||
try {
|
||||
publicId = decodeURIComponent(obsMatch[1])
|
||||
} catch {
|
||||
publicId = ''
|
||||
}
|
||||
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
|
||||
} else {
|
||||
initializePwa()
|
||||
root.render(<App />)
|
||||
}
|
||||
function DanmakuEmoticon({segment}:{segment:Extract<DanmakuSegment,{type:'emoticon'}>}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}</>; return <img className={`danmaku-emoticon${segment.standalone?' standalone':''}`} src={segment.url} width={segment.width||undefined} height={segment.height||undefined} alt={segment.text} title={segment.text} referrerPolicy="no-referrer" decoding="async" draggable={false} onError={()=>setFailed(true)}/> }
|
||||
function DanmakuBody({payload}:{payload:any}) { 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>)}</> }
|
||||
function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?<DanmakuBody payload={p}/>:eventRenderers[item.type]?.(p)||'送来了一份互动';
|
||||
return <article className={`card ${gift?'gift':''} ${item.type==='live.danmaku'?'danmaku':''} ${expanded?'expanded':'compact'} ${tier}`} key={item.key}>
|
||||
<CardDecor count={settings.particleCount} variant={item.decorVariant}/>
|
||||
{gift&&<div className="gift-art">{gift.animationUrl||gift.imageUrl?<img src={gift.animationUrl||gift.imageUrl} onError={e=>{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:<span>✦</span>}</div>}
|
||||
<div className="copy"><b>{v.name||'直播间观众'}</b><span className={isDanmaku?'danmaku-content':undefined}>{body}</span>{gift?.priceCny>0&&<em>¥ {Number(gift.priceCny).toFixed(2)}</em>}</div>{tier==='featured'&&<div className="particles">✦ ✧ ✦</div>}
|
||||
</article> }
|
||||
function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef<HTMLDivElement>(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState<string>(); const fontFactor=settings.fontScale/100
|
||||
useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[])
|
||||
useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems])
|
||||
useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape])
|
||||
return <main ref={root} className={`overlay ${shape} ${settings.lowPerformanceMode?'low-motion':''}`} style={{['--motion' as string]:`${settings.motionIntensity/100}`,['--unfold-duration' as string]:`${settings.unfoldDurationMs||defaults.unfoldDurationMs}ms`,['--particle-duration' as string]:`${400000/Math.min(300,Math.max(25,settings.particleSpeed||defaults.particleSpeed))}ms`,['--font-title' as string]:`${18*fontFactor}px`,['--font-body' as string]:`${18*fontFactor}px`,['--font-expanded' as string]:`${26*fontFactor}px`,['--font-compact' as string]:`${15*fontFactor}px`}}><section className={`wall ${items.length?'awake':''}`}><header><i className={connected?'online':''}/><span>{settings.title}</span></header><div className="cards">{items.map(item=><Card item={item} settings={settings} expanded={item.key===expandedKey} key={item.key}/>)}</div></section></main> }
|
||||
function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})}
|
||||
async function copyToClipboard(text:string){
|
||||
if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}}
|
||||
const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select()
|
||||
let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()}
|
||||
return copied
|
||||
}
|
||||
const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}]
|
||||
function Control(){
|
||||
const [password,setPassword]=useState(''); const [settings,setSettings]=useState<Settings>(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1])
|
||||
const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[])
|
||||
useEffect(()=>{load().catch(()=>{})},[load])
|
||||
const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()}
|
||||
const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)}
|
||||
const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}}
|
||||
if(!settings)return <main className="control login"><h1>弹幕猪控制台</h1><form onSubmit={login}><input type="password" autoFocus placeholder="管理员密码" value={password} onChange={e=>setPassword(e.target.value)}/><button>进入</button>{error&&<p>{error}</p>}</form></main>
|
||||
const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value})
|
||||
const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'}
|
||||
return <main className="control"><section><h1>青玉弹幕姬</h1><p>改动会立即同步到所有 OBS 浏览器源。</p><label>标题<input value={settings.title} onChange={e=>edit('title',e.target.value)}/></label><label>字号 <input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={e=>edit('fontScale',+e.target.value)}/><output>{settings.fontScale}%</output></label><label>最大可见条数 <input type="range" min="1" max="12" value={settings.maxVisible} onChange={e=>edit('maxVisible',+e.target.value)}/><output>{settings.maxVisible}</output></label><label>自动收缩秒数 <input type="range" min="2" max="60" value={settings.collapseAfterSeconds} onChange={e=>edit('collapseAfterSeconds',+e.target.value)}/><output>{settings.collapseAfterSeconds}s</output></label><label>卷轴展开时长 <input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={e=>edit('unfoldDurationMs',+e.target.value)}/><output>{(settings.unfoldDurationMs/1000).toFixed(1)}s</output></label><label>动效强度 <input type="range" min="0" max="100" value={settings.motionIntensity} onChange={e=>edit('motionIntensity',+e.target.value)}/><output>{settings.motionIntensity}%</output></label><label>每卡粒子数量 <input type="range" min="0" max="12" step="1" value={settings.particleCount} onChange={e=>edit('particleCount',+e.target.value)}/><output>{settings.particleCount}</output></label><label>粒子动画速度 <input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={e=>edit('particleSpeed',+e.target.value)}/><output>{settings.particleSpeed}%</output></label><fieldset>{(Object.keys(labels) as (keyof typeof labels)[]).map(k=><label key={k}><input type="checkbox" checked={settings[k]} onChange={e=>edit(k,e.target.checked)}/>{labels[k]}</label>)}</fieldset><label>高价值礼物(厘)<input type="number" value={settings.highValueThreshold} onChange={e=>edit('highValueThreshold',+e.target.value)}/></label><label>特别高价值(厘)<input type="number" value={settings.featuredValueThreshold} onChange={e=>edit('featuredValueThreshold',+e.target.value)}/></label><div className="buttons"><button type="button" onClick={save}>保存并同步</button><button type="button" className="secondary" onClick={copy}>复制 OBS 地址</button></div>{obsAddress&&<label className="obs-address">OBS 浏览器源地址<input readOnly value={obsAddress} onFocus={e=>e.currentTarget.select()}/></label>}{message&&<p className="success">{message}</p>}{error&&<p>{error}</p>}</section><section className="preview"><h2>自适应预览</h2><p>选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。</p><div className="preset-buttons">{previewPresets.map(size=><button type="button" className={size.label===previewSize.label?'active':'secondary'} onClick={()=>setPreviewSize(size)} key={size.label}>{size.label}<small>{size.width}×{size.height}</small></button>)}</div><div className="preview-viewport"><div className="preview-frame" style={{width:previewSize.width,height:previewSize.height}}><Overlay preview previewSettings={settings}/></div></div></section></main>
|
||||
}
|
||||
const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?<Control/>:<Overlay/>);
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { defaultOverlaySettings } from './types'
|
||||
import type { OverlaySettings } from './types'
|
||||
|
||||
type Envelope = {
|
||||
id: string
|
||||
type: string
|
||||
payload: LivePayload & {
|
||||
code?: string
|
||||
settings?: Partial<OverlaySettings>
|
||||
}
|
||||
}
|
||||
|
||||
type LivePayload = {
|
||||
viewer?: { uid?: string; name?: string }
|
||||
text?: string
|
||||
message?: string
|
||||
guardName?: string
|
||||
quantity?: number
|
||||
comboId?: string
|
||||
segments?: DanmakuSegment[]
|
||||
gift?: {
|
||||
name?: string
|
||||
totalPrice?: number
|
||||
priceCny?: number
|
||||
imageUrl?: string
|
||||
animationUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
type Item = Envelope & {
|
||||
key: string
|
||||
received: number
|
||||
decorVariant: number
|
||||
}
|
||||
|
||||
type DanmakuSegment =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'emoticon'
|
||||
text: string
|
||||
unique?: string
|
||||
url: string
|
||||
width?: number
|
||||
height?: number
|
||||
isDynamic?: boolean
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
type OverlayProps = {
|
||||
preview?: boolean
|
||||
previewSettings?: OverlaySettings
|
||||
publicId?: string
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
|
||||
const decorVariantCount = 6
|
||||
|
||||
function stableHash(value: string) {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
function chooseDecorVariant(seed: string, previous?: number) {
|
||||
const hash = stableHash(seed)
|
||||
const base = hash % decorVariantCount
|
||||
if (previous === undefined || base !== previous) return base
|
||||
return (base + 1 + ((hash >>> 8) % (decorVariantCount - 1))) % decorVariantCount
|
||||
}
|
||||
|
||||
function CardDecor({ count, variant }: { count: number; variant: number }) {
|
||||
const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0)))
|
||||
const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount
|
||||
return (
|
||||
<div className={`card-decor decor-v${normalized}`} aria-hidden="true">
|
||||
<i className="card-decor-surface" />
|
||||
<div className="card-particle-layer">
|
||||
{cardParticles.slice(0, visible).map((kind, index) => (
|
||||
<i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function streamUrl(publicId: string) {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function parseSettings(value: unknown): OverlaySettings {
|
||||
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
||||
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
|
||||
}
|
||||
|
||||
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
|
||||
settings: OverlaySettings
|
||||
items: Item[]
|
||||
setItems: Dispatch<SetStateAction<Item[]>>
|
||||
connection: 'idle' | 'connecting' | 'connected' | 'denied'
|
||||
} {
|
||||
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
|
||||
const settingsRef = useRef(settings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !publicId || !accessToken) {
|
||||
setConnection('idle')
|
||||
return
|
||||
}
|
||||
|
||||
let dead = false
|
||||
let socket: WebSocket | undefined
|
||||
let timer = 0
|
||||
let retries = 0
|
||||
|
||||
const open = () => {
|
||||
setConnection('connecting')
|
||||
socket = new WebSocket(streamUrl(publicId))
|
||||
socket.onopen = () => {
|
||||
retries = 0
|
||||
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (dead) return
|
||||
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
|
||||
setConnection('denied')
|
||||
return
|
||||
}
|
||||
setConnection('connecting')
|
||||
const delay = Math.min(12_000, 1200 * 2 ** Math.min(retries, 3))
|
||||
retries += 1
|
||||
timer = window.setTimeout(open, delay)
|
||||
}
|
||||
socket.onmessage = event => {
|
||||
try {
|
||||
const envelope = JSON.parse(event.data) as Envelope
|
||||
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
|
||||
setConnection('connected')
|
||||
return
|
||||
}
|
||||
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
|
||||
setConnection('denied')
|
||||
socket?.close(1008, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
setConnection('connected')
|
||||
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
|
||||
setSettings(parseSettings(envelope.payload?.settings))
|
||||
return
|
||||
}
|
||||
setItems(old => {
|
||||
const current = settingsRef.current
|
||||
if (!enabled(envelope.type, current)) return old
|
||||
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)
|
||||
})
|
||||
} catch {
|
||||
// A malformed upstream event must not break a long-running OBS source.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open()
|
||||
return () => {
|
||||
dead = true
|
||||
window.clearTimeout(timer)
|
||||
socket?.close()
|
||||
}
|
||||
}, [accessToken, disabled, publicId])
|
||||
|
||||
useEffect(() => {
|
||||
setItems(current => current.slice(0, settings.maxVisible))
|
||||
}, [settings.maxVisible])
|
||||
|
||||
return { settings, items, setItems, connection }
|
||||
}
|
||||
|
||||
function giftTier(item: Item, settings: OverlaySettings) {
|
||||
const price = item.payload?.gift?.totalPrice || 0
|
||||
return price >= settings.featuredValueThreshold
|
||||
? 'featured'
|
||||
: price >= settings.highValueThreshold
|
||||
? 'high'
|
||||
: 'normal'
|
||||
}
|
||||
|
||||
const eventRenderers: Record<string, (payload: LivePayload) => string> = {
|
||||
'live.enter': () => '踏入了云台',
|
||||
'live.superchat': payload => payload.message || '',
|
||||
'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`,
|
||||
'live.like': () => '点亮了一颗星',
|
||||
'live.share': () => '分享了直播间',
|
||||
}
|
||||
|
||||
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (failed) return <>{segment.text}</>
|
||||
return (
|
||||
<img
|
||||
className={`danmaku-emoticon${segment.standalone ? ' standalone' : ''}`}
|
||||
src={segment.url}
|
||||
width={segment.width || undefined}
|
||||
height={segment.height || undefined}
|
||||
alt={segment.text}
|
||||
title={segment.text}
|
||||
referrerPolicy="no-referrer"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DanmakuBody({ payload }: { payload: LivePayload }) {
|
||||
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>)}</>
|
||||
}
|
||||
|
||||
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) || '送来了一份互动'
|
||||
|
||||
return (
|
||||
<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>}
|
||||
</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>}
|
||||
</div>
|
||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function Overlay({ preview = false, previewSettings, publicId, accessToken }: OverlayProps) {
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const events = useEvents(preview, publicId, accessToken)
|
||||
const { items, setItems } = events
|
||||
const settings = previewSettings || events.settings
|
||||
const [shape, setShape] = useState('standard')
|
||||
const [expandedKey, setExpandedKey] = useState<string>()
|
||||
const fontFactor = settings.fontScale / 100
|
||||
|
||||
useEffect(() => {
|
||||
if (!root.current) return
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
setShape(width < 380 ? 'narrow' : height < 420 ? 'short' : 'standard')
|
||||
})
|
||||
observer.observe(root.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (preview && !items.length) {
|
||||
setItems([
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' },
|
||||
},
|
||||
{
|
||||
id: 'gift-preview',
|
||||
key: 'gift-preview',
|
||||
received: Date.now() - 1000,
|
||||
decorVariant: 3,
|
||||
type: 'live.gift',
|
||||
payload: {
|
||||
viewer: { name: '星光旅人' },
|
||||
quantity: 1,
|
||||
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' },
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [items.length, preview, setItems])
|
||||
|
||||
useEffect(() => {
|
||||
const newest = items[0]
|
||||
if (!newest) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
}
|
||||
setExpandedKey(newest.key)
|
||||
const densityFactor = shape === 'short' ? 0.6 : 1
|
||||
const timer = window.setTimeout(
|
||||
() => setExpandedKey(key => key === newest.key ? undefined : key),
|
||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [items, settings.collapseAfterSeconds, shape])
|
||||
|
||||
const missingAccess = !preview && (!publicId || !accessToken)
|
||||
return (
|
||||
<main
|
||||
ref={root}
|
||||
className={`overlay ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||
data-connection={events.connection}
|
||||
style={{
|
||||
['--motion' as string]: `${settings.motionIntensity / 100}`,
|
||||
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
|
||||
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
|
||||
['--font-body' as string]: `${18 * fontFactor}px`,
|
||||
['--font-expanded' as string]: `${26 * fontFactor}px`,
|
||||
['--font-compact' as string]: `${15 * fontFactor}px`,
|
||||
}}
|
||||
>
|
||||
<section className="wall">
|
||||
{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} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function tokenFromFragment(): string {
|
||||
const hash = location.hash.startsWith('#') ? location.hash.slice(1) : location.hash
|
||||
return new URLSearchParams(hash).get('token') ?? ''
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
|
||||
interface InstallChoice {
|
||||
outcome: 'accepted' | 'dismissed'
|
||||
platform: string
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[]
|
||||
readonly userChoice: Promise<InstallChoice>
|
||||
prompt(): Promise<void>
|
||||
}
|
||||
|
||||
interface PwaSnapshot {
|
||||
online: boolean
|
||||
standalone: boolean
|
||||
installPrompt?: BeforeInstallPromptEvent
|
||||
registration?: ServiceWorkerRegistration
|
||||
waitingWorker?: ServiceWorker
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
const updateBlockers = new Map<string, string>()
|
||||
let snapshot: PwaSnapshot = {
|
||||
online: navigator.onLine,
|
||||
standalone: isStandalone(),
|
||||
}
|
||||
let initialized = false
|
||||
let reloadForUpdate = false
|
||||
|
||||
function isStandalone(): boolean {
|
||||
const iosNavigator = navigator as Navigator & { standalone?: boolean }
|
||||
return window.matchMedia('(display-mode: standalone)').matches || iosNavigator.standalone === true
|
||||
}
|
||||
|
||||
function emit(patch: Partial<PwaSnapshot>) {
|
||||
snapshot = { ...snapshot, ...patch }
|
||||
listeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function addManifest() {
|
||||
if (document.head.querySelector('link[rel="manifest"]')) return
|
||||
const manifest = document.createElement('link')
|
||||
manifest.rel = 'manifest'
|
||||
manifest.href = '/control/manifest.webmanifest'
|
||||
document.head.append(manifest)
|
||||
}
|
||||
|
||||
function observeRegistration(registration: ServiceWorkerRegistration) {
|
||||
emit({
|
||||
registration,
|
||||
waitingWorker: registration.waiting && navigator.serviceWorker.controller
|
||||
? registration.waiting
|
||||
: snapshot.waitingWorker,
|
||||
})
|
||||
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const worker = registration.installing
|
||||
if (!worker) return
|
||||
worker.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
emit({ waitingWorker: worker })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}))
|
||||
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames
|
||||
.filter(name => name.startsWith('lxc-control-shell-'))
|
||||
.map(name => caches.delete(name)))
|
||||
}
|
||||
|
||||
export function cleanupLegacyPwa() {
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||
void removeLegacyRootWorker().catch(error => {
|
||||
console.warn('旧版 PWA 清理失败', error)
|
||||
})
|
||||
}
|
||||
|
||||
async function registerWorker() {
|
||||
try {
|
||||
await removeLegacyRootWorker()
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
`/control/sw.js?v=${encodeURIComponent(__PWA_BUILD_ID__)}`,
|
||||
{ scope: '/control/', updateViaCache: 'none' },
|
||||
)
|
||||
observeRegistration(registration)
|
||||
} catch (error) {
|
||||
// A failed PWA registration must never stop the online control console.
|
||||
console.warn('控制台 PWA 注册失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function initializePwa() {
|
||||
if (initialized || location.pathname === '/obs' || location.pathname.startsWith('/obs/')) return
|
||||
initialized = true
|
||||
addManifest()
|
||||
|
||||
const displayMode = window.matchMedia('(display-mode: standalone)')
|
||||
const updateConnection = () => emit({ online: navigator.onLine })
|
||||
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
|
||||
if (modernListener) modernListener.call(displayMode, 'change', 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 }))
|
||||
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (!reloadForUpdate) return
|
||||
reloadForUpdate = false
|
||||
location.reload()
|
||||
})
|
||||
|
||||
const start = () => void registerWorker()
|
||||
if (document.readyState === 'complete') start()
|
||||
else window.addEventListener('load', start, { once: true })
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && navigator.onLine) {
|
||||
void snapshot.registration?.update()
|
||||
}
|
||||
})
|
||||
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/')
|
||||
return inControlScope ? `/control/${name}` : `/${name}`
|
||||
}
|
||||
|
||||
export function usePwaUpdateBlocker(key: string, reason: string, active: boolean) {
|
||||
useEffect(() => {
|
||||
if (active) updateBlockers.set(key, reason)
|
||||
else updateBlockers.delete(key)
|
||||
return () => { updateBlockers.delete(key) }
|
||||
}, [active, key, reason])
|
||||
}
|
||||
|
||||
async function requestInstall() {
|
||||
const prompt = snapshot.installPrompt
|
||||
if (!prompt) return
|
||||
await prompt.prompt()
|
||||
await prompt.userChoice
|
||||
emit({ installPrompt: undefined, standalone: isStandalone() })
|
||||
}
|
||||
|
||||
function applyUpdate() {
|
||||
const worker = snapshot.waitingWorker
|
||||
if (!worker) return
|
||||
const reasons = [...new Set(updateBlockers.values())]
|
||||
if (reasons.length > 0) {
|
||||
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
|
||||
return
|
||||
}
|
||||
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
|
||||
if (!confirmed) return
|
||||
reloadForUpdate = true
|
||||
worker.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
|
||||
export function PwaControls() {
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
const canInstall = Boolean(state.installPrompt) && !state.standalone
|
||||
if (state.online && !state.waitingWorker && !canInstall) return null
|
||||
|
||||
return (
|
||||
<div className="pwa-controls" aria-live="polite">
|
||||
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
|
||||
{state.waitingWorker && (
|
||||
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
||||
更新可用
|
||||
</button>
|
||||
)}
|
||||
{canInstall && (
|
||||
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
|
||||
安装到设备
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
*{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))}.wall header{align-self:flex-end;display:flex;gap:8px;align-items:center;padding:8px 14px;border:1px solid rgba(144,255,240,.33);border-radius:999px;background:linear-gradient(110deg,rgba(13,47,63,.72),rgba(29,115,107,.44));backdrop-filter:blur(12px);letter-spacing:.08em;font-size:clamp(12px,2.5vw,17px);transition:.5s}.wall:not(.awake) header{opacity:.72}.wall header i{width:7px;height:7px;border-radius:50%;background:#55736f}.wall header i.online{background:#74ffd9;box-shadow:0 0 10px #4bffc7}.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,.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}}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type OverlaySettings = {
|
||||
fontScale: number
|
||||
showDanmaku: boolean
|
||||
showEnter: boolean
|
||||
showGift: boolean
|
||||
showSuperchat: boolean
|
||||
showGuard: boolean
|
||||
showLike: boolean
|
||||
showShare: boolean
|
||||
maxVisible: number
|
||||
collapseAfterSeconds: number
|
||||
unfoldDurationMs: number
|
||||
motionIntensity: number
|
||||
particleCount: number
|
||||
particleSpeed: number
|
||||
lowPerformanceMode: boolean
|
||||
highValueThreshold: number
|
||||
featuredValueThreshold: number
|
||||
}
|
||||
|
||||
export const defaultOverlaySettings: OverlaySettings = {
|
||||
fontScale: 140,
|
||||
showDanmaku: true,
|
||||
showEnter: true,
|
||||
showGift: true,
|
||||
showSuperchat: true,
|
||||
showGuard: true,
|
||||
showLike: false,
|
||||
showShare: false,
|
||||
maxVisible: 5,
|
||||
collapseAfterSeconds: 12,
|
||||
unfoldDurationMs: 1000,
|
||||
motionIntensity: 70,
|
||||
particleCount: 8,
|
||||
particleSpeed: 100,
|
||||
lowPerformanceMode: false,
|
||||
highValueThreshold: 10_000,
|
||||
featuredValueThreshold: 100_000,
|
||||
}
|
||||
|
||||
export type UserRole = 'system_admin' | 'user' | string
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
username: string
|
||||
roomId?: string
|
||||
displayName?: string
|
||||
role: UserRole
|
||||
totpEnabled?: boolean
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
user: AuthUser | null
|
||||
setupRequired: boolean
|
||||
}
|
||||
|
||||
export type ComponentSummary = {
|
||||
id: string
|
||||
publicId: string
|
||||
kind: string
|
||||
name: string
|
||||
enabled?: boolean
|
||||
settings?: OverlaySettings
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CookieCloudSource = {
|
||||
roomId: string
|
||||
cookieCloud: {
|
||||
host: string
|
||||
key: string
|
||||
keyConfigured?: boolean
|
||||
password?: string
|
||||
passwordConfigured?: boolean
|
||||
}
|
||||
connected?: boolean
|
||||
detail?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type Invitation = {
|
||||
id: string
|
||||
code?: string
|
||||
codePrefix?: string
|
||||
roomId: string
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
expiresAt?: string
|
||||
consumedAt?: string | null
|
||||
revokedAt?: string | null
|
||||
}
|
||||
|
||||
export type TotpEnrollment = {
|
||||
enrollmentToken: string
|
||||
qrSvg?: string
|
||||
qrDataUrl?: string
|
||||
otpauthUri?: string
|
||||
manualKey: string
|
||||
expiresAt?: string
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __PWA_BUILD_ID__: string
|
||||
@@ -1,3 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({ plugins: [react()] })
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
// A new URL makes the browser check and stage every deployed service worker.
|
||||
// The waiting worker is still activated explicitly by the user in the console.
|
||||
const pwaBuildId = Date.now().toString(36)
|
||||
const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'control-pwa-assets',
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'control/sw.js',
|
||||
source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId),
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
define: {
|
||||
__PWA_BUILD_ID__: JSON.stringify(pwaBuildId),
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+211
-4
@@ -8,6 +8,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -209,6 +219,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -401,6 +417,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
@@ -412,6 +439,19 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -420,9 +460,21 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
@@ -540,6 +592,12 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.17.0"
|
||||
@@ -702,6 +760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -769,6 +828,41 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||
dependencies = [
|
||||
"deadpool-runtime",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-postgres"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"deadpool",
|
||||
"getrandom 0.2.17",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
@@ -1228,6 +1322,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -1608,6 +1708,15 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
@@ -1832,21 +1941,25 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
name = "lxc-stream-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"blivedm",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"futures",
|
||||
"deadpool-postgres",
|
||||
"futures-channel",
|
||||
"hmac 0.12.1",
|
||||
"http 1.4.2",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"totp-rs",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -2053,6 +2166,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
@@ -2201,6 +2324,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -2350,6 +2479,17 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -2390,10 +2530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"fallible-iterator",
|
||||
"postgres-protocol",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2460,6 +2602,23 @@ version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "qrcodegen"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
|
||||
|
||||
[[package]]
|
||||
name = "qrcodegen-image"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e3dd60f5b603f72c307455fc52deec52ada1ba53c7580918bb2a8e3247d4fe7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"qrcodegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -2579,7 +2738,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"chacha20 0.10.1",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
@@ -3653,6 +3812,24 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac 0.12.1",
|
||||
"qrcodegen-image",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -3903,6 +4080,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -3921,6 +4108,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
@@ -4773,6 +4966,20 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
@@ -5,23 +5,27 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws", "json"] }
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
# Patched local copy of the published blivedm_rs crate. The patch preserves
|
||||
# the upstream raw payload so the application can retain UID, price and event
|
||||
# identifiers for atomic accounting and gift de-duplication.
|
||||
blivedm = { path = "../../vendor/blivedm", default-features = false }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
futures = "0.3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
deadpool-postgres = "0.14"
|
||||
futures-channel = "0.3"
|
||||
hmac = "0.12"
|
||||
http = "1"
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-serde_json-1", "with-uuid-1", "with-chrono-0_4"] }
|
||||
tokio-util = "0.7"
|
||||
toml = "0.8"
|
||||
totp-rs = { version = "5.7", features = ["gen_secret", "qr", "zeroize"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
-- Identity, tenant ownership and component foundations.
|
||||
--
|
||||
-- Raw invitation, enrollment, session, recovery and component access tokens
|
||||
-- must never be stored in PostgreSQL. Their SHA-256 digests are the only
|
||||
-- persisted representation. TOTP and CookieCloud secrets are encrypted by the
|
||||
-- application with XChaCha20-Poly1305 before they reach this schema.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
username_normalized TEXT NOT NULL UNIQUE,
|
||||
room_id TEXT NOT NULL UNIQUE CHECK (room_id ~ '^[1-9][0-9]*$'),
|
||||
role TEXT NOT NULL CHECK (role IN ('system_admin', 'user')),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
|
||||
totp_secret_ciphertext BYTEA NOT NULL CHECK (octet_length(totp_secret_ciphertext) >= 16),
|
||||
totp_secret_nonce BYTEA NOT NULL CHECK (octet_length(totp_secret_nonce) = 24),
|
||||
last_totp_step BIGINT,
|
||||
totp_enrolled_at TIMESTAMPTZ NOT NULL,
|
||||
auth_version BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
disabled_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- A room is an account invariant rather than editable profile data.
|
||||
CREATE OR REPLACE FUNCTION prevent_user_room_id_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.room_id IS DISTINCT FROM OLD.room_id THEN
|
||||
RAISE EXCEPTION 'a user room_id is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS users_room_id_immutable ON users;
|
||||
CREATE TRIGGER users_room_id_immutable
|
||||
BEFORE UPDATE OF room_id ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_user_room_id_change();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id UUID PRIMARY KEY,
|
||||
code_digest BYTEA NOT NULL UNIQUE CHECK (octet_length(code_digest) = 32),
|
||||
code_prefix TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL CHECK (room_id ~ '^[1-9][0-9]*$'),
|
||||
grant_role TEXT NOT NULL DEFAULT 'user' CHECK (grant_role IN ('system_admin', 'user')),
|
||||
created_by UUID REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_by UUID UNIQUE REFERENCES users(id) ON DELETE RESTRICT,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
CHECK ((consumed_by IS NULL) = (consumed_at IS NULL)),
|
||||
-- Only the one-time bootstrap path may mint the first system administrator.
|
||||
CHECK (grant_role <> 'system_admin' OR created_by IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS invitations_created_by_idx
|
||||
ON invitations(created_by, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS invitations_room_idx
|
||||
ON invitations(room_id, expires_at DESC);
|
||||
|
||||
-- Pending enrollment is deliberately separate from users. An account does not
|
||||
-- exist until a valid TOTP has been confirmed. Rows are short lived and are
|
||||
-- pruned opportunistically by registration calls.
|
||||
CREATE TABLE IF NOT EXISTS pending_registrations (
|
||||
id UUID PRIMARY KEY,
|
||||
enrollment_token_digest BYTEA NOT NULL UNIQUE
|
||||
CHECK (octet_length(enrollment_token_digest) = 32),
|
||||
invitation_id UUID NOT NULL UNIQUE REFERENCES invitations(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
username_normalized TEXT NOT NULL UNIQUE,
|
||||
room_id TEXT NOT NULL UNIQUE CHECK (room_id ~ '^[1-9][0-9]*$'),
|
||||
totp_secret_ciphertext BYTEA NOT NULL CHECK (octet_length(totp_secret_ciphertext) >= 16),
|
||||
totp_secret_nonce BYTEA NOT NULL CHECK (octet_length(totp_secret_nonce) = 24),
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS pending_registrations_expiry_idx
|
||||
ON pending_registrations(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_digest BYTEA NOT NULL UNIQUE CHECK (octet_length(token_digest) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
user_agent_hash BYTEA,
|
||||
ip_prefix TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS user_sessions_active_user_idx
|
||||
ON user_sessions(user_id, expires_at DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS user_sessions_expiry_idx
|
||||
ON user_sessions(expires_at)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_digest BYTEA NOT NULL CHECK (octet_length(code_digest) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
consumed_at TIMESTAMPTZ,
|
||||
UNIQUE (user_id, code_digest)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS recovery_codes_available_idx
|
||||
ON recovery_codes(user_id)
|
||||
WHERE consumed_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cookiecloud_credentials (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
host TEXT NOT NULL,
|
||||
secrets_ciphertext BYTEA NOT NULL CHECK (octet_length(secrets_ciphertext) >= 16),
|
||||
secrets_nonce BYTEA NOT NULL CHECK (octet_length(secrets_nonce) = 24),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The current product assigns exactly one immutable Bilibili source to an
|
||||
-- account. Keeping it as an explicit entity gives component routing a stable
|
||||
-- source_id while preserving the one-account/one-room product rule.
|
||||
CREATE TABLE IF NOT EXISTS live_sources (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'bilibili' CHECK (provider = 'bilibili'),
|
||||
room_id TEXT NOT NULL UNIQUE CHECK (room_id ~ '^[1-9][0-9]*$'),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (owner_user_id, id)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION enforce_live_source_account_room()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = NEW.owner_user_id AND room_id = NEW.room_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'live source room_id must equal its owning account room_id';
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND (NEW.owner_user_id IS DISTINCT FROM OLD.owner_user_id
|
||||
OR NEW.room_id IS DISTINCT FROM OLD.room_id) THEN
|
||||
RAISE EXCEPTION 'live source ownership and room_id are immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS live_source_account_room ON live_sources;
|
||||
CREATE TRIGGER live_source_account_room
|
||||
BEFORE INSERT OR UPDATE OF owner_user_id,room_id ON live_sources
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_live_source_account_room();
|
||||
|
||||
-- Every future OBS feature is a component instance. Component-specific state
|
||||
-- belongs in dedicated tables when it becomes relational; settings remain JSON
|
||||
-- so a new renderer does not require a core schema rewrite.
|
||||
CREATE TABLE IF NOT EXISTS component_instances (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
source_id UUID NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind ~ '^[a-z][a-z0-9_.-]{1,63}$'),
|
||||
name TEXT NOT NULL,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
|
||||
settings_version INTEGER NOT NULL DEFAULT 1 CHECK (settings_version > 0),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (owner_user_id, id),
|
||||
FOREIGN KEY (owner_user_id, source_id)
|
||||
REFERENCES live_sources(owner_user_id, id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS component_instances_owner_kind_idx
|
||||
ON component_instances(owner_user_id, kind, created_at);
|
||||
CREATE INDEX IF NOT EXISTS component_instances_source_idx
|
||||
ON component_instances(owner_user_id, source_id, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS component_access_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_user_id UUID NOT NULL,
|
||||
component_instance_id UUID NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
token_digest BYTEA NOT NULL UNIQUE CHECK (octet_length(token_digest) = 32),
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY['events:subscribe']::TEXT[],
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (owner_user_id, component_instance_id)
|
||||
REFERENCES component_instances(owner_user_id, id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS component_access_tokens_component_idx
|
||||
ON component_access_tokens(component_instance_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS audit_log_actor_time_idx
|
||||
ON audit_log(actor_user_id, created_at DESC);
|
||||
|
||||
-- Legacy settings remain readable during the staged migration. main.rs can
|
||||
-- associate and copy each row into the owner's initial danmaku component, then
|
||||
-- stop writing this table without a destructive migration.
|
||||
ALTER TABLE overlay_settings
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE overlay_settings
|
||||
ADD COLUMN IF NOT EXISTS component_instance_id UUID REFERENCES component_instances(id)
|
||||
ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS overlay_settings_owner_idx
|
||||
ON overlay_settings(owner_user_id);
|
||||
|
||||
-- The old outbox is currently unused. Adding a nullable owner makes old rows
|
||||
-- valid while ensuring any newly adopted outbox workflow can be tenant-aware.
|
||||
ALTER TABLE live_session_outbox
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- Database-enforced tenant isolation for tables that are always accessed in a
|
||||
-- known user's context. Call `set_config('app.user_id', <uuid>, true)` inside a
|
||||
-- transaction before touching them. FORCE also protects against accidental
|
||||
-- table-owner bypass by the runtime role.
|
||||
ALTER TABLE cookiecloud_credentials ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE cookiecloud_credentials FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS cookiecloud_credentials_owner ON cookiecloud_credentials;
|
||||
CREATE POLICY cookiecloud_credentials_owner ON cookiecloud_credentials
|
||||
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
|
||||
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
|
||||
|
||||
ALTER TABLE component_instances ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE component_instances FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS component_instances_owner ON component_instances;
|
||||
CREATE POLICY component_instances_owner ON component_instances
|
||||
USING (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
|
||||
|
||||
ALTER TABLE live_sources ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_sources FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS live_sources_owner ON live_sources;
|
||||
CREATE POLICY live_sources_owner ON live_sources
|
||||
USING (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
|
||||
|
||||
ALTER TABLE component_access_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE component_access_tokens FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS component_access_tokens_owner ON component_access_tokens;
|
||||
CREATE POLICY component_access_tokens_owner ON component_access_tokens
|
||||
USING (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
|
||||
|
||||
-- A presented component token is the one case where the owner is not known
|
||||
-- before lookup. This narrowly scoped function crosses RLS using only a
|
||||
-- full-entropy SHA-256 digest, returns no secret material, and pins search_path
|
||||
-- to prevent object-shadowing attacks. Once the owner is known, all component
|
||||
-- reads/writes continue in a normal tenant transaction.
|
||||
CREATE OR REPLACE FUNCTION lookup_component_access_token(p_token_digest BYTEA)
|
||||
RETURNS TABLE (
|
||||
token_id UUID,
|
||||
owner_user_id UUID,
|
||||
component_instance_id UUID,
|
||||
scopes TEXT[]
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
account_id UUID;
|
||||
BEGIN
|
||||
-- FORCE RLS intentionally remains enabled. Enter each active account's
|
||||
-- context before checking the digest instead of granting a broad bypass.
|
||||
FOR account_id IN
|
||||
SELECT account.id FROM public.users AS account WHERE account.status='active'
|
||||
LOOP
|
||||
PERFORM pg_catalog.set_config('app.user_id', account_id::TEXT, true);
|
||||
RETURN QUERY
|
||||
SELECT token.id, token.owner_user_id, token.component_instance_id, token.scopes
|
||||
FROM public.component_access_tokens AS token
|
||||
JOIN public.component_instances AS component
|
||||
ON component.id = token.component_instance_id
|
||||
AND component.owner_user_id = token.owner_user_id
|
||||
WHERE token.owner_user_id = account_id
|
||||
AND token.token_digest = p_token_digest
|
||||
AND token.revoked_at IS NULL
|
||||
AND (token.expires_at IS NULL OR token.expires_at > pg_catalog.now())
|
||||
AND component.enabled
|
||||
LIMIT 1;
|
||||
IF FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Startup source enumeration is another service operation whose tenant is not
|
||||
-- known in advance. This function returns only routing identifiers (never
|
||||
-- CookieCloud or TOTP material) and enters each account's RLS context in turn.
|
||||
CREATE OR REPLACE FUNCTION list_active_live_sources()
|
||||
RETURNS TABLE (
|
||||
owner_user_id UUID,
|
||||
source_id UUID,
|
||||
room_id TEXT
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
account RECORD;
|
||||
BEGIN
|
||||
FOR account IN
|
||||
SELECT users.id,users.room_id
|
||||
FROM public.users
|
||||
WHERE users.status='active'
|
||||
ORDER BY users.created_at
|
||||
LOOP
|
||||
PERFORM pg_catalog.set_config('app.user_id', account.id::TEXT, true);
|
||||
RETURN QUERY
|
||||
SELECT account.id,source.id,account.room_id
|
||||
FROM public.live_sources AS source
|
||||
WHERE source.owner_user_id=account.id AND source.enabled;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- The bootstrap flow is the sole way to create a system administrator.
|
||||
-- This database constraint closes the last concurrent-request race even if
|
||||
-- two bootstrap enrollments reach their final transaction simultaneously.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_single_system_admin
|
||||
ON users ((role))
|
||||
WHERE role = 'system_admin';
|
||||
|
||||
-- PostgreSQL grants EXECUTE on new functions to PUBLIC by default. These two
|
||||
-- SECURITY DEFINER helpers deliberately cross tenant discovery boundaries and
|
||||
-- must only be callable by their owner (the current runtime/migration role).
|
||||
REVOKE EXECUTE ON FUNCTION public.lookup_component_access_token(BYTEA) FROM PUBLIC;
|
||||
REVOKE EXECUTE ON FUNCTION public.list_active_live_sources() FROM PUBLIC;
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{Semaphore, mpsc};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::AuthService,
|
||||
components::ComponentRegistry,
|
||||
config::Config,
|
||||
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
|
||||
db::{ActiveTenant, Db},
|
||||
domain::LiveEvent,
|
||||
live::{
|
||||
LiveProvider, SourceContext,
|
||||
bilibili::BilibiliProvider,
|
||||
supervisor::{ProviderFactory, SourceSupervisor},
|
||||
},
|
||||
rate_limit::AuthRateLimiter,
|
||||
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
|
||||
repository::TenantRepository,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub db: Db,
|
||||
pub auth: AuthService,
|
||||
pub repository: TenantRepository,
|
||||
pub registry: ComponentRegistry,
|
||||
pub hub: EventHub,
|
||||
pub supervisor: SourceSupervisor,
|
||||
pub login_limiter: AuthRateLimiter,
|
||||
pub enrollment_limiter: AuthRateLimiter,
|
||||
pub component_socket_slots: Arc<Semaphore>,
|
||||
pub http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn build(config: Config) -> Result<Self, String> {
|
||||
let config = Arc::new(config);
|
||||
let db = Db::connect(&config.database_url, 16).map_err(|error| error.to_string())?;
|
||||
migrate(&db).await?;
|
||||
let auth = AuthService::new(
|
||||
db.clone(),
|
||||
config.data_encryption_key,
|
||||
config.totp_issuer.clone(),
|
||||
Duration::from_secs((config.session_ttl_hours as u64) * 3_600),
|
||||
Duration::from_secs((config.registration_ttl_minutes as u64) * 60),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let registry = ComponentRegistry::with_builtin_components();
|
||||
let component_cache = Arc::new(InMemoryComponentStore::default());
|
||||
let hub = EventHub::new(512);
|
||||
let router = SourceEventRouter::new(registry.clone(), component_cache.clone(), hub.clone());
|
||||
let repository =
|
||||
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
|
||||
repository
|
||||
.hydrate_all()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let provider_factory = Arc::new(BilibiliProviderFactory {
|
||||
auth: auth.clone(),
|
||||
config: config.clone(),
|
||||
http: http.clone(),
|
||||
});
|
||||
let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512);
|
||||
let supervisor = SourceSupervisor::new(provider_factory, source_events);
|
||||
let event_router = router.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = source_event_rx.recv().await {
|
||||
match event_router.route(event).await {
|
||||
Ok(report) => {
|
||||
if !report.failures.is_empty() {
|
||||
warn!(
|
||||
failures = report.failures.len(),
|
||||
"component routing completed with failures"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => error!(%error, "source event routing failed"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let state = Self {
|
||||
config,
|
||||
db,
|
||||
auth,
|
||||
repository,
|
||||
registry,
|
||||
hub,
|
||||
supervisor,
|
||||
login_limiter: AuthRateLimiter::default(),
|
||||
enrollment_limiter: AuthRateLimiter::new(
|
||||
12,
|
||||
Duration::from_secs(5 * 60),
|
||||
Duration::from_secs(10 * 60),
|
||||
),
|
||||
component_socket_slots: Arc::new(Semaphore::new(128)),
|
||||
http,
|
||||
};
|
||||
state.start_all_sources().await?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub async fn start_all_sources(&self) -> Result<(), String> {
|
||||
for tenant in self
|
||||
.db
|
||||
.list_active_tenants()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
{
|
||||
if let Err(error) = self.start_source(tenant.clone()).await {
|
||||
warn!(user_id = %tenant.user_id, room_id = %tenant.room_id, %error, "live source is not started");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_source(&self, tenant: ActiveTenant) -> Result<(), String> {
|
||||
self.repository
|
||||
.hydrate_tenant(tenant.user_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.supervisor
|
||||
.start(SourceContext {
|
||||
owner_id: tenant.user_id,
|
||||
source_id: tenant.source_id,
|
||||
room_id: tenant.room_id,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn restart_user_source(&self, owner_id: Uuid) -> Result<(), String> {
|
||||
let tenant = self
|
||||
.db
|
||||
.list_active_tenants()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_iter()
|
||||
.find(|tenant| tenant.user_id == owner_id)
|
||||
.ok_or_else(|| "live source was not found".to_string())?;
|
||||
self.start_source(tenant).await
|
||||
}
|
||||
|
||||
pub async fn import_legacy_owner(&self, owner_id: Uuid, room_id: &str) -> Result<Uuid, String> {
|
||||
let credentials = CookieCloudCredentials {
|
||||
host: self.config.legacy_cookiecloud_host.clone(),
|
||||
secrets: CookieCloudSecrets {
|
||||
key: self.config.legacy_cookiecloud_key.clone(),
|
||||
password: self.config.legacy_cookiecloud_password.clone(),
|
||||
},
|
||||
};
|
||||
self.auth
|
||||
.set_cookiecloud_credentials(owner_id, &credentials)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let fallback = serde_json::to_value(&self.config.legacy_overlay_defaults)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let settings = self
|
||||
.repository
|
||||
.legacy_overlay_settings(room_id, fallback)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let component_id = self
|
||||
.auth
|
||||
.import_legacy_overlay_settings(owner_id, settings)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !self.config.legacy_obs_access_token.trim().is_empty() {
|
||||
self.auth
|
||||
.import_component_access_token(
|
||||
owner_id,
|
||||
component_id,
|
||||
"Legacy OBS browser source",
|
||||
&["events:subscribe".into()],
|
||||
self.config.legacy_obs_access_token.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
self.repository
|
||||
.hydrate_tenant(owner_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.restart_user_source(owner_id).await?;
|
||||
Ok(component_id)
|
||||
}
|
||||
}
|
||||
|
||||
struct BilibiliProviderFactory {
|
||||
auth: AuthService,
|
||||
config: Arc<Config>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderFactory for BilibiliProviderFactory {
|
||||
async fn build(&self, source: &SourceContext) -> Result<Arc<dyn LiveProvider>, String> {
|
||||
let stored = self
|
||||
.auth
|
||||
.get_cookiecloud_credentials(source.owner_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
|
||||
self.config.allowed_cookiecloud_host(&stored.host)?;
|
||||
let cookie = fetch_bilibili_cookie(&self.http, &stored).await?;
|
||||
Ok(Arc::new(BilibiliProvider::new(
|
||||
cookie,
|
||||
self.config.gift_refresh_seconds,
|
||||
self.config.gift_request_timeout_seconds,
|
||||
self.config.emoticon_refresh_seconds,
|
||||
self.config.emoticon_request_timeout_seconds,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate(db: &Db) -> Result<(), String> {
|
||||
let mut client = db.get().await.map_err(|error| error.to_string())?;
|
||||
let transaction = client
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
transaction
|
||||
.batch_execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (\
|
||||
version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\
|
||||
)",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
transaction
|
||||
.query_one("SELECT pg_advisory_xact_lock(1280529235)", &[])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
for (version, sql) in [
|
||||
(1_i32, include_str!("../migrations/001_initial.sql")),
|
||||
(
|
||||
2_i32,
|
||||
include_str!("../migrations/002_overlay_settings.sql"),
|
||||
),
|
||||
(3_i32, include_str!("../migrations/003_multitenancy.sql")),
|
||||
(4_i32, include_str!("../migrations/004_auth_hardening.sql")),
|
||||
] {
|
||||
let applied = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)",
|
||||
&[&version],
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.get::<_, bool>(0);
|
||||
if !applied {
|
||||
transaction
|
||||
.batch_execute(sql)
|
||||
.await
|
||||
.map_err(|error| format!("migration {version} failed: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO schema_migrations(version) VALUES($1)",
|
||||
&[&version],
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
info!(version, "database migration applied");
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
error::Error,
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||
overlay::OverlaySettings,
|
||||
};
|
||||
|
||||
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ComponentError {
|
||||
EmptyKind,
|
||||
AlreadyRegistered(String),
|
||||
NotRegistered(String),
|
||||
InvalidSettings {
|
||||
kind: String,
|
||||
detail: String,
|
||||
},
|
||||
UnsupportedSettingsVersion {
|
||||
kind: String,
|
||||
found: u32,
|
||||
expected: u32,
|
||||
},
|
||||
Projection(String),
|
||||
Handler(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ComponentError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::EmptyKind => formatter.write_str("component kind cannot be empty"),
|
||||
Self::AlreadyRegistered(kind) => {
|
||||
write!(formatter, "component kind `{kind}` is already registered")
|
||||
}
|
||||
Self::NotRegistered(kind) => {
|
||||
write!(formatter, "component kind `{kind}` is not registered")
|
||||
}
|
||||
Self::InvalidSettings { kind, detail } => {
|
||||
write!(
|
||||
formatter,
|
||||
"invalid settings for component `{kind}`: {detail}"
|
||||
)
|
||||
}
|
||||
Self::UnsupportedSettingsVersion {
|
||||
kind,
|
||||
found,
|
||||
expected,
|
||||
} => write!(
|
||||
formatter,
|
||||
"component `{kind}` settings version {found} is unsupported; expected {expected}"
|
||||
),
|
||||
Self::Projection(detail) => write!(formatter, "component projection failed: {detail}"),
|
||||
Self::Handler(detail) => write!(formatter, "component event handler failed: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ComponentError {}
|
||||
|
||||
/// Persisted component instance. `owner_id` is trusted tenancy context and is
|
||||
/// omitted from public serialization.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentInstance {
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing)]
|
||||
pub owner_id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub settings_version: u32,
|
||||
pub settings: Value,
|
||||
}
|
||||
|
||||
impl ComponentInstance {
|
||||
pub fn new(
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
kind: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
settings_version: u32,
|
||||
settings: Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
owner_id,
|
||||
source_id,
|
||||
kind: kind.into(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
settings_version,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct EventSubscription {
|
||||
kinds: BTreeSet<LiveEventKind>,
|
||||
}
|
||||
|
||||
impl EventSubscription {
|
||||
pub fn new(kinds: impl IntoIterator<Item = LiveEventKind>) -> Self {
|
||||
Self {
|
||||
kinds: kinds.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, event: &LiveEvent) -> bool {
|
||||
self.kinds.contains(&event.kind())
|
||||
}
|
||||
|
||||
pub fn contains(&self, kind: LiveEventKind) -> bool {
|
||||
self.kinds.contains(&kind)
|
||||
}
|
||||
|
||||
pub fn kinds(&self) -> impl Iterator<Item = LiveEventKind> + '_ {
|
||||
self.kinds.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Static behavior and settings contract for one component kind.
|
||||
pub trait ComponentDefinition: Send + Sync {
|
||||
fn kind(&self) -> &'static str;
|
||||
fn settings_version(&self) -> u32;
|
||||
fn default_settings(&self) -> Value;
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError>;
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError>;
|
||||
|
||||
/// Override when a component changes its settings schema. Keeping migration
|
||||
/// here allows old instances to be upgraded without teaching the router
|
||||
/// about component-specific fields.
|
||||
fn migrate_settings(
|
||||
&self,
|
||||
from_version: u32,
|
||||
settings: Value,
|
||||
) -> Result<Value, ComponentError> {
|
||||
if from_version == self.settings_version() {
|
||||
Ok(settings)
|
||||
} else {
|
||||
Err(ComponentError::UnsupportedSettingsVersion {
|
||||
kind: self.kind().to_owned(),
|
||||
found: from_version,
|
||||
expected: self.settings_version(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A passive, side-effect-free transformation for a browser-facing component.
|
||||
/// It may filter or reshape an event, but must not write business data.
|
||||
pub trait EventProjection: Send + Sync {
|
||||
fn project(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Option<ComponentMessage>, ComponentError>;
|
||||
}
|
||||
|
||||
/// Future returned by a durable business handler without requiring an
|
||||
/// `async-trait` dependency.
|
||||
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>;
|
||||
|
||||
/// An active handler may perform durable side effects (for example recording a
|
||||
/// song request). It runs independently of WebSocket receiver count and should
|
||||
/// implement idempotency in its persistence layer.
|
||||
pub trait EventHandler: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
fn accepts(&self, _component: &ComponentInstance, _event: &LiveEvent) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
component: &'a ComponentInstance,
|
||||
event: Arc<LiveEvent>,
|
||||
) -> HandlerFuture<'a>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PassthroughProjection;
|
||||
|
||||
impl EventProjection for PassthroughProjection {
|
||||
fn project(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Option<ComponentMessage>, ComponentError> {
|
||||
ComponentMessage::from_live_event(component.id, event)
|
||||
.map(Some)
|
||||
.map_err(|error| ComponentError::Projection(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DanmakuOverlayDefinition;
|
||||
|
||||
impl DanmakuOverlayDefinition {
|
||||
fn parse(&self, settings: Value) -> Result<OverlaySettings, ComponentError> {
|
||||
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: DANMAKU_OVERLAY_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentDefinition for DanmakuOverlayDefinition {
|
||||
fn kind(&self) -> &'static str {
|
||||
DANMAKU_OVERLAY_KIND
|
||||
}
|
||||
|
||||
fn settings_version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_settings(&self) -> Value {
|
||||
serde_json::to_value(OverlaySettings::default())
|
||||
.expect("OverlaySettings is always JSON serializable")
|
||||
}
|
||||
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
|
||||
let settings = self.parse(settings)?.sanitize();
|
||||
serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: DANMAKU_OVERLAY_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
let settings = self.parse(settings.clone())?;
|
||||
let mut kinds = Vec::with_capacity(9);
|
||||
if settings.show_danmaku {
|
||||
kinds.push(LiveEventKind::Danmaku);
|
||||
}
|
||||
if settings.show_enter {
|
||||
kinds.push(LiveEventKind::Enter);
|
||||
}
|
||||
if settings.show_gift {
|
||||
kinds.extend([LiveEventKind::Gift, LiveEventKind::GiftCombo]);
|
||||
}
|
||||
if settings.show_superchat {
|
||||
kinds.push(LiveEventKind::SuperChat);
|
||||
}
|
||||
if settings.show_guard {
|
||||
kinds.push(LiveEventKind::GuardPurchase);
|
||||
}
|
||||
if settings.show_like {
|
||||
kinds.push(LiveEventKind::Like);
|
||||
}
|
||||
if settings.show_share {
|
||||
kinds.push(LiveEventKind::Share);
|
||||
}
|
||||
Ok(EventSubscription::new(kinds))
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable routing snapshot returned by the registry. All contained trait
|
||||
/// objects are `Arc`, so routing never holds the registry lock across awaits.
|
||||
#[derive(Clone)]
|
||||
pub struct ComponentRuntime {
|
||||
definition: Arc<dyn ComponentDefinition>,
|
||||
projection: Arc<dyn EventProjection>,
|
||||
handlers: Vec<Arc<dyn EventHandler>>,
|
||||
}
|
||||
|
||||
impl ComponentRuntime {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
self.definition.kind()
|
||||
}
|
||||
|
||||
pub fn definition(&self) -> Arc<dyn ComponentDefinition> {
|
||||
self.definition.clone()
|
||||
}
|
||||
|
||||
pub fn validated_settings(
|
||||
&self,
|
||||
instance: &ComponentInstance,
|
||||
) -> Result<Value, ComponentError> {
|
||||
let settings = self
|
||||
.definition
|
||||
.migrate_settings(instance.settings_version, instance.settings.clone())?;
|
||||
self.definition.validate_settings(settings)
|
||||
}
|
||||
|
||||
pub fn subscriptions(
|
||||
&self,
|
||||
instance: &ComponentInstance,
|
||||
) -> Result<EventSubscription, ComponentError> {
|
||||
let settings = self.validated_settings(instance)?;
|
||||
self.definition.subscriptions(&settings)
|
||||
}
|
||||
|
||||
pub fn project(
|
||||
&self,
|
||||
instance: &ComponentInstance,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Option<ComponentMessage>, ComponentError> {
|
||||
self.projection.project(instance, event)
|
||||
}
|
||||
|
||||
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
|
||||
self.handlers.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ComponentRegistry {
|
||||
entries: Arc<RwLock<HashMap<String, ComponentRuntime>>>,
|
||||
}
|
||||
|
||||
impl ComponentRegistry {
|
||||
/// Create an empty registry for tests or applications that select their own
|
||||
/// component modules.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry used by the current application. Future modules can be added by
|
||||
/// calling `register` during bootstrap.
|
||||
pub fn with_builtin_components() -> Self {
|
||||
let registry = Self::new();
|
||||
registry
|
||||
.register(
|
||||
Arc::new(DanmakuOverlayDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&self,
|
||||
definition: Arc<dyn ComponentDefinition>,
|
||||
projection: Arc<dyn EventProjection>,
|
||||
) -> Result<(), ComponentError> {
|
||||
let kind = definition.kind().trim();
|
||||
if kind.is_empty() {
|
||||
return Err(ComponentError::EmptyKind);
|
||||
}
|
||||
let mut entries = self
|
||||
.entries
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if entries.contains_key(kind) {
|
||||
return Err(ComponentError::AlreadyRegistered(kind.to_owned()));
|
||||
}
|
||||
entries.insert(
|
||||
kind.to_owned(),
|
||||
ComponentRuntime {
|
||||
definition,
|
||||
projection,
|
||||
handlers: Vec::new(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_handler(
|
||||
&self,
|
||||
kind: &str,
|
||||
handler: Arc<dyn EventHandler>,
|
||||
) -> Result<(), ComponentError> {
|
||||
let mut entries = self
|
||||
.entries
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let runtime = entries
|
||||
.get_mut(kind)
|
||||
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?;
|
||||
runtime.handlers.push(handler);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
|
||||
self.entries
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))
|
||||
}
|
||||
|
||||
pub fn validate_settings(
|
||||
&self,
|
||||
kind: &str,
|
||||
from_version: u32,
|
||||
settings: Value,
|
||||
) -> Result<Value, ComponentError> {
|
||||
let runtime = self.runtime(kind)?;
|
||||
let settings = runtime
|
||||
.definition
|
||||
.migrate_settings(from_version, settings)?;
|
||||
runtime.definition.validate_settings(settings)
|
||||
}
|
||||
|
||||
pub fn kinds(&self) -> Vec<String> {
|
||||
let mut kinds: Vec<_> = self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
kinds.sort();
|
||||
kinds
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComponentRegistry {
|
||||
fn default() -> Self {
|
||||
Self::with_builtin_components()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtin_overlay_settings_are_sanitized_and_define_subscriptions() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let mut settings = serde_json::to_value(OverlaySettings::default()).unwrap();
|
||||
settings["maxVisible"] = Value::from(250);
|
||||
settings["showGift"] = Value::Bool(false);
|
||||
settings["showLike"] = Value::Bool(true);
|
||||
|
||||
let validated = registry
|
||||
.validate_settings(DANMAKU_OVERLAY_KIND, 1, settings)
|
||||
.unwrap();
|
||||
assert_eq!(validated["maxVisible"], 12);
|
||||
|
||||
let instance = ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
DANMAKU_OVERLAY_KIND,
|
||||
"弹幕姬",
|
||||
1,
|
||||
validated,
|
||||
);
|
||||
let subscriptions = registry
|
||||
.runtime(DANMAKU_OVERLAY_KIND)
|
||||
.unwrap()
|
||||
.subscriptions(&instance)
|
||||
.unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Danmaku));
|
||||
assert!(subscriptions.contains(LiveEventKind::Like));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_component_kinds_are_rejected() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let result = registry.register(
|
||||
Arc::new(DanmakuOverlayDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
);
|
||||
assert!(matches!(result, Err(ComponentError::AlreadyRegistered(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_settings_versions_fail_closed() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let result = registry.validate_settings(
|
||||
DANMAKU_OVERLAY_KIND,
|
||||
99,
|
||||
serde_json::to_value(OverlaySettings::default()).unwrap(),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ComponentError::UnsupportedSettingsVersion { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use std::{env, fs, net::IpAddr, path::PathBuf};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{credentials::normalize_cookiecloud_host, overlay::OverlaySettings};
|
||||
|
||||
/// Process-level configuration. Tenant-owned room, CookieCloud and component
|
||||
/// settings are imported from the legacy sections once and then live in
|
||||
/// PostgreSQL; these values are not used as global runtime state afterwards.
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub bind_address: IpAddr,
|
||||
pub database_url: String,
|
||||
pub bootstrap_password: String,
|
||||
pub legacy_room_id: String,
|
||||
pub legacy_cookiecloud_host: String,
|
||||
pub legacy_cookiecloud_key: String,
|
||||
pub legacy_cookiecloud_password: String,
|
||||
pub cookiecloud_allowed_hosts: Vec<String>,
|
||||
pub legacy_obs_access_token: String,
|
||||
pub legacy_overlay_defaults: OverlaySettings,
|
||||
pub log_filter: String,
|
||||
pub gift_refresh_seconds: u64,
|
||||
pub gift_request_timeout_seconds: u64,
|
||||
pub emoticon_refresh_seconds: u64,
|
||||
pub emoticon_request_timeout_seconds: u64,
|
||||
pub data_encryption_key: [u8; 32],
|
||||
pub session_ttl_hours: i64,
|
||||
pub registration_ttl_minutes: i64,
|
||||
pub invitation_ttl_hours: i64,
|
||||
pub totp_issuer: String,
|
||||
pub secure_cookies: bool,
|
||||
pub derived_encryption_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileConfig {
|
||||
connection: ConnectionConfig,
|
||||
#[serde(default)]
|
||||
server: ServerConfig,
|
||||
database: DatabaseConfig,
|
||||
cookiecloud: CookieCloudConfig,
|
||||
admin: AdminConfig,
|
||||
obs: ObsConfig,
|
||||
#[serde(default)]
|
||||
security: SecurityConfig,
|
||||
#[serde(default)]
|
||||
gifts: GiftsConfig,
|
||||
#[serde(default)]
|
||||
emoticons: EmoticonsConfig,
|
||||
#[serde(default)]
|
||||
overlay: OverlayFileConfig,
|
||||
#[serde(default)]
|
||||
logging: LoggingConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConnectionConfig {
|
||||
room_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ServerConfig {
|
||||
port: Option<u16>,
|
||||
bind_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DatabaseConfig {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CookieCloudConfig {
|
||||
host: String,
|
||||
key: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AdminConfig {
|
||||
password: String,
|
||||
session_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ObsConfig {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SecurityConfig {
|
||||
data_encryption_key: Option<String>,
|
||||
session_ttl_hours: Option<i64>,
|
||||
registration_ttl_minutes: Option<i64>,
|
||||
invitation_ttl_hours: Option<i64>,
|
||||
totp_issuer: Option<String>,
|
||||
secure_cookies: Option<bool>,
|
||||
cookiecloud_allowed_hosts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct GiftsConfig {
|
||||
refresh_interval_seconds: Option<u64>,
|
||||
request_timeout_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct EmoticonsConfig {
|
||||
refresh_interval_seconds: Option<u64>,
|
||||
request_timeout_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OverlayFileConfig {
|
||||
font_scale: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
unfold_duration_ms: Option<u16>,
|
||||
motion_intensity: Option<u8>,
|
||||
particle_count: Option<u8>,
|
||||
particle_speed: Option<u16>,
|
||||
low_performance_mode: Option<bool>,
|
||||
high_value_threshold: Option<i64>,
|
||||
featured_value_threshold: Option<i64>,
|
||||
#[serde(default)]
|
||||
events: OverlayEventsConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OverlayEventsConfig {
|
||||
danmaku: Option<bool>,
|
||||
enter: Option<bool>,
|
||||
gift: Option<bool>,
|
||||
superchat: Option<bool>,
|
||||
guard: Option<bool>,
|
||||
like: Option<bool>,
|
||||
share: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LoggingConfig {
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let path = config_path()?;
|
||||
let source = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("Cannot read configuration {}: {error}", path.display()))?;
|
||||
let file: FileConfig = toml::from_str(&source)
|
||||
.map_err(|error| format!("Invalid TOML in {}: {error}", path.display()))?;
|
||||
|
||||
validate_non_empty("connection.room_id", &file.connection.room_id)?;
|
||||
validate_non_empty("database.url", &file.database.url)?;
|
||||
validate_non_empty("admin.password", &file.admin.password)?;
|
||||
validate_non_empty("admin.session_secret", &file.admin.session_secret)?;
|
||||
|
||||
let legacy_cookiecloud_host = normalize_cookiecloud_host(&file.cookiecloud.host)?;
|
||||
let cookiecloud_allowed_hosts = file
|
||||
.security
|
||||
.cookiecloud_allowed_hosts
|
||||
.unwrap_or_else(|| vec![legacy_cookiecloud_host.clone()])
|
||||
.into_iter()
|
||||
.map(|host| normalize_cookiecloud_host(&host))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if cookiecloud_allowed_hosts.is_empty() {
|
||||
return Err("security.cookiecloud_allowed_hosts must not be empty".into());
|
||||
}
|
||||
if !cookiecloud_allowed_hosts.contains(&legacy_cookiecloud_host) {
|
||||
return Err(
|
||||
"security.cookiecloud_allowed_hosts must include cookiecloud.host for legacy import"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let bind_address = file
|
||||
.server
|
||||
.bind_address
|
||||
.as_deref()
|
||||
.unwrap_or("127.0.0.1")
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| "server.bind_address must be an IPv4 or IPv6 address".to_string())?;
|
||||
let (data_encryption_key, derived_encryption_key) =
|
||||
match file.security.data_encryption_key.as_deref() {
|
||||
Some(value) if !value.trim().is_empty() => (decode_key(value)?, false),
|
||||
_ => (
|
||||
derive_key(
|
||||
&file.admin.session_secret,
|
||||
b"lxc-streamutils/data-encryption/v1",
|
||||
),
|
||||
true,
|
||||
),
|
||||
};
|
||||
Ok(Self {
|
||||
port: file.server.port.unwrap_or(9719),
|
||||
bind_address,
|
||||
database_url: file.database.url,
|
||||
bootstrap_password: file.admin.password,
|
||||
legacy_room_id: file.connection.room_id,
|
||||
legacy_cookiecloud_host,
|
||||
legacy_cookiecloud_key: file.cookiecloud.key,
|
||||
legacy_cookiecloud_password: file.cookiecloud.password,
|
||||
cookiecloud_allowed_hosts,
|
||||
legacy_obs_access_token: file.obs.access_token,
|
||||
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
||||
log_filter: file.logging.filter.unwrap_or_else(|| {
|
||||
"lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()
|
||||
}),
|
||||
gift_refresh_seconds: file
|
||||
.gifts
|
||||
.refresh_interval_seconds
|
||||
.unwrap_or(600)
|
||||
.clamp(60, 86_400),
|
||||
gift_request_timeout_seconds: file
|
||||
.gifts
|
||||
.request_timeout_seconds
|
||||
.unwrap_or(10)
|
||||
.clamp(2, 120),
|
||||
emoticon_refresh_seconds: file
|
||||
.emoticons
|
||||
.refresh_interval_seconds
|
||||
.unwrap_or(600)
|
||||
.clamp(60, 86_400),
|
||||
emoticon_request_timeout_seconds: file
|
||||
.emoticons
|
||||
.request_timeout_seconds
|
||||
.unwrap_or(10)
|
||||
.clamp(2, 120),
|
||||
data_encryption_key,
|
||||
session_ttl_hours: file.security.session_ttl_hours.unwrap_or(12).clamp(1, 720),
|
||||
registration_ttl_minutes: file
|
||||
.security
|
||||
.registration_ttl_minutes
|
||||
.unwrap_or(15)
|
||||
.clamp(5, 120),
|
||||
invitation_ttl_hours: file
|
||||
.security
|
||||
.invitation_ttl_hours
|
||||
.unwrap_or(72)
|
||||
.clamp(1, 8_760),
|
||||
totp_issuer: file
|
||||
.security
|
||||
.totp_issuer
|
||||
.unwrap_or_else(|| "danmaku.luoxingci.com".into()),
|
||||
secure_cookies: file.security.secure_cookies.unwrap_or(true),
|
||||
derived_encryption_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn allowed_cookiecloud_host(&self, value: &str) -> Result<String, String> {
|
||||
let normalized = normalize_cookiecloud_host(value)?;
|
||||
if self.cookiecloud_allowed_hosts.contains(&normalized) {
|
||||
Ok(normalized)
|
||||
} else {
|
||||
Err("CookieCloud host is not approved by this deployment".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_cookiecloud_host(&self) -> &str {
|
||||
self.cookiecloud_allowed_hosts
|
||||
.first()
|
||||
.expect("configuration requires at least one CookieCloud host")
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Result<PathBuf, String> {
|
||||
let mut args = env::args_os().skip(1);
|
||||
let mut path = PathBuf::from("config.toml");
|
||||
while let Some(argument) = args.next() {
|
||||
if argument == "--config" {
|
||||
path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unknown argument: {argument:?}; use --config <path>"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn validate_non_empty(name: &str, value: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() {
|
||||
Err(format!("{name} must not be empty"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_key(value: &str) -> Result<[u8; 32], String> {
|
||||
let value = value.trim();
|
||||
let bytes = STANDARD
|
||||
.decode(value)
|
||||
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value))
|
||||
.map_err(|_| "security.data_encryption_key must be base64-encoded".to_string())?;
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| "security.data_encryption_key must decode to exactly 32 bytes".to_string())
|
||||
}
|
||||
|
||||
fn derive_key(secret: &str, domain: &[u8]) -> [u8; 32] {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(domain);
|
||||
digest.update([0]);
|
||||
digest.update(secret.as_bytes());
|
||||
digest.finalize().into()
|
||||
}
|
||||
|
||||
fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
let default = OverlaySettings::default();
|
||||
OverlaySettings {
|
||||
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
||||
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
||||
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
||||
show_gift: file.events.gift.unwrap_or(default.show_gift),
|
||||
show_superchat: file.events.superchat.unwrap_or(default.show_superchat),
|
||||
show_guard: file.events.guard.unwrap_or(default.show_guard),
|
||||
show_like: file.events.like.unwrap_or(default.show_like),
|
||||
show_share: file.events.share.unwrap_or(default.show_share),
|
||||
max_visible: file.max_visible.unwrap_or(default.max_visible),
|
||||
collapse_after_seconds: file
|
||||
.collapse_after_seconds
|
||||
.unwrap_or(default.collapse_after_seconds),
|
||||
unfold_duration_ms: file
|
||||
.unfold_duration_ms
|
||||
.unwrap_or(default.unfold_duration_ms),
|
||||
motion_intensity: file.motion_intensity.unwrap_or(default.motion_intensity),
|
||||
particle_count: file.particle_count.unwrap_or(default.particle_count),
|
||||
particle_speed: file.particle_speed.unwrap_or(default.particle_speed),
|
||||
low_performance_mode: file
|
||||
.low_performance_mode
|
||||
.unwrap_or(default.low_performance_mode),
|
||||
high_value_threshold: file
|
||||
.high_value_threshold
|
||||
.unwrap_or(default.high_value_threshold),
|
||||
featured_value_threshold: file
|
||||
.featured_value_threshold
|
||||
.unwrap_or(default.featured_value_threshold),
|
||||
}
|
||||
.sanitize()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_exactly_32_byte_base64_key() {
|
||||
let value = STANDARD.encode([7_u8; 32]);
|
||||
assert_eq!(decode_key(&value).unwrap(), [7_u8; 32]);
|
||||
assert!(decode_key("too-short").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_separates_derived_keys() {
|
||||
assert_ne!(derive_key("secret", b"a"), derive_key("secret", b"b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use reqwest::{
|
||||
Url,
|
||||
header::{REFERER, USER_AGENT},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The secret portion is encrypted as one JSON document in PostgreSQL. The
|
||||
/// host remains queryable so status pages can show where a tenant connects
|
||||
/// without ever returning its key or password.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieCloudSecrets {
|
||||
pub key: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CookieCloudCredentials {
|
||||
pub host: String,
|
||||
pub secrets: CookieCloudSecrets,
|
||||
}
|
||||
|
||||
impl CookieCloudCredentials {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
normalize_cookiecloud_host(&self.host)?;
|
||||
if self.secrets.key.trim().is_empty() || self.secrets.password.is_empty() {
|
||||
return Err("CookieCloud key and password are required".into());
|
||||
}
|
||||
if self.secrets.key.len() > 256 || self.secrets.password.len() > 4_096 {
|
||||
return Err("CookieCloud credentials are too long".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize a deployment-approved CookieCloud base URL. Tenant input is
|
||||
/// matched against these exact canonical values before any network request.
|
||||
pub fn normalize_cookiecloud_host(value: &str) -> Result<String, String> {
|
||||
if value.len() > 2_048 {
|
||||
return Err("CookieCloud host is too long".into());
|
||||
}
|
||||
let mut url = Url::parse(value.trim()).map_err(|_| "CookieCloud host is not a valid URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err("CookieCloud host must be an absolute http:// or https:// URL".into());
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("CookieCloud host must not contain URL credentials".into());
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err("CookieCloud host must not contain a query or fragment".into());
|
||||
}
|
||||
let normalized_path = url.path().trim_end_matches('/').to_owned();
|
||||
url.set_path(&normalized_path);
|
||||
Ok(url.as_str().trim_end_matches('/').to_owned())
|
||||
}
|
||||
|
||||
pub fn cookiecloud_endpoint(credentials: &CookieCloudCredentials) -> Result<Url, String> {
|
||||
credentials.validate()?;
|
||||
let host = normalize_cookiecloud_host(&credentials.host)?;
|
||||
let mut endpoint = Url::parse(&host).map_err(|_| "CookieCloud host is not a valid URL")?;
|
||||
endpoint
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "CookieCloud host cannot be used as a base URL")?
|
||||
.pop_if_empty()
|
||||
.push("get")
|
||||
.push(credentials.secrets.key.trim());
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// Resolve only Bilibili cookies from a CookieCloud sync bucket. The returned
|
||||
/// value is kept in the listener task and is never included in status/errors.
|
||||
pub async fn fetch_bilibili_cookie(
|
||||
client: &reqwest::Client,
|
||||
credentials: &CookieCloudCredentials,
|
||||
) -> Result<String, String> {
|
||||
let endpoint = cookiecloud_endpoint(credentials)?;
|
||||
let response = client
|
||||
.post(endpoint)
|
||||
.form(&[("password", credentials.secrets.password.as_str())])
|
||||
.header(REFERER, "https://live.bilibili.com/")
|
||||
.header(USER_AGENT, "Mozilla/5.0 lxc-streamutils/2.0")
|
||||
.send()
|
||||
.await
|
||||
// reqwest errors can include the full URL, whose path contains the
|
||||
// CookieCloud key. Keep that bearer-like value out of logs/responses.
|
||||
.map_err(|_| "CookieCloud network request failed".to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("CookieCloud returned HTTP {}", response.status()));
|
||||
}
|
||||
let value: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "CookieCloud response was not valid JSON".to_string())?;
|
||||
cookie_header(&value)
|
||||
}
|
||||
|
||||
fn cookie_header(value: &Value) -> Result<String, String> {
|
||||
let mut cookies = Vec::new();
|
||||
if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) {
|
||||
for (domain, stored) in domains {
|
||||
if !domain.contains("bilibili.com") {
|
||||
continue;
|
||||
}
|
||||
let entries: Vec<&Value> = if let Some(array) = stored.as_array() {
|
||||
array.iter().collect()
|
||||
} else {
|
||||
stored
|
||||
.as_object()
|
||||
.map(|values| values.values().collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for item in entries {
|
||||
let Some(name) = item.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(value) = item.get("value").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
// Cookie names cannot contain these delimiters. Ignore malformed
|
||||
// upstream entries instead of allowing header injection.
|
||||
if name.is_empty()
|
||||
|| name.contains([';', '=', '\r', '\n'])
|
||||
|| value.contains([';', '\r', '\n'])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
cookies.push(format!("{name}={value}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cookies.iter().any(|cookie| cookie.starts_with("SESSDATA=")) {
|
||||
Ok(cookies.join("; "))
|
||||
} else {
|
||||
Err("CookieCloud does not contain a Bilibili SESSDATA cookie".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_only_bilibili_cookie_data() {
|
||||
let value = json!({"cookie_data": {
|
||||
".bilibili.com": [
|
||||
{"name":"SESSDATA","value":"session"},
|
||||
{"name":"bili_jct","value":"csrf"}
|
||||
],
|
||||
"example.com": [{"name":"secret","value":"must-not-leak"}]
|
||||
}});
|
||||
let result = cookie_header(&value).unwrap();
|
||||
assert!(result.contains("SESSDATA=session"));
|
||||
assert!(result.contains("bili_jct=csrf"));
|
||||
assert!(!result.contains("must-not-leak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_authenticated_cookie() {
|
||||
let value = json!({"cookie_data": {"bilibili.com": [
|
||||
{"name":"buvid3","value":"anonymous"}
|
||||
]}});
|
||||
assert!(cookie_header(&value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookiecloud_key_is_encoded_as_one_path_segment() {
|
||||
let credentials = CookieCloudCredentials {
|
||||
host: "https://cookies.example.test/base/".into(),
|
||||
secrets: CookieCloudSecrets {
|
||||
key: "bucket/../../admin?x=1".into(),
|
||||
password: "secret".into(),
|
||||
},
|
||||
};
|
||||
let endpoint = cookiecloud_endpoint(&credentials).unwrap();
|
||||
assert_eq!(
|
||||
endpoint.as_str(),
|
||||
"https://cookies.example.test/base/get/bucket%2F..%2F..%2Fadmin%3Fx=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_host_rejects_embedded_credentials_and_queries() {
|
||||
assert!(normalize_cookiecloud_host("https://user:pass@example.test").is_err());
|
||||
assert!(normalize_cookiecloud_host("https://example.test/?next=internal").is_err());
|
||||
assert_eq!(
|
||||
normalize_cookiecloud_host("https://example.test/base/").unwrap(),
|
||||
"https://example.test/base"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
|
||||
use serde_json::Value;
|
||||
use tokio_postgres::{Config as PostgresConfig, NoTls, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Cloneable database handle intended to live directly in Axum's `AppState`.
|
||||
///
|
||||
/// HTTP handlers should acquire a pooled connection through [`Db::get`]. Any
|
||||
/// query against an RLS-protected tenant table must run in a transaction after
|
||||
/// calling [`Db::set_tenant`]. `SET LOCAL` is important: a session-level setting
|
||||
/// could otherwise leak an identity when the connection returns to the pool.
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pool: Pool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn from_pool(pool: Pool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &Pool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
pub fn connect(database_url: &str, max_size: usize) -> Result<Self, DbError> {
|
||||
let config = PostgresConfig::from_str(database_url)
|
||||
.map_err(|error| DbError::Configuration(error.to_string()))?;
|
||||
let manager = Manager::from_config(
|
||||
config,
|
||||
NoTls,
|
||||
ManagerConfig {
|
||||
recycling_method: RecyclingMethod::Fast,
|
||||
},
|
||||
);
|
||||
let pool = Pool::builder(manager)
|
||||
.runtime(Runtime::Tokio1)
|
||||
.max_size(max_size.max(1))
|
||||
.build()
|
||||
.map_err(|error| DbError::Configuration(error.to_string()))?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Result<Object, DbError> {
|
||||
self.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|error| DbError::Pool(error.to_string()))
|
||||
}
|
||||
|
||||
/// Applies the additive multi-tenant migration. The legacy migrations must
|
||||
/// already have run because this migration deliberately links their tables.
|
||||
pub async fn migrate_multitenancy(&self) -> Result<(), DbError> {
|
||||
let client = self.get().await?;
|
||||
client
|
||||
.batch_execute(include_str!("../migrations/003_multitenancy.sql"))
|
||||
.await
|
||||
.map_err(DbError::Postgres)
|
||||
}
|
||||
|
||||
pub async fn set_tenant(transaction: &Transaction<'_>, user_id: Uuid) -> Result<(), DbError> {
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT set_config('app.user_id', $1, true)",
|
||||
&[&user_id.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(DbError::Postgres)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal, non-secret account inventory for the source supervisor at
|
||||
/// startup. Component rows are deliberately loaded in a second,
|
||||
/// tenant-scoped query so RLS remains effective.
|
||||
pub async fn list_active_tenants(&self) -> Result<Vec<ActiveTenant>, DbError> {
|
||||
let client = self.get().await?;
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT owner_user_id,room_id,source_id FROM list_active_live_sources()",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| ActiveTenant {
|
||||
user_id: row.get(0),
|
||||
room_id: row.get(1),
|
||||
source_id: row.get(2),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_tenant_components(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<ComponentRecord>, DbError> {
|
||||
let mut client = self.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Self::set_tenant(&transaction, user_id).await?;
|
||||
let rows = transaction
|
||||
.query(
|
||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
||||
FROM component_instances WHERE owner_user_id=$1 ORDER BY created_at",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| ComponentRecord {
|
||||
id: row.get(0),
|
||||
owner_user_id: user_id,
|
||||
source_id: row.get(1),
|
||||
kind: row.get(2),
|
||||
name: row.get(3),
|
||||
settings: row.get(4),
|
||||
settings_version: row.get(5),
|
||||
enabled: row.get(6),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActiveTenant {
|
||||
pub user_id: Uuid,
|
||||
pub room_id: String,
|
||||
pub source_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ComponentRecord {
|
||||
pub id: Uuid,
|
||||
pub owner_user_id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub settings: Value,
|
||||
pub settings_version: i32,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DbError {
|
||||
Configuration(String),
|
||||
Pool(String),
|
||||
Postgres(tokio_postgres::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for DbError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Configuration(message) => write!(formatter, "database configuration: {message}"),
|
||||
Self::Pool(message) => write!(formatter, "database pool: {message}"),
|
||||
Self::Postgres(error) => write!(formatter, "database query: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DbError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Postgres(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio_postgres::Error> for DbError {
|
||||
fn from(error: tokio_postgres::Error) -> Self {
|
||||
Self::Postgres(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Version of the component-facing WebSocket envelope.
|
||||
pub const COMPONENT_PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
/// Stable event categories used by component subscriptions.
|
||||
///
|
||||
/// Keep these independent from Bilibili command names: another live provider can
|
||||
/// normalize its payloads into the same domain events later.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LiveEventKind {
|
||||
Danmaku,
|
||||
Enter,
|
||||
Gift,
|
||||
GiftCombo,
|
||||
SuperChat,
|
||||
GuardPurchase,
|
||||
Like,
|
||||
Share,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LiveEventKind {
|
||||
pub const fn wire_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Danmaku => "live.danmaku",
|
||||
Self::Enter => "live.enter",
|
||||
Self::Gift => "live.gift",
|
||||
Self::GiftCombo => "live.gift.combo",
|
||||
Self::SuperChat => "live.superchat",
|
||||
Self::GuardPurchase => "live.guard.buy",
|
||||
Self::Like => "live.like",
|
||||
Self::Share => "live.share",
|
||||
Self::Unknown => "live.unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformViewer {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "lowercase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum DanmakuSegment {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Emoticon {
|
||||
text: String,
|
||||
unique: Option<String>,
|
||||
url: String,
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
is_dynamic: bool,
|
||||
standalone: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DanmakuEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub segments: Vec<DanmakuSegment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnterEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GiftDetails {
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
pub coin_type: String,
|
||||
pub unit_price: i64,
|
||||
pub total_price: i64,
|
||||
pub price_cny: f64,
|
||||
pub image_url: Option<String>,
|
||||
pub animation_url: Option<String>,
|
||||
pub effect_type: Option<String>,
|
||||
pub stay_time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GiftEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
pub gift: GiftDetails,
|
||||
pub quantity: i32,
|
||||
pub source_event_id: String,
|
||||
}
|
||||
|
||||
/// A visual combo update. Durable business handlers should normally consume
|
||||
/// `Gift`, not both `Gift` and `GiftCombo`, to avoid counting the same gift twice.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GiftComboEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
pub gift: GiftDetails,
|
||||
pub quantity: i32,
|
||||
pub combo_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SuperChatEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
pub message: String,
|
||||
pub price: i64,
|
||||
pub source_event_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GuardPurchaseEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
pub guard_name: String,
|
||||
pub quantity: i32,
|
||||
pub price: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewerInteractionEvent {
|
||||
pub viewer: PlatformViewer,
|
||||
}
|
||||
|
||||
/// Escape hatch for provider commands that have not been normalized yet.
|
||||
/// Providers should put only bounded, sanitized metadata here, never credentials
|
||||
/// or an unbounded raw packet.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnknownLiveEvent {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||
pub enum LiveEventPayload {
|
||||
Danmaku(DanmakuEvent),
|
||||
Enter(EnterEvent),
|
||||
Gift(GiftEvent),
|
||||
GiftCombo(GiftComboEvent),
|
||||
SuperChat(SuperChatEvent),
|
||||
GuardPurchase(GuardPurchaseEvent),
|
||||
Like(ViewerInteractionEvent),
|
||||
Share(ViewerInteractionEvent),
|
||||
Unknown(UnknownLiveEvent),
|
||||
}
|
||||
|
||||
impl LiveEventPayload {
|
||||
pub const fn kind(&self) -> LiveEventKind {
|
||||
match self {
|
||||
Self::Danmaku(_) => LiveEventKind::Danmaku,
|
||||
Self::Enter(_) => LiveEventKind::Enter,
|
||||
Self::Gift(_) => LiveEventKind::Gift,
|
||||
Self::GiftCombo(_) => LiveEventKind::GiftCombo,
|
||||
Self::SuperChat(_) => LiveEventKind::SuperChat,
|
||||
Self::GuardPurchase(_) => LiveEventKind::GuardPurchase,
|
||||
Self::Like(_) => LiveEventKind::Like,
|
||||
Self::Share(_) => LiveEventKind::Share,
|
||||
Self::Unknown(_) => LiveEventKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize only the variant body. The discriminant is carried by the
|
||||
/// envelope's `type` field for compatibility with the browser SDK.
|
||||
pub fn to_wire_payload(&self) -> Result<Value, serde_json::Error> {
|
||||
match self {
|
||||
Self::Danmaku(value) => serde_json::to_value(value),
|
||||
Self::Enter(value) => serde_json::to_value(value),
|
||||
Self::Gift(value) => serde_json::to_value(value),
|
||||
Self::GiftCombo(value) => serde_json::to_value(value),
|
||||
Self::SuperChat(value) => serde_json::to_value(value),
|
||||
Self::GuardPurchase(value) => serde_json::to_value(value),
|
||||
Self::Like(value) => serde_json::to_value(value),
|
||||
Self::Share(value) => serde_json::to_value(value),
|
||||
Self::Unknown(value) => serde_json::to_value(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical provider-independent event.
|
||||
///
|
||||
/// `owner_id` is an internal routing boundary and is deliberately omitted when
|
||||
/// serializing. It must always be supplied by trusted source configuration, not
|
||||
/// by provider input or an HTTP request body.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveEvent {
|
||||
#[serde(skip_serializing)]
|
||||
pub owner_id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub provider: String,
|
||||
pub room_id: String,
|
||||
pub id: Uuid,
|
||||
pub occurred_at_ms: i64,
|
||||
pub received_at_ms: i64,
|
||||
#[serde(default)]
|
||||
pub simulated: bool,
|
||||
pub payload: LiveEventPayload,
|
||||
}
|
||||
|
||||
impl LiveEvent {
|
||||
pub fn new(
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
provider: impl Into<String>,
|
||||
room_id: impl Into<String>,
|
||||
payload: LiveEventPayload,
|
||||
) -> Self {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
Self {
|
||||
owner_id,
|
||||
source_id,
|
||||
provider: provider.into(),
|
||||
room_id: room_id.into(),
|
||||
id: Uuid::new_v4(),
|
||||
occurred_at_ms: now,
|
||||
received_at_ms: now,
|
||||
simulated: false,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn kind(&self) -> LiveEventKind {
|
||||
self.payload.kind()
|
||||
}
|
||||
|
||||
pub const fn wire_type(&self) -> &'static str {
|
||||
self.kind().wire_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Component-scoped wire event produced by a passive projection.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentMessage {
|
||||
#[serde(skip_serializing)]
|
||||
pub owner_id: Uuid,
|
||||
pub component_id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub version: u16,
|
||||
pub id: Uuid,
|
||||
/// RFC3339 timestamp retained for compatibility with the existing browser
|
||||
/// protocol; canonical events use milliseconds internally.
|
||||
pub occurred_at: String,
|
||||
pub room_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
impl ComponentMessage {
|
||||
pub fn from_live_event(
|
||||
component_id: Uuid,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Self, serde_json::Error> {
|
||||
Ok(Self {
|
||||
owner_id: event.owner_id,
|
||||
component_id,
|
||||
source_id: event.source_id,
|
||||
version: COMPONENT_PROTOCOL_VERSION,
|
||||
id: event.id,
|
||||
occurred_at: chrono::DateTime::<chrono::Utc>::from_timestamp_millis(
|
||||
event.occurred_at_ms,
|
||||
)
|
||||
.map(|timestamp| timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
|
||||
.unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_owned()),
|
||||
room_id: event.room_id.clone(),
|
||||
event_type: event.wire_type().to_owned(),
|
||||
payload: event.payload.to_wire_payload()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn viewer() -> PlatformViewer {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_event_projects_to_legacy_compatible_wire_shape() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
let source_id = Uuid::new_v4();
|
||||
let component_id = Uuid::new_v4();
|
||||
let event = LiveEvent::new(
|
||||
owner_id,
|
||||
source_id,
|
||||
"bilibili",
|
||||
"123",
|
||||
LiveEventPayload::Danmaku(DanmakuEvent {
|
||||
viewer: viewer(),
|
||||
text: "晚上好".into(),
|
||||
segments: vec![DanmakuSegment::Text {
|
||||
text: "晚上好".into(),
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
let projected = ComponentMessage::from_live_event(component_id, &event).unwrap();
|
||||
assert_eq!(projected.event_type, "live.danmaku");
|
||||
assert_eq!(projected.payload["viewer"]["uid"], "42");
|
||||
assert_eq!(projected.payload["text"], "晚上好");
|
||||
assert_eq!(projected.component_id, component_id);
|
||||
|
||||
let serialized = serde_json::to_value(projected).unwrap();
|
||||
assert!(serialized.get("ownerId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gift_and_combo_have_distinct_subscription_kinds() {
|
||||
assert_eq!(LiveEventKind::Gift.wire_name(), "live.gift");
|
||||
assert_eq!(LiveEventKind::GiftCombo.wire_name(), "live.gift.combo");
|
||||
assert_ne!(LiveEventKind::Gift, LiveEventKind::GiftCombo);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
//! Reusable application core for the multi-tenant livestream component host.
|
||||
//!
|
||||
//! The binary is intentionally only a configuration/bootstrap shell. Providers,
|
||||
//! authentication, tenant repositories, typed events and component runtimes are
|
||||
//! exported here so future gift, song-request and overlay components can be
|
||||
//! developed and tested without growing `main.rs` back into a monolith.
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod components;
|
||||
pub mod config;
|
||||
pub mod credentials;
|
||||
pub mod db;
|
||||
pub mod domain;
|
||||
pub mod http_api;
|
||||
pub mod live;
|
||||
pub mod overlay;
|
||||
pub mod rate_limit;
|
||||
pub mod realtime;
|
||||
pub mod repository;
|
||||
@@ -0,0 +1,756 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient};
|
||||
use futures_channel::mpsc as futures_mpsc;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::{
|
||||
DanmakuEvent, DanmakuSegment, EnterEvent, GiftComboEvent, GiftDetails, GiftEvent,
|
||||
GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer, SuperChatEvent,
|
||||
UnknownLiveEvent, ViewerInteractionEvent,
|
||||
},
|
||||
live::{LiveProvider, SourceContext, SourceStatus},
|
||||
overlay::{EmoticonCatalog, EmoticonMeta, GiftCatalog, normalize_image_url},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BilibiliProvider {
|
||||
cookie: Arc<str>,
|
||||
gift_catalog: GiftCatalog,
|
||||
emoticon_catalog: EmoticonCatalog,
|
||||
gift_refresh_seconds: u64,
|
||||
gift_timeout_seconds: u64,
|
||||
emoticon_refresh_seconds: u64,
|
||||
emoticon_timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl BilibiliProvider {
|
||||
pub fn new(
|
||||
cookie: String,
|
||||
gift_refresh_seconds: u64,
|
||||
gift_timeout_seconds: u64,
|
||||
emoticon_refresh_seconds: u64,
|
||||
emoticon_timeout_seconds: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
cookie: cookie.into(),
|
||||
gift_catalog: GiftCatalog::default(),
|
||||
emoticon_catalog: EmoticonCatalog::default(),
|
||||
gift_refresh_seconds,
|
||||
gift_timeout_seconds,
|
||||
emoticon_refresh_seconds,
|
||||
emoticon_timeout_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn gift_catalog_size(&self) -> usize {
|
||||
self.gift_catalog.len().await
|
||||
}
|
||||
|
||||
pub async fn emoticon_catalog_size(&self) -> usize {
|
||||
self.emoticon_catalog.len().await
|
||||
}
|
||||
|
||||
async fn initial_catalogs(&self, room_id: &str) {
|
||||
if let Err(error) = self
|
||||
.gift_catalog
|
||||
.refresh(room_id, self.gift_timeout_seconds)
|
||||
.await
|
||||
{
|
||||
warn!(%error, %room_id, "initial gift catalog refresh failed");
|
||||
}
|
||||
if let Err(error) = self
|
||||
.emoticon_catalog
|
||||
.refresh(room_id, &self.cookie, self.emoticon_timeout_seconds)
|
||||
.await
|
||||
{
|
||||
warn!(%error, %room_id, "initial emoticon catalog refresh failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_catalog_refreshes(&self, room_id: String, cancel: CancellationToken) {
|
||||
let gift = self.gift_catalog.clone();
|
||||
let gift_interval = self.gift_refresh_seconds;
|
||||
let gift_timeout = self.gift_timeout_seconds;
|
||||
let gift_room = room_id.clone();
|
||||
let gift_cancel = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = gift_cancel.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(gift_interval)) => {
|
||||
match gift.refresh(&gift_room, gift_timeout).await {
|
||||
Ok(count) => info!(count, room_id = %gift_room, "gift catalog refreshed"),
|
||||
Err(error) => warn!(%error, room_id = %gift_room, "gift catalog refresh failed; retaining cache"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let emoticons = self.emoticon_catalog.clone();
|
||||
let cookie = self.cookie.clone();
|
||||
let interval = self.emoticon_refresh_seconds;
|
||||
let timeout = self.emoticon_timeout_seconds;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval)) => {
|
||||
match emoticons.refresh(&room_id, &cookie, timeout).await {
|
||||
Ok(count) => info!(count, room_id = %room_id, "emoticon catalog refreshed"),
|
||||
Err(error) => warn!(%error, room_id = %room_id, "emoticon catalog refresh failed; retaining cache"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn enrich(&self, context: &SourceContext, raw: ProviderEvent) -> LiveEvent {
|
||||
let payload = match raw {
|
||||
ProviderEvent::Enter { viewer } => LiveEventPayload::Enter(EnterEvent { viewer }),
|
||||
ProviderEvent::Danmaku {
|
||||
viewer,
|
||||
text,
|
||||
emoticons,
|
||||
} => {
|
||||
let segments = danmaku_segments(&self.emoticon_catalog, &text, emoticons).await;
|
||||
LiveEventPayload::Danmaku(DanmakuEvent {
|
||||
viewer,
|
||||
text,
|
||||
segments,
|
||||
})
|
||||
}
|
||||
ProviderEvent::Gift {
|
||||
viewer,
|
||||
name,
|
||||
gift_id,
|
||||
battery,
|
||||
quantity,
|
||||
event_id,
|
||||
} => LiveEventPayload::Gift(GiftEvent {
|
||||
viewer,
|
||||
gift: gift_details(&self.gift_catalog, name, gift_id, battery, quantity).await,
|
||||
quantity: quantity.max(1),
|
||||
source_event_id: event_id,
|
||||
}),
|
||||
ProviderEvent::GiftCombo {
|
||||
viewer,
|
||||
name,
|
||||
gift_id,
|
||||
battery,
|
||||
quantity,
|
||||
combo_id,
|
||||
} => LiveEventPayload::GiftCombo(GiftComboEvent {
|
||||
viewer,
|
||||
gift: gift_details(&self.gift_catalog, name, gift_id, battery, quantity).await,
|
||||
quantity: quantity.max(1),
|
||||
combo_id,
|
||||
}),
|
||||
ProviderEvent::SuperChat {
|
||||
viewer,
|
||||
message,
|
||||
price,
|
||||
event_id,
|
||||
} => LiveEventPayload::SuperChat(SuperChatEvent {
|
||||
viewer,
|
||||
message,
|
||||
price,
|
||||
source_event_id: event_id,
|
||||
}),
|
||||
ProviderEvent::Guard {
|
||||
viewer,
|
||||
name,
|
||||
quantity,
|
||||
price,
|
||||
} => LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
|
||||
viewer,
|
||||
guard_name: name,
|
||||
quantity,
|
||||
price,
|
||||
}),
|
||||
ProviderEvent::Like { viewer } => {
|
||||
LiveEventPayload::Like(ViewerInteractionEvent { viewer })
|
||||
}
|
||||
ProviderEvent::Share { viewer } => {
|
||||
LiveEventPayload::Share(ViewerInteractionEvent { viewer })
|
||||
}
|
||||
ProviderEvent::Unknown { command } => LiveEventPayload::Unknown(UnknownLiveEvent {
|
||||
command,
|
||||
metadata: json!({}),
|
||||
}),
|
||||
};
|
||||
LiveEvent::new(
|
||||
context.owner_id,
|
||||
context.source_id,
|
||||
self.provider_name(),
|
||||
context.room_id.clone(),
|
||||
payload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LiveProvider for BilibiliProvider {
|
||||
fn provider_name(&self) -> &'static str {
|
||||
"bilibili"
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Arc<Self>,
|
||||
context: SourceContext,
|
||||
events: mpsc::Sender<Arc<LiveEvent>>,
|
||||
status: watch::Sender<SourceStatus>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
self.initial_catalogs(&context.room_id).await;
|
||||
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
|
||||
|
||||
let (raw_sender, mut raw_receiver) = mpsc::channel::<ProviderEvent>(256);
|
||||
let cookie = self.cookie.to_string();
|
||||
let room_id = context.room_id.clone();
|
||||
let listener_cancel = cancel.clone();
|
||||
let listener_status = status.clone();
|
||||
let listener = tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
let (upstream_sender, mut upstream_receiver) = futures_mpsc::channel(256);
|
||||
let mut client = BiliLiveClient::new_auto(Some(&cookie), &room_id, upstream_sender)?;
|
||||
client.set_read_timeout(Some(Duration::from_secs(1)))?;
|
||||
client.send_auth();
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: true,
|
||||
cookie_cloud: true,
|
||||
detail: "Connected with authenticated blivedm_rs listener".into(),
|
||||
});
|
||||
while !listener_cancel.is_cancelled() {
|
||||
if let Err(error) = client.receive() {
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: format!("Bilibili connection error: {error}"),
|
||||
});
|
||||
}
|
||||
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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
client.close();
|
||||
Ok(())
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
value = raw_receiver.recv() => {
|
||||
let Some(value) = value else { break };
|
||||
let event = Arc::new(self.enrich(&context, value).await);
|
||||
if events.send(event).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel.cancel();
|
||||
listener
|
||||
.await
|
||||
.map_err(|error| format!("listener task failed: {error}"))??;
|
||||
let _ = status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: context.room_id,
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: "Listener stopped".into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EmoticonHint {
|
||||
text: String,
|
||||
unique: Option<String>,
|
||||
url: Option<String>,
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
is_dynamic: bool,
|
||||
bulge_display: bool,
|
||||
standalone: bool,
|
||||
}
|
||||
|
||||
enum ProviderEvent {
|
||||
Enter {
|
||||
viewer: PlatformViewer,
|
||||
},
|
||||
Danmaku {
|
||||
viewer: PlatformViewer,
|
||||
text: String,
|
||||
emoticons: Vec<EmoticonHint>,
|
||||
},
|
||||
Gift {
|
||||
viewer: PlatformViewer,
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
event_id: String,
|
||||
},
|
||||
GiftCombo {
|
||||
viewer: PlatformViewer,
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
combo_id: String,
|
||||
},
|
||||
SuperChat {
|
||||
viewer: PlatformViewer,
|
||||
message: String,
|
||||
price: i64,
|
||||
event_id: String,
|
||||
},
|
||||
Guard {
|
||||
viewer: PlatformViewer,
|
||||
name: String,
|
||||
quantity: i32,
|
||||
price: i64,
|
||||
},
|
||||
Like {
|
||||
viewer: PlatformViewer,
|
||||
},
|
||||
Share {
|
||||
viewer: PlatformViewer,
|
||||
},
|
||||
Unknown {
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
let raw = match message {
|
||||
BiliMessage::Raw(value) => value,
|
||||
_ => return None,
|
||||
};
|
||||
let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
|
||||
let data = raw.get("data").unwrap_or(&raw);
|
||||
let viewer = |uid: &Value, name: &Value| {
|
||||
Some(PlatformViewer {
|
||||
uid: uid
|
||||
.as_i64()
|
||||
.map(|id| id.to_string())
|
||||
.or_else(|| uid.as_str().map(str::to_owned))?,
|
||||
name: name.as_str()?.to_owned(),
|
||||
})
|
||||
};
|
||||
let data_viewer = |value: &Value| {
|
||||
viewer(
|
||||
value.get("uid")?,
|
||||
value
|
||||
.get("uname")
|
||||
.or_else(|| value.pointer("/sender_uinfo/base/name"))
|
||||
.or_else(|| value.pointer("/user_info/uname"))?,
|
||||
)
|
||||
};
|
||||
match command.as_str() {
|
||||
"DANMU_MSG" => {
|
||||
let info = raw.get("info")?.as_array()?;
|
||||
let text = info.get(1)?.as_str()?.to_owned();
|
||||
Some(ProviderEvent::Danmaku {
|
||||
viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?,
|
||||
emoticons: parse_danmaku_emoticons(info, &text),
|
||||
text,
|
||||
})
|
||||
}
|
||||
"SEND_GIFT" => Some(ProviderEvent::Gift {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
.get("giftName")
|
||||
.or_else(|| data.get("gift_name"))?
|
||||
.as_str()?
|
||||
.to_owned(),
|
||||
gift_id: data
|
||||
.get("giftId")
|
||||
.or_else(|| data.get("gift_id"))
|
||||
.and_then(Value::as_i64),
|
||||
battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
|
||||
quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32,
|
||||
event_id: data
|
||||
.get("tid")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}-{}",
|
||||
data.get("uid").unwrap_or(&Value::Null),
|
||||
data.get("timestamp").unwrap_or(&Value::Null)
|
||||
)
|
||||
}),
|
||||
}),
|
||||
"COMBO_SEND" => Some(ProviderEvent::GiftCombo {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
.get("gift_name")
|
||||
.or_else(|| data.get("giftName"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("礼物")
|
||||
.to_owned(),
|
||||
gift_id: data
|
||||
.get("gift_id")
|
||||
.or_else(|| data.get("giftId"))
|
||||
.and_then(Value::as_i64),
|
||||
battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
|
||||
quantity: data
|
||||
.get("combo_num")
|
||||
.or_else(|| data.get("total_num"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1) as i32,
|
||||
combo_id: data
|
||||
.get("combo_id")
|
||||
.map(Value::to_string)
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
"INTERACT_WORD" => Some(ProviderEvent::Enter {
|
||||
viewer: data_viewer(data)?,
|
||||
}),
|
||||
"GUARD_BUY" => Some(ProviderEvent::Guard {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
.get("gift_name")
|
||||
.or_else(|| data.get("giftName"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("舰长")
|
||||
.to_owned(),
|
||||
quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32,
|
||||
price: data.get("price").and_then(Value::as_i64).unwrap_or(0),
|
||||
}),
|
||||
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
|
||||
viewer: data_viewer(data)?,
|
||||
message: data
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
price: data.get("price").and_then(Value::as_i64).unwrap_or(0),
|
||||
event_id: data
|
||||
.get("id")
|
||||
.map(Value::to_string)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
}),
|
||||
"LIKE_INFO_V3_CLICK" => Some(ProviderEvent::Like {
|
||||
viewer: data_viewer(data)?,
|
||||
}),
|
||||
"SHARE" => Some(ProviderEvent::Share {
|
||||
viewer: data_viewer(data)?,
|
||||
}),
|
||||
_ => Some(ProviderEvent::Unknown { command }),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_object(value: &Value) -> Option<Value> {
|
||||
match value {
|
||||
Value::Object(_) => Some(value.clone()),
|
||||
Value::String(value) => serde_json::from_str::<Value>(value)
|
||||
.ok()
|
||||
.filter(Value::is_object),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_as_u32(value: Option<&Value>) -> Option<u32> {
|
||||
value.and_then(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
.or_else(|| value.as_str()?.parse().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn value_is_truthy(value: Option<&Value>) -> bool {
|
||||
value.is_some_and(|value| {
|
||||
value.as_bool().unwrap_or(false)
|
||||
|| value.as_i64().is_some_and(|number| number != 0)
|
||||
|| value.as_str().is_some_and(|text| text == "1")
|
||||
})
|
||||
}
|
||||
|
||||
fn emoticon_hint(value: &Value, text: &str, standalone: bool) -> Option<EmoticonHint> {
|
||||
let value = json_object(value)?;
|
||||
let unique = value
|
||||
.get("emoticon_unique")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let url = value
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_image_url);
|
||||
if unique.is_none() && url.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(EmoticonHint {
|
||||
text: text.to_owned(),
|
||||
unique,
|
||||
url,
|
||||
width: value_as_u32(value.get("width")),
|
||||
height: value_as_u32(value.get("height")),
|
||||
is_dynamic: value_is_truthy(value.get("is_dynamic")),
|
||||
bulge_display: value_is_truthy(value.get("bulge_display")),
|
||||
standalone,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
|
||||
let Some(header) = info.first().and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut hints = Vec::new();
|
||||
if let Some(direct) = header
|
||||
.get(13)
|
||||
.and_then(|value| emoticon_hint(value, text, true))
|
||||
.or_else(|| {
|
||||
header.iter().find_map(|value| {
|
||||
let object = json_object(value)?;
|
||||
object.get("url")?;
|
||||
emoticon_hint(&object, text, true)
|
||||
})
|
||||
})
|
||||
{
|
||||
hints.push(direct);
|
||||
}
|
||||
let extra = header.iter().find_map(|value| {
|
||||
let object = json_object(value)?;
|
||||
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(unique) = extra
|
||||
.get("emoticon_unique")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if !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
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ResolvedEmoticon {
|
||||
text: String,
|
||||
unique: Option<String>,
|
||||
url: String,
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
is_dynamic: bool,
|
||||
standalone: bool,
|
||||
}
|
||||
|
||||
fn emoticon_segment(emoticon: &ResolvedEmoticon) -> DanmakuSegment {
|
||||
DanmakuSegment::Emoticon {
|
||||
text: emoticon.text.clone(),
|
||||
unique: emoticon.unique.clone(),
|
||||
url: emoticon.url.clone(),
|
||||
width: emoticon.width,
|
||||
height: emoticon.height,
|
||||
is_dynamic: emoticon.is_dynamic,
|
||||
standalone: emoticon.standalone,
|
||||
}
|
||||
}
|
||||
|
||||
async fn danmaku_segments(
|
||||
catalog: &EmoticonCatalog,
|
||||
text: &str,
|
||||
hints: Vec<EmoticonHint>,
|
||||
) -> Vec<DanmakuSegment> {
|
||||
let mut resolved = Vec::<ResolvedEmoticon>::new();
|
||||
for hint in hints {
|
||||
let fallback: Option<EmoticonMeta> = catalog.get(hint.unique.as_deref(), &hint.text).await;
|
||||
let Some(url) = hint
|
||||
.url
|
||||
.clone()
|
||||
.or_else(|| fallback.as_ref().map(|value| value.url.clone()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let standalone = hint.standalone
|
||||
|| (hint.bulge_display && hint.text == text)
|
||||
|| fallback
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.bulge_display && hint.text == text);
|
||||
let value = ResolvedEmoticon {
|
||||
text: if hint.text.is_empty() {
|
||||
fallback
|
||||
.as_ref()
|
||||
.map(|value| value.emoji.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
hint.text
|
||||
},
|
||||
unique: hint
|
||||
.unique
|
||||
.or_else(|| fallback.as_ref().and_then(|value| value.unique.clone())),
|
||||
url,
|
||||
width: hint
|
||||
.width
|
||||
.or_else(|| fallback.as_ref().and_then(|value| value.width)),
|
||||
height: hint
|
||||
.height
|
||||
.or_else(|| fallback.as_ref().and_then(|value| value.height)),
|
||||
is_dynamic: hint.is_dynamic || fallback.as_ref().is_some_and(|value| value.is_dynamic),
|
||||
standalone,
|
||||
};
|
||||
if !resolved
|
||||
.iter()
|
||||
.any(|existing| existing.text == value.text && existing.standalone == value.standalone)
|
||||
{
|
||||
resolved.push(value);
|
||||
}
|
||||
}
|
||||
if let Some(emoticon) = resolved.iter().find(|value| value.standalone) {
|
||||
return vec![emoticon_segment(emoticon)];
|
||||
}
|
||||
let mut segments = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < text.len() {
|
||||
let remaining = &text[cursor..];
|
||||
let mut next: Option<(usize, usize)> = None;
|
||||
for (index, emoticon) in resolved.iter().enumerate() {
|
||||
if emoticon.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(position) = remaining.find(&emoticon.text) else {
|
||||
continue;
|
||||
};
|
||||
if next.is_none_or(|(old_index, old_position)| {
|
||||
position < old_position
|
||||
|| (position == old_position
|
||||
&& emoticon.text.len() > resolved[old_index].text.len())
|
||||
}) {
|
||||
next = Some((index, position));
|
||||
}
|
||||
}
|
||||
let Some((index, position)) = next else {
|
||||
segments.push(DanmakuSegment::Text {
|
||||
text: remaining.to_owned(),
|
||||
});
|
||||
break;
|
||||
};
|
||||
let absolute = cursor + position;
|
||||
if absolute > cursor {
|
||||
segments.push(DanmakuSegment::Text {
|
||||
text: text[cursor..absolute].to_owned(),
|
||||
});
|
||||
}
|
||||
segments.push(emoticon_segment(&resolved[index]));
|
||||
cursor = absolute + resolved[index].text.len();
|
||||
}
|
||||
if segments.is_empty() {
|
||||
segments.push(DanmakuSegment::Text {
|
||||
text: text.to_owned(),
|
||||
});
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
async fn gift_details(
|
||||
catalog: &GiftCatalog,
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
) -> GiftDetails {
|
||||
let metadata = catalog.get(gift_id, &name).await;
|
||||
let unit_price = metadata
|
||||
.as_ref()
|
||||
.map(|gift| gift.unit_price)
|
||||
.unwrap_or_else(|| i64::from(battery.max(0)));
|
||||
let total_price = unit_price.saturating_mul(i64::from(quantity.max(1)));
|
||||
GiftDetails {
|
||||
id: metadata.as_ref().and_then(|gift| gift.id).or(gift_id),
|
||||
name: metadata
|
||||
.as_ref()
|
||||
.map(|gift| gift.name.clone())
|
||||
.unwrap_or(name),
|
||||
coin_type: metadata
|
||||
.as_ref()
|
||||
.map(|gift| gift.coin_type.clone())
|
||||
.unwrap_or_else(|| "gold".into()),
|
||||
unit_price,
|
||||
total_price,
|
||||
price_cny: total_price as f64 / 1000.0,
|
||||
image_url: metadata.as_ref().and_then(|gift| gift.image_url.clone()),
|
||||
animation_url: metadata
|
||||
.as_ref()
|
||||
.and_then(|gift| gift.animation_url.clone()),
|
||||
effect_type: metadata.as_ref().and_then(|gift| gift.effect_type.clone()),
|
||||
stay_time: metadata.as_ref().and_then(|gift| gift.stay_time),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_raw_danmaku_to_provider_event() {
|
||||
let message = normalize(BiliMessage::Raw(
|
||||
json!({"cmd":"DANMU_MSG:4:0:2","info":[[],"晚上好",[12345,"观众"]]}),
|
||||
))
|
||||
.unwrap();
|
||||
match message {
|
||||
ProviderEvent::Danmaku { viewer, text, .. } => {
|
||||
assert_eq!(viewer.uid, "12345");
|
||||
assert_eq!(viewer.name, "观众");
|
||||
assert_eq!(text, "晚上好");
|
||||
}
|
||||
_ => panic!("expected danmaku"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_events_do_not_forward_raw_payload() {
|
||||
let event = normalize(BiliMessage::Raw(json!({
|
||||
"cmd":"FUTURE_SECRET_EVENT",
|
||||
"data":{"cookie":"must-not-cross-provider-boundary"}
|
||||
})))
|
||||
.unwrap();
|
||||
match event {
|
||||
ProviderEvent::Unknown { command } => assert_eq!(command, "FUTURE_SECRET_EVENT"),
|
||||
_ => panic!("expected unknown event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
pub mod bilibili;
|
||||
pub mod supervisor;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::LiveEvent;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SourceStatus {
|
||||
pub source_id: Uuid,
|
||||
pub room_id: String,
|
||||
pub connected: bool,
|
||||
pub cookie_cloud: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl SourceStatus {
|
||||
pub fn starting(source_id: Uuid, room_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
source_id,
|
||||
room_id: room_id.into(),
|
||||
connected: false,
|
||||
cookie_cloud: false,
|
||||
detail: "Waiting for CookieCloud credentials".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SourceContext {
|
||||
pub owner_id: Uuid,
|
||||
/// Stable database identity for the account's single fixed live source.
|
||||
/// It remains distinct from the owner id so future provider/source models
|
||||
/// do not leak the current one-room product rule into event contracts.
|
||||
pub source_id: Uuid,
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait LiveProvider: Send + Sync {
|
||||
fn provider_name(&self) -> &'static str;
|
||||
|
||||
async fn run(
|
||||
self: Arc<Self>,
|
||||
context: SourceContext,
|
||||
events: mpsc::Sender<Arc<LiveEvent>>,
|
||||
status: watch::Sender<SourceStatus>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{RwLock, mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::LiveEvent,
|
||||
live::{LiveProvider, SourceContext, SourceStatus},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProviderFactory: Send + Sync {
|
||||
async fn build(&self, source: &SourceContext) -> Result<Arc<dyn LiveProvider>, String>;
|
||||
}
|
||||
|
||||
struct RunningSource {
|
||||
cancel: CancellationToken,
|
||||
status: watch::Receiver<SourceStatus>,
|
||||
}
|
||||
|
||||
/// Owns exactly one provider task per account/source. Restart always cancels
|
||||
/// the prior generation before starting another, which prevents the duplicate
|
||||
/// listeners produced by the legacy `/reconnect` handler.
|
||||
#[derive(Clone)]
|
||||
pub struct SourceSupervisor {
|
||||
factory: Arc<dyn ProviderFactory>,
|
||||
events: mpsc::Sender<Arc<LiveEvent>>,
|
||||
running: Arc<RwLock<HashMap<Uuid, RunningSource>>>,
|
||||
}
|
||||
|
||||
impl SourceSupervisor {
|
||||
pub fn new(factory: Arc<dyn ProviderFactory>, events: mpsc::Sender<Arc<LiveEvent>>) -> Self {
|
||||
Self {
|
||||
factory,
|
||||
events,
|
||||
running: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self, source: SourceContext) -> Result<(), String> {
|
||||
self.stop(source.source_id).await;
|
||||
let cancel = CancellationToken::new();
|
||||
let provider = match self.factory.build(&source).await {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
let (_, status) = watch::channel(SourceStatus {
|
||||
source_id: source.source_id,
|
||||
room_id: source.room_id.clone(),
|
||||
connected: false,
|
||||
cookie_cloud: false,
|
||||
detail: error.clone(),
|
||||
});
|
||||
self.running
|
||||
.write()
|
||||
.await
|
||||
.insert(source.source_id, RunningSource { cancel, status });
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let initial = SourceStatus::starting(source.source_id, source.room_id.clone());
|
||||
let (status_tx, status_rx) = watch::channel(initial);
|
||||
self.running.write().await.insert(
|
||||
source.source_id,
|
||||
RunningSource {
|
||||
cancel: cancel.clone(),
|
||||
status: status_rx,
|
||||
},
|
||||
);
|
||||
let events = self.events.clone();
|
||||
let source_id = source.source_id;
|
||||
let terminal_room_id = source.room_id.clone();
|
||||
let terminal_status = status_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
info!(%source_id, room_id = %source.room_id, provider = provider.provider_name(), "starting live source");
|
||||
if let Err(error) = provider.run(source, events, status_tx, cancel).await {
|
||||
error!(%source_id, %error, "live source stopped with error");
|
||||
let _ = terminal_status.send(SourceStatus {
|
||||
source_id,
|
||||
room_id: terminal_room_id,
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: error,
|
||||
});
|
||||
}
|
||||
// Keep the terminal status visible. A deliberate stop or restart
|
||||
// removes the receiver first, so sends from an older task cannot
|
||||
// affect a newer generation.
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart(&self, source: SourceContext) -> Result<(), String> {
|
||||
self.start(source).await
|
||||
}
|
||||
|
||||
pub async fn stop(&self, source_id: Uuid) {
|
||||
if let Some(entry) = self.running.write().await.remove(&source_id) {
|
||||
entry.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_all(&self) {
|
||||
let mut running = self.running.write().await;
|
||||
for (_, entry) in running.drain() {
|
||||
entry.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn status(&self, source_id: Uuid) -> Option<SourceStatus> {
|
||||
self.running
|
||||
.read()
|
||||
.await
|
||||
.get(&source_id)
|
||||
.map(|entry| entry.status.borrow().clone())
|
||||
}
|
||||
|
||||
pub async fn statuses(&self) -> Vec<SourceStatus> {
|
||||
self.running
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.map(|entry| entry.status.borrow().clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+37
-1374
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,12 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_postgres::NoTls;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverlaySettings {
|
||||
pub title: String,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
pub show_danmaku: bool,
|
||||
@@ -37,7 +34,6 @@ pub struct OverlaySettings {
|
||||
impl Default for OverlaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "洛星瓷专用弹幕猪!".into(),
|
||||
font_scale: default_font_scale(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
@@ -61,10 +57,6 @@ impl Default for OverlaySettings {
|
||||
|
||||
impl OverlaySettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.title = self.title.trim().chars().take(48).collect();
|
||||
if self.title.is_empty() {
|
||||
self.title = Self::default().title;
|
||||
}
|
||||
self.max_visible = self.max_visible.clamp(1, 12);
|
||||
self.font_scale = self.font_scale.clamp(50, 300);
|
||||
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
||||
@@ -95,45 +87,6 @@ fn default_particle_speed() -> u16 {
|
||||
100
|
||||
}
|
||||
|
||||
pub async fn load_settings(
|
||||
database_url: &str,
|
||||
room_id: &str,
|
||||
defaults: OverlaySettings,
|
||||
) -> Result<OverlaySettings, String> {
|
||||
let (client, connection) = tokio_postgres::connect(database_url, NoTls)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
});
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT settings FROM overlay_settings WHERE room_id=$1",
|
||||
&[&room_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(row
|
||||
.and_then(|row| serde_json::from_value::<OverlaySettings>(row.get::<_, Value>(0)).ok())
|
||||
.unwrap_or(defaults)
|
||||
.sanitize())
|
||||
}
|
||||
|
||||
pub async fn save_settings(
|
||||
database_url: &str,
|
||||
room_id: &str,
|
||||
settings: &OverlaySettings,
|
||||
) -> Result<(), String> {
|
||||
let (client, connection) = tokio_postgres::connect(database_url, NoTls)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
});
|
||||
client.execute("INSERT INTO overlay_settings(room_id,settings,updated_at) VALUES($1,$2,now()) ON CONFLICT(room_id) DO UPDATE SET settings=EXCLUDED.settings,updated_at=now()", &[&room_id, &json!(settings)]).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GiftMeta {
|
||||
@@ -276,17 +229,6 @@ impl GiftCatalog {
|
||||
*self.by_name.write().await = names;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn spawn_refresh(self, room_id: String, interval_seconds: u64, timeout_seconds: u64) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(interval_seconds)).await;
|
||||
if let Err(error) = self.refresh(&room_id, timeout_seconds).await {
|
||||
warn!(%error, "gift catalog refresh failed; retaining the last successful cache");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_catalog(
|
||||
@@ -441,6 +383,7 @@ fn normalize_name(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_current_gift_panel_fields_and_indexes_by_id_and_name() {
|
||||
@@ -497,7 +440,6 @@ mod tests {
|
||||
#[test]
|
||||
fn settings_are_bounded_before_persistence() {
|
||||
let settings = OverlaySettings {
|
||||
title: " ".into(),
|
||||
font_scale: 999,
|
||||
max_visible: 99,
|
||||
collapse_after_seconds: 1,
|
||||
@@ -510,7 +452,6 @@ mod tests {
|
||||
..OverlaySettings::default()
|
||||
}
|
||||
.sanitize();
|
||||
assert_eq!(settings.title, "洛星瓷专用弹幕猪!");
|
||||
assert_eq!(settings.font_scale, 300);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
@@ -532,6 +473,7 @@ mod tests {
|
||||
fn old_saved_settings_receive_new_field_defaults() {
|
||||
let mut value = json!(OverlaySettings::default());
|
||||
let object = value.as_object_mut().expect("settings object");
|
||||
object.insert("title".into(), json!("旧版弹幕栏标题"));
|
||||
object.remove("fontScale");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthRateLimiter {
|
||||
attempts: Arc<Mutex<HashMap<String, AttemptBucket>>>,
|
||||
window: Duration,
|
||||
block_for: Duration,
|
||||
max_failures: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AttemptBucket {
|
||||
failures: VecDeque<Instant>,
|
||||
blocked_until: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RateLimited {
|
||||
pub retry_after: Duration,
|
||||
}
|
||||
|
||||
impl Default for AuthRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new(5, Duration::from_secs(5 * 60), Duration::from_secs(10 * 60))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthRateLimiter {
|
||||
pub fn new(max_failures: usize, window: Duration, block_for: Duration) -> Self {
|
||||
Self {
|
||||
attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||
window,
|
||||
block_for,
|
||||
max_failures: max_failures.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check both account and network dimensions. Callers intentionally receive
|
||||
/// one generic result so this cannot be used to enumerate usernames.
|
||||
pub async fn check(&self, username: &str, ip: &str) -> Result<(), RateLimited> {
|
||||
let now = Instant::now();
|
||||
let mut attempts = self.attempts.lock().await;
|
||||
for key in keys(username, ip) {
|
||||
let bucket = attempts.entry(key).or_default();
|
||||
prune(bucket, now, self.window);
|
||||
if let Some(until) = bucket.blocked_until.filter(|until| *until > now) {
|
||||
return Err(RateLimited {
|
||||
retry_after: until.duration_since(now),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn failure(&self, username: &str, ip: &str) {
|
||||
let now = Instant::now();
|
||||
let mut attempts = self.attempts.lock().await;
|
||||
for key in keys(username, ip) {
|
||||
let bucket = attempts.entry(key).or_default();
|
||||
prune(bucket, now, self.window);
|
||||
bucket.failures.push_back(now);
|
||||
if bucket.failures.len() >= self.max_failures {
|
||||
bucket.blocked_until = Some(now + self.block_for);
|
||||
bucket.failures.clear();
|
||||
}
|
||||
}
|
||||
// Opportunistic pruning bounds memory for a public login endpoint.
|
||||
if attempts.len() > 8_192 {
|
||||
attempts.retain(|_, bucket| {
|
||||
prune(bucket, now, self.window);
|
||||
!bucket.failures.is_empty() || bucket.blocked_until.is_some_and(|until| until > now)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn success(&self, username: &str, ip: &str) {
|
||||
let mut attempts = self.attempts.lock().await;
|
||||
// A successful account verification clears the account bucket. Keep
|
||||
// the IP bucket so one valid account cannot reset an attack on others.
|
||||
attempts.remove(&format!("account:{}", normalize_username(username)));
|
||||
let _ = ip;
|
||||
}
|
||||
|
||||
/// Consume one request from an IP-scoped budget. This is used for costly
|
||||
/// anonymous enrollment work even when a request would otherwise succeed.
|
||||
pub async fn consume_ip(&self, namespace: &str, ip: &str) -> Result<(), RateLimited> {
|
||||
let identity = format!("{namespace}:{}", normalize_ip(ip));
|
||||
self.check(&identity, ip).await?;
|
||||
self.failure(&identity, ip).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(username: &str, ip: &str) -> [String; 2] {
|
||||
[
|
||||
format!("account:{}", normalize_username(username)),
|
||||
format!("network:{}", normalize_ip(ip)),
|
||||
]
|
||||
}
|
||||
|
||||
fn normalize_username(value: &str) -> String {
|
||||
value.trim().to_lowercase()
|
||||
}
|
||||
|
||||
fn normalize_ip(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
"unknown".into()
|
||||
} else {
|
||||
value.chars().take(96).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn prune(bucket: &mut AttemptBucket, now: Instant, window: Duration) {
|
||||
while bucket
|
||||
.failures
|
||||
.front()
|
||||
.is_some_and(|timestamp| now.duration_since(*timestamp) >= window)
|
||||
{
|
||||
bucket.failures.pop_front();
|
||||
}
|
||||
if bucket.blocked_until.is_some_and(|until| until <= now) {
|
||||
bucket.blocked_until = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocks_account_and_network_after_threshold() {
|
||||
let limiter = AuthRateLimiter::new(2, Duration::from_secs(60), Duration::from_secs(60));
|
||||
assert!(limiter.check("Streamer", "127.0.0.1").await.is_ok());
|
||||
limiter.failure("Streamer", "127.0.0.1").await;
|
||||
limiter.failure("Streamer", "127.0.0.1").await;
|
||||
assert!(limiter.check("streamer", "127.0.0.1").await.is_err());
|
||||
assert!(limiter.check("another", "127.0.0.1").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_budget_counts_successful_anonymous_work() {
|
||||
let limiter = AuthRateLimiter::new(2, Duration::from_secs(60), Duration::from_secs(60));
|
||||
assert!(limiter.consume_ip("enroll", "127.0.0.1").await.is_ok());
|
||||
assert!(limiter.consume_ip("enroll", "127.0.0.1").await.is_ok());
|
||||
assert!(limiter.consume_ip("enroll", "127.0.0.1").await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
components::{ComponentError, ComponentInstance, ComponentRegistry},
|
||||
domain::{ComponentMessage, LiveEvent},
|
||||
};
|
||||
|
||||
/// Component-scoped in-process fanout. There is deliberately no global
|
||||
/// receiver: possession of a receiver for component A cannot observe component
|
||||
/// B, even when both consume the same source.
|
||||
#[derive(Clone)]
|
||||
pub struct EventHub {
|
||||
capacity: usize,
|
||||
channels: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Arc<ComponentMessage>>>>>,
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity: capacity.max(1),
|
||||
channels: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn sender(&self, component_id: Uuid) -> broadcast::Sender<Arc<ComponentMessage>> {
|
||||
if let Some(sender) = self
|
||||
.channels
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(&component_id)
|
||||
.cloned()
|
||||
{
|
||||
return sender;
|
||||
}
|
||||
let mut channels = self
|
||||
.channels
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
channels
|
||||
.entry(component_id)
|
||||
.or_insert_with(|| broadcast::channel(self.capacity).0)
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self, component_id: Uuid) -> broadcast::Receiver<Arc<ComponentMessage>> {
|
||||
self.sender(component_id).subscribe()
|
||||
}
|
||||
|
||||
/// Publish to exactly one component. A missing receiver is not an error:
|
||||
/// side-effect handlers are run by the router independently of this hub.
|
||||
pub fn publish(&self, component_id: Uuid, message: Arc<ComponentMessage>) -> usize {
|
||||
self.sender(component_id).send(message).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn receiver_count(&self, component_id: Uuid) -> usize {
|
||||
self.channels
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(&component_id)
|
||||
.map_or(0, broadcast::Sender::receiver_count)
|
||||
}
|
||||
|
||||
/// Remove the sender after a component is deleted or its token is revoked.
|
||||
/// Existing receivers observe channel closure once outstanding sender clones
|
||||
/// are dropped.
|
||||
pub fn remove(&self, component_id: Uuid) -> bool {
|
||||
self.channels
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.remove(&component_id)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn active_component_count(&self) -> usize {
|
||||
self.channels
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventHub {
|
||||
fn default() -> Self {
|
||||
Self::new(256)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ComponentStoreError {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl ComponentStoreError {
|
||||
pub fn new(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ComponentStoreError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ComponentStoreError {}
|
||||
|
||||
pub type ComponentStoreFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
|
||||
|
||||
/// Persistence port used by source routing. A PostgreSQL implementation should
|
||||
/// always scope its query by both owner and source; the router repeats that
|
||||
/// check as defense in depth.
|
||||
pub trait ComponentInstanceStore: Send + Sync {
|
||||
fn list_enabled_for_source<'a>(
|
||||
&'a self,
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
) -> ComponentStoreFuture<'a>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct InMemoryComponentStore {
|
||||
instances: Arc<RwLock<Vec<ComponentInstance>>>,
|
||||
}
|
||||
|
||||
impl InMemoryComponentStore {
|
||||
pub fn new(instances: Vec<ComponentInstance>) -> Self {
|
||||
Self {
|
||||
instances: Arc::new(RwLock::new(instances)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upsert(&self, instance: ComponentInstance) {
|
||||
let mut instances = self
|
||||
.instances
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(current) = instances.iter_mut().find(|item| item.id == instance.id) {
|
||||
*current = instance;
|
||||
} else {
|
||||
instances.push(instance);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, component_id: Uuid) -> bool {
|
||||
let mut instances = self
|
||||
.instances
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let old_len = instances.len();
|
||||
instances.retain(|instance| instance.id != component_id);
|
||||
old_len != instances.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentInstanceStore for InMemoryComponentStore {
|
||||
fn list_enabled_for_source<'a>(
|
||||
&'a self,
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
) -> ComponentStoreFuture<'a> {
|
||||
Box::pin(async move {
|
||||
Ok(self
|
||||
.instances
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.iter()
|
||||
.filter(|instance| {
|
||||
instance.enabled
|
||||
&& instance.owner_id == owner_id
|
||||
&& instance.source_id == source_id
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RouteStage {
|
||||
Scope,
|
||||
Registry,
|
||||
Settings,
|
||||
Handler,
|
||||
Projection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RouteFailure {
|
||||
pub component_id: Uuid,
|
||||
pub stage: RouteStage,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct RouteReport {
|
||||
pub considered: usize,
|
||||
pub matched: usize,
|
||||
pub handler_runs: usize,
|
||||
pub projected: usize,
|
||||
pub receiver_deliveries: usize,
|
||||
pub failures: Vec<RouteFailure>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum RouteError {
|
||||
Store(ComponentStoreError),
|
||||
}
|
||||
|
||||
impl fmt::Display for RouteError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Store(error) => write!(formatter, "cannot resolve source components: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RouteError {}
|
||||
|
||||
impl From<ComponentStoreError> for RouteError {
|
||||
fn from(value: ComponentStoreError) -> Self {
|
||||
Self::Store(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub type DispatchFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<RouteReport, RouteError>> + Send + 'a>>;
|
||||
|
||||
/// Provider/source-facing abstraction. A Bilibili adapter only needs this port;
|
||||
/// it does not need to know about WebSockets or any concrete component kind.
|
||||
pub trait SourceEventSink: Send + Sync {
|
||||
fn dispatch<'a>(&'a self, event: Arc<LiveEvent>) -> DispatchFuture<'a>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SourceEventRouter {
|
||||
registry: ComponentRegistry,
|
||||
components: Arc<dyn ComponentInstanceStore>,
|
||||
hub: EventHub,
|
||||
}
|
||||
|
||||
impl SourceEventRouter {
|
||||
pub fn new(
|
||||
registry: ComponentRegistry,
|
||||
components: Arc<dyn ComponentInstanceStore>,
|
||||
hub: EventHub,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
components,
|
||||
hub,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &ComponentRegistry {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn hub(&self) -> &EventHub {
|
||||
&self.hub
|
||||
}
|
||||
|
||||
pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> {
|
||||
let components = self
|
||||
.components
|
||||
.list_enabled_for_source(event.owner_id, event.source_id)
|
||||
.await?;
|
||||
let mut report = RouteReport {
|
||||
considered: components.len(),
|
||||
..RouteReport::default()
|
||||
};
|
||||
|
||||
for component in components {
|
||||
if !component.enabled
|
||||
|| component.owner_id != event.owner_id
|
||||
|| component.source_id != event.source_id
|
||||
{
|
||||
report.failures.push(RouteFailure {
|
||||
component_id: component.id,
|
||||
stage: RouteStage::Scope,
|
||||
detail: "component owner/source does not match the source event".into(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let runtime = match self.registry.runtime(&component.kind) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
report.failures.push(component_failure(
|
||||
&component,
|
||||
RouteStage::Registry,
|
||||
error,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let subscription = match runtime.subscriptions(&component) {
|
||||
Ok(subscription) => subscription,
|
||||
Err(error) => {
|
||||
report.failures.push(component_failure(
|
||||
&component,
|
||||
RouteStage::Settings,
|
||||
error,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !subscription.matches(&event) {
|
||||
continue;
|
||||
}
|
||||
report.matched += 1;
|
||||
|
||||
// Active handlers are independent from projection and fanout. A
|
||||
// handler failure is reported but does not make an OBS projection
|
||||
// disappear.
|
||||
for handler in runtime.handlers() {
|
||||
if !handler.accepts(&component, &event) {
|
||||
continue;
|
||||
}
|
||||
report.handler_runs += 1;
|
||||
if let Err(error) = handler.handle(&component, event.clone()).await {
|
||||
report.failures.push(RouteFailure {
|
||||
component_id: component.id,
|
||||
stage: RouteStage::Handler,
|
||||
detail: format!("{}: {error}", handler.name()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match runtime.project(&component, &event) {
|
||||
Ok(Some(message)) => {
|
||||
if message.owner_id != component.owner_id
|
||||
|| message.source_id != component.source_id
|
||||
|| message.component_id != component.id
|
||||
{
|
||||
report.failures.push(RouteFailure {
|
||||
component_id: component.id,
|
||||
stage: RouteStage::Projection,
|
||||
detail: "projection changed component tenancy scope".into(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
report.receiver_deliveries += self.hub.publish(component.id, Arc::new(message));
|
||||
report.projected += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => report.failures.push(component_failure(
|
||||
&component,
|
||||
RouteStage::Projection,
|
||||
error,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceEventSink for SourceEventRouter {
|
||||
fn dispatch<'a>(&'a self, event: Arc<LiveEvent>) -> DispatchFuture<'a> {
|
||||
Box::pin(async move { self.route(event).await })
|
||||
}
|
||||
}
|
||||
|
||||
fn component_failure(
|
||||
component: &ComponentInstance,
|
||||
stage: RouteStage,
|
||||
error: ComponentError,
|
||||
) -> RouteFailure {
|
||||
RouteFailure {
|
||||
component_id: component.id,
|
||||
stage,
|
||||
detail: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::json;
|
||||
use tokio::sync::broadcast::error::TryRecvError;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
components::{DANMAKU_OVERLAY_KIND, EventHandler, HandlerFuture},
|
||||
domain::{DanmakuEvent, LiveEventPayload, PlatformViewer},
|
||||
overlay::OverlaySettings,
|
||||
};
|
||||
|
||||
fn danmaku(owner_id: Uuid, source_id: Uuid) -> Arc<LiveEvent> {
|
||||
Arc::new(LiveEvent::new(
|
||||
owner_id,
|
||||
source_id,
|
||||
"bilibili",
|
||||
"123",
|
||||
LiveEventPayload::Danmaku(DanmakuEvent {
|
||||
viewer: PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
},
|
||||
text: "晚上好".into(),
|
||||
segments: Vec::new(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn overlay(owner_id: Uuid, source_id: Uuid) -> ComponentInstance {
|
||||
ComponentInstance::new(
|
||||
owner_id,
|
||||
source_id,
|
||||
DANMAKU_OVERLAY_KIND,
|
||||
"弹幕姬",
|
||||
1,
|
||||
serde_json::to_value(OverlaySettings::default()).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_hub_does_not_leak_between_component_channels() {
|
||||
let hub = EventHub::new(8);
|
||||
let owner_id = Uuid::new_v4();
|
||||
let source_id = Uuid::new_v4();
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let mut first_rx = hub.subscribe(first);
|
||||
let mut second_rx = hub.subscribe(second);
|
||||
let event = danmaku(owner_id, source_id);
|
||||
let message = ComponentMessage::from_live_event(first, &event).unwrap();
|
||||
|
||||
assert_eq!(hub.publish(first, Arc::new(message)), 1);
|
||||
assert_eq!(first_rx.try_recv().unwrap().component_id, first);
|
||||
assert!(matches!(second_rx.try_recv(), Err(TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_a_channel_disconnects_existing_token_subscribers() {
|
||||
let hub = EventHub::new(8);
|
||||
let component_id = Uuid::new_v4();
|
||||
let mut old_receiver = hub.subscribe(component_id);
|
||||
|
||||
assert!(hub.remove(component_id));
|
||||
assert!(matches!(
|
||||
old_receiver.recv().await,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed)
|
||||
));
|
||||
|
||||
let mut new_receiver = hub.subscribe(component_id);
|
||||
let event = danmaku(Uuid::new_v4(), Uuid::new_v4());
|
||||
let message = ComponentMessage::from_live_event(component_id, &event).unwrap();
|
||||
assert_eq!(hub.publish(component_id, Arc::new(message)), 1);
|
||||
assert_eq!(
|
||||
new_receiver.recv().await.unwrap().component_id,
|
||||
component_id
|
||||
);
|
||||
}
|
||||
|
||||
struct CountingHandler(Arc<AtomicUsize>);
|
||||
|
||||
impl EventHandler for CountingHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"counting-handler"
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
_component: &'a ComponentInstance,
|
||||
_event: Arc<LiveEvent>,
|
||||
) -> HandlerFuture<'a> {
|
||||
Box::pin(async move {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn router_runs_handlers_without_receivers_and_projects_the_event() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
let source_id = Uuid::new_v4();
|
||||
let component = overlay(owner_id, source_id);
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let registry = ComponentRegistry::default();
|
||||
registry
|
||||
.register_handler(
|
||||
DANMAKU_OVERLAY_KIND,
|
||||
Arc::new(CountingHandler(count.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
let store = Arc::new(InMemoryComponentStore::new(vec![component]));
|
||||
let router = SourceEventRouter::new(registry, store, EventHub::new(8));
|
||||
|
||||
let report = router.route(danmaku(owner_id, source_id)).await.unwrap();
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(report.handler_runs, 1);
|
||||
assert_eq!(report.projected, 1);
|
||||
assert_eq!(report.receiver_deliveries, 0);
|
||||
assert!(report.failures.is_empty());
|
||||
}
|
||||
|
||||
struct LeakyStore {
|
||||
instances: Vec<ComponentInstance>,
|
||||
}
|
||||
|
||||
impl ComponentInstanceStore for LeakyStore {
|
||||
fn list_enabled_for_source<'a>(
|
||||
&'a self,
|
||||
_owner_id: Uuid,
|
||||
_source_id: Uuid,
|
||||
) -> ComponentStoreFuture<'a> {
|
||||
Box::pin(async move { Ok(self.instances.clone()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn router_rejects_cross_owner_rows_even_if_store_is_buggy() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
let source_id = Uuid::new_v4();
|
||||
let other = overlay(Uuid::new_v4(), source_id);
|
||||
let component_id = other.id;
|
||||
let store = Arc::new(LeakyStore {
|
||||
instances: vec![other],
|
||||
});
|
||||
let router = SourceEventRouter::new(ComponentRegistry::default(), store, EventHub::new(8));
|
||||
|
||||
let report = router.route(danmaku(owner_id, source_id)).await.unwrap();
|
||||
assert_eq!(report.projected, 0);
|
||||
assert_eq!(report.failures.len(), 1);
|
||||
assert_eq!(report.failures[0].component_id, component_id);
|
||||
assert_eq!(report.failures[0].stage, RouteStage::Scope);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_event_categories_are_not_projected() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
let source_id = Uuid::new_v4();
|
||||
let mut component = overlay(owner_id, source_id);
|
||||
component.settings["showDanmaku"] = json!(false);
|
||||
let store = Arc::new(InMemoryComponentStore::new(vec![component]));
|
||||
let router = SourceEventRouter::new(ComponentRegistry::default(), store, EventHub::new(8));
|
||||
|
||||
let report = router.route(danmaku(owner_id, source_id)).await.unwrap();
|
||||
assert_eq!(report.matched, 0);
|
||||
assert_eq!(report.projected, 0);
|
||||
assert!(report.failures.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
components::{ComponentInstance, ComponentRegistry},
|
||||
db::{ComponentRecord, Db, DbError},
|
||||
realtime::InMemoryComponentStore,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantRepository {
|
||||
db: Db,
|
||||
registry: ComponentRegistry,
|
||||
cache: Arc<InMemoryComponentStore>,
|
||||
}
|
||||
|
||||
impl TenantRepository {
|
||||
pub fn new(db: Db, registry: ComponentRegistry, cache: Arc<InMemoryComponentStore>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
registry,
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache(&self) -> Arc<InMemoryComponentStore> {
|
||||
self.cache.clone()
|
||||
}
|
||||
|
||||
pub async fn hydrate_all(&self) -> Result<(), RepositoryError> {
|
||||
for tenant in self.db.list_active_tenants().await? {
|
||||
self.hydrate_tenant(tenant.user_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hydrate_tenant(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<ComponentView>, RepositoryError> {
|
||||
let rows = self.db.list_tenant_components(owner_id).await?;
|
||||
let mut views = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let component = self.validate_loaded_component(component_from_record(row)?)?;
|
||||
self.cache.upsert(component.clone());
|
||||
views.push(ComponentView::from(&component));
|
||||
}
|
||||
Ok(views)
|
||||
}
|
||||
|
||||
pub async fn list_components(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<ComponentView>, RepositoryError> {
|
||||
self.hydrate_tenant(owner_id).await
|
||||
}
|
||||
|
||||
pub async fn create_component(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
) -> Result<ComponentInstance, RepositoryError> {
|
||||
let runtime = self
|
||||
.registry
|
||||
.runtime(kind)
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 80 {
|
||||
return Err(RepositoryError::Invalid(
|
||||
"component name must contain 1-80 characters".into(),
|
||||
));
|
||||
}
|
||||
let source_id = self.source_id(owner_id).await?;
|
||||
let component = ComponentInstance::new(
|
||||
owner_id,
|
||||
source_id,
|
||||
runtime.kind(),
|
||||
name,
|
||||
runtime.definition().settings_version(),
|
||||
runtime.definition().default_settings(),
|
||||
);
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,true)",
|
||||
&[
|
||||
&component.id,
|
||||
&owner_id,
|
||||
&source_id,
|
||||
&component.kind,
|
||||
&component.name,
|
||||
&component.settings,
|
||||
&(component.settings_version as i32),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
self.cache.upsert(component.clone());
|
||||
Ok(component)
|
||||
}
|
||||
|
||||
pub async fn delete_component(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
component_id: Uuid,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||
&[&owner_id, &component_id],
|
||||
)
|
||||
.await?;
|
||||
if changed != 1 {
|
||||
return Err(RepositoryError::NotFound);
|
||||
}
|
||||
transaction.commit().await?;
|
||||
self.cache.remove(component_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_component(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
component_id: Uuid,
|
||||
) -> Result<ComponentInstance, RepositoryError> {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
||||
FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||
&[&owner_id, &component_id],
|
||||
)
|
||||
.await?
|
||||
.ok_or(RepositoryError::NotFound)?;
|
||||
transaction.commit().await?;
|
||||
self.validate_loaded_component(component_from_record(ComponentRecord {
|
||||
id: row.get(0),
|
||||
owner_user_id: owner_id,
|
||||
source_id: row.get(1),
|
||||
kind: row.get(2),
|
||||
name: row.get(3),
|
||||
settings: row.get(4),
|
||||
settings_version: row.get(5),
|
||||
enabled: row.get(6),
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn update_component_settings(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
component_id: Uuid,
|
||||
settings: Value,
|
||||
) -> Result<ComponentInstance, RepositoryError> {
|
||||
let current = self.get_component(owner_id, component_id).await?;
|
||||
let validated = self
|
||||
.registry
|
||||
.validate_settings(¤t.kind, current.settings_version, settings)
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE component_instances SET settings=$1,updated_at=now() \
|
||||
WHERE id=$2 AND owner_user_id=$3",
|
||||
&[&validated, &component_id, &owner_id],
|
||||
)
|
||||
.await?;
|
||||
if changed != 1 {
|
||||
return Err(RepositoryError::NotFound);
|
||||
}
|
||||
transaction.commit().await?;
|
||||
let updated = ComponentInstance {
|
||||
settings: validated,
|
||||
..current
|
||||
};
|
||||
self.cache.upsert(updated.clone());
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn source_id(&self, owner_id: Uuid) -> Result<Uuid, RepositoryError> {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT id FROM live_sources WHERE owner_user_id=$1 AND enabled",
|
||||
&[&owner_id],
|
||||
)
|
||||
.await?
|
||||
.ok_or(RepositoryError::NotFound)?;
|
||||
transaction.commit().await?;
|
||||
Ok(row.get(0))
|
||||
}
|
||||
|
||||
pub async fn room_id(&self, owner_id: Uuid) -> Result<String, RepositoryError> {
|
||||
let client = self.db.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT room_id FROM users WHERE id=$1 AND status='active'",
|
||||
&[&owner_id],
|
||||
)
|
||||
.await?
|
||||
.ok_or(RepositoryError::NotFound)?;
|
||||
Ok(row.get(0))
|
||||
}
|
||||
|
||||
pub async fn token_summary(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
component_id: Uuid,
|
||||
) -> Result<ComponentTokenSummary, RepositoryError> {
|
||||
let _ = self.get_component(owner_id, component_id).await?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT created_at,last_used_at FROM component_access_tokens \
|
||||
WHERE owner_user_id=$1 AND component_instance_id=$2 AND revoked_at IS NULL \
|
||||
AND (expires_at IS NULL OR expires_at>now()) ORDER BY created_at DESC LIMIT 1",
|
||||
&[&owner_id, &component_id],
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(ComponentTokenSummary {
|
||||
configured: row.is_some(),
|
||||
updated_at: row.as_ref().map(|row| row.get(0)),
|
||||
last_used_at: row.as_ref().and_then(|row| row.get(1)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn revoke_component_tokens(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
component_id: Uuid,
|
||||
) -> Result<u64, RepositoryError> {
|
||||
let _ = self.get_component(owner_id, component_id).await?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE component_access_tokens SET revoked_at=now() \
|
||||
WHERE owner_user_id=$1 AND component_instance_id=$2 AND revoked_at IS NULL",
|
||||
&[&owner_id, &component_id],
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub async fn setup_required(&self) -> Result<bool, RepositoryError> {
|
||||
let client = self.db.get().await?;
|
||||
Ok(!client
|
||||
.query_one("SELECT EXISTS(SELECT 1 FROM users)", &[])
|
||||
.await?
|
||||
.get::<_, bool>(0))
|
||||
}
|
||||
|
||||
pub async fn list_invitations(
|
||||
&self,
|
||||
actor_id: Uuid,
|
||||
) -> Result<Vec<InvitationView>, RepositoryError> {
|
||||
let client = self.db.get().await?;
|
||||
require_system_admin(&client, actor_id).await?;
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT id,code_prefix,room_id,created_at,expires_at,consumed_at,revoked_at \
|
||||
FROM invitations WHERE grant_role='user' ORDER BY created_at DESC LIMIT 250",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| InvitationView {
|
||||
id: row.get(0),
|
||||
code_prefix: row.get(1),
|
||||
room_id: row.get(2),
|
||||
created_at: row.get(3),
|
||||
expires_at: row.get(4),
|
||||
consumed_at: row.get(5),
|
||||
revoked_at: row.get(6),
|
||||
max_uses: 1,
|
||||
used_count: if row.get::<_, Option<DateTime<Utc>>>(5).is_some() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn legacy_overlay_settings(
|
||||
&self,
|
||||
room_id: &str,
|
||||
fallback: Value,
|
||||
) -> Result<Value, RepositoryError> {
|
||||
let client = self.db.get().await?;
|
||||
Ok(client
|
||||
.query_opt(
|
||||
"SELECT settings FROM overlay_settings WHERE room_id=$1",
|
||||
&[&room_id],
|
||||
)
|
||||
.await?
|
||||
.map(|row| row.get(0))
|
||||
.unwrap_or(fallback))
|
||||
}
|
||||
|
||||
fn validate_loaded_component(
|
||||
&self,
|
||||
mut component: ComponentInstance,
|
||||
) -> Result<ComponentInstance, RepositoryError> {
|
||||
component.settings = self
|
||||
.registry
|
||||
.validate_settings(
|
||||
&component.kind,
|
||||
component.settings_version,
|
||||
component.settings,
|
||||
)
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
Ok(component)
|
||||
}
|
||||
}
|
||||
|
||||
fn component_from_record(record: ComponentRecord) -> Result<ComponentInstance, RepositoryError> {
|
||||
let settings_version = u32::try_from(record.settings_version)
|
||||
.map_err(|_| RepositoryError::Invalid("negative settings version".into()))?;
|
||||
Ok(ComponentInstance {
|
||||
id: record.id,
|
||||
owner_id: record.owner_user_id,
|
||||
source_id: record.source_id,
|
||||
kind: record.kind,
|
||||
name: record.name,
|
||||
enabled: record.enabled,
|
||||
settings_version,
|
||||
settings: record.settings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn require_system_admin(
|
||||
client: &deadpool_postgres::Object,
|
||||
actor_id: Uuid,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let allowed = client
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND role='system_admin' AND status='active')",
|
||||
&[&actor_id],
|
||||
)
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RepositoryError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentView {
|
||||
pub id: Uuid,
|
||||
pub public_id: Uuid,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub settings: Value,
|
||||
}
|
||||
|
||||
impl From<&ComponentInstance> for ComponentView {
|
||||
fn from(component: &ComponentInstance) -> Self {
|
||||
Self {
|
||||
id: component.id,
|
||||
public_id: component.id,
|
||||
kind: component.kind.clone(),
|
||||
name: component.name.clone(),
|
||||
enabled: component.enabled,
|
||||
settings: component.settings.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentTokenSummary {
|
||||
pub configured: bool,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvitationView {
|
||||
pub id: Uuid,
|
||||
pub code_prefix: String,
|
||||
pub room_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub consumed_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub max_uses: i32,
|
||||
pub used_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepositoryError {
|
||||
NotFound,
|
||||
Forbidden,
|
||||
Invalid(String),
|
||||
Database(DbError),
|
||||
Postgres(tokio_postgres::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for RepositoryError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotFound => formatter.write_str("resource was not found"),
|
||||
Self::Forbidden => formatter.write_str("operation is not permitted"),
|
||||
Self::Invalid(message) => write!(formatter, "invalid value: {message}"),
|
||||
Self::Database(error) => error.fmt(formatter),
|
||||
Self::Postgres(error) => error.fmt(formatter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepositoryError {}
|
||||
|
||||
impl From<DbError> for RepositoryError {
|
||||
fn from(value: DbError) -> Self {
|
||||
Self::Database(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio_postgres::Error> for RepositoryError {
|
||||
fn from(value: tokio_postgres::Error) -> Self {
|
||||
Self::Postgres(value)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,20 @@ services:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,noexec,nosuid,nodev
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
|
||||
- ./config.toml:/app/config.toml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9719/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
+58
-22
@@ -1,56 +1,92 @@
|
||||
# 洛星瓷直播弹幕姬配置。
|
||||
# 洛星瓷多用户直播组件服务配置。
|
||||
#
|
||||
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项;该文件包含
|
||||
# CookieCloud、数据库和访问令牌等敏感信息,请不要提交或公开分享。
|
||||
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项。这个文件
|
||||
# 包含首次迁移所需的 CookieCloud、bootstrap proof 和旧 OBS 令牌,请不要
|
||||
# 提交或公开分享。多用户运行数据在初始化后保存在 PostgreSQL 中。
|
||||
# Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。
|
||||
|
||||
# 与 blivedm_rs 一致的连接配置段。实际 Bilibili Cookie 不写在这里,
|
||||
# 服务会通过下方 [cookiecloud] 在运行时获取最新 Cookie。
|
||||
# 旧单用户配置兼容段:room_id 只在数据库没有任何账户时,绑定给首次
|
||||
# 创建的 system_admin,并且此后不可修改。后续账号的 room_id 由系统
|
||||
# 管理员创建邀请码时指定,不读取此字段。
|
||||
# 实际 Bilibili Cookie 不直接写在配置中,而是通过 CookieCloud 获取。
|
||||
[connection]
|
||||
room_id = "000000"
|
||||
room_id = "123456" # 替换为首个系统管理员的真实直播间 ID;不能以 0 开头。
|
||||
# cookies = "SESSDATA=..." # 不需要;由 CookieCloud 托管。
|
||||
|
||||
# HTTP、WebSocket 及静态前端监听端口。host 网络模式下为宿主机端口。
|
||||
# HTTP、WebSocket 及静态前端监听地址。host 网络模式下只绑定
|
||||
# 127.0.0.1,让同机 Nginx 代理,不要将应用端口直接暴露到公网。
|
||||
[server]
|
||||
bind_address = "127.0.0.1"
|
||||
port = 9719
|
||||
|
||||
# 弹幕姬设置数据库。此账号必须可创建和修改 overlay_settings 表。
|
||||
# 多用户数据库。保存账户、邀请码、TOTP 注册状态、会话、恢复码摘要、
|
||||
# 加密 CookieCloud 凭据、直播源、组件/设置、OBS token 摘要和审计日志。
|
||||
# 数据库账号必须能执行项目迁移;租户表会启用 RLS。
|
||||
[database]
|
||||
url = "postgresql://streamutils:replace-with-database-password@127.0.0.1:5432/streamutils?sslmode=disable"
|
||||
|
||||
# 外部部署的 CookieCloud 实例。key 是同步 UUID;password 是同步密码。
|
||||
# 旧单用户配置兼容段:只在首次 system_admin 初始化时导入到该账户,
|
||||
# 随后 Key 和密码会使用 security.data_encryption_key 加密存入 PostgreSQL。
|
||||
# 受邀用户在自己的 /control 页面填写各自的 CookieCloud;不同账户不会
|
||||
# 共用凭据。迁移完成后修改此段不会覆盖数据库中的凭据。
|
||||
# key 是 CookieCloud 同步 UUID;password 是同步密码。
|
||||
[cookiecloud]
|
||||
host = "http://127.0.0.1:8088"
|
||||
key = "replace-with-cookiecloud-uuid"
|
||||
password = "replace-with-cookiecloud-password"
|
||||
|
||||
# 管理台登录密码和用于签发 HTTP-only 会话 Cookie 的随机密钥。
|
||||
# session_secret 建议至少 32 个随机字符。
|
||||
# password 是一次性 bootstrap proof,只能在数据库尚无账户时授权创建
|
||||
# 第一个 system_admin。它不会成为账户密码,也不能用于日常登录。
|
||||
# 所有账户都是 passwordless:以后使用 username + TOTP(或恢复码)登录。
|
||||
# session_secret 是旧配置兼容字段,目前仍为必填;填写了下方独立的数据
|
||||
# 加密密钥后,它不用于签发数据库会话。两个值都应使用独立随机内容。
|
||||
[admin]
|
||||
password = "replace-with-a-long-admin-password"
|
||||
password = "replace-with-a-one-time-bootstrap-secret"
|
||||
session_secret = "replace-with-at-least-32-random-characters"
|
||||
|
||||
# OBS 页面只读订阅令牌;浏览器源 URL 为 /obs?token=<access_token>。
|
||||
# 多用户身份安全配置。用 `openssl rand -base64 32` 生成独立的 32 字节
|
||||
# Base64 密钥。它使用 XChaCha20-Poly1305 加密 TOTP Secret 和各用户的
|
||||
# CookieCloud Key/密码;遗失或更换后无法恢复已有密文,请安全备份。
|
||||
[security]
|
||||
data_encryption_key = "replace-with-32-random-bytes-in-base64"
|
||||
# TOTP 验证器中显示的 issuer;上线后不建议随意更改。
|
||||
totp_issuer = "danmaku.luoxingci.com"
|
||||
# 登录会话、二维码绑定流程和普通邀请码的默认有效期。
|
||||
session_ttl_hours = 12
|
||||
registration_ttl_minutes = 15
|
||||
invitation_ttl_hours = 72
|
||||
# 公开站点必须由 Nginx/可信反代提供 HTTPS,并保持此项为 true。
|
||||
# 只有本机、无敏感数据的纯 HTTP 调试才能临时设为 false。
|
||||
secure_cookies = true
|
||||
# 用户只能配置此白名单中的 CookieCloud 基础地址,防止任意服务端
|
||||
# 请求。必须包含上方 cookiecloud.host;可填多个管理员批准的实例。
|
||||
cookiecloud_allowed_hosts = ["http://127.0.0.1:8088"]
|
||||
|
||||
# 旧 OBS token 兼容段:只导入首个 system_admin 的默认弹幕组件一次。
|
||||
# 新组件通过控制台生成独立、可撤销且只存摘要的 token,地址格式为:
|
||||
# /obs/<publicId>#token=<component-token>
|
||||
# 没有需要迁移的旧 OBS 浏览器源时可设为空字符串。
|
||||
[obs]
|
||||
access_token = "replace-with-a-long-random-obs-token"
|
||||
|
||||
# Bilibili 礼物目录缓存。启动时会立即加载,之后按此间隔刷新;
|
||||
# 请求失败会继续使用上一次成功结果。刷新间隔最小 60 秒。
|
||||
# 各活动直播源的 Bilibili 礼物目录缓存。启动时立即加载,之后按此
|
||||
# 间隔刷新;失败时保留上一次成功结果。刷新间隔最小 60 秒。
|
||||
[gifts]
|
||||
refresh_interval_seconds = 600
|
||||
request_timeout_seconds = 10
|
||||
|
||||
# Bilibili 直播间表情目录缓存。接口使用 CookieCloud 中的 SESSDATA;
|
||||
# 实时消息自带的表情图片始终优先,目录仅用于补齐缺失的图片地址。
|
||||
# 各用户 Bilibili 直播间表情目录缓存。接口使用该用户加密保存的
|
||||
# CookieCloud/SESSDATA;消息自带图片始终优先,目录仅补齐缺失地址。
|
||||
[emoticons]
|
||||
refresh_interval_seconds = 600
|
||||
request_timeout_seconds = 10
|
||||
|
||||
# OBS 弹幕姬的初始设置。仅当该直播间尚未通过 /control 保存过设置时使用;
|
||||
# 一旦控制台保存,PostgreSQL 中的设置优先。价格阈值单位为千分之一元,
|
||||
# 因此 10000 = 10 元、100000 = 100 元。
|
||||
# 旧弹幕姬初始设置兼容段:只在首次 system_admin 初始化时,作为其默认
|
||||
# danmaku_overlay 的旧数据迁移/回退值。其他受邀用户使用组件内建默认值;
|
||||
# 所有用户之后都在 /control 按组件修改,PostgreSQL 设置始终优先。
|
||||
# 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此
|
||||
# 10000 = 10 元、100000 = 100 元。
|
||||
[overlay]
|
||||
title = "洛星瓷专用弹幕猪!"
|
||||
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
|
||||
font_scale = 140
|
||||
max_visible = 5
|
||||
@@ -66,7 +102,7 @@ low_performance_mode = false
|
||||
high_value_threshold = 10000
|
||||
featured_value_threshold = 100000
|
||||
|
||||
# 初始显示的事件类别,同样可在 /control 中随时修改并实时同步到 OBS。
|
||||
# 首个旧组件迁移时显示的事件类别;之后在 /control 中按组件修改。
|
||||
[overlay.events]
|
||||
danmaku = true
|
||||
enter = true
|
||||
|
||||
+23
@@ -3,6 +3,7 @@
|
||||
|
||||
use native_tls::TlsStream;
|
||||
use serde_json::Value;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::TcpStream;
|
||||
use std::panic;
|
||||
use tungstenite::{client, Message, WebSocket};
|
||||
@@ -134,6 +135,14 @@ impl BiliLiveClient {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(tungstenite::Error::Io(error))
|
||||
if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) =>
|
||||
{
|
||||
// A short read timeout lets an embedding application check
|
||||
// its cancellation token without treating idle rooms as a
|
||||
// disconnected WebSocket.
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("read msg error: {}", e);
|
||||
log::warn!("{}", msg);
|
||||
@@ -147,6 +156,20 @@ impl BiliLiveClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound a blocking read so supervisors can stop/restart one tenant's
|
||||
/// listener without leaking the previous connection.
|
||||
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) -> Result<(), String> {
|
||||
self.ws
|
||||
.get_mut()
|
||||
.get_mut()
|
||||
.set_read_timeout(timeout)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
let _ = self.ws.close(None);
|
||||
}
|
||||
|
||||
fn connect_with_auth(
|
||||
cookies: &str,
|
||||
room_id: &str,
|
||||
|
||||
Reference in New Issue
Block a user