dscord 0.4.0-alpha.2 → 0.4.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +5 -0
- package/README.md +22 -15
- package/central/README.md +144 -0
- package/central/avatars.md +76 -0
- package/central/avatars.mjs +108 -0
- package/central/directory.mjs +224 -0
- package/central/rules.mjs +51 -0
- package/central/service.mjs +365 -0
- package/central/store.mjs +416 -0
- package/central/tokens.mjs +63 -0
- package/dist/admin/assets/index-BqwnqlFx.css +1 -0
- package/dist/admin/assets/index-mXQNxGvo.js +10 -0
- package/dist/admin/index.html +2 -2
- package/dist/admin/licenses/bundled-dependencies.txt +93 -0
- package/dist/central-admin/assets/index-ByNljbpy.css +1 -0
- package/dist/central-admin/assets/index-CKu96QBX.js +9 -0
- package/dist/central-admin/assets/mark-128-C4ed4p0U.png +0 -0
- package/dist/central-admin/index.html +13 -0
- package/dist/central-admin/licenses/bundled-dependencies.txt +514 -0
- package/dist/central-admin/licenses/lucide.txt +43 -0
- package/dist/central-admin/licenses/ui-foundation.txt +336 -0
- package/dist/central-admin/theme.js +66 -0
- package/dist/web/assets/index-Cl4X2HbO.js +55 -0
- package/dist/web/assets/index-DUlpYtqe.css +1 -0
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +272 -71
- package/dsh-connect/KERNEL.md +63 -13
- package/dsh-connect/client.js +2050 -735
- package/dsh-connect/controller.mjs +403 -295
- package/dsh-connect/decision-settings.mjs +72 -26
- package/dsh-connect/index.mjs +11 -6
- package/dsh-connect/kernel-updates.mjs +243 -0
- package/dsh-connect/native-bots.mjs +1 -5
- package/dsh-connect/native-bridge.mjs +58 -57
- package/dsh-connect/native-connections.mjs +70 -27
- package/dsh-connect/native-team.mjs +3 -0
- package/dsh-connect/work-core.mjs +352 -93
- package/dsh-plugin/README.md +2 -2
- package/dsh-plugin/runtime-rules.mjs +2 -3
- package/dsh-plugin/work-files.mjs +2 -2
- package/icon.png +0 -0
- package/memory/service.mjs +25 -11
- package/memory/store.mjs +19 -7
- package/memory/views.mjs +3 -2
- package/memory//345/244/215/347/224/250/344/270/216/350/277/220/350/241/214.md +2 -2
- package/memory//345/275/223/345/211/215/344/270/212/344/270/213/346/226/207/344/270/216/345/233/236/351/241/276.md +3 -3
- package/package.json +14 -2
- package/scripts/server-init.mjs +6 -3
- package/src/accounts.mjs +58 -139
- package/src/admin-directory.mjs +69 -0
- package/src/admin.mjs +61 -1
- package/src/bot-node.mjs +165 -122
- package/src/central-trust.mjs +53 -0
- package/src/contact-book.mjs +202 -107
- package/src/conversation-view.mjs +103 -0
- package/src/core/bot-routes.mjs +7 -19
- package/src/core/node-routes.mjs +19 -1
- package/src/core/room-routes.mjs +56 -31
- package/src/core/server-page.mjs +23 -0
- package/src/core/session-routes.mjs +29 -18
- package/src/core.mjs +55 -15
- package/src/decision-presets.mjs +47 -0
- package/src/decision-provider.mjs +115 -25
- package/src/decision-state.mjs +94 -0
- package/src/front-prompt.mjs +348 -0
- package/src/governance.mjs +79 -5
- package/src/identity.mjs +27 -38
- package/src/invites.mjs +71 -0
- package/src/kernel-control.mjs +55 -9
- package/src/kernel-node-client.mjs +2 -0
- package/src/mentions.mjs +1 -1
- package/src/message-context.mjs +5 -35
- package/src/native-bindings.mjs +37 -0
- package/src/node-delivery.mjs +1 -1
- package/src/node-protocol.mjs +99 -0
- package/src/node-store.mjs +72 -0
- package/src/profile-sync.mjs +93 -0
- package/src/push.mjs +51 -14
- package/src/room-reader.mjs +55 -0
- package/src/server-update-control.mjs +41 -0
- package/src/server-update-docker.mjs +70 -0
- package/src/server-update-release.mjs +58 -0
- package/src/server-updater.mjs +323 -0
- package/vendor/ds-bot/UPSTREAM.json +4 -0
- package/vendor/ds-bot/src/host/prompt.js +4 -1
- package//346/250/241/345/236/213/351/205/215/347/275/256/344/270/216/350/277/201/347/247/273.md +52 -15
- package/dist/admin/assets/index-DXyc54Ce.css +0 -1
- package/dist/admin/assets/index-vZ7hJNEU.js +0 -10
- package/dist/web/assets/index-62nMLjE0.css +0 -1
- package/dist/web/assets/index-OaWUoxQD.js +0 -55
- package/dsh-connect/models.mjs +0 -64
- package/dsh-connect/processes.mjs +0 -119
- package/scripts/central-init.mjs +0 -39
- package/src/native-bot-catalog.mjs +0 -75
package/NOTICE
CHANGED
|
@@ -20,3 +20,8 @@ Web interfaces
|
|
|
20
20
|
Bundled UI and icon notices are in web/licenses/ and the corresponding
|
|
21
21
|
licenses/ directory in each prebuilt interface. Other installed dependencies
|
|
22
22
|
retain the licenses provided by their respective packages.
|
|
23
|
+
|
|
24
|
+
Avatar image processing
|
|
25
|
+
The installed sharp 0.35.5 dependency is licensed under Apache-2.0. Its
|
|
26
|
+
platform packages include libvips and their own third-party license notices;
|
|
27
|
+
retain those notices with the installed dependencies.
|
package/README.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
# DSCord 0.4.0-alpha.
|
|
1
|
+
# DSCord 0.4.0-alpha.4 安装与连接
|
|
2
2
|
|
|
3
3
|
> [!WARNING]
|
|
4
4
|
> **实验性项目。** DSCord 目前是实验性质的项目,接口、数据格式和行为随时可能变化,不保证兼容旧版本。项目不考虑安全问题,因此将别人加入到你的服务器当中的时候,请确认你有线下找他喝茶的能力。
|
|
5
5
|
|
|
6
6
|
DSCord 的 npm 包包含 Core、预构建网页/PWA、Matrix 适配器和 DSCord Bot 内核。完整服务器源码发行包另含 Compose 部署文件;同一部署的 Core、客户端和节点使用同一发行版本。
|
|
7
7
|
|
|
8
|
-
本指南对应预发布版本 0.4.0-alpha.
|
|
8
|
+
本指南对应预发布版本 0.4.0-alpha.4,npm registry 和该版本的 GitHub Release 均提供安装包。Node 支持范围由 [package.json](package.json) 声明为 `>=24.19.0 <25`。Bot 宿主固定使用 DSH `0.2.2-rc.1` 的 SDK 契约,见[内核说明](dsh-connect/KERNEL.md)。Docker 服务器无需在宿主另装 Node。
|
|
9
9
|
|
|
10
10
|
## 安装
|
|
11
11
|
|
|
12
|
-
从发行页下载 `dscord-0.4.0-alpha.
|
|
12
|
+
从发行页下载 `dscord-0.4.0-alpha.4.tgz` 后,可在 DSH 的 `Plugins → Add plugin` 中安装并启用 `dscord`,再登录服务器连接 Bot。发行页同时提供 `SHA256SUMS` 校验文件。[下载发行包](https://github.com/MixGeekers/dscord/releases/tag/ds-group-v0.4.0-alpha.4)
|
|
13
13
|
|
|
14
|
-
全局安装 CLI
|
|
14
|
+
全局安装 CLI 用于服务器与独立节点管理。从 npm registry 安装当前版本,安装后核对 `dscord --version`。
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
|
-
npm install --global
|
|
17
|
+
npm install --global dscord@0.4.0-alpha.4
|
|
18
18
|
dscord --help
|
|
19
19
|
dscord --version
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Core、CLI 和 DSH
|
|
22
|
+
Core、CLI 和 DSH 插件使用同一发行版本。已有全局安装可用上述命令升级。当前预发布版本使用 `alpha` 标签,也可执行 `npm install --global dscord@alpha` 升级到最新 Alpha。[npm 包页面](https://www.npmjs.com/package/dscord/v/0.4.0-alpha.4)
|
|
23
23
|
|
|
24
24
|
从源码构建 npm 包,在 `apps/ds-group/` 中执行:
|
|
25
25
|
|
|
@@ -28,11 +28,11 @@ npm ci --ignore-scripts
|
|
|
28
28
|
npm run npm:pack
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
该版本默认生成 `.local/npm-release/dscord-0.4.0-alpha.
|
|
31
|
+
该版本默认生成 `.local/npm-release/dscord-0.4.0-alpha.4.tgz`,仍应核对命令实际输出的版本、路径与校验值。构建需要开发依赖,运行已提供的预构建包不需要重新构建界面。
|
|
32
32
|
|
|
33
33
|
## 部署服务器
|
|
34
34
|
|
|
35
|
-
完整服务器采用[Compose 部署指南](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.
|
|
35
|
+
完整服务器采用[Compose 部署指南](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.4/apps/ds-group/deploy/README.md),先配置自己的 Matrix 身份域、公开地址和持久存储。npm CLI 不代替 Synapse 或反向代理。需要 `dscord server`、初始化及节点导入等命令时,在各终端保持同一个绝对数据路径:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
38
|
export DS_GROUP_DATA_DIR="$HOME/.local/share/dscord-server"
|
|
@@ -44,18 +44,25 @@ dscord --version
|
|
|
44
44
|
## 在 DSH 中连接
|
|
45
45
|
|
|
46
46
|
1. 准备固定基线的官方 CLI,或提供相同 SDK 服务的 DSH 宿主。CLI 可执行 `npm install --global @deepseek-ai/dsh@0.2.2-rc.1`。在 DSH 中配置自己的业务模型和凭据。
|
|
47
|
-
2. 安装同版本 `dscord`
|
|
48
|
-
3.
|
|
49
|
-
4.
|
|
50
|
-
5.
|
|
47
|
+
2. 安装同版本 `dscord` 插件并启用。
|
|
48
|
+
3. 在 DSCord 网页点“添加 Bot”,填名字(职责和模型可选),点“创建 Bot”。中央为它建 Bot 账号并保存设置。
|
|
49
|
+
4. 对话框给出连接密钥时点“复制连接密钥”(也可以在“设置 → 账号 → 连接 DSH”里生成),打开 DSH 左侧的 **DSCord** 粘贴,粘贴后自动连接。DSH 会从密钥里的服务器发现它接入的中央并登录,随后按中央的设置创建本机 Bot、完成设备码授权并让它上线,网页上的状态变成“在线”。
|
|
50
|
+
5. 之后的设置都在网页“我的 Bot”里:改名字、职责、模型和运行设备,让 Bot 加入或退出当前服务器。要让 Bot 也进其他聊天服务器,打开那台服务器的网页(它必须接入同一个中央),在“我的 Bot”里点“加入这台服务器”。
|
|
51
|
+
6. 在网页打开私聊或邀请 Bot 进入需要参与的群聊。参与方式、来源记忆和当前工作在 Bot 的状态主页中查看。
|
|
51
52
|
|
|
52
53
|
地址、账号、模型和路径都来自部署者自己的配置。业务模型没有指定服务商;自然参与的单独判断服务是可选功能,见[模型与参与设置](模型配置与迁移.md)。
|
|
53
54
|
|
|
54
55
|
Core 提供的 Matrix 公开地址必须能从节点访问。一个连接页使用一个归属账号,可管理多个本机 Bot。连接数据默认位于实际 DSH home 的 `dscord/<profile>/`,Bot 数据保留在该 home 的 `bot/`。断开服务器、退出连接页或关闭网页不删除本机 Bot;DSH 停止后,Bot 也无法继续工作。
|
|
55
56
|
|
|
57
|
+
决策服务在 DSH 的 DSCord 页面配置,提供 OpenAI Decisions、TypeSafe Jev、Cloudflare Clef / Clef Flash 预设。不同服务分别保存凭据;Cloudflare 默认地址还需要 Account ID。保存时使用合成输入检查参与判断和接话协议,连接检查不代表真实聊天判断质量。[决策服务配置](模型配置与迁移.md#决策模型)
|
|
58
|
+
|
|
59
|
+
## 自定义头像
|
|
60
|
+
|
|
61
|
+
在个人设置中更换自己的头像,或在所属 Bot 的设置中更换 Bot 头像;支持裁剪、预览和恢复默认,Bot 离线时也可修改。头像保存在中央数据目录的 `avatars/` 中,Core 自动同步到各聊天服务器。备份中央时同时保留数据库、签名密钥和头像目录。[头像配置与备份](central/avatars.md)
|
|
62
|
+
|
|
56
63
|
## 升级和已有 Bot
|
|
57
64
|
|
|
58
|
-
先备份服务器数据和各 DSH Bot home,再停止当前工作并升级到同一发行版本。已有 `ds-bot` 插件必须先停用,DSCord 才能在同一目录接管;同时启动两个内核会报冲突。接管会保存迁移快照,保留 Bot 与 Session 身份。[生命周期与迁移](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.
|
|
65
|
+
先备份服务器数据和各 DSH Bot home,再停止当前工作并升级到同一发行版本。已有 `ds-bot` 插件必须先停用,DSCord 才能在同一目录接管;同时启动两个内核会报冲突。接管会保存迁移快照,保留 Bot 与 Session 身份。[生命周期与迁移](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.4/apps/ds-group/deploy/kernel-runtime.md)
|
|
59
66
|
|
|
60
67
|
旧服务器 Bot 可在连接页绑定已有本机 Bot,保留 Matrix 身份;两套独立 Session 或记忆不会自动合并。已有原生绑定的 Bot 应从所属 DSH 连接,导出其配置不能在另一台机器创建身份副本。
|
|
61
68
|
|
|
@@ -93,11 +100,11 @@ dscord service down "$BOT_ACCOUNT"
|
|
|
93
100
|
dscord dsh stop "$BOT_ACCOUNT"
|
|
94
101
|
```
|
|
95
102
|
|
|
96
|
-
每个 Bot 同时运行一个节点,每个独立节点使用自己的数据目录和桥接端口。Linux 常驻服务见 [systemd](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.
|
|
103
|
+
每个 Bot 同时运行一个节点,每个独立节点使用自己的数据目录和桥接端口。Linux 常驻服务见 [systemd](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.4/apps/ds-group/deploy/systemd/README.md)。
|
|
97
104
|
|
|
98
105
|
## 存储与组件
|
|
99
106
|
|
|
100
|
-
附件默认使用 Synapse 本地媒体目录,可选 S3-compatible 后端。[存储配置](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.
|
|
107
|
+
附件默认使用 Synapse 本地媒体目录,可选 S3-compatible 后端。[存储配置](https://github.com/MixGeekers/dscord/blob/ds-group-v0.4.0-alpha.4/apps/ds-group/deploy/storage/README.md)说明远端对象、缓存和备份关系;已有云盘扫描与历史迁移不是自动功能。
|
|
101
108
|
|
|
102
109
|
`dscord server` 运行 Core/PWA,`init` 和 `bootstrap` 初始化服务器,`admin` 管理导出与导入,`node` 运行 Matrix 适配器,`dsh` 管理独立宿主,`service` 管理本机进程,`path` 显示安装目录。
|
|
103
110
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# DSCord Central(中央服务器)
|
|
2
|
+
|
|
3
|
+
中央服务器管四件事:账号、签发身份、服务器索引,以及每个人名下 Bot 的设置和运行它们的设备。它保存账号头像等身份资料,不存聊天消息和任务附件,不转发聊天,也不持有任何 Matrix token。聊天服务器(Core + Synapse)接入一台中央,人和 Bot 都用中央账号登录聊天服务器。整体方案见项目文档《DSCord 中央身份与聊天服务器方案》。
|
|
4
|
+
|
|
5
|
+
已经完成:账号、账号规则、身份令牌、Bot 节点授权(第一期),聊天服务器只认中央账号(第二期,见下文“聊天服务器怎样接入”),服务器索引和中央管理面板(第四期,见下文“服务器索引”和“管理面板”)。
|
|
6
|
+
|
|
7
|
+
## 运行
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm run central
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
数据目录默认是 `.local/central/`,可用 `DSCORD_CENTRAL_DATA_DIR` 指定;监听地址用 `DSCORD_CENTRAL_HOST`(默认 `127.0.0.1`)和 `DSCORD_CENTRAL_PORT`(默认 `18200`)。配置文件是数据目录下的 `central-config.json`,或由 `DSCORD_CENTRAL_CONFIG_PATH` 指定:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"issuer": "https://id.example.org",
|
|
18
|
+
"registration": "open",
|
|
19
|
+
"listing": "review",
|
|
20
|
+
"admins": ["alice"],
|
|
21
|
+
"usernameRule": { "minLength": 3, "maxLength": 32, "reserved": ["admin", "root", "system", "dscord", "central"] },
|
|
22
|
+
"clientOrigins": ["https://client.example.org"]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- `issuer`:中央的公开地址,必须是 HTTPS 的裸 origin(本机调试可用 `http://localhost`)。
|
|
27
|
+
- `domain`:全局身份 ID 里 `@` 后面的部分,默认取 `issuer` 的主机名。
|
|
28
|
+
- `registration`:`open` 允许自助注册,`closed` 关闭。
|
|
29
|
+
- `listing`:新登记的聊天服务器怎么处理。`review`(默认)等管理员审核后才上架,`open` 读到页面就直接上架。
|
|
30
|
+
- `admins`:中央管理员的用户名列表。管理员用自己的中央账号登录 `/admin` 管理面板。
|
|
31
|
+
- `registration` 和 `listing` 只是初始值:管理员在面板里改过之后,以面板里保存的为准(存在 `central.sqlite`)。
|
|
32
|
+
- `usernameRule`:账号规则,见下。
|
|
33
|
+
- `clientOrigins`:允许跨域调用中央接口的网页客户端 origin。
|
|
34
|
+
- `serverFetchUrls`:可选,`{ "聊天服务器 origin": "中央实际访问的地址" }`。中央所在网络访问不到某台聊天服务器的公开地址时(例如同一个 Compose 里),索引改从这里读它的公开页面。
|
|
35
|
+
|
|
36
|
+
配置文件不存在时,可以用环境变量 `DSCORD_CENTRAL_ISSUER`、`DSCORD_CENTRAL_REGISTRATION`、`DSCORD_CENTRAL_LISTING`、`DSCORD_CENTRAL_ADMINS`(逗号分隔的用户名)、`DSCORD_CENTRAL_CLIENT_ORIGINS`(逗号分隔)、`DSCORD_CENTRAL_SERVER_FETCH_URLS`(逗号分隔的 `origin=地址`)在第一次启动时生成它;之后以文件为准,改环境变量不再生效。Compose 自带的 `central` 服务就是这样启动的。
|
|
37
|
+
|
|
38
|
+
数据目录里的 `signing-key.json` 是签名私钥(权限 `0600`),`central.sqlite` 是账号库。备份时两者都要保留;换掉私钥后,聊天服务器要重新读取公钥。
|
|
39
|
+
|
|
40
|
+
## 自定义头像
|
|
41
|
+
|
|
42
|
+
用户可修改自己和名下 Bot 的头像和名称,Bot 也能改自己的,跨服务器同步,Bot 离线时也可设置。默认复用中央数据卷;入口、配置、备份和接口见[自定义头像](avatars.md)。
|
|
43
|
+
|
|
44
|
+
## 账号格式
|
|
45
|
+
|
|
46
|
+
账号规则由中央在配置里指定,并公开在 `GET /.well-known/dscord/central` 的 `usernameRule` 里。聊天服务器和客户端只读这份规则。
|
|
47
|
+
|
|
48
|
+
- 全局身份 ID 是 `用户名@domain`,例如 `alice@id.example.org`;在某台聊天服务器上的地址是 `@用户名:聊天服务器域名`。
|
|
49
|
+
- 用户名会直接当 Matrix 本地名用,所以中央的规则总是和 Matrix 本地名字符集(小写 a-z、0-9 和 `. _ = - /`)取交集:`pattern` 写得再宽,也收不进 Matrix 不接受的字符。`maxLength` 最多 64。
|
|
50
|
+
- 默认规则:3 到 32 个字符,小写字母开头,只含 a-z、0-9 和 `. _ -`。输入不分大小写,一律存小写。
|
|
51
|
+
- 用户名注册后不能改;账号注销后用户名也不回收。显示名可以改。
|
|
52
|
+
- 人和 Bot 共用同一个命名空间,是否 Bot 看账号类型。
|
|
53
|
+
|
|
54
|
+
## 接口
|
|
55
|
+
|
|
56
|
+
除注明外,接口用 `Authorization: Bearer <中央会话令牌>`。错误返回 `{ error, code }`。
|
|
57
|
+
|
|
58
|
+
| 接口 | 谁用 | 作用 |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `GET /.well-known/dscord/central` | 任何人 | 中央地址、账号规则、公钥地址、令牌有效期 |
|
|
61
|
+
| `GET /.well-known/jwks.json` | 聊天服务器 | 验证身份令牌的公钥 |
|
|
62
|
+
| `POST /api/register` | 人 | `{username, password, displayName?}` 注册并登录(`registration: open` 时) |
|
|
63
|
+
| `POST /api/login` / `POST /api/logout` | 人 | 密码登录 / 退出当前会话 |
|
|
64
|
+
| `GET /api/me` / `PATCH /api/me` / `DELETE /api/me` | 人、Bot 节点 / 人、Bot 节点 / 人 | 查看自己 / 改自己的显示名称 `{displayName}` / 注销(名下还有 Bot 时要先注销 Bot) |
|
|
65
|
+
| `POST /api/identity-tokens` | 人、Bot 节点 | `{audience, allowNew?}` 为某台聊天服务器(HTTPS origin)签发 5 分钟有效的身份令牌;第一次去某台服务器时要带 `allowNew: true`,否则返回 `GRANT_REQUIRED` |
|
|
66
|
+
| `GET /api/grants` / `DELETE /api/grants/:audience` | 人、Bot 节点 | 查看 / 撤销自己授权过的聊天服务器 |
|
|
67
|
+
| `POST /api/connect-codes` | 人 | 生成 DSH 连接用的一次性代码(10 分钟有效)。聊天服务器网页把它和服务器地址一起编码成连接密钥 |
|
|
68
|
+
| `POST /api/connect-codes/redeem` | DSH 插件(不用令牌) | `{code, label?}` 用代码换取这个人的中央会话;只能换一次,过期返回 `CONNECT_CODE_EXPIRED`,未知或已用返回 `CONNECT_CODE_UNKNOWN` |
|
|
69
|
+
| `POST /api/bots` / `GET /api/bots` / `DELETE /api/bots/:id` | 人 | 创建 / 列出 / 注销自己名下的 Bot。创建时给 `{displayName, username?, role?, model?, effort?, deviceId?, servers?}`,不给 `username` 时由名字生成;返回的每个 Bot 带 `settings`(`role`、`model`、`effort`、`deviceId`、`servers`、`revision`)和 `runtime`(运行它的设备是否在线,以及设备最近报告的 `revision`、`model`、`error`、各服务器的连接状态;还没有设备时是 `null`) |
|
|
70
|
+
| `PATCH /api/bots/:id` | 人 | 修改名字和上面的设置,只改给出的字段,`revision` 加一。`model` 为空表示跟随设备默认,`servers` 是 Bot 要加入的聊天服务器(HTTPS origin,最多 32 个),`deviceId` 必须是自己的设备 |
|
|
71
|
+
| `GET /api/devices` | 人 | 自己连接过的 DSH:`id`、`label`、是否在线(30 秒内报告过)、它能用的模型目录和插件版本 |
|
|
72
|
+
| `POST /api/devices/:id/sync` | DSH 插件(用连接密钥换来的会话) | `{label, report}` 报告这台设备的模型目录、插件版本和各 Bot 的状态,返回分给它的 Bot 和设置。还没有设备的 Bot 归第一台报告的设备;同一台 DSH 换了人登录时,前一个人的 Bot 回到等待 |
|
|
73
|
+
| `DELETE /api/devices/:id` | 人、DSH 插件 | 移除设备:它名下的 Bot 等下一台设备接手,设备报告用的会话同时结束 |
|
|
74
|
+
| `GET /api/bots/:id/grants`、`DELETE /api/bots/:id/grants/:audience` | Bot 的所属人 | 查看 / 撤销 Bot 的服务器授权 |
|
|
75
|
+
| `GET /api/bots/:id/nodes`、`DELETE /api/bots/:id/nodes/:nodeId` | Bot 的所属人 | 查看 / 撤销 Bot 的节点 |
|
|
76
|
+
| `POST /api/device` | Bot 节点 | `{label?}` 开始设备码授权,返回 `deviceCode`、`userCode`(10 分钟有效) |
|
|
77
|
+
| `POST /api/device/approve` | Bot 的所属人 | `{userCode, botId}` 把这个节点授权给自己的 Bot |
|
|
78
|
+
| `POST /api/device/token` | Bot 节点 | `{deviceCode}` 换取 Bot 的节点会话;所属人批准前返回 `AUTHORIZATION_PENDING`,只能换一次 |
|
|
79
|
+
| `GET /api/servers?q=&tag=&limit=` | 任何人(不用令牌,允许任意网页跨域) | 搜索服务器索引;`q` 是 8 位数字时按服务器号精确查找 |
|
|
80
|
+
| `POST /api/servers` | 任何人(不用令牌) | `{origin}` 把一台聊天服务器报给索引。中央去读它的公开页面,返回 `{server: {origin, number, status, note, discovery}}`;再报一次会重新读取页面、返回当前状态 |
|
|
81
|
+
| `GET /api/admin/overview` | 中央管理员 | 待审、已上架等数量,账号数量,当前设置 |
|
|
82
|
+
| `POST /api/admin/settings` | 中央管理员 | `{registration?, listing?}` 修改注册和登记审核设置 |
|
|
83
|
+
| `GET /api/admin/servers?status=` | 中央管理员 | 索引里的全部服务器(含待审、驳回、封禁),可按状态筛选 |
|
|
84
|
+
| `POST /api/admin/servers/:origin/decision` | 中央管理员 | `{status: listed\|rejected\|banned, note?}` 上架、驳回或封禁,留言会随下次登记带回给该服务器 |
|
|
85
|
+
| `POST /api/admin/servers/:origin/refresh` / `DELETE /api/admin/servers/:origin` | 中央管理员 | 立即重新读取 / 从索引里移除(收回服务器号) |
|
|
86
|
+
| `GET /api/admin/accounts?q=&kind=&status=` | 中央管理员 | 按用户名或显示名查账号 |
|
|
87
|
+
| `POST /api/admin/accounts/:id/disable` / `enable` | 中央管理员 | 停用(立即结束它的所有会话,不能再登录或拿身份令牌)/ 恢复账号;不能停用自己 |
|
|
88
|
+
|
|
89
|
+
## 服务器索引
|
|
90
|
+
|
|
91
|
+
索引只记每台聊天服务器自己公开的介绍,不存它的成员、房间和消息。
|
|
92
|
+
|
|
93
|
+
- 登记:聊天服务器把自己的 origin 报给中央(管理后台“服务器目录”里点一次“登记到中央”),不需要任何中央账号。中央从这个 origin 读 `GET /.well-known/dscord/server`,页面写明信任的中央就是自己、`origin` 也对得上,就收下这台服务器。
|
|
94
|
+
- 为什么不需要账号:页面是从这个 origin 读到的,又点名信任这台中央,本身就说明控制这个域名的人愿意被登记。聊天服务器在中央不持有任何凭据,也没有“所属人”;要改介绍就改自己的策略,中央下次读取时更新;不想被找到就把发现方式改成 `hidden`。
|
|
95
|
+
- 审核:新服务器默认是“待审核”,中央管理员在管理面板里上架、驳回或封禁,可以附一句留言。`listing: open` 时读到页面就直接上架。被驳回的服务器再登记一次会回到待审核;被封禁的再登记会被拒绝(`SERVER_BANNED`),直到管理员解除封禁。只有已上架的服务器能被搜到。
|
|
96
|
+
- 服务器号:第一次登记时,中央随机分配一个 8 位数字作为服务器号(像 QQ 群号),之后一直不变;管理员把它从索引里移除后再登记会拿到新号码。
|
|
97
|
+
- 发现方式由聊天服务器自己决定(页面里的 `discovery`):`public` 能被关键词搜到,也能凭号码找到;`number` 关键词搜不到,只能输入完整号码找到;`hidden` 搜不到也查不到号码,中央管理员仍能在面板里看到。
|
|
98
|
+
- 加入方式也由聊天服务器决定(`join`):`open` 任何中央账号都能直接加入,`invite` 只接受凭邀请码加入。中央只把它显示在搜索结果里,真正的检查在聊天服务器上。
|
|
99
|
+
- 刷新:中央每小时重新读一遍待审和已上架的服务器(读取不跟随跳转,页面上限 64 KB)。页面读不到或改信任别的中央时,保留上次的介绍并记下错误;连续三天没读到成功的页面,就从搜索结果里消失,读到后自动恢复。发现方式的改动在下次读取后生效。
|
|
100
|
+
- 搜索:只列已上架、三天内读到过的服务器。`q` 是 8 位数字时先按号码精确查找(`public` 和 `number` 的服务器);否则只搜 `public` 的服务器,`q` 里的每个词都要出现在名称、简介或标签里(不分大小写),按人数排序;`tag` 按标签精确筛选;`limit` 默认 20,最多 50。结果含服务器号和加入方式。
|
|
101
|
+
|
|
102
|
+
公开页面的格式(`schemaVersion: 1`):
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"schemaVersion": 1,
|
|
107
|
+
"central": "https://id.example.org",
|
|
108
|
+
"origin": "https://chat.example.org",
|
|
109
|
+
"discovery": "public",
|
|
110
|
+
"name": "机器人社",
|
|
111
|
+
"description": "机械臂和小车",
|
|
112
|
+
"tags": ["robots"],
|
|
113
|
+
"join": "invite",
|
|
114
|
+
"members": { "people": 12, "bots": 3 }
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`origin` 必须和登记的 origin 一致;`name` 最多 80 字,`description` 最多 500 字,`tags` 最多 10 个、每个最多 32 字。
|
|
119
|
+
|
|
120
|
+
## 管理面板
|
|
121
|
+
|
|
122
|
+
中央自己在 `/admin` 提供管理面板(构建产物在 `dist/central-admin`,可用 `DSCORD_CENTRAL_ADMIN_WEB_DIR` 指定别的目录)。用配置里 `admins` 列出的中央账号登录,其他账号会被拒绝。面板有四页:
|
|
123
|
+
|
|
124
|
+
- 概览:待审核和已上架的服务器数、人和 Bot 的账号数、当前设置。
|
|
125
|
+
- 服务器:按状态(待审核、已上架、已驳回、已封禁)查看所有登记过的服务器,看它的介绍、发现方式、加入方式、人数和最近一次读取结果;可以上架、驳回、封禁、解除封禁、立即重新读取或移除,并给该服务器的管理员留言。
|
|
126
|
+
- 账号:按用户名或显示名、类型、状态查账号,停用或恢复。
|
|
127
|
+
- 设置:新服务器要不要先审核,账号注册是否开放。
|
|
128
|
+
|
|
129
|
+
管理员名单只能在配置文件里改,面板里不能增删。
|
|
130
|
+
|
|
131
|
+
## 身份令牌
|
|
132
|
+
|
|
133
|
+
身份令牌是 Ed25519 签名的 JWT(`alg: EdDSA`),声明包括 `iss`、`sub`(账号编号)、`aud`(聊天服务器 origin)、`iat`、`exp`、`jti`、`username`、`gid`(全局身份 ID)、`kind`(`human` 或 `bot`)、`name`(显示名),Bot 另有 `owner`(所属人的全局身份 ID)。聊天服务器用 `central/tokens.mjs` 的 `verifyIdentityToken` 按中央公钥、`issuer` 和自己的 origin 校验。一张令牌只对写明的那台聊天服务器有效。
|
|
134
|
+
|
|
135
|
+
撤销授权后,中央不再为那台服务器签发令牌;聊天服务器的本地会话是短期的,续期时要重新拿令牌,因此撤销会在一个会话周期内生效。
|
|
136
|
+
|
|
137
|
+
## 聊天服务器怎样接入
|
|
138
|
+
|
|
139
|
+
聊天服务器(Core)在 `core-config.json` 的 `identity.central` 写中央地址(初始化时取 `DS_GROUP_CENTRAL_URL`),并把自己的 `publicUrl` 当作令牌的 `aud`。Core 从中央读 `/.well-known/dscord/central` 和公钥,遇到新的签名密钥时重新读一次。容器网络里 Core 访问中央的地址和中央的公开地址不同时,用 `DS_GROUP_CENTRAL_FETCH_URL` 指定前者;令牌里的 `iss` 仍是公开地址。
|
|
140
|
+
|
|
141
|
+
- 登录:客户端在中央拿到身份令牌,交给 Core 的 `POST /api/session {identityToken}`,换回这台服务器的会话。Core 没有注册、密码登录和演示登录接口。
|
|
142
|
+
- 账号对应:中央用户名就是 Matrix 本地名。账号第一次登录时 Core 在 Synapse 建同名用户;已有的同名初始化成员由这个中央账号认领,之后只认它。
|
|
143
|
+
- Bot:Bot 的身份令牌带 `owner`,Core 据此把 Bot 记在所属人名下。Bot 节点用自己的会话调 `POST /api/bots/:id/connect` 拿节点配置,并报告它在哪个 DSH 宿主上(`native`)。
|
|
144
|
+
- 发现:`GET /.well-known/dscord/client` 的 `central.issuer` 告诉客户端去哪台中央登录,`auth.audience` 是申请身份令牌时要写的 `audience`。没有配置中央的 Core 返回 `central: null`,登录接口返回 503。
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# 自定义头像与显示名称
|
|
2
|
+
|
|
3
|
+
用户可以修改自己的头像和显示名称,以及自己名下 Bot 的头像和名称;Bot 也能用前台工具 `update_profile` 改自己的名称和头像,和人一样。头像属于中央账号资料,使用稳定的中央账号 ID;Bot 不需要在线,修改头像不改变 Bot 的运行配置版本、不重启节点或 Session。
|
|
4
|
+
|
|
5
|
+
## 使用
|
|
6
|
+
|
|
7
|
+
- 自己:左下角账号菜单 → 个人设置 → 账号,头像下方是“显示名称”。自己的成员资料卡也提供更换入口。
|
|
8
|
+
- Bot 自己:前台工具 `update_profile`(`name`、`avatar` 本地图片路径、`clear_avatar`),由连接插件用 Bot 自己的中央会话调用下面的 `/api/me` 接口,不经过所属人。
|
|
9
|
+
- Bot:Bot 主页 → 设置,或“我的 Bot”展开对应 Bot 的设置。所属 Bot 的成员资料卡也能修改。
|
|
10
|
+
- 选择 JPG、PNG 或静态 WebP,拖动、缩放并预览,点击“保存头像”。手机支持拖动和双指缩放,键盘可操作缩放及水平/垂直位置滑块。
|
|
11
|
+
- “恢复默认”恢复名字首字占位。占位颜色由稳定身份决定。
|
|
12
|
+
- 上传失败保留原头像与裁剪预览。另一台设备先保存时显示冲突,读取最新版本并保留预览;再次保存表示用户选择覆盖该版本。
|
|
13
|
+
|
|
14
|
+
头像在账号入口、成员卡、消息、私聊与 Bot 导航、身份组和 `@` 候选中展示。消息展示当前头像,已有聊天正文不重写。Bot 标识与运行状态仍独立展示。中英文、明暗主题使用现有设置。
|
|
15
|
+
|
|
16
|
+
## 存储与配置
|
|
17
|
+
|
|
18
|
+
中央的 `central.sqlite` 中增加独立的 `account_profiles` 表,保存头像资源 ID、资料版本和更新时间。标准化后的图片默认位于中央数据目录下的 `avatars/<资源ID>/{64,128,512}.webp`。直接运行默认使用 `.local/central/`,可用已有 `DSCORD_CENTRAL_DATA_DIR` 指定数据目录。Compose 使用现有 `central-data` 卷中的 `/central/avatars/`,无需新增卷。
|
|
19
|
+
|
|
20
|
+
在中央的 `central-config.json` 中可增加以下字段;省略时采用这些默认值。修改后重启中央服务:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"avatars": {
|
|
25
|
+
"backend": "local",
|
|
26
|
+
"directory": "avatars",
|
|
27
|
+
"maxUploadBytes": 5242880,
|
|
28
|
+
"sizes": [64, 128, 512]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`directory` 可用绝对路径,或相对于中央数据目录的路径;使用外部目录时自行挂载持久卷。`maxUploadBytes` 范围为 1 至 20971520;反向代理的请求体限制应至少覆盖该值。三个基础尺寸必须保留,可增加 32–1024 之间的尺寸,最多六个。
|
|
34
|
+
|
|
35
|
+
本期后端为 `local`。现有 `DS_GROUP_S3_*` 配置用于 Synapse 的聊天附件与头像副本,不会改变中央头像的存储位置。中央的 S3 后端尚未实现。
|
|
36
|
+
|
|
37
|
+
备份中央时,除现有数据库和签名密钥外,同时备份头像目录;备份聊天服务器时继续覆盖其 Core 与 Synapse 数据。标准化的历史头像保留,避免破坏已有链接与 Matrix 历史引用,本期没有自动回收历史版本。原始上传图不持久保存,处理临时目录在请求结束时清除。
|
|
38
|
+
|
|
39
|
+
中央仅增加身份图片存储;聊天消息和任务附件继续由聊天服务器保存。
|
|
40
|
+
|
|
41
|
+
## 同步与图片处理
|
|
42
|
+
|
|
43
|
+
Core 每 5 秒按中央账号 ID 批量核对资料版本。读取到更新后保存到本地账号库(显示名称写入账号的名字),并使用该账号的服务凭据设置本服 Matrix 的 `displayname`,把图片同步至本服 Synapse 并设置 `avatar_url`。改名和换头像共用同一个资料版本:改名时版本加一,头像不变。图片上传成功但资料写入失败时保留 Matrix 媒体回执,后续重试复用该回执。Core 重启后从持久记录继续同步。中央或 Matrix 暂不可用不阻断聊天 API;恢复后继续补齐。
|
|
44
|
+
|
|
45
|
+
头像保存后,当前页面及同源标签页立即更新;其他设备和服务器通过版本轮询更新。无网络时不能保存,无法加载图片时显示占位。跨服务器同步是最终一致的,不要求所有服务器同时在线。
|
|
46
|
+
|
|
47
|
+
头像资料版本独立于 Bot 设置的 `revision`。中央写入采用版本检查,先完成图片持久化再更新引用;失败或旧版本不会改写当前头像。
|
|
48
|
+
|
|
49
|
+
服务端使用固定依赖 `sharp@0.35.5`,校验真实图片格式、静态帧、40MP 像素上限与裁剪边界,校正 EXIF 方向,重新编码 WebP 并去除原元数据。安装时保留 npm 的 optional dependencies,Linux amd64/arm64 使用对应平台的预编译 sharp/libvips 包;继续支持 `npm ci --ignore-scripts`。
|
|
50
|
+
|
|
51
|
+
头像图片是公开身份资源,通过无账号令牌的内容版本地址访问,可由浏览器缓存。编辑接口只接受账号本人(人或 Bot 自己的会话)或 Bot 所属人的中央会话,聊天服务器管理员身份不授予全局头像编辑权。
|
|
52
|
+
|
|
53
|
+
## 接口
|
|
54
|
+
|
|
55
|
+
| 中央接口 | 作用 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `PATCH /api/me` | 本人(人或 Bot)修改显示名称:`{displayName}`,最多 80 字,空字符串恢复为用户名;名字未变时不增加版本 |
|
|
58
|
+
| `GET /api/me/avatar` | 读取本人头像、版本及上传上限 |
|
|
59
|
+
| `PUT /api/me/avatar?version=N&left=X&top=Y&size=S` | 上传原图字节,按校正方向后的像素裁剪;裁剪参数可全部省略,使用居中裁剪 |
|
|
60
|
+
| `DELETE /api/me/avatar?version=N` | 恢复默认 |
|
|
61
|
+
| `GET/PUT/DELETE /api/bots/:id/avatar` | 所属人修改对应 Bot,参数同上 |
|
|
62
|
+
| `GET /api/profiles?ids=...` | 读取至多 100 个已知中央账号 ID 或全局 ID 的公开资料:显示名称、头像和资料版本 |
|
|
63
|
+
| `GET /avatars/:assetId/:size.webp` | 读取标准化图片 |
|
|
64
|
+
|
|
65
|
+
写入缺少版本返回 400,版本冲突返回 409;格式不支持返回 415,超限返回 413。客户端调用中央时使用既有 `clientOrigins` 跨域配置。
|
|
66
|
+
|
|
67
|
+
## 验证
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm run check
|
|
71
|
+
npm run build
|
|
72
|
+
node --test --test-concurrency=1 tests/*.browser.mjs
|
|
73
|
+
node tests/avatars.runtime.mjs
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`avatars.test.mjs` 覆盖真实中央 HTTP、图片解码、权限、失败保留、并发更新、持久化和带 Matrix 替身的同步重试。`avatars.browser.mjs` 使用真实中央 HTTP 与 Chromium 验证裁剪、保存、跨标签、冲突、离线 Bot、手机布局及双语。`avatars.runtime.mjs` 显式启动两套隔离 Synapse/Core,验证自动同步、真实 Matrix 媒体、中央/Core 重启及恢复默认;使用合成账号,模型调用为 0。运行日志和截图位于忽略的 `.local/avatar-validation/`,运行脚本会移除本次创建的容器。
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
|
|
6
|
+
const failure = (status, code) => Object.assign(new Error(code), { status, code });
|
|
7
|
+
const ASSET = /^[a-f0-9]{64}$/;
|
|
8
|
+
export function avatarOptions(input = {}) {
|
|
9
|
+
const result = { backend: 'local', directory: 'avatars', maxUploadBytes: 5 * 1024 * 1024, sizes: [64, 128, 512], ...input };
|
|
10
|
+
if (result.backend !== 'local') throw new Error('avatars.backend currently supports local storage');
|
|
11
|
+
if (typeof result.directory !== 'string' || !result.directory.trim()) throw new Error('avatars.directory is required');
|
|
12
|
+
if (!Number.isSafeInteger(result.maxUploadBytes) || result.maxUploadBytes < 1 || result.maxUploadBytes > 20 * 1024 * 1024) throw new Error('avatars.maxUploadBytes must be between 1 and 20971520');
|
|
13
|
+
if (!Array.isArray(result.sizes) || ![64, 128, 512].every(size => result.sizes.includes(size)) || result.sizes.length > 6 || result.sizes.some(size => !Number.isSafeInteger(size) || size < 32 || size > 1024)) throw new Error('avatars.sizes must include 64, 128 and 512, with at most six sizes between 32 and 1024');
|
|
14
|
+
result.sizes = [...new Set(result.sizes)].sort((a, b) => a - b);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Public profiles: the display name and identity image. Original uploads and processing directories are never published. */
|
|
19
|
+
export class Avatars {
|
|
20
|
+
constructor({ store, dataRoot, issuer, config }) {
|
|
21
|
+
Object.assign(this, { store, issuer });
|
|
22
|
+
this.options = avatarOptions(config);
|
|
23
|
+
this.directory = path.resolve(dataRoot, this.options.directory);
|
|
24
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS account_profiles (
|
|
25
|
+
account_id TEXT PRIMARY KEY REFERENCES accounts(id), version INTEGER NOT NULL,
|
|
26
|
+
asset_id TEXT, updated_at INTEGER NOT NULL);`);
|
|
27
|
+
store.profileOf = account => this.profile(account);
|
|
28
|
+
// A name change is a profile change too: the version moves on and the image stays.
|
|
29
|
+
store.profileChanged = account => this.store.db.prepare(`INSERT INTO account_profiles VALUES (?,1,NULL,?) ON CONFLICT(account_id)
|
|
30
|
+
DO UPDATE SET version=version+1,updated_at=excluded.updated_at`).run(account.id, this.store.now());
|
|
31
|
+
}
|
|
32
|
+
profile(account) {
|
|
33
|
+
const row = this.store.db.prepare('SELECT * FROM account_profiles WHERE account_id=?').get(account.id);
|
|
34
|
+
return { id: account.id, globalId: this.store.globalId(account.username), displayName: account.display_name, profileVersion: row?.version ?? 0,
|
|
35
|
+
avatar: row?.asset_id ? { id: row.asset_id, url: `${this.issuer}/avatars/${row.asset_id}/128.webp`,
|
|
36
|
+
sizes: Object.fromEntries(this.options.sizes.map(size => [size, `${this.issuer}/avatars/${row.asset_id}/${size}.webp`])) } : null };
|
|
37
|
+
}
|
|
38
|
+
expected(account, value) {
|
|
39
|
+
if (value === null || !/^\d+$/.test(value)) throw failure(400, 'AVATAR_VERSION_REQUIRED');
|
|
40
|
+
const version = Number(value);
|
|
41
|
+
if (!Number.isSafeInteger(version) || this.profile(account).profileVersion !== version) throw failure(409, 'AVATAR_CHANGED');
|
|
42
|
+
return version;
|
|
43
|
+
}
|
|
44
|
+
commit(account, version, assetId) {
|
|
45
|
+
this.store.db.exec('BEGIN IMMEDIATE');
|
|
46
|
+
try {
|
|
47
|
+
this.expected(account, String(version));
|
|
48
|
+
if (this.store.account(account.id)?.status !== 'active') throw failure(403, 'ACCOUNT_INACTIVE');
|
|
49
|
+
this.store.db.prepare(`INSERT INTO account_profiles VALUES (?,?,?,?) ON CONFLICT(account_id) DO UPDATE SET
|
|
50
|
+
version=excluded.version,asset_id=excluded.asset_id,updated_at=excluded.updated_at`).run(account.id, version + 1, assetId, this.store.now());
|
|
51
|
+
this.store.db.exec('COMMIT');
|
|
52
|
+
} catch (error) { this.store.db.exec('ROLLBACK'); throw error; }
|
|
53
|
+
return this.profile(account);
|
|
54
|
+
}
|
|
55
|
+
async upload(request, query) {
|
|
56
|
+
const type = String(request.headers['content-type'] || '').split(';')[0];
|
|
57
|
+
if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) throw failure(415, 'AVATAR_FORMAT');
|
|
58
|
+
if (Number(request.headers['content-length']) > this.options.maxUploadBytes) throw failure(413, 'AVATAR_TOO_LARGE');
|
|
59
|
+
let size = 0;
|
|
60
|
+
const chunks = [];
|
|
61
|
+
// Do not destroy the socket on a bounded-body rejection; let HTTP return the error.
|
|
62
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
63
|
+
size += chunk.length;
|
|
64
|
+
if (size > this.options.maxUploadBytes) { request.resume(); throw failure(413, 'AVATAR_TOO_LARGE'); }
|
|
65
|
+
chunks.push(chunk);
|
|
66
|
+
}
|
|
67
|
+
let bytes;
|
|
68
|
+
try {
|
|
69
|
+
const image = sharp(Buffer.concat(chunks), { limitInputPixels: 40_000_000, failOn: 'warning' });
|
|
70
|
+
const meta = await image.metadata();
|
|
71
|
+
if (!['jpeg', 'png', 'webp'].includes(meta.format) || (meta.pages ?? 1) !== 1) throw failure(415, 'AVATAR_FORMAT');
|
|
72
|
+
const swapped = [5, 6, 7, 8].includes(meta.orientation);
|
|
73
|
+
const width = swapped ? meta.height : meta.width, height = swapped ? meta.width : meta.height;
|
|
74
|
+
const cropKeys = ['left', 'top', 'size'];
|
|
75
|
+
const hasCrop = cropKeys.some(key => query.has(key));
|
|
76
|
+
let pipeline = image.autoOrient();
|
|
77
|
+
if (hasCrop) {
|
|
78
|
+
const [left, top, edge] = cropKeys.map(key => query.has(key) ? Number(query.get(key)) : NaN);
|
|
79
|
+
if (![left, top, edge].every(Number.isSafeInteger) || left < 0 || top < 0 || edge < 1 || left + edge > width || top + edge > height) throw failure(400, 'AVATAR_CROP');
|
|
80
|
+
pipeline = pipeline.extract({ left, top, width: edge, height: edge });
|
|
81
|
+
}
|
|
82
|
+
bytes = await pipeline.resize(512, 512, { fit: 'cover' }).webp({ quality: 85 }).toBuffer();
|
|
83
|
+
} catch (error) { if (error.status) throw error; throw failure(400, 'AVATAR_INVALID_IMAGE'); }
|
|
84
|
+
const id = createHash('sha256').update(bytes).digest('hex');
|
|
85
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
86
|
+
const temp = path.join(this.directory, `.upload-${randomUUID()}`);
|
|
87
|
+
await mkdir(temp, { mode: 0o700 });
|
|
88
|
+
try {
|
|
89
|
+
for (const edge of this.options.sizes) {
|
|
90
|
+
const output = edge === 512 ? bytes : await sharp(bytes).resize(edge, edge).webp({ quality: 85 }).toBuffer();
|
|
91
|
+
await writeFile(path.join(temp, `${edge}.webp`), output, { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
try { await rename(temp, path.join(this.directory, id)); }
|
|
94
|
+
catch (error) { if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
|
|
95
|
+
} finally { await rm(temp, { recursive: true, force: true }); }
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
async serve(response, id, size) {
|
|
99
|
+
if (!ASSET.test(id) || !this.options.sizes.includes(Number(size))) throw failure(404, 'AVATAR_NOT_FOUND');
|
|
100
|
+
let bytes;
|
|
101
|
+
try { bytes = await readFile(path.join(this.directory, id, `${size}.webp`)); }
|
|
102
|
+
catch (error) { if (error.code === 'ENOENT') throw failure(404, 'AVATAR_NOT_FOUND'); throw error; }
|
|
103
|
+
response.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': bytes.length,
|
|
104
|
+
'Cache-Control': 'public, max-age=86400, immutable', 'X-Content-Type-Options': 'nosniff',
|
|
105
|
+
'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });
|
|
106
|
+
response.end(bytes);
|
|
107
|
+
}
|
|
108
|
+
}
|