dscord 0.2.0-alpha.1 → 0.4.0-alpha.3

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.
Files changed (231) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +27 -0
  3. package/README.md +77 -49
  4. package/bin/dscord.mjs +5 -4
  5. package/central/README.md +144 -0
  6. package/central/avatars.md +74 -0
  7. package/central/avatars.mjs +105 -0
  8. package/central/directory.mjs +224 -0
  9. package/central/rules.mjs +51 -0
  10. package/central/service.mjs +362 -0
  11. package/central/store.mjs +400 -0
  12. package/central/tokens.mjs +63 -0
  13. package/cordis.patch.yml +3 -0
  14. package/deploy/synapse/dscord_guard.py +212 -0
  15. package/dist/admin/assets/index-B6oXrmH-.js +10 -0
  16. package/dist/admin/assets/index-C7fyavkW.css +1 -0
  17. package/dist/admin/assets/mark-128-C4ed4p0U.png +0 -0
  18. package/dist/admin/index.html +13 -0
  19. package/dist/admin/licenses/bundled-dependencies.txt +421 -0
  20. package/dist/admin/licenses/lucide.txt +43 -0
  21. package/dist/admin/licenses/ui-foundation.txt +336 -0
  22. package/dist/admin/theme.js +66 -0
  23. package/dist/central-admin/assets/index-C-slX89W.css +1 -0
  24. package/dist/central-admin/assets/index-C06XyvSK.js +9 -0
  25. package/dist/central-admin/assets/mark-128-C4ed4p0U.png +0 -0
  26. package/dist/central-admin/index.html +13 -0
  27. package/dist/central-admin/licenses/bundled-dependencies.txt +421 -0
  28. package/dist/central-admin/licenses/lucide.txt +43 -0
  29. package/dist/central-admin/licenses/ui-foundation.txt +336 -0
  30. package/dist/central-admin/theme.js +66 -0
  31. package/dist/web/assets/apple-touch-icon-C_qAgjR1.png +0 -0
  32. package/dist/web/assets/common-DGlCXKV6.js +5 -0
  33. package/dist/web/assets/favicon-32-CEKpvmlx.png +0 -0
  34. package/dist/web/assets/favicon-6aWeKmcy.ico +0 -0
  35. package/dist/web/assets/index-C5Owa1w-.js +55 -0
  36. package/dist/web/assets/index-Cr0sYP4g.css +1 -0
  37. package/dist/web/assets/{manifest-eq_1S_IC.webmanifest → manifest-Ca-bIZpP.webmanifest} +2 -8
  38. package/dist/web/assets/mark-128-C4ed4p0U.png +0 -0
  39. package/dist/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  40. package/dist/web/client-config.json +3 -0
  41. package/dist/web/icons/apple-touch-icon.png +0 -0
  42. package/dist/web/icons/favicon-32.png +0 -0
  43. package/dist/web/icons/favicon.ico +0 -0
  44. package/dist/web/icons/icon-192.png +0 -0
  45. package/dist/web/icons/icon-512.png +0 -0
  46. package/dist/web/icons/mark-128.png +0 -0
  47. package/dist/web/index.html +9 -7
  48. package/dist/web/licenses/bundled-dependencies.txt +2452 -0
  49. package/dist/web/licenses/ui-foundation.txt +336 -0
  50. package/dist/web/manifest.webmanifest +2 -8
  51. package/dist/web/sw.js +233 -79
  52. package/dist/web/theme.js +27 -5
  53. package/dsh-connect/KERNEL.md +90 -0
  54. package/dsh-connect/client.js +29858 -0
  55. package/dsh-connect/controller.mjs +525 -0
  56. package/dsh-connect/decision-settings.mjs +111 -0
  57. package/dsh-connect/index.mjs +44 -0
  58. package/dsh-connect/kernel-runtime.mjs +120 -0
  59. package/dsh-connect/kernel-updates.mjs +243 -0
  60. package/dsh-connect/native-bots.mjs +93 -0
  61. package/dsh-connect/native-bridge.mjs +466 -0
  62. package/dsh-connect/native-connections.mjs +201 -0
  63. package/dsh-connect/native-team.mjs +63 -0
  64. package/dsh-connect/work-core.mjs +656 -0
  65. package/dsh-plugin/README.md +32 -31
  66. package/dsh-plugin/index.mjs +18 -11
  67. package/dsh-plugin/model-profile.mjs +73 -0
  68. package/dsh-plugin/runtime-dependencies-lock.json +696 -6
  69. package/dsh-plugin/runtime-dependencies-pnpm-lock.yaml +489 -0
  70. package/dsh-plugin/runtime-dependencies.json +7 -2
  71. package/dsh-plugin/runtime-rules.mjs +10 -2
  72. package/dsh-plugin/runtime.mjs +330 -45
  73. package/dsh-plugin/scoped-work.mjs +73 -0
  74. package/dsh-plugin/work-files.mjs +24 -5
  75. package/icon.png +0 -0
  76. package/memory/contacts.mjs +36 -0
  77. package/memory/ingress.mjs +57 -0
  78. package/memory/model-options.mjs +9 -0
  79. package/memory/native.mjs +374 -0
  80. package/memory/observation-policy.mjs +67 -0
  81. package/memory/ombre.mjs +165 -0
  82. package/memory/panel.mjs +88 -0
  83. package/memory/review.mjs +90 -0
  84. package/memory/service.mjs +443 -0
  85. package/memory/snapshot-context.mjs +95 -0
  86. package/memory/source-query.mjs +87 -0
  87. package/memory/store.mjs +351 -0
  88. package/memory/views.mjs +305 -0
  89. package/memory//345/244/215/347/224/250/344/270/216/350/277/220/350/241/214.md +47 -0
  90. 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 +73 -0
  91. package/package.json +59 -7
  92. package/scripts/admin.mjs +41 -0
  93. package/scripts/dsh-node.mjs +92 -54
  94. package/scripts/memory-setup.mjs +21 -0
  95. package/scripts/runtime-config.mjs +55 -23
  96. package/scripts/runtime-dependencies.mjs +79 -0
  97. package/scripts/server-admin.mjs +25 -3
  98. package/scripts/server-bootstrap.mjs +55 -9
  99. package/scripts/server-init.mjs +92 -11
  100. package/scripts/service.mjs +39 -14
  101. package/service/ombre/LICENSE +21 -0
  102. package/service/ombre/SOURCE.json +15 -0
  103. package/service/ombre/controlled_store.py +295 -0
  104. package/service/ombre/harrier_profile.py +320 -0
  105. package/service/ombre/protocol.py +67 -0
  106. package/service/ombre/requirements.lock.txt +1100 -0
  107. package/service/ombre/semantic.py +449 -0
  108. package/service/ombre/service.py +204 -0
  109. package/src/accounts.mjs +292 -0
  110. package/src/admin-auth.mjs +246 -0
  111. package/src/admin-directory.mjs +69 -0
  112. package/src/admin.mjs +326 -0
  113. package/src/attachment-access.mjs +57 -0
  114. package/src/attention.mjs +140 -0
  115. package/src/bot-node.mjs +888 -75
  116. package/src/bot-work-policy.mjs +77 -0
  117. package/src/bot-work.mjs +276 -0
  118. package/src/budget.mjs +22 -7
  119. package/src/central-trust.mjs +53 -0
  120. package/src/collaboration.mjs +178 -0
  121. package/src/contact-book.mjs +301 -0
  122. package/src/core/attachment-routes.mjs +43 -0
  123. package/src/core/bot-routes.mjs +25 -0
  124. package/src/core/node-routes.mjs +66 -0
  125. package/src/core/room-routes.mjs +285 -0
  126. package/src/core/router.mjs +31 -0
  127. package/src/core/server-page.mjs +23 -0
  128. package/src/core/session-routes.mjs +69 -0
  129. package/src/core.mjs +157 -360
  130. package/src/decision-presets.mjs +47 -0
  131. package/src/decision-provider.mjs +185 -0
  132. package/src/file-formats.mjs +1 -1
  133. package/src/governance.mjs +424 -0
  134. package/src/http-json.mjs +19 -0
  135. package/src/identity.mjs +89 -0
  136. package/src/invites.mjs +71 -0
  137. package/src/kernel-control.mjs +419 -0
  138. package/src/kernel-node-client.mjs +164 -0
  139. package/src/matrix-client.mjs +70 -13
  140. package/src/media.mjs +50 -28
  141. package/src/mentions.mjs +28 -8
  142. package/src/message-context.mjs +115 -0
  143. package/src/model-config.mjs +51 -0
  144. package/src/model-failure.mjs +5 -0
  145. package/src/native-bindings.mjs +37 -0
  146. package/src/native-request.mjs +42 -0
  147. package/src/node-auth.mjs +60 -0
  148. package/src/node-delivery.mjs +3 -3
  149. package/src/node-files.mjs +29 -7
  150. package/src/node-protocol.mjs +99 -0
  151. package/src/node-store.mjs +606 -13
  152. package/src/node-work.mjs +43 -0
  153. package/src/participant-labels.mjs +9 -0
  154. package/src/profile-sync.mjs +89 -0
  155. package/src/push.mjs +23 -3
  156. package/src/room-media.mjs +122 -0
  157. package/src/synapse-control.mjs +409 -0
  158. package/src/tasks.mjs +19 -5
  159. package/src/verified-media.mjs +283 -0
  160. package/src/web-mode.mjs +9 -0
  161. package/vendor/ds-bot/NOTICE +86 -0
  162. package/vendor/ds-bot/UPSTREAM.json +83 -5
  163. package/vendor/ds-bot/client.js +15079 -9868
  164. package/vendor/ds-bot/index.js +6 -2
  165. package/vendor/ds-bot/package.json +9 -4
  166. package/vendor/ds-bot/src/host/approvals.js +54 -7
  167. package/vendor/ds-bot/src/host/browser/script-child.js +136 -0
  168. package/vendor/ds-bot/src/host/browser/script-runner.js +77 -0
  169. package/vendor/ds-bot/src/host/browser/script.js +1 -0
  170. package/vendor/ds-bot/src/host/browser/session.js +19 -29
  171. package/vendor/ds-bot/src/host/capabilities.js +6 -3
  172. package/vendor/ds-bot/src/host/channel.js +214 -54
  173. package/vendor/ds-bot/src/host/computer/catalog.js +91 -0
  174. package/vendor/ds-bot/src/host/computer/index.js +1213 -0
  175. package/vendor/ds-bot/src/host/computer/leases.js +71 -0
  176. package/vendor/ds-bot/src/host/connectors/catalog.js +96 -0
  177. package/vendor/ds-bot/src/host/connectors/gateway.js +264 -0
  178. package/vendor/ds-bot/src/host/connectors/index.js +863 -0
  179. package/vendor/ds-bot/src/host/connectors/mail/index.js +613 -0
  180. package/vendor/ds-bot/src/host/connectors/mail/presets.js +23 -0
  181. package/vendor/ds-bot/src/host/connectors/mail/transport.js +306 -0
  182. package/vendor/ds-bot/src/host/connectors/oauth.js +191 -0
  183. package/vendor/ds-bot/src/host/constants.js +1 -1
  184. package/vendor/ds-bot/src/host/context-failure.js +11 -0
  185. package/vendor/ds-bot/src/host/delivery.js +6 -3
  186. package/vendor/ds-bot/src/host/feed.js +10 -3
  187. package/vendor/ds-bot/src/host/groups.js +58 -16
  188. package/vendor/ds-bot/src/host/home-lock.js +103 -35
  189. package/vendor/ds-bot/src/host/im/format.js +190 -0
  190. package/vendor/ds-bot/src/host/im/index.js +943 -0
  191. package/vendor/ds-bot/src/host/im/platforms/dingtalk.js +207 -0
  192. package/vendor/ds-bot/src/host/im/platforms/discord.js +234 -0
  193. package/vendor/ds-bot/src/host/im/platforms/feishu.js +352 -0
  194. package/vendor/ds-bot/src/host/im/platforms/index.js +12 -0
  195. package/vendor/ds-bot/src/host/im/platforms/qq.js +216 -0
  196. package/vendor/ds-bot/src/host/im/platforms/slack.js +210 -0
  197. package/vendor/ds-bot/src/host/im/platforms/telegram.js +249 -0
  198. package/vendor/ds-bot/src/host/im/platforms/wecom.js +217 -0
  199. package/vendor/ds-bot/src/host/im/platforms/weixin.js +256 -0
  200. package/vendor/ds-bot/src/host/images.js +22 -7
  201. package/vendor/ds-bot/src/host/learning.js +27 -7
  202. package/vendor/ds-bot/src/host/legacy.mjs +4 -0
  203. package/vendor/ds-bot/src/host/local-agents/acp.js +232 -0
  204. package/vendor/ds-bot/src/host/local-agents/adapter.js +540 -0
  205. package/vendor/ds-bot/src/host/local-agents/claude.js +179 -0
  206. package/vendor/ds-bot/src/host/local-agents/codex.js +175 -0
  207. package/vendor/ds-bot/src/host/local-agents/discover.js +99 -0
  208. package/vendor/ds-bot/src/host/local-agents/engines.js +65 -0
  209. package/vendor/ds-bot/src/host/local-agents/index.js +235 -0
  210. package/vendor/ds-bot/src/host/local-agents/jsonrpc.js +111 -0
  211. package/vendor/ds-bot/src/host/local-agents/mcp.js +141 -0
  212. package/vendor/ds-bot/src/host/memory.js +14 -1
  213. package/vendor/ds-bot/src/host/message-source.js +56 -0
  214. package/vendor/ds-bot/src/host/parts.js +259 -48
  215. package/vendor/ds-bot/src/host/providers.js +142 -17
  216. package/vendor/ds-bot/src/host/resume.js +54 -4
  217. package/vendor/ds-bot/src/host/roster.js +25 -3
  218. package/vendor/ds-bot/src/host/schedules.js +101 -7
  219. package/vendor/ds-bot/src/host/steward.js +2 -1
  220. package/vendor/ds-bot/src/host/store.js +52 -5
  221. package/vendor/ds-bot/src/host/tools.js +102 -15
  222. package/vendor/ds-bot/src/host/turns.js +28 -5
  223. package/vendor/ds-bot/src/host/update.js +3 -0
  224. package/vendor/ds-bot/src/shared/im-platforms.js +86 -0
  225. package//346/250/241/345/236/213/351/205/215/347/275/256/344/270/216/350/277/201/347/247/273.md +99 -0
  226. package/dist/web/assets/apple-touch-icon-BVvy03qx.png +0 -0
  227. package/dist/web/assets/icon-C8lhqe5n.svg +0 -6
  228. package/dist/web/assets/index-D2MsIF_C.js +0 -20
  229. package/dist/web/assets/index-DpwihgYi.css +0 -1
  230. package/dist/web/icons/icon.svg +0 -6
  231. package/vendor/ds-bot/README.md +0 -258
@@ -0,0 +1,74 @@
1
+ # 自定义头像
2
+
3
+ 用户可以修改自己的头像,以及自己名下 Bot 的头像。头像属于中央账号资料,使用稳定的中央账号 ID;Bot 不需要在线,修改头像不改变 Bot 的运行配置版本、不重启节点或 Session。
4
+
5
+ ## 使用
6
+
7
+ - 自己:左下角账号菜单 → 个人设置 → 账号。自己的成员资料卡也提供更换入口。
8
+ - Bot:Bot 主页 → 设置,或“我的 Bot”展开对应 Bot 的设置。所属 Bot 的成员资料卡也能修改。
9
+ - 选择 JPG、PNG 或静态 WebP,拖动、缩放并预览,点击“保存头像”。手机支持拖动和双指缩放,键盘可操作缩放及水平/垂直位置滑块。
10
+ - “恢复默认”恢复名字首字占位。占位颜色由稳定身份决定。
11
+ - 上传失败保留原头像与裁剪预览。另一台设备先保存时显示冲突,读取最新版本并保留预览;再次保存表示用户选择覆盖该版本。
12
+
13
+ 头像在账号入口、成员卡、消息、私聊与 Bot 导航、身份组和 `@` 候选中展示。消息展示当前头像,已有聊天正文不重写。Bot 标识与运行状态仍独立展示。中英文、明暗主题使用现有设置。
14
+
15
+ ## 存储与配置
16
+
17
+ 中央的 `central.sqlite` 中增加独立的 `account_profiles` 表,保存头像资源 ID、资料版本和更新时间。标准化后的图片默认位于中央数据目录下的 `avatars/<资源ID>/{64,128,512}.webp`。直接运行默认使用 `.local/central/`,可用已有 `DSCORD_CENTRAL_DATA_DIR` 指定数据目录。Compose 使用现有 `central-data` 卷中的 `/central/avatars/`,无需新增卷。
18
+
19
+ 在中央的 `central-config.json` 中可增加以下字段;省略时采用这些默认值。修改后重启中央服务:
20
+
21
+ ```json
22
+ {
23
+ "avatars": {
24
+ "backend": "local",
25
+ "directory": "avatars",
26
+ "maxUploadBytes": 5242880,
27
+ "sizes": [64, 128, 512]
28
+ }
29
+ }
30
+ ```
31
+
32
+ `directory` 可用绝对路径,或相对于中央数据目录的路径;使用外部目录时自行挂载持久卷。`maxUploadBytes` 范围为 1 至 20971520;反向代理的请求体限制应至少覆盖该值。三个基础尺寸必须保留,可增加 32–1024 之间的尺寸,最多六个。
33
+
34
+ 本期后端为 `local`。现有 `DS_GROUP_S3_*` 配置用于 Synapse 的聊天附件与头像副本,不会改变中央头像的存储位置。中央的 S3 后端尚未实现。
35
+
36
+ 备份中央时,除现有数据库和签名密钥外,同时备份头像目录;备份聊天服务器时继续覆盖其 Core 与 Synapse 数据。标准化的历史头像保留,避免破坏已有链接与 Matrix 历史引用,本期没有自动回收历史版本。原始上传图不持久保存,处理临时目录在请求结束时清除。
37
+
38
+ 中央仅增加身份图片存储;聊天消息和任务附件继续由聊天服务器保存。
39
+
40
+ ## 同步与图片处理
41
+
42
+ Core 每 5 秒按中央账号 ID 批量核对资料版本。读取到更新后保存到本地账号库,并使用该账号的服务凭据将图片同步至本服 Synapse,设置对应的 Matrix `avatar_url`。图片上传成功但资料写入失败时保留 Matrix 媒体回执,后续重试复用该回执。Core 重启后从持久记录继续同步。中央或 Matrix 暂不可用不阻断聊天 API;恢复后继续补齐。
43
+
44
+ 头像保存后,当前页面及同源标签页立即更新;其他设备和服务器通过版本轮询更新。无网络时不能保存,无法加载图片时显示占位。跨服务器同步是最终一致的,不要求所有服务器同时在线。
45
+
46
+ 头像资料版本独立于 Bot 设置的 `revision`。中央写入采用版本检查,先完成图片持久化再更新引用;失败或旧版本不会改写当前头像。
47
+
48
+ 服务端使用固定依赖 `sharp@0.35.5`,校验真实图片格式、静态帧、40MP 像素上限与裁剪边界,校正 EXIF 方向,重新编码 WebP 并去除原元数据。安装时保留 npm 的 optional dependencies,Linux amd64/arm64 使用对应平台的预编译 sharp/libvips 包;继续支持 `npm ci --ignore-scripts`。
49
+
50
+ 头像图片是公开身份资源,通过无账号令牌的内容版本地址访问,可由浏览器缓存。编辑接口只接受本人或 Bot 所属人的中央会话,聊天服务器管理员身份不授予全局头像编辑权。
51
+
52
+ ## 接口
53
+
54
+ | 中央接口 | 作用 |
55
+ | --- | --- |
56
+ | `GET /api/me/avatar` | 读取本人头像、版本及上传上限 |
57
+ | `PUT /api/me/avatar?version=N&left=X&top=Y&size=S` | 上传原图字节,按校正方向后的像素裁剪;裁剪参数可全部省略,使用居中裁剪 |
58
+ | `DELETE /api/me/avatar?version=N` | 恢复默认 |
59
+ | `GET/PUT/DELETE /api/bots/:id/avatar` | 所属人修改对应 Bot,参数同上 |
60
+ | `GET /api/profiles?ids=...` | 读取至多 100 个已知中央账号 ID 或全局 ID 的公开头像版本 |
61
+ | `GET /avatars/:assetId/:size.webp` | 读取标准化图片 |
62
+
63
+ 写入缺少版本返回 400,版本冲突返回 409;格式不支持返回 415,超限返回 413。客户端调用中央时使用既有 `clientOrigins` 跨域配置。
64
+
65
+ ## 验证
66
+
67
+ ```sh
68
+ npm run check
69
+ npm run build
70
+ node --test --test-concurrency=1 tests/*.browser.mjs
71
+ node tests/avatars.runtime.mjs
72
+ ```
73
+
74
+ `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,105 @@
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
+ /** Identity images only. 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
+ }
29
+ profile(account) {
30
+ const row = this.store.db.prepare('SELECT * FROM account_profiles WHERE account_id=?').get(account.id);
31
+ return { id: account.id, globalId: this.store.globalId(account.username), profileVersion: row?.version ?? 0,
32
+ avatar: row?.asset_id ? { id: row.asset_id, url: `${this.issuer}/avatars/${row.asset_id}/128.webp`,
33
+ sizes: Object.fromEntries(this.options.sizes.map(size => [size, `${this.issuer}/avatars/${row.asset_id}/${size}.webp`])) } : null };
34
+ }
35
+ expected(account, value) {
36
+ if (value === null || !/^\d+$/.test(value)) throw failure(400, 'AVATAR_VERSION_REQUIRED');
37
+ const version = Number(value);
38
+ if (!Number.isSafeInteger(version) || this.profile(account).profileVersion !== version) throw failure(409, 'AVATAR_CHANGED');
39
+ return version;
40
+ }
41
+ commit(account, version, assetId) {
42
+ this.store.db.exec('BEGIN IMMEDIATE');
43
+ try {
44
+ this.expected(account, String(version));
45
+ if (this.store.account(account.id)?.status !== 'active') throw failure(403, 'ACCOUNT_INACTIVE');
46
+ this.store.db.prepare(`INSERT INTO account_profiles VALUES (?,?,?,?) ON CONFLICT(account_id) DO UPDATE SET
47
+ version=excluded.version,asset_id=excluded.asset_id,updated_at=excluded.updated_at`).run(account.id, version + 1, assetId, this.store.now());
48
+ this.store.db.exec('COMMIT');
49
+ } catch (error) { this.store.db.exec('ROLLBACK'); throw error; }
50
+ return this.profile(account);
51
+ }
52
+ async upload(request, query) {
53
+ const type = String(request.headers['content-type'] || '').split(';')[0];
54
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) throw failure(415, 'AVATAR_FORMAT');
55
+ if (Number(request.headers['content-length']) > this.options.maxUploadBytes) throw failure(413, 'AVATAR_TOO_LARGE');
56
+ let size = 0;
57
+ const chunks = [];
58
+ // Do not destroy the socket on a bounded-body rejection; let HTTP return the error.
59
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
60
+ size += chunk.length;
61
+ if (size > this.options.maxUploadBytes) { request.resume(); throw failure(413, 'AVATAR_TOO_LARGE'); }
62
+ chunks.push(chunk);
63
+ }
64
+ let bytes;
65
+ try {
66
+ const image = sharp(Buffer.concat(chunks), { limitInputPixels: 40_000_000, failOn: 'warning' });
67
+ const meta = await image.metadata();
68
+ if (!['jpeg', 'png', 'webp'].includes(meta.format) || (meta.pages ?? 1) !== 1) throw failure(415, 'AVATAR_FORMAT');
69
+ const swapped = [5, 6, 7, 8].includes(meta.orientation);
70
+ const width = swapped ? meta.height : meta.width, height = swapped ? meta.width : meta.height;
71
+ const cropKeys = ['left', 'top', 'size'];
72
+ const hasCrop = cropKeys.some(key => query.has(key));
73
+ let pipeline = image.autoOrient();
74
+ if (hasCrop) {
75
+ const [left, top, edge] = cropKeys.map(key => query.has(key) ? Number(query.get(key)) : NaN);
76
+ if (![left, top, edge].every(Number.isSafeInteger) || left < 0 || top < 0 || edge < 1 || left + edge > width || top + edge > height) throw failure(400, 'AVATAR_CROP');
77
+ pipeline = pipeline.extract({ left, top, width: edge, height: edge });
78
+ }
79
+ bytes = await pipeline.resize(512, 512, { fit: 'cover' }).webp({ quality: 85 }).toBuffer();
80
+ } catch (error) { if (error.status) throw error; throw failure(400, 'AVATAR_INVALID_IMAGE'); }
81
+ const id = createHash('sha256').update(bytes).digest('hex');
82
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
83
+ const temp = path.join(this.directory, `.upload-${randomUUID()}`);
84
+ await mkdir(temp, { mode: 0o700 });
85
+ try {
86
+ for (const edge of this.options.sizes) {
87
+ const output = edge === 512 ? bytes : await sharp(bytes).resize(edge, edge).webp({ quality: 85 }).toBuffer();
88
+ await writeFile(path.join(temp, `${edge}.webp`), output, { mode: 0o600 });
89
+ }
90
+ try { await rename(temp, path.join(this.directory, id)); }
91
+ catch (error) { if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
92
+ } finally { await rm(temp, { recursive: true, force: true }); }
93
+ return id;
94
+ }
95
+ async serve(response, id, size) {
96
+ if (!ASSET.test(id) || !this.options.sizes.includes(Number(size))) throw failure(404, 'AVATAR_NOT_FOUND');
97
+ let bytes;
98
+ try { bytes = await readFile(path.join(this.directory, id, `${size}.webp`)); }
99
+ catch (error) { if (error.code === 'ENOENT') throw failure(404, 'AVATAR_NOT_FOUND'); throw error; }
100
+ response.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': bytes.length,
101
+ 'Cache-Control': 'public, max-age=86400, immutable', 'X-Content-Type-Options': 'nosniff',
102
+ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });
103
+ response.end(bytes);
104
+ }
105
+ }
@@ -0,0 +1,224 @@
1
+ import { randomInt } from 'node:crypto';
2
+ import { failure } from './store.mjs';
3
+
4
+ export const SERVER_WELL_KNOWN = '/.well-known/dscord/server';
5
+ /** A server whose public page has not been read for this long drops out of search until it is read again. */
6
+ export const LISTING_STALE_MS = 3 * 24 * 60 * 60 * 1000;
7
+ const FETCH_TIMEOUT_MS = 10_000;
8
+ const MAX_BODY_BYTES = 64 * 1024;
9
+ const MAX_RESULTS = 50;
10
+ /** How a server can be found: by keyword search, only by its exact number, or not at all. */
11
+ export const DISCOVERY = ['public', 'number', 'hidden'];
12
+ /** How people join: anyone with a central account, or only with an invitation from the server. */
13
+ export const JOIN = ['open', 'invite'];
14
+ /** Where a listing stands with this central's administrators. Only listed servers are searchable. */
15
+ export const LISTING_STATUS = ['pending', 'listed', 'rejected', 'banned'];
16
+ /** Whether a newly submitted server waits for an administrator ('review') or is listed at once ('open'). */
17
+ export const LISTING_MODES = ['review', 'open'];
18
+ // Server numbers are eight digits, handed out at random so they say nothing about when a server was listed.
19
+ const NUMBER_MIN = 10_000_000;
20
+ const NUMBER_SPAN = 90_000_000;
21
+
22
+ const iso = value => value === null || value === undefined ? null : new Date(value).toISOString();
23
+
24
+ function text(value, max, field) {
25
+ if (value === undefined || value === null) return '';
26
+ if (typeof value !== 'string' || value.length > max || /[\u0000-\u0008\u000b-\u001f\u007f]/.test(value)) throw failure(502, 'SERVER_PAGE_INVALID', `${field} is not valid`);
27
+ return value.trim();
28
+ }
29
+
30
+ function count(value) {
31
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
32
+ }
33
+
34
+ /** The public page a chat server serves at /.well-known/dscord/server, reduced to what the index keeps. */
35
+ export function readServerPage(value, origin) {
36
+ if (!value || typeof value !== 'object' || value.schemaVersion !== 1) throw failure(502, 'SERVER_PAGE_INVALID', 'Unknown server page version');
37
+ if (value.origin !== origin) throw failure(502, 'SERVER_PAGE_INVALID', 'The page names a different origin');
38
+ const tags = Array.isArray(value.tags) ? value.tags : [];
39
+ if (tags.length > 10) throw failure(502, 'SERVER_PAGE_INVALID', 'At most 10 tags');
40
+ return {
41
+ central: typeof value.central === 'string' ? value.central : '',
42
+ discovery: DISCOVERY.includes(value.discovery) ? value.discovery : 'hidden',
43
+ page: {
44
+ name: text(value.name, 80, 'name') || new URL(origin).hostname,
45
+ description: text(value.description, 500, 'description'),
46
+ tags: [...new Set(tags.map(tag => text(tag, 32, 'tag').toLowerCase()).filter(Boolean))],
47
+ members: { people: count(value.members?.people), bots: count(value.members?.bots) },
48
+ join: JOIN.includes(value.join) ? value.join : 'invite',
49
+ },
50
+ };
51
+ }
52
+
53
+ async function readJson(response) {
54
+ const reader = response.body?.getReader();
55
+ if (!reader) return response.json();
56
+ const chunks = [];
57
+ let size = 0;
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ size += value.byteLength;
62
+ if (size > MAX_BODY_BYTES) { await reader.cancel(); throw failure(502, 'SERVER_PAGE_INVALID', 'The server page is too large'); }
63
+ chunks.push(value);
64
+ }
65
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
66
+ }
67
+
68
+ /**
69
+ * The server index. A chat server submits its origin; the index reads the server's public page from that origin and
70
+ * keeps reading it. A page served at the origin and naming this central is the server's consent to be listed, so
71
+ * submitting needs no account. This central's administrators decide what is listed: new servers wait for review
72
+ * unless the central lists them at once.
73
+ */
74
+ export class ServerDirectory {
75
+ constructor({ db, issuer, now = Date.now, fetch = globalThis.fetch, fetchVia = {} }) {
76
+ Object.assign(this, { db, issuer, now, fetch, fetchVia });
77
+ db.exec(`CREATE TABLE IF NOT EXISTS servers (origin TEXT PRIMARY KEY, number INTEGER NOT NULL UNIQUE,
78
+ status TEXT NOT NULL CHECK(status IN ('pending','listed','rejected','banned')), note TEXT NOT NULL DEFAULT '',
79
+ discovery TEXT NOT NULL, page_json TEXT NOT NULL, submitted_at INTEGER NOT NULL, reviewed_at INTEGER,
80
+ fetched_at INTEGER, last_error TEXT, checked_at INTEGER NOT NULL);`);
81
+ }
82
+
83
+ async read(origin) {
84
+ const base = this.fetchVia[origin] || origin;
85
+ let response;
86
+ try {
87
+ response = await this.fetch(`${base}${SERVER_WELL_KNOWN}`, { headers: { Accept: 'application/json' }, redirect: 'error',
88
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
89
+ } catch { throw failure(502, 'SERVER_UNREACHABLE', 'Could not reach the server page'); }
90
+ if (response.status === 404) throw failure(409, 'SERVER_PAGE_MISSING', 'The server does not publish a directory page');
91
+ if (!response.ok) throw failure(502, 'SERVER_UNREACHABLE', `The server page answered ${response.status}`);
92
+ let body;
93
+ try { body = await readJson(response); } catch (error) { throw error.code ? error : failure(502, 'SERVER_PAGE_INVALID', 'The server page is not JSON'); }
94
+ const page = readServerPage(body, origin);
95
+ if (page.central !== this.issuer) throw failure(409, 'CENTRAL_MISMATCH', 'The server trusts a different central');
96
+ return page;
97
+ }
98
+
99
+ /**
100
+ * Reads the page and keeps the server. A new server is pending under review and listed at once otherwise;
101
+ * a rejected server that submits again goes back to pending. A banned origin stays out.
102
+ */
103
+ async submit(origin, { review = true } = {}) {
104
+ const existing = this.server(origin);
105
+ if (existing?.status === 'banned') throw failure(403, 'SERVER_BANNED', 'This central does not list the server');
106
+ const read = await this.read(origin);
107
+ const at = this.now();
108
+ const page = JSON.stringify(read.page);
109
+ if (existing) {
110
+ const status = existing.status === 'rejected' ? 'pending' : existing.status;
111
+ this.db.prepare(`UPDATE servers SET status=?, discovery=?, page_json=?, submitted_at=CASE WHEN ?<>status THEN ? ELSE submitted_at END,
112
+ fetched_at=?, last_error=NULL, checked_at=? WHERE origin=?`).run(status, read.discovery, page, status, at, at, at, origin);
113
+ } else {
114
+ // A server keeps its number for as long as it stays in the index.
115
+ for (;;) {
116
+ const number = NUMBER_MIN + randomInt(NUMBER_SPAN);
117
+ try {
118
+ this.db.prepare(`INSERT INTO servers(origin,number,status,discovery,page_json,submitted_at,reviewed_at,fetched_at,checked_at)
119
+ VALUES (?,?,?,?,?,?,?,?,?)`).run(origin, number, review ? 'pending' : 'listed', read.discovery, page, at, review ? null : at, at, at);
120
+ break;
121
+ } catch (error) { if (!String(error.message).includes('UNIQUE')) throw error; }
122
+ }
123
+ }
124
+ return this.submittedView(this.server(origin));
125
+ }
126
+
127
+ server(origin) { return this.db.prepare('SELECT * FROM servers WHERE origin=?').get(origin); }
128
+
129
+ existing(origin) {
130
+ const row = this.server(origin);
131
+ if (!row) throw failure(404, 'SERVER_NOT_FOUND');
132
+ return row;
133
+ }
134
+
135
+ /** Reads the page again. A page that cannot be read keeps the last listing, which drops out of search once stale. */
136
+ async refresh(origin) {
137
+ const row = this.server(origin);
138
+ if (!row) return undefined;
139
+ const at = this.now();
140
+ try {
141
+ const read = await this.read(origin);
142
+ this.db.prepare('UPDATE servers SET discovery=?, page_json=?, fetched_at=?, last_error=NULL, checked_at=? WHERE origin=?')
143
+ .run(read.discovery, JSON.stringify(read.page), at, at, origin);
144
+ } catch (error) {
145
+ if (!error.code) throw error;
146
+ this.db.prepare('UPDATE servers SET last_error=?, checked_at=? WHERE origin=?').run(error.code, at, origin);
147
+ }
148
+ return this.server(origin);
149
+ }
150
+
151
+ async refreshAll() {
152
+ for (const { origin } of this.db.prepare("SELECT origin FROM servers WHERE status IN ('pending','listed') ORDER BY checked_at").all()) {
153
+ await this.refresh(origin);
154
+ }
155
+ }
156
+
157
+ /** An administrator's decision: list, reject or ban a server, with an optional note the server's operators can read. */
158
+ decide(origin, status, note = '') {
159
+ if (!['listed', 'rejected', 'banned'].includes(status)) throw failure(400, 'LISTING_STATUS_INVALID');
160
+ if (typeof note !== 'string' || note.length > 500) throw failure(400, 'NOTE_INVALID');
161
+ this.existing(origin);
162
+ this.db.prepare('UPDATE servers SET status=?, note=?, reviewed_at=? WHERE origin=?').run(status, note.trim(), this.now(), origin);
163
+ return this.adminView(this.server(origin));
164
+ }
165
+
166
+ /** Drops a server from the index, number and all; a removed server may submit again and gets a new number. */
167
+ remove(origin) {
168
+ if (!this.db.prepare('DELETE FROM servers WHERE origin=?').run(origin).changes) throw failure(404, 'SERVER_NOT_FOUND');
169
+ }
170
+
171
+ /** What a submitting server learns: its number and where its listing stands. */
172
+ submittedView(row) {
173
+ return { origin: row.origin, number: row.number, status: row.status, note: row.note, discovery: row.discovery };
174
+ }
175
+
176
+ adminView(row) {
177
+ return { ...this.submittedView(row), ...JSON.parse(row.page_json), submittedAt: iso(row.submitted_at), reviewedAt: iso(row.reviewed_at),
178
+ fetchedAt: iso(row.fetched_at), checkedAt: iso(row.checked_at), lastError: row.last_error };
179
+ }
180
+
181
+ /** Every server in the index for its administrators, newest submissions first, optionally of one status. */
182
+ list({ status = '' } = {}) {
183
+ if (status && !LISTING_STATUS.includes(status)) throw failure(400, 'LISTING_STATUS_INVALID');
184
+ const rows = status ? this.db.prepare('SELECT * FROM servers WHERE status=? ORDER BY submitted_at DESC').all(status)
185
+ : this.db.prepare('SELECT * FROM servers ORDER BY submitted_at DESC').all();
186
+ return rows.map(row => this.adminView(row));
187
+ }
188
+
189
+ counts() {
190
+ return Object.fromEntries(LISTING_STATUS.map(status => [status,
191
+ this.db.prepare('SELECT COUNT(*) AS n FROM servers WHERE status=?').get(status).n]));
192
+ }
193
+
194
+ /**
195
+ * Listed servers read within the last three days. A query of digits finds the server with that number, unless it is hidden;
196
+ * otherwise only public servers are searched, and every word must appear in the name, description or tags.
197
+ */
198
+ search({ q = '', tag = '', limit = 20 } = {}) {
199
+ const query = String(q).trim();
200
+ const fresh = this.now() - LISTING_STALE_MS;
201
+ if (/^\d{8}$/.test(query)) {
202
+ const row = this.db.prepare("SELECT * FROM servers WHERE number=? AND status='listed' AND discovery IN ('public','number') AND fetched_at>=?")
203
+ .get(Number(query), fresh);
204
+ if (row) return [this.publicView(row, JSON.parse(row.page_json))];
205
+ }
206
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
207
+ const wanted = String(tag).trim().toLowerCase();
208
+ const size = Math.min(Math.max(Number.parseInt(limit, 10) || 20, 1), MAX_RESULTS);
209
+ return this.db.prepare("SELECT * FROM servers WHERE status='listed' AND discovery='public' AND fetched_at>=?").all(fresh)
210
+ .map(row => ({ row, page: JSON.parse(row.page_json) }))
211
+ .filter(({ page }) => {
212
+ if (wanted && !page.tags.includes(wanted)) return false;
213
+ const haystack = [page.name, page.description, ...page.tags].join('\n').toLowerCase();
214
+ return words.every(word => haystack.includes(word));
215
+ })
216
+ .sort((a, b) => b.page.members.people - a.page.members.people || a.page.name.localeCompare(b.page.name))
217
+ .slice(0, size)
218
+ .map(({ row, page }) => this.publicView(row, page));
219
+ }
220
+
221
+ publicView(row, page) {
222
+ return { origin: row.origin, number: row.number, ...page, updatedAt: iso(row.fetched_at) };
223
+ }
224
+ }
@@ -0,0 +1,51 @@
1
+ // The central server owns the account format. Chat servers and clients read the
2
+ // published rule; they never define their own.
3
+
4
+ // A username becomes the Matrix localpart on every chat server, so a central rule
5
+ // can only narrow the characters Matrix allows in a localpart.
6
+ export const MATRIX_LOCALPART = /^[a-z0-9._=\-/]+$/;
7
+ const MAX_LOCALPART = 64;
8
+
9
+ export const DEFAULT_USERNAME_RULE = Object.freeze({
10
+ pattern: '^[a-z][a-z0-9._-]*$',
11
+ minLength: 3,
12
+ maxLength: 32,
13
+ reserved: Object.freeze(['admin', 'root', 'system', 'dscord', 'central']),
14
+ });
15
+
16
+ function fail(message) {
17
+ throw new Error(`usernameRule: ${message}`);
18
+ }
19
+
20
+ /** The rule from central configuration, checked once at start. */
21
+ export function usernameRule(input = {}) {
22
+ const rule = { ...DEFAULT_USERNAME_RULE, ...input };
23
+ if (typeof rule.pattern !== 'string' || !rule.pattern.startsWith('^') || !rule.pattern.endsWith('$')) fail('pattern must be anchored with ^ and $');
24
+ let expression;
25
+ try { expression = new RegExp(rule.pattern, 'u'); }
26
+ catch (error) { fail(`pattern does not compile: ${error.message}`); }
27
+ if (!Number.isSafeInteger(rule.minLength) || rule.minLength < 1) fail('minLength must be a positive integer');
28
+ if (!Number.isSafeInteger(rule.maxLength) || rule.maxLength < rule.minLength || rule.maxLength > MAX_LOCALPART) {
29
+ fail(`maxLength must be between minLength and ${MAX_LOCALPART}`);
30
+ }
31
+ if (!Array.isArray(rule.reserved) || rule.reserved.some(name => typeof name !== 'string')) fail('reserved must be a list of names');
32
+ const reserved = [...new Set(rule.reserved.map(name => name.toLowerCase()))].sort();
33
+ return Object.freeze({
34
+ pattern: rule.pattern, minLength: rule.minLength, maxLength: rule.maxLength, reserved: Object.freeze(reserved),
35
+ /** The stored (lowercase) username, or a reason it is refused. */
36
+ check(value) {
37
+ if (typeof value !== 'string') return { error: 'USERNAME_REQUIRED' };
38
+ const name = value.trim().toLowerCase();
39
+ if (name.length < rule.minLength || name.length > rule.maxLength) return { error: 'USERNAME_LENGTH' };
40
+ if (!MATRIX_LOCALPART.test(name) || !expression.test(name)) return { error: 'USERNAME_CHARACTERS' };
41
+ if (reserved.includes(name)) return { error: 'USERNAME_RESERVED' };
42
+ return { username: name };
43
+ },
44
+ });
45
+ }
46
+
47
+ /** What chat servers and clients see. */
48
+ export function publicRule(rule) {
49
+ return { pattern: rule.pattern, minLength: rule.minLength, maxLength: rule.maxLength, reserved: [...rule.reserved],
50
+ charset: MATRIX_LOCALPART.source, caseInsensitive: true, immutable: true };
51
+ }