diff --git a/.github/workflows/relay-binaries.yml b/.github/workflows/relay-binaries.yml new file mode 100644 index 0000000..d4056b0 --- /dev/null +++ b/.github/workflows/relay-binaries.yml @@ -0,0 +1,40 @@ +name: Relay binaries +# Builds the dependency-free Go broadcast relay for every common platform and +# attaches it to the release, so scripts/relay-quickstart.sh can fetch it. +on: + push: + tags: ['*'] + workflow_dispatch: +permissions: + contents: write +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Vet and build + working-directory: relay/go + run: | + go vet ./... + mkdir -p ../../out + for t in linux/amd64 linux/arm64 linux/arm linux/386 linux/mips linux/mipsle linux/riscv64 \ + windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 freebsd/amd64 android/arm64; do + os=${t%/*}; arch=${t#*/}; ext=; [ "$os" = windows ] && ext=.exe + GOOS=$os GOARCH=$arch GOMIPS=softfloat GOARM=7 CGO_ENABLED=0 \ + go build -trimpath -ldflags='-s -w' -o ../../out/htmlroom-relay-$os-$arch$ext . + done + cd ../../out && sha256sum htmlroom-relay-* > SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: htmlroom-relay + path: out/* + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "HTML Room $GITHUB_REF_NAME" + gh release upload "$GITHUB_REF_NAME" out/* --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a6ac74..55dd57a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,19 @@ jobs: - run: npx playwright install --with-deps chromium - run: npm run test:browser - run: npm run test:pages + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Vet, format check and cross-compile the broadcast relay + working-directory: relay/go + run: | + test -z "$(gofmt -l .)" + go vet ./... + for t in linux/amd64 linux/mipsle windows/amd64 darwin/arm64; do GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null .; done worker: runs-on: ubuntu-latest env: @@ -52,7 +65,7 @@ jobs: - run: | docker run -d --name htmlroom -p 127.0.0.1:18080:8080 htmlroom:test for i in $(seq 1 30); do curl -fs http://127.0.0.1:18080/healthz && break; sleep 1; done - curl -fs http://127.0.0.1:18080/hr.html | grep -q '创建 HTML 房间' + curl -fs http://127.0.0.1:18080/hr.html | grep -q '创建加密聊天室' installer: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/README.md b/README.md index 1be0019..b852950 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,40 @@ - 🔒 **上锁的信**:你们发的每句话都会先“锁”起来再送出去。负责传话的服务器只能看到一堆乱码,看不到内容。 - 💨 **说完就散**:本项目不会持久保存聊天记录。关掉网页,这个页面里的聊天内容就没了;对方仍可复制或截图。 +### 一张图看懂 + +整个项目就是一个 `index.html`:怎么打开它,它就扮演什么角色。 + +```mermaid +flowchart TD + F["index.html(一个文件)
同时是网页和服务器"] + F --> A["空白文件 · 浏览器
创建器(可离线)"] + F --> B["房间文件 · 浏览器
加密聊天室"] + F --> C["node index.html
中继服务器"] + A --> G["生成房间文件
新钥匙,可设口令"] + G -- 发给朋友 --> B + B -. "↻ 每个房间文件也能再生新房间" .-> G + B <--> R["中继(只转发密文)
wss:// 经典 · bc+ / nostr+ / mqtt+ 去中心化"] + C --> R + + classDef file fill:#F1EFE8,stroke:#5F5E5A,color:#2C2C2A + classDef browser fill:#EEEDFE,stroke:#534AB7,color:#26215C + classDef relay fill:#E1F5EE,stroke:#0F6E56,color:#04342C + class F file + class A,B,G browser + class C,R relay +``` + +- **浏览器打开空白文件**:创建器,断网也能生成房间文件。 +- **浏览器打开房间文件**:加密聊天室;房间文件里也带着创建器,可以继续生成新房间。 +- **`node index.html`**:中继服务器;空白文件运行时还会提供创建页面,房间文件运行时不公开自己。 +- 聊天要经过中继,中继只看得到密文。 + ## 三步开始聊天 **第 1 步:创建房间** -打开 [房间生成器](https://freecode100year.github.io/htmlroom/),其他设置都不用改。 -如果想多一层保险,可以点「随机生成高熵口令」设个密码(相当于门上再加一把锁)。 +打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。 +如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。 **第 2 步:发给朋友** 点「创建房间」后,你可以: @@ -38,6 +67,28 @@ > 💡 每点一次「创建房间」都会生成一个**全新的**房间,就算密码一样,也进不了以前的房间。 +## 不联网也能创建:一个文件就是整个网站 + +- **每个房间文件都能再生一个新房间。** 打开任意 `index.html`,展开「创建一个新房间」,就能生成钥匙不同、可设新口令的新房间文件。全程在文件里完成,**不需要联网,也不依赖任何网站**;生成的文件和官方构建逐字节一致。 +- **离线创建器。** 网站本身就是一个不带房间的 `index.html`。在网站底部点「下载离线版」保存下来,以后断网也能用它创建房间。 +- 只有聊天时需要连上中继。 + +## 房间文件同时是服务器 + +同一个 `index.html`,用浏览器打开是聊天室;装了 [Node.js](https://nodejs.org/)(18 或更新)的电脑上运行它,就是中继服务器,什么都不用安装: + +```sh +node index.html # 本机中继 + 创建页面 http://127.0.0.1:8080/ +node index.html --tunnel # 再加一个免费公网地址(需要 cloudflared) +node index.html --help # 全部选项 +``` + +- 空白创建器运行时,同时提供创建页面和中继,**一个文件就是一个完整的网站**。 +- 房间文件运行时只当中继,**不会把自己当网页公开**,以免泄露房间钥匙。 +- 支持经典房间(`wss://…`)和去中心化房间(`bc+wss://…/bc/`)。其他设备需要 `wss://`:加 `--tunnel`,或用 `--tls-cert`/`--tls-key`。 +- 如果提示 `Unknown file extension ".html"`,说明文件所在目录上层有声明 `"type": "module"` 的 package.json:换个目录,或复制成 `relay.cjs` 再运行 `node relay.cjs`。 +- 只运行你信任或核验过(`node scripts/verify-room-file.mjs 文件`)的文件:运行等于在电脑上执行文件里的代码。 + ## 能做什么,不能做什么 | ✅ 可以 | ❌ 不可以 | @@ -47,7 +98,11 @@ | 每条消息最多 **500 字** | 不联网使用(聊天时必须有网) | | 断网后自动重连 | 找回已关闭页面里的消息 | -创建房间时可按优先级填写 **1–3 个中继**(以逗号或空格分隔)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。 +创建房间时可按优先级填写 **1–3 个经典中继**(以逗号或空格分隔),或改用下文的[去中心化中继](#去中心化房间不依赖任何一台服务器)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。 + +**在房间文件里直接填自己的中继:** 新创建的房间文件默认允许自定义中继。打开文件后,进房间前可以把「中继服务器」改成任意 `wss://` 地址(也可以填去中心化中继),只对这次打开有效、不改动文件。大家要连同一个中继才能互相看到消息;去中心化中继只要有一个相同即可。创建时在「中继服务器(一般不用改)」里取消勾选「允许在房间文件里自定义中继」,文件就只能连列出的中继,更严格。 + +**想改用自己搭的中继?** 打开房间文件,展开「换一个中继服务器」,填入自己的 `wss://` 地址即可生成同一房间的新文件(密钥、口令不变,无需口令);也可在生成器页面的「已经有房间文件?换一个中继服务器」中转换旧文件。把新文件发给所有成员后,大家就在你的中继上聊天,并重新核对安全码。详见 [更换中继](docs/DEVELOPER.md#更换中继迁移到自建中继)。 多中继配置使用新版房间格式,旧文件/链接需重新生成。网页房间链接只支持站点预先允许的中继,其他组合请使用下载的房间文件。配置静态网页的多个中继见 [开发说明](docs/DEVELOPER.md#多中继房间)。 @@ -72,6 +127,28 @@ - 本项目还没有经过第三方专业机构的安全审计。 - 想要更放心,就一定要**核对安全码**。 +## 去中心化房间:不依赖任何一台服务器 + +创建房间时,中继除了填经典的 `wss://…`,还可以填以下三种去中心化中继,最多 5 个,可以混用: + +| 写法 | 是什么 | 谁来提供 | +| --- | --- | --- | +| `bc+wss://主机/bc/` | 只负责转发的中继 | 本项目任何部署(Cloudflare、Docker、一键安装),或下面的单文件中继;任意「同一路径收到的消息转发给其他连接」的 WebSocket 服务器都兼容 | +| `nostr+wss://…` | Nostr 中继 | 公共 Nostr 网络中的中继,或自建 | +| `mqtt+wss://…/mqtt` | MQTT 代理(WebSocket) | mosquitto、EMQX 等,路由器、树莓派都能跑 | + +消息**同时发往全部中继**并自动去重:只要有一个还通,大家就能聊天,也不会出现连到不同中继的人互相看不到的情况。成员编号、在线名单都由各成员自己协商,中继只负责转发看不懂的密文;没有房间密钥的人发来的数据会被直接丢弃。旧房间文件可以用「换一个中继服务器」直接改成去中心化房间,密钥和口令不变。 + +> 代价:没有中继替你挡住乱发数据的人(他们只能浪费流量,解不开也伪造不了消息);公共 Nostr/MQTT 中继能看到你的 IP 和收发时间,规范要求不保存临时消息,但无法保证。成员变化后请重新核对安全码。部分公共 Nostr 中继要求登录或付费,会在页面上提示被拒绝,换一个即可。 + +### 一条命令开一个中继:不要域名、不要公网 IP、不要 root + +```sh +curl -fsSL https://raw.githubusercontent.com/Freecode100Year/htmlroom/main/scripts/relay-quickstart.sh | sh -s -- --tunnel +``` + +脚本下载对应平台的单文件中继(校验 SHA-256),并通过 Cloudflare 免费临时隧道得到公网地址,最后打印一行 `bc+wss://xxxx.trycloudflare.com/bc/`,把它填进房间即可。支持 Linux、macOS、FreeBSD、WSL、Termux 和 OpenWrt。Windows 可从 [Releases](https://github.com/Freecode100Year/htmlroom/releases) 下载 `htmlroom-relay-windows-amd64.exe`,安装 [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 后运行 `htmlroom-relay-windows-amd64.exe -tunnel`。临时隧道地址每次启动都会变化;长期使用请配域名和 HTTPS,详见 [单文件中继](docs/DEVELOPER.md#单文件中继go)。 + ## 部署自己的聊天室 ### Cloudflare:前端和中继一起部署(推荐) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 55334d3..1d5041b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,6 +9,8 @@ Copyright (c) 2026 Freecode100Year. MIT License; see LICENSE and docs/PROVENANCE lib/vendor/mlkem.js bundles ML-KEM-768 from @noble/post-quantum 0.7.1 and supporting @noble/hashes and @noble/curves code. The original source comments and license notices are retained. +html-room/mesh.js bundles BIP-340 Schnorr signing over secp256k1 from @noble/curves 1.9.7 and SHA-256 from @noble/hashes 1.8.0 (npm, exact versions in package-lock.json), used only to sign Nostr events with a per-session throwaway key. + Copyright (c) 2024 Paul Miller (https://paulmillr.com). Copyright (c) 2022 Paul Miller (paulmillr.com). diff --git a/browser-tests/custom-relay.mjs b/browser-tests/custom-relay.mjs new file mode 100644 index 0000000..57565bf --- /dev/null +++ b/browser-tests/custom-relay.mjs @@ -0,0 +1,78 @@ +// A room file made for a relay that is gone: members type their own relay into +// the file and chat. Strict files show no relay box; links opened on the site do. +import { chromium } from 'playwright'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import { makeConfig } from '../html-room/crypto.js'; +import { fragmentFor } from '../html-room/transport.js'; +import { render } from '../scripts/build-html-room.mjs'; +import { nodeRelay, nostrRelay } from '../test/fixtures/mesh-relays.mjs'; + +const dir = await mkdtemp(tmpdir() + '/hr-custom-'); +const own = await nodeRelay(), nostr = await nostrRelay(); +let browser; +try { + const dead = 'wss://relay-that-is-gone.invalid'; + const config = await makeConfig(dead, 'pw', { custom: true }); + const path = dir + '/room.html'; await writeFile(path, (await render(config, config.relays)).html); + browser = await chromium.launch({ headless: true }); + const errors = [], sockets = []; + const open = async (relays) => { + const page = await browser.newPage(); + page.on('pageerror', e => errors.push(e.message)); page.on('websocket', s => sockets.push(s.url())); + await page.goto(pathToFileURL(path).href); + assert.equal(await page.locator('#custom-relay').isVisible(), true); + assert.equal(await page.locator('#relay-input').inputValue(), dead); + await page.locator('#password').fill('pw'); + if (relays) await page.locator('#relay-input').fill(relays); + await page.locator('#enter').click(); + return page; + }; + // A bad address is reported and does not count as a wrong password. + const probe = await open('http://nope.example'); + await probe.locator('#status').filter({ hasText: '中继地址不对' }).waitFor(); + assert.equal(await probe.locator('#enter').isEnabled(), true); + await probe.close(); + + // Classic: both type this project's relay. + const a = await open(own.url), b = await open(own.url); + for (const p of [a, b]) { + await p.locator('#relay-status').filter({ hasText: own.url }).waitFor({ timeout: 15000 }); + await p.locator('#status').filter({ hasText: '已加密连接' }).waitFor({ timeout: 20000 }); + } + await a.locator('#text').fill('文件里自己填的中继'); await a.locator('#send').click(); + await b.locator('#messages li').filter({ hasText: '文件里自己填的中继' }).waitFor({ timeout: 15000 }); + assert.ok(!sockets.some(u => u.includes('relay-that-is-gone')), 'the file\'s own relay was never tried'); + + // Decentralized: different relay lists that share one relay still meet. + const c = await open(`bc+${own.url}/bc/ nostr+${nostr.url}`), d = await open(`nostr+${nostr.url}`); + for (const p of [c, d]) await p.locator('#status').filter({ hasText: '已加密连接' }).waitFor({ timeout: 30000 }); + await d.locator('#text').fill('只共用一个 Nostr 中继'); await d.locator('#send').click(); + await c.locator('#messages li').filter({ hasText: '只共用一个 Nostr 中继' }).waitFor({ timeout: 20000 }); + + // Strict files and web links have no relay box. + const strict = await makeConfig(own.url, undefined), strictPath = dir + '/strict.html'; + await writeFile(strictPath, (await render(strict, strict.relays)).html); + const s = await browser.newPage(); await s.goto(pathToFileURL(strictPath).href); + await s.locator('#status').filter({ hasText: '进入房间' }).waitFor(); + assert.equal(await s.locator('#custom-relay').isVisible(), false); + const webConfig = await makeConfig(own.url, undefined, { custom: true }); + const w = await browser.newPage(); await w.goto(`http://127.0.0.1:${own.port}/hr.html${fragmentFor(webConfig)}`); + await w.locator('#status').filter({ hasText: '进入房间' }).waitFor(); + assert.equal(await w.locator('#custom-relay').isVisible(), true, 'the hosted creator can reach typed relays too'); + + // The creation page makes custom-relay files by default, strict when unticked. + const maker = await browser.newPage(); await maker.goto(`http://127.0.0.1:${own.port}/`); + const made = async () => { await maker.locator('#mk-make').click(); await maker.locator('#mk-out').waitFor({ state: 'visible' }); + const dl = maker.waitForEvent('download'); await maker.locator('#mk-download').click(); const f = dir + '/made.html'; await (await dl).saveAs(f); + return (await import('node:fs/promises')).readFile(f, 'utf8'); }; + assert.match(await made(), /connect-src wss: /); + await maker.locator('#mk-advanced summary').click(); await maker.locator('#mk-custom').uncheck(); + assert.doesNotMatch(await made(), /connect-src wss: /); + assert.deepEqual(errors, []); + console.log('PASS: custom relays typed into the file (classic and decentralized), bad address handling, strict files without the box, links on the site with it, maker default'); +} finally { + await browser?.close(); await Promise.all([own.stop(), nostr.stop()]); await rm(dir, { recursive: true, force: true }); +} diff --git a/browser-tests/file-server.mjs b/browser-tests/file-server.mjs new file mode 100644 index 0000000..1b89dd6 --- /dev/null +++ b/browser-tests/file-server.mjs @@ -0,0 +1,69 @@ +// One file is the whole system: `node index.html` serves the creator and +// relays; the browser creates a room there, two members chat through it, and +// the room file they got runs as a relay itself. +import { chromium } from 'playwright'; +import { spawn } from 'node:child_process'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import { render } from '../scripts/build-html-room.mjs'; +import { checkHtml } from '../scripts/check-html-room.mjs'; +import { configFromRoomHtml } from '../html-room/template.js'; + +const dir = await mkdtemp(tmpdir() + '/hr-file-server-'); +const children = []; +const start = (file, port) => new Promise((done, fail) => { + const child = spawn(process.execPath, [file, '--port', String(port), '--host', '127.0.0.1'], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] }); + children.push(child); let out = ''; + child.stdout.on('data', d => { out += d; if (out.includes('listening')) done(child); }); + child.on('exit', c => fail(new Error(`exited ${c}: ${out}`))); +}); +let browser; +try { + const blank = await render({}, []); + await writeFile(dir + '/index.html', blank.html); + const port = 19700 + Math.floor(Math.random() * 200), site = `http://127.0.0.1:${port}`; + await start(dir + '/index.html', port); + browser = await chromium.launch({ headless: true }); + const errors = [], watch = p => { p.on('pageerror', e => errors.push(e.message)); p.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); return p; }; + const maker = watch(await browser.newPage()); + await maker.goto(site + '/'); + await maker.locator('#mk-advanced > summary').click(); + assert.equal(await maker.locator('#mk-relay').inputValue(), `ws://127.0.0.1:${port}`, 'the file\'s own relay is the default'); + await maker.locator('#mk-pass').fill('file server'); + await maker.locator('#mk-make').click(); await maker.locator('#mk-link-row').waitFor({ state: 'visible' }); + const link = await maker.locator('#mk-link').inputValue(); + const dl = maker.waitForEvent('download'); await maker.locator('#mk-download').click(); + const roomPath = dir + '/room.html'; await (await dl).saveAs(roomPath); + const roomHtml = await readFile(roomPath, 'utf8'); + await checkHtml(roomHtml, blank.scriptHash); + assert.ok(roomHtml.startsWith(' +加密聊天房间

加密聊天房间

正在启动……如果一直停在这里,请看下面的说明。

页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。

    + diff --git a/dist/hr.css b/dist/hr.css deleted file mode 100644 index 8f0d04a..0000000 --- a/dist/hr.css +++ /dev/null @@ -1 +0,0 @@ -:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#121820;color:#edf2f7}body{max-width:760px;margin:auto;padding:20px}h1{font-size:1.5rem}p{line-height:1.6}aside{border:1px solid #dcac58;padding:12px;border-radius:10px;color:#ffe0a3}input,textarea,button{font:inherit;padding:12px;border:1px solid #65778c;border-radius:8px;background:#202d3b;color:inherit;box-sizing:border-box}input,textarea{width:100%;margin:8px 0}button{cursor:pointer}button:disabled{opacity:.5}ol{padding:0;list-style:none}li{padding:12px;margin:8px 0;background:#202d3b;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere}small{display:block;color:#b9c8d8;margin-top:6px}[hidden]{display:none}#status{color:#a7d9ff}textarea{min-height:90px} diff --git a/dist/hr.html b/dist/hr.html index 49abb3a..a55968b 100644 --- a/dist/hr.html +++ b/dist/hr.html @@ -1 +1,35 @@ -HTML Room · 创建或进入房间

    创建 HTML 房间

    同一份文件进入同一聊天房间,生成过程在本浏览器内完成。

    + +加密聊天房间

    加密聊天房间

    正在启动……如果一直停在这里,请看下面的说明。

    页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。

      + diff --git a/dist/hr.js b/dist/hr.js deleted file mode 100644 index 14e9408..0000000 --- a/dist/hr.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{var Lt=Object.defineProperty;var _t=(t,e,r)=>e in t?Lt(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var _=(t,e,r)=>_t(t,typeof e!="symbol"?e+"":e,r);var M=[];var Br=new TextEncoder;var Mt=[256,512,1024,2048,4096];function se(t){let e=Mt.find(s=>s>t.length)??t.length+1,r=new Uint8Array(e);return r.set(t),r[t.length]=128,r}function oe(t){let e=t.length-1;for(;e>=0&&t[e]===0;)e--;return e>=0&&t[e]===128?t.subarray(0,e):t}var q=new TextEncoder,D=t=>Array.from(new Uint8Array(t),e=>e.toString(16).padStart(2,"0")).join("");function P(t,e){if(typeof t!="string"||t.length!==e*2||!/^[0-9a-f]+$/.test(t))throw Error("\u914D\u7F6E\u5B57\u8282\u683C\u5F0F\u9519\u8BEF");return Uint8Array.from(t.match(/../g),r=>parseInt(r,16))}var N=t=>crypto.getRandomValues(new Uint8Array(t));async function He(t){let e=typeof t=="string"?P(t,32):t;if(!(e instanceof Uint8Array)||e.length!==32)throw Error("\u623F\u95F4\u5BC6\u94A5\u957F\u5EA6\u9519\u8BEF");let r=await crypto.subtle.importKey("raw",e,"HKDF",!1,["deriveBits"]),s=async a=>new Uint8Array(await crypto.subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:new Uint8Array(32),info:q.encode(`private-talk/html-room/v1/${a}`)},r,256)),o=await s("access"),n=await s("key"),i=await s("bind");return{access:o,keyBytes:n,bind:i,roomId:D(await crypto.subtle.digest("SHA-256",o)),key:await crypto.subtle.importKey("raw",n,"AES-GCM",!1,["encrypt","decrypt"])}}function Ae(t){let e=new URL(t);if(!/^(?:[a-z0-9.-]+|\[[0-9a-f:]+\])$/.test(e.hostname))throw Error("\u4E2D\u7EE7\u4E3B\u673A\u540D\u542B\u4E0D\u5B89\u5168\u5B57\u7B26");if(e.username||e.password||e.search||e.hash||e.pathname!=="/"||!["ws:","wss:"].includes(e.protocol))throw Error("\u4E2D\u7EE7\u5FC5\u987B\u662F\u7CBE\u786E\u7684 WS/WSS \u6E90");if(e.protocol==="ws:"&&!["localhost","127.0.0.1","[::1]"].includes(e.hostname))throw Error("\u8FDC\u7A0B\u4E2D\u7EE7\u5FC5\u987B\u4F7F\u7528 WSS");return e.origin}var xe={name:"PBKDF2",hash:"SHA-256",iterations:31e4};function ie(t){let e=Array.isArray(t)?t:typeof t=="string"?t.trim().split(/[\s,]+/):[],r=[...new Set(e.map(Ae))];if(!r.length||r.length>3)throw Error("\u8BF7\u63D0\u4F9B 1 \u5230 3 \u4E2A\u4E0D\u540C\u4E2D\u7EE7");return r}var je=t=>q.encode(`private-talk/html-room/v1/wrap|${t}`);async function Ne(t,e){if(typeof t!="string"||!t.length||q.encode(t).length>1024)throw Error("\u53E3\u4EE4\u957F\u5EA6\u65E0\u6548");let r=await crypto.subtle.importKey("raw",q.encode(t),"PBKDF2",!1,["deriveKey"]);return crypto.subtle.deriveKey({...xe,salt:e},r,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}function G(t){if(!t||t.version!==2||!/^[0-9a-f]{64}$/.test(t.roomId))throw Error("\u623F\u95F4\u7248\u672C\u6216\u5730\u5740\u9519\u8BEF");if(!Array.isArray(t.relays)||JSON.stringify(ie(t.relays))!==JSON.stringify(t.relays))throw Error("\u4E2D\u7EE7\u5217\u8868\u683C\u5F0F\u9519\u8BEF");let e=["version","relays","roomId","encrypted"];if(t.encrypted===!0){let r=[...e,"salt","iv","ciphertext","kdf"];if(Object.keys(t).some(s=>!r.includes(s))||!t.kdf||Object.keys(t.kdf).length!==3||Object.entries(xe).some(([s,o])=>t.kdf[s]!==o))throw Error("\u4E0D\u652F\u6301\u7684 KDF \u914D\u7F6E");P(t.salt,16),P(t.iv,12),P(t.ciphertext,48)}else{if(t.encrypted!==!1||Object.keys(t).some(r=>![...e,"master"].includes(r)))throw Error("\u914D\u7F6E\u5B57\u6BB5\u9519\u8BEF");P(t.master,32)}return t}async function Be(t,e){let r=ie(t),s=N(32),o=await He(s),n={version:2,relays:r,roomId:o.roomId,encrypted:!!e};if(!e)return{...n,master:D(s)};let i=N(16),a=N(12),l=await Ne(e,i),c=await crypto.subtle.encrypt({name:"AES-GCM",iv:a,additionalData:je(n.roomId)},l,s);return{...n,salt:D(i),iv:D(a),ciphertext:D(c),kdf:{...xe}}}async function Re(t,e){G(t);let r=t.encrypted?new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:P(t.iv,12),additionalData:je(t.roomId)},await Ne(e,P(t.salt,16)),P(t.ciphertext,48))):P(t.master,32),s=await He(r);if(r.fill(0),s.roomId!==t.roomId)throw Error("\u623F\u95F4\u5730\u5740\u4E0E\u5BC6\u94A5\u4E0D\u5339\u914D");return s}async function Pe(t,e){let r=N(12),s=await crypto.subtle.encrypt({name:"AES-GCM",iv:r,additionalData:q.encode(`private-talk/html-room/v1/keys|${t.roomId}`)},t.key,se(q.encode(e)));return{iv:btoa(String.fromCharCode(...r)),ct:btoa(String.fromCharCode(...new Uint8Array(s)))}}async function Fe(t,e){let r=s=>Uint8Array.from(atob(s),o=>o.charCodeAt(0));return new TextDecoder().decode(oe(new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:r(e.iv),additionalData:q.encode(`private-talk/html-room/v1/keys|${t.roomId}`)},t.key,r(e.ct)))))}var ae=BigInt(4294967295),ze=BigInt(32);function Ht(t,e=!1){return e?{h:Number(t&ae),l:Number(t>>ze&ae)}:{h:Number(t>>ze&ae)|0,l:Number(t&ae)|0}}function jt(t,e=!1){let r=t.length,s=new Uint32Array(r),o=new Uint32Array(r);for(let n=0;nt?`"${t}" `:"";function F(t,e=""){if(typeof t!="number")throw new TypeError(pe(e)+"expected number, got "+typeof t);if(!Number.isSafeInteger(t)||t<0)throw new RangeError(pe(e)+"expected integer >= 0, got "+t);return t}function Nt(t,e=""){if(typeof t!="boolean")throw new TypeError(pe(e)+"expected boolean, got type="+typeof t);return t}function R(t,e,r=""){if(Xe(t)&&(e===void 0||t.length===e))return t;e!==void 0&&F(e,"length");let s=Xe(t),o=e!==void 0?` of length ${e}`:"",n=s?`length=${t.length}`:`type=${typeof t}`,i=pe(r)+"expected Uint8Array"+o+", got "+n;throw s?new RangeError(i):new TypeError(i)}var Bt=(t,e)=>{if(t===null||typeof t!="object"||Array.isArray(t))throw new TypeError((e==="object"?"":`"${e}" `)+"expected object, got type="+typeof t)},qe=(t,e)=>{Bt(t,e);let r=Object.getPrototypeOf(t);if(r!==Object.prototype&&r!==null)throw new TypeError(`"${e}" expected plain object`);if(Object.prototype.hasOwnProperty.call(t,"__proto__"))throw new TypeError(`"${e}.__proto__" is not allowed`)};function Ge(t,e=!0){if(t.destroyed)throw new Error("hash was destroyed");if(e&&t.finished)throw new Error("digest() was already called")}function Rt(t,e){R(t,void 0,"output");let r=e.outputLen;if(!(t.length>=r))throw new RangeError('"output" expected length >= '+r)}function Ke(t){return new Uint32Array(t.buffer,t.byteOffset,Math.floor(t.byteLength/4))}function et(...t){for(let e=0;e>>8&65280|t>>>24&255}function zt(t){for(let e=0;et:zt;function tt(t,e,r="opts"){return qe(t,"defaults"),e!==void 0&&qe(e,r),Object.assign(Object.create(null),t,e)}function rt(t,e={}){if(typeof t!="function")throw new TypeError('"hashCons" expected function, got type='+typeof t);e=tt({},e,"info");let r=(o,n)=>t(n).update(o).digest(),s=t(void 0);return r.outputLen=s.outputLen,r.blockLen=s.blockLen,r.canXOF=s.canXOF,r.create=o=>t(o),Object.assign(r,e),Object.freeze(r)}function Xt(t=32){F(t,"bytesLength");let e=typeof globalThis=="object"?globalThis.crypto:null;if(typeof e?.getRandomValues!="function")throw new Error("crypto.getRandomValues must be defined");if(t>65536)throw new RangeError(`"bytesLength" expected <= 65536, got ${t}`);return e.getRandomValues(new Uint8Array(t))}var ue=t=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,t])}),qt=BigInt(0),Z=BigInt(1),Gt=BigInt(2),Jt=BigInt(7),Vt=BigInt(256),Yt=BigInt(113),nt=[],st=[],ot=[];for(let t=0,e=Z,r=1,s=0;t<24;t++){[r,s]=[s,(2*r+3*s)%5],nt.push(2*(5*s+r)),st.push((t+1)*(t+2)/2%64);let o=qt;for(let n=0;n<7;n++)e=(e<>Jt)*Yt)%Vt,e&Gt&&(o^=Z<<(Z<t<>>32-r,er=(t,e,r)=>e<>>32-r,tr=(t,e,r)=>e<>>64-r,rr=(t,e,r)=>t<>>64-r,Je=(t,e,r)=>r>32?tr(t,e,r):Zt(t,e,r),Ve=(t,e,r)=>r>32?rr(t,e,r):er(t,e,r),Y=new Uint32Array(10);function nr(t,e=24){if(!(t instanceof Uint32Array))throw new TypeError('"s" expected Uint32Array(50), got type='+typeof t);if(t.length!==50)throw new RangeError('"s" expected Uint32Array(50), got length='+t.length);if(F(e,"rounds"),e<1||e>24)throw new Error('"rounds" expected integer 1..24');for(let r=24-e;r<24;r++){for(let n=0;n<10;n++)Y[n]=t[n]^t[n+10]^t[n+20]^t[n+30]^t[n+40];for(let n=0;n<10;n+=2){let i=(n+8)%10,a=(n+2)%10,l=Y[a],c=Y[a+1],d=Je(l,c,1)^Y[i],u=Ve(l,c,1)^Y[i+1];for(let v=0;v<50;v+=10)t[n+v]^=d,t[n+v+1]^=u}let s=t[2],o=t[3];for(let n=0;n<24;n++){let i=st[n],a=Je(s,o,i),l=Ve(s,o,i),c=nt[n];s=t[c],o=t[c+1],t[c]=a,t[c+1]=l}for(let n=0;n<50;n+=10){let i=t[n],a=t[n+1],l=t[n+2],c=t[n+3];t[n]^=~t[n+2]&t[n+4],t[n+1]^=~t[n+3]&t[n+5],t[n+2]^=~t[n+4]&t[n+6],t[n+3]^=~t[n+5]&t[n+7],t[n+4]^=~t[n+6]&t[n+8],t[n+5]^=~t[n+7]&t[n+9],t[n+6]^=~t[n+8]&i,t[n+7]^=~t[n+9]&a,t[n+8]^=~i&l,t[n+9]^=~a&c}t[0]^=Wt[r],t[1]^=Qt[r]}et(Y)}var at=class ct{constructor(e,r,s,o=!1,n=24){_(this,"state");_(this,"pos",0);_(this,"posOut",0);_(this,"finished",!1);_(this,"state32");_(this,"destroyed",!1);_(this,"blockLen");_(this,"suffix");_(this,"outputLen");_(this,"canXOF");_(this,"enableXOF",!1);_(this,"rounds");if(F(e,"blockLen"),F(r,"suffix"),F(n,"rounds"),Nt(o,"enableXOF"),this.blockLen=e,this.suffix=r,this.outputLen=s,this.enableXOF=o,this.canXOF=o,this.rounds=n,F(s,"outputLen"),!(0=r?Ke(e):void 0;for(let c=0;c=r){for(let u=0,v=c/4;u=s&&this.keccak();let i=Math.min(s-this.posOut,n-o);e.set(r.subarray(this.posOut,this.posOut+i),o),this.posOut+=i,o+=i}return e}xofInto(e){if(!this.enableXOF)throw new Error("XOF is not enabled");return this.writeInto(e)}xof(e){return F(e),this.xofInto(new Uint8Array(e))}digestInto(e){if(Rt(e,this),this.finished)throw new Error("digest() was already called");this.writeInto(e.length===this.outputLen?e:e.subarray(0,this.outputLen)),this.destroy()}digest(){let e=new Uint8Array(this.outputLen);return this.digestInto(e),e}destroy(){this.destroyed=!0,et(this.state)}_cloneInto(e){let{blockLen:r,suffix:s,outputLen:o,rounds:n,enableXOF:i}=this;return e||(e=new ct(r,s,o,i,n)),e.blockLen=r,e.state32.set(this.state32),e.pos=this.pos,e.posOut=this.posOut,e.finished=this.finished,e.rounds=n,e.suffix=s,e.outputLen=o,e.enableXOF=i,e.canXOF=this.canXOF,e.destroyed=this.destroyed,e}},lt=(t,e,r,s={})=>rt(()=>new at(e,t,r),s),sr=lt(6,136,32,ue(8)),or=lt(6,72,64,ue(10)),pt=(t,e,r,s={})=>rt((o={})=>(o=tt({},o),new at(e,t,o.dkLen===void 0?r:o.dkLen,!0)),s),ir=pt(31,168,16,ue(11)),ht=pt(31,136,32,ue(12));function Se(t,e="object"){if(t===null||typeof t!="object"||Array.isArray(t))throw new TypeError(e==="object"?"expected valid options object":`"${e}" expected object, got type=${typeof t}`);return t}function ar(t,e={},r={},s="object"){Se(t,s),Se(e,"fields"),Se(r,"optFields");function o(i,a,l){let c=s==="object"?`param "${String(i)}"`:`"${s}.${String(i)}"`,d=t[i];if(!Object.prototype.hasOwnProperty.call(t,i)&&(l?d!==void 0:a!=="function"))throw new TypeError(`${c} is invalid: expected own property`);if(l&&d===void 0)return;let u=typeof d;if(u!==a||d===null)throw new TypeError(`${c} is invalid: expected ${a}, got ${u}`)}let n=(i,a)=>Object.entries(i).forEach(([l,c])=>o(l,c,a));n(e,!1),n(r,!0)}function re(t,e="n"){if(typeof t!="number")throw new TypeError(`wrong u32 integer "${e}": expected number, got type=${typeof t}`);if(!Number.isSafeInteger(t)||t<0||t>4294967295)throw new RangeError(`wrong u32 integer "${e}": expected 0..4294967295, got ${t}`);return t}function ut(t){return re(t,"x"),(t&t-1)===0&&t!==0}function dt(t,e){if(re(t),typeof e!="number")throw new TypeError('"bits" expected number, got type='+typeof e);if(!Number.isSafeInteger(e)||e<0||e>32)throw new Error(`expected integer 0 <= bits <= 32, got ${e}`);let r=0;for(let s=0;s>>=1)r=r<<1|t&1;return r>>>0}function ft(t){return re(t),31-Math.clz32(t)}function Ye(t){if(!t||typeof t!="object"||typeof t.length!="number")throw new TypeError('"values" expected array-like, got type='+typeof t);let e=t.length;if(!ut(e))throw new Error("expected positive power-of-two length, got "+e);let r=ft(e);for(let s=0;s{ar(e,{N:"number",roots:"object",dit:"boolean"},{invertButterflies:"boolean",skipStages:"number",brp:"boolean"},"coreOpts");let{N:r,roots:s,dit:o,invertButterflies:n=!1,skipStages:i=0,brp:a=!0}=e;re(r,"coreOpts.N");let l=ft(r);if(!ut(r))throw new Error("FFT: Polynomial size should be power of two");re(i,"coreOpts.skipStages");let c=l===0?0:l-1;if(i>c)throw new Error(`FFT: wrong skipStages: expected 0 <= skipStages <= ${c}`);if(s.length!==r)throw new Error(`FFT: wrong roots length: expected ${r}, got ${s.length}`);let d=o!==n;return u=>{if(u.length!==r)throw new Error("FFT: wrong Polynomial length");o&&a&&Ye(u);for(let v=0,m=1;v>1,S=r>>y;for(let E=0;E{}){if(!Array.isArray(t))throw new TypeError(`"${e}" expected array, got type=${typeof t}`);for(let s=0;stypeof o=="number"?o:o.bytesLen,s=e.reduce((o,n)=>o+r(n),0);return{bytesLen:s,encode:o=>{let n=new Uint8Array(s);for(let i=0,a=0;i{R(o,s,t);let n=[];for(let i of e){let a=r(i),l=o.subarray(0,a);n.push(typeof i=="number"?l:i.decode(l)),o=o.subarray(a)}return n}}}function Te(t,e){let r=t,s=e*r.bytesLen;return{bytesLen:s,encode:o=>{let n=cr(o,"u");if(n.length!==e)throw new RangeError(`vecCoder.encode: wrong length=${n.length}. Expected: ${e}`);let i=new Uint8Array(s);for(let a=0,l=0;a{R(o,s);let n=[];for(let i=0;i32)throw new RangeError('"bits" expected <= 32, got '+t);return t===32?4294967295:~(-1<>>0}var lr=t=>{let{newPoly:e,N:r,Q:s,F:o,ROOT_OF_UNITY:n,brvBits:i,isKyber:a}=t,l=(p,h=s)=>{let w=p%h|0;return(w>=0?w|0:h+w|0)|0},c=(p,h=s)=>{let w=l(p,h)|0;return(w>h>>1?w-h|0:w)|0};function d(){let p=e(r);for(let h=0;h{throw new Error("not implemented")},m=a?{add:(p,h)=>{let w=p+h|0;return w>=s?w-s|0:w},sub:(p,h)=>{let w=p-h|0;return w<0?w+s|0:w},mul:(p,h)=>l((p|0)*(h|0))|0,inv:v}:{add:(p,h)=>l((p|0)+(h|0))|0,sub:(p,h)=>l((p|0)-(h|0))|0,mul:(p,h)=>l((p|0)*(h|0))|0,inv:v},y={N:r,roots:u,invertButterflies:!0,skipStages:a?1:0,brp:!1},f=We(m,{dit:!1,...y}),T=We(m,{dit:!0,...y}),S={encode:p=>f(p),decode:p=>{T(p);for(let h=0;hS.encode(p),decode:p=>S.decode(p)},bitsCoder:(p,h)=>{for(let g=0,b=0;g32&&Ze(b),b%=8;let w=Ze(p),A=p*(r/8);return{bytesLen:A,encode:g=>{let b=g,x=new Uint8Array(A);for(let $=0,k=0,U=0,j=0;$=8;U-=8,k>>=8)x[j++]=k&255;return x},decode:g=>{let b=e(r);for(let x=0,$=0,k=0,U=0;x=p;k-=p,$>>=p)b[U++]=h.decode($&w);return b}}}}},pr=t=>(e,r)=>{r||(r=t.blockLen);let s=new Uint8Array(e.length+2);s.set(e);let o=e.length,n=new Uint8Array(r),i=t.create({}),a=0,l=0;return{stats:()=>({calls:a,xofs:l}),get:(c,d)=>(s[o+0]=c,s[o+1]=d,i.destroy(),i=t.create({}).update(s),a++,()=>(l++,i.xofInto(n))),clean:()=>{i.destroy(),O(n,s)}}},hr=pr(ir);var I=256,C=3329,ur=3303,dr=17,L=lr({N:I,Q:C,F:ur,ROOT_OF_UNITY:dr,newPoly:t=>new Uint16Array(t),brvBits:7,isKyber:!0}),fr=Object.freeze({512:Object.freeze({N:I,Q:C,K:2,ETA1:3,ETA2:2,du:10,dv:4,RBGstrength:128}),768:Object.freeze({N:I,Q:C,K:3,ETA1:2,ETA2:2,du:10,dv:4,RBGstrength:192}),1024:Object.freeze({N:I,Q:C,K:4,ETA1:2,ETA2:2,du:11,dv:5,RBGstrength:256})}),yr=t=>{if(t>=12)return{encode:r=>r,decode:r=>r>=C?r-C:r};let e=2**(t-1);return{encode:r=>((r<r*C+e>>>t}},wr=t=>L.bitsCoder(t,t===12?{encode:e=>e,decode:e=>e>=C?e-C:e}:{encode:e=>e,decode:e=>e}),ee=t=>t===12?wr(12):L.bitsCoder(t,yr(t));function J(t,e){let r=t,s=e;for(let o=0;o=C?n-C:n}}function gr(t,e){let r=t,s=e;for(let o=0;o>1)];o&1&&(n=-n);let{c0:i,c1:a}=mr(r[2*o+0],r[2*o+1],s[2*o+0],s[2*o+1],n);r[2*o+0]=i,r[2*o+1]=a}return r}function $e(t){let e=t,r=new Uint16Array(I);for(let s=0;s>0|o[n+1]<<8)&4095,a=(o[n+1]>>4|o[n+2]<<4)&4095;i{let r=new Uint16Array(I),s=Ke(t);he(s);let o=0;for(let n=0,i=0,a=0,l=0;n>=1,o+=1,o===e?(l=a,a=0):o===2*e&&(r[i++]=L.mod(l-a),a=0,o=0)}if(he(s),o)throw new Error(`sampleCBD: leftover bits: ${o}`);return r};function te(t,e,r,s){return br(t(s*I/4,e,r),s)}var kr=t=>{let e=t,{K:r,PRF:s,XOF:o,HASH512:n,ETA1:i,ETA2:a,du:l,dv:c}=e,d=ee(1),u=ee(c),v=ee(l),m=le("publicKey",Te(ee(12),r),32),y=Te(ee(12),r),f=le("ciphertext",Te(v,r),u),T=le("seed",32,32),S=(E,p,h,w)=>{let A=[];for(let k=0;k{B(E,32,"seed");let p=new Uint8Array(33);p.set(E),p[32]=r;let h=n(p),[w,A]=T.decode(h),g=[],b=[];for(let k=0;k{let[w,A]=m.decode(E),g=o(A),b=S(w,(x,$)=>$e(g.get(x,$)),p,h);return g.clean(),b},prepare:E=>{let[p,h]=m.decode(E),w=o(h),A=[];for(let g=0;gS(p.map(x=>x.slice()),(x,$)=>A[x*r+$].slice(),g,b),clean:()=>O(p,A)}},decrypt:(E,p)=>{let[h,w]=f.decode(E),A=y.decode(p),g=new Uint16Array(I);for(let x=0;x{let f=m.subarray(0,384*e.K),T=i.encode(i.decode(Qe(f))),S=W(T,f);if(O(T),!S)throw new Error(`ML-KEM.${y}: wrong publicKey modulus`)},v=Object.freeze({...a,seed:64,msg:c,msgRand:c,secretKey:l.bytesLen});return Object.freeze({info:Object.freeze({type:"ml-kem"}),lengths:v,keygen:m=>{let y=m===void 0,f=y?Ee(d):m,T,S;try{B(f,d,"seed");let E=r.keygen(f.subarray(0,32)),p=E.publicKey;T=E.secretKey,S=s(p);let h=l.encode([T,p,S,f.subarray(32)]);return{publicKey:p,secretKey:h}}finally{T!==void 0&&O(T),S!==void 0&&O(S),y&&O(f)}},getPublicKey:m=>{let[y,f,T,S]=l.decode(m);return Uint8Array.from(f)},encapsulate:(m,y)=>{let f=y===void 0,T=f?Ee(c):y,S;try{return B(m,a.publicKey,"publicKey"),B(T,c,"message"),u(m,"encapsulate"),S=o.create().update(T).update(s(m)).digest(),{cipherText:r.encrypt(m,T,S.subarray(32,64)),sharedSecret:S.subarray(0,32)}}finally{S!==void 0&&O(S.subarray(32)),f&&O(T)}},decapsulate:(m,y)=>{B(y,l.bytesLen,"secretKey"),B(m,a.cipherText,"cipherText");let f=l.bytesLen-96,T=f+32,S=s(y.subarray(f/2,T));if(!W(S,y.subarray(T,T+32)))throw new Error("invalid secretKey: hash check failed");let[E,p,h,w]=l.decode(y),A=r.decrypt(m,E),g=o.create().update(A).update(h).digest(),b=g.subarray(0,32),x=r.encrypt(p,A,g.subarray(32,64)),$=W(m,x),k=n.create({dkLen:32}).update(w).update(m).digest();return O(A,x,g.subarray(32),$?k:b),$?b:k},prepare:m=>{B(m,a.publicKey,"publicKey"),u(m,"prepare");let y=Qe(m),f=s(y),T=r.prepare(y);return Object.freeze({publicKey:y,encapsulate:S=>{let E=S===void 0,p=E?Ee(c):S,h;try{return B(p,c,"message"),h=o.create().update(p).update(f).digest(),{cipherText:T.encrypt(p,h.subarray(32,64)),sharedSecret:h.subarray(0,32)}}finally{h!==void 0&&O(h.subarray(32)),E&&O(p)}},decapsulate:(S,E)=>{B(E,l.bytesLen,"secretKey"),B(S,a.cipherText,"cipherText");let[p,h,w,A]=l.decode(E);if(!W(h,y)||!W(w,f))throw new Error("ML-KEM.decapsulate: secretKey does not match prepared publicKey");let g=r.decrypt(S,p),b=o.create().update(g).update(f).digest(),x=b.subarray(0,32),$=T.encrypt(g,b.subarray(32,64)),k=W(S,$),U=n.create({dkLen:32}).update(A).update(S).digest();return O(g,$,b.subarray(32),k?U:x),k?x:U},clean:T.clean})}})}function Ar(t,e,r){return ht.create({dkLen:t}).update(e).update(new Uint8Array([r])).digest()}var xr={HASH256:sr,HASH512:or,KDF:ht,XOF:hr,PRF:Ar},Sr=t=>vr({...xr,...t}),de=Sr(fr[768]);var H=new TextEncoder,Oe=new TextDecoder,xt={name:"ECDH",namedCurve:"P-256"},yt={name:"ECDSA",namedCurve:"P-256"},fe={name:"ECDSA",hash:"SHA-256"},Er=2e3,Tr=100,$r=600*1e3,wt=60*1e3,Kr=20*1e3,Or=4,Cr=4e3,gt=1580,Ir=1452,Ur=/^(\d{1,9})\.(\d{1,9})$/,ye=/^[A-Za-z0-9+/]+={0,2}$/,Lr=/,"s":"([A-Za-z0-9+/]{86}==)"\}$/,X=t=>btoa(String.fromCharCode(...new Uint8Array(t))),z=t=>Uint8Array.from(atob(t),e=>e.charCodeAt(0)),mt=t=>Array.from(new Uint8Array(t),e=>e.toString(16).padStart(2,"0")).join("");async function bt(t,e){let r=await crypto.subtle.importKey("raw",t,{name:"HMAC",hash:"SHA-256"},!1,["sign"]);return new Uint8Array(await crypto.subtle.sign("HMAC",r,new Uint8Array([e])))}async function Ce(t){return{mk:await bt(t,1),next:await bt(t,2)}}async function kt(t,e){return crypto.subtle.importKey("raw",t,"AES-GCM",!1,e)}async function we(t,e,r){let s=crypto.getRandomValues(new Uint8Array(12)),o=await crypto.subtle.encrypt({name:"AES-GCM",iv:s,additionalData:H.encode(r)},t,e);return{iv:X(s),ct:X(o)}}async function Ie(t,{iv:e,ct:r},s){return new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:z(e),additionalData:H.encode(s)},t,z(r)))}var _r=(t,e)=>{let r=new Uint8Array(t.length+e.length);return r.set(t),r.set(e,t.length),r};async function vt(){let t=await crypto.subtle.generateKey(xt,!1,["deriveBits"]);return{pair:t,epk:X(await crypto.subtle.exportKey("raw",t.publicKey))}}var At=t=>`pt31a|${t.from}|${t.id}|${t.t}|${t.epk}|${t.hello?1:0}|${JSON.stringify(t.w)}`,ge=class t{static async create(e,r,s){let o=new t;return o.key=e,o.bind=r,o.me=s,o.signing=await crypto.subtle.generateKey(yt,!1,["sign","verify"]),o.spk=X(await crypto.subtle.exportKey("raw",o.signing.publicKey)),o.kem=de.keygen(),o.kpk=X(o.kem.publicKey),o.ecdh=await vt(),o.ecdhSince=Date.now(),o.oldEcdh=[],o.peers=new Map,o.changed=[],await o.rekey(0),o}get epk(){return this.ecdh.epk}async rekey(e=this.mine.epoch+1,r=Date.now()){this.mine&&r-this.ecdhSince>=Kr&&(this.oldEcdh=[{ecdh:this.ecdh,until:r+wt},...this.oldEcdh].slice(0,Or),this.ecdh=await vt(),this.ecdhSince=r),this.oldEcdh=this.oldEcdh.filter(s=>s.until>r),this.mine={epoch:e,ck:crypto.getRandomValues(new Uint8Array(32)),c:0,since:r,handed:new Map};for(let s of this.peers.values())s.sent=-1,s.gotAt=null}due(e=Date.now()){return this.mine.c>=Tr||e-this.mine.since>=$r}forget(e){this.peers.delete(e)}async pairKey(e,r,s=!1){let o=s?`${e.epk}|${r.kcIn??r.kc}`:e.epk;if(r.wraps.has(o))return r.wraps.get(o);let n=await crypto.subtle.importKey("raw",z(r.epk),xt,!1,[]),i=new Uint8Array(await crypto.subtle.deriveBits({name:"ECDH",public:n},e.pair.privateKey,256)),a=await crypto.subtle.importKey("raw",s?_r(i,r.ss):i,"HKDF",!1,["deriveKey"]),l=H.encode(`private-talk/${s?"v3.2":"v3"}/pair|${[e.epk,r.epk].sort().join("|")}`),c=await crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:this.bind,info:l},a,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"]);return r.wraps.set(o,c),c}pqFor(e,r){if(r.v!==32||this.mer!==this.me&&this.peers.has(r)&&this.peers.get(r).sent!==this.mine.epoch)}async announce(e,r,s=Date.now()){return(await this.announceAll(e,r,s,1/0))[0]}async announceAll(e,r,s=Date.now(),o=Cr){let n=this.mine;for(let[y,f]of n.handed)s-f.at>wt&&n.handed.delete(y);let i=[...this.peers].some(([y,f])=>y{let y=a&&u?{q:1,k:this.kpk,...c}:{q:1,...c},f={kind:"keys",v:32,from:this.me,id:mt(crypto.getRandomValues(new Uint8Array(8))),t:s,epk:this.epk,spk:this.spk,hello:!!r&&u,w:y};f.sig=X(await crypto.subtle.sign(fe,this.signing.privateKey,H.encode(At(f)))),l.push(JSON.stringify(f)),c={},d=0,u=!1},m=()=>430+(a&&u?gt+8:0);for(let y of e){let f=this.peers.get(y);if(!f)continue;let{pq:T,can:S}=this.pqFor(y,f);if(!S)continue;let E=f.gotAt!=null&&n.handed.has(f.gotAt),p=E?f.gotAt:n.c,h=E?n.handed.get(p).ck:n.ck;E||(f.gotAt=p,n.handed.has(p)||n.handed.set(p,{ck:h,at:s}));let w=H.encode(JSON.stringify({e:n.epoch,ck:X(h),c:p})),A=T?{...await we(await this.pairKey(this.ecdh,f,!0),w,`pt32w|${this.me}|${y}|${f.epk}`),q:1,...f.kc&&{kc:f.kc}}:await we(await this.pairKey(this.ecdh,f),w,`pt3w|${this.me}|${y}|${f.epk}`),g=JSON.stringify(A).length+8;Object.keys(c).length&&m()+d+g>o&&await v(),c[y]=A,d+=g,f.sent=this.mine.epoch}return(u||Object.keys(c).length)&&await v(),l}async receive(e,r){if(!e||e.kind!=="keys"||e.from!==r||r===this.me||typeof e.epk!="string"||!ye.test(e.epk)||e.epk.length>120||!e.w||typeof e.w!="object")return null;let s=typeof e.spk=="string",o=null;if(s){if(!ye.test(e.spk)||e.spk.length>120||typeof e.sig!="string")throw new Error("bad announcement");let a=this.peers.get(r);if(o=a?.spk===e.spk?a.verify:await crypto.subtle.importKey("raw",z(e.spk),yt,!1,["verify"]),!await crypto.subtle.verify(fe,o,z(e.sig),H.encode(At(e))))throw new Error("bad signature")}let n=this.peers.get(r);n&&(n.spk??null)!==(s?e.spk:null)&&(n.spk&&this.changed.push(r),n=null),n?n.epk!==e.epk&&(s?(n.epk=e.epk,n.wraps=new Map):(n={epk:e.epk,spk:null,verify:null,wraps:new Map,recv:null,prev:null,sent:-1,gotAt:null,v:30},this.peers.set(r,n))):(n={epk:e.epk,spk:s?e.spk:null,verify:o,wraps:new Map,recv:null,prev:null,sent:-1,gotAt:null,v:s?31:30,kpk:null,ss:null,kc:null,kcIn:null,pqIn:!1},this.peers.set(r,n)),s&&e.w.q===1&&n.v!==32&&(n.v=32,n.sent=-1),s&&typeof e.w.k=="string"&&e.w.k.length===gt&&ye.test(e.w.k)&&e.w.k!==n.kpk&&(Object.assign(n,{kpk:e.w.k,ss:null,kc:null,kcIn:null,v:32}),n.wraps=new Map);let i=e.w[this.me];if(i&&typeof i.iv=="string"&&typeof i.ct=="string"){let a=i.q===1;a&&s&&(n.v=32),a&&ru.ecdh)],c=null;if(!a||n.ss)for(let u of l)try{c=JSON.parse(Oe.decode(await Ie(await this.pairKey(u,n,a),i,`${a?"pt32w":"pt3w"}|${r}|${this.me}|${u.epk}`)));break}catch{}c&&a&&this.me=0&&d.length===32&&(!n.recv||c.e>n.recv.epoch)?(n.prev=n.recv,n.recv={epoch:c.e,ck:d,c:c.c},n.pqIn=a):c&&a&&n.recv&&c.e===n.recv.epoch&&(n.pqIn=!0)}return e.hello&&(n.sent=-1),n}takeChanged(){let e=this.changed;return this.changed=[],e}ready(e,r=!1){return e.every(s=>s===this.me||this.peers.get(s)?.sent===this.mine.epoch&&(!r||this.peers.get(s).v>=31))}quantumSafe(e){return e.every(r=>r===this.me||this.peers.get(r)?.v===32&&this.peers.get(r).ss&&this.peers.get(r).pqIn)}older(e){return e.filter(r=>r!==this.me&&this.peers.get(r)?.v===30)}async pairSecret(e,r){let s=this.peers.get(e);if(!s?.ss)throw new Error("no pair secret");let o=await crypto.subtle.importKey("raw",s.ss,"HKDF",!1,["deriveKey"]),n=H.encode(`private-talk/v3.2/for|${r}|${Math.min(e,this.me)}|${Math.max(e,this.me)}`);return crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:this.bind,info:n},o,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async sealFor(e,r,s){return we(await this.pairSecret(e,s),H.encode(r),`pt32f|${this.me}|${e}|${s}`)}async openFrom(e,r,s){return Oe.decode(await Ie(await this.pairSecret(e,s),r,`pt32f|${e}|${this.me}|${s}`))}async seal(e){let r=this.mine,s=`${r.epoch}.${r.c}`,{mk:o,next:n}=await Ce(r.ck);r.ck=n,r.c++;let i=X(await crypto.subtle.sign(fe,this.signing.privateKey,H.encode(`pt31m|${this.me}|${s}|${e}`))),a=`${e.slice(0,-1)},"s":"${i}"}`;return{...await we(await kt(o,["encrypt"]),se(H.encode(a)),`pt3|${this.me}|${s}`),h:s}}async open(e,r){let s=Ur.exec(e.h??""),o=this.peers.get(r);if(!s||!o?.recv)throw new Error("no key");let n=Number(s[1]),i=Number(s[2]),a=n===o.recv.epoch?"recv":n===o.prev?.epoch?"prev":null,l=o[a];if(!a||iEr)throw new Error("out of step");let c=l.ck;for(let f=l.c;f=31){if(!y)throw new Error("unsigned");if(v=`${v.slice(0,y.index)}}`,!await crypto.subtle.verify(fe,o.verify,z(y[1]),H.encode(`pt31m|${r}|${e.h}|${v}`)))throw new Error("bad signature");m=!0}return o[a]={epoch:n,ck:u,c:i+1},{text:v,missed:i-l.c,signed:m}}async safetyCode(e){let r=i=>i===this.me?this.spk:this.peers.get(i)?.spk??this.peers.get(i)?.epk??"?",s=e.map(i=>`${i}:${r(i)}`).sort().join("|"),o=new Uint8Array(await crypto.subtle.digest("SHA-256",H.encode(`private-talk/v3.1/safety|${mt(this.bind)}|${s}`))),n=0n;for(let i of o.subarray(0,12))n=n<<8n|BigInt(i);return(n%10n**20n).toString().padStart(20,"0").replace(/(\d{5})(?=\d)/g,"$1 ")}};function be(t){let e=t.trim();return!e||Array.from(e).length>500||new TextEncoder().encode(e).byteLength>2048?null:e}var me=600*1e3,St=/^[0-9a-f]{16}$/;function Et(t,e,r=Date.now(),s,o,n){let i=Array.from(crypto.getRandomValues(new Uint8Array(8)),l=>l.toString(16).padStart(2,"0")).join(""),a=Number.isInteger(n)&&n>=0&&n<=5;return JSON.stringify({from:t,id:i,t:r,text:e,...s&&{g:s},...o&&{f:o},...a&&{q:n}})}function Tt(t,e,r,s=Date.now()){let o;try{o=JSON.parse(t)}catch{return null}if(!o||typeof o!="object"||typeof o.text!="string"||typeof o.id!="string"||!St.test(o.id)||!Number.isInteger(o.from)||o.from<1||o.from>25||o.from!==e.from||!Number.isFinite(o.t)||!Number.isFinite(e.at)||Math.abs(s-o.t)>me||Math.abs(e.at-o.t)>me)return null;let n=Number.isInteger(o.q)&&o.q>=0&&o.q<=5;if(n){if(o.text!==""&&be(o.text)!==o.text)return null}else if(be(o.text)!==o.text)return null;for(let[c,d]of r)Math.abs(s-d)>2*me&&r.delete(c);if(r.has(o.id))return null;r.set(o.id,o.t);let i=o.g&&typeof o.g=="object"&&!Array.isArray(o.g)?o.g:void 0,a=o.f&&typeof o.f=="object"&&!Array.isArray(o.f)?o.f:void 0,l=n?o.q:void 0;return{text:o.text,id:o.id,g:i,f:a,q:l}}function Ue(t,e,r,s=Date.now(),o="keys"){let n;try{n=JSON.parse(t)}catch{return null}return!n||typeof n!="object"||n.kind!==o||typeof n.id!="string"||!St.test(n.id)||!Number.isInteger(n.from)||n.from!==e.from||!Number.isFinite(n.t)||Math.abs(s-n.t)>me||r.has(n.id)?null:(r.set(n.id,n.t),n)}var Dr=()=>new Map,ke=class{constructor(e,r,{storage:s=Dr(),emit:o=()=>{}}={}){Object.assign(this,{d:e,config:r,store:s,emit:o,deviceId:D(N(16)),present:[],pins:new Map,seen:new Map,own:new Map,queued:[],receipts:[],received:new Map,group:null,ws:null,serial:Promise.resolve()}),this.store.set("deviceId",this.deviceId)}run(e){return this.serial=this.serial.then(()=>{if(!this.stopped)return e()}).catch(()=>this.emit("notice","\u6536\u5230\u65E0\u6CD5\u9A8C\u8BC1\u7684\u6570\u636E\uFF0C\u5DF2\u5FFD\u7565\u3002")),this.serial}send(e){!this.stopped&&this.ws?.readyState===1&&this.ws.send(JSON.stringify(e))}async announce(e=!1){if(!this.group)return;let r=this.group.owed(this.present);if(!(!e&&!r.length))for(let s of await this.group.announceAll(r,e))this.send({type:"key",...await Pe(this.d,s)})}ready(){return!this.stopped&&this.group&&this.present.length>1&&this.group.ready(this.present,!0)&&this.group.quantumSafe(this.present)}async incoming(e){if(this.stopped)return;if(e.type==="pong"){this.lastPong=Date.now();return}if(e.type==="wait"){this.nextSend=Date.now()+Math.min(3e4,Math.max(1e3,e.ms||6e3));return}if(e.type==="hello"){this.resume=e.resume,(!this.group||this.group.me!==e.you)&&(this.group=await ge.create(this.d.key,this.d.bind,e.you),this.own.clear(),this.received.clear(),this.receipts=[],this.present=[]),await this.announce(!0);return}if(!this.group)return;if(e.type==="members"){if(!Array.isArray(e.members)||e.members.some(i=>!Number.isInteger(i)||i<1||i>25))return;let n=this.present.filter(i=>!e.members.includes(i));for(let i of n){this.group.forget(i);for(let a of this.received.keys())a.startsWith(`${i}|`)&&this.received.delete(a)}this.present=e.members,n.length&&await this.group.rekey(),await this.announce(!0),this.emit("presence",this.present.length),await this.update();return}if(e.from===this.group.me){let n=this.own.get(e.h);e.type==="msg"&&n&&n.frame.iv===e.iv&&n.frame.ct===e.ct&&(n.echo=!0,this.emit("delivery",{id:n.id,text:"\u5DF2\u63D0\u4EA4\u4E2D\u7EE7"}));return}if(!this.present.includes(e.from))return;if(e.type==="key"){let n=Ue(await Fe(this.d,e),e,this.seen);if(!n||n.v!==32||!n.spk)return;let i=this.pins.get(e.from),a=await this.group.receive(n,e.from);if(!a)return;if(i&&i!==a.spk){this.halt(),this.emit("fatal","\u6210\u5458\u7B7E\u540D\u8EAB\u4EFD\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u901A\u8FC7\u72EC\u7ACB\u6E20\u9053\u6838\u5B9E\u5E76\u91CD\u65B0\u6253\u5F00\u6587\u4EF6\u3002");return}this.pins.set(e.from,a.spk),a?.lost&&(a.sent=-1),await this.announce(),await this.update();return}if(!["msg","seen"].includes(e.type)||!e.h)return;let r=`${e.from}|${e.h}`,s=this.received.get(r);if(s){e.type==="msg"&&s.iv===e.iv&&s.ct===e.ct&&this.receipts.length<100&&this.receipts.push([e.from,e.h]);return}let o=await this.group.open(e,e.from);if(!(!o.signed||this.group.peers.get(e.from)?.v!==32))if(e.type==="seen"){let n=Ue(o.text,e,this.seen,Date.now(),"seen");if(n&&Array.isArray(n.l)&&n.l.length<=25)for(let i of n.l){if(!Array.isArray(i)||i[0]!==this.group.me)continue;let a=this.own.get(i[1]);a&&a.targets.includes(e.from)&&(a.confirmed.add(e.from),this.emit("delivery",{id:a.id,text:`\u5BA2\u6237\u7AEF\u786E\u8BA4 ${a.confirmed.size}/${a.targets.length}`}))}}else{let n=Tt(o.text,e,this.seen);if(!n||n.g||n.f)return;for(this.received.set(r,{iv:e.iv,ct:e.ct});this.received.size>200;)this.received.delete(this.received.keys().next().value);this.emit("message",{id:n.id,from:e.from,text:n.text}),this.receipts.length<100&&this.receipts.push([e.from,e.h])}}async update(){this.ready()&&this.emit("ready",await this.group.safetyCode(this.present)),await this.flush()}queue(e){if(this.stopped||(e=be(e),!e||this.queued.length>=20))return!1;let r=D(N(8));return this.queued.push({id:r,text:e}),this.emit("message",{id:r,from:"\u6211",text:e}),this.emit("delivery",{id:r,text:"\u6392\u961F\u4E2D"}),this.run(()=>this.flush()),!0}async flush(){if(!(!this.ready()||this.ws?.readyState!==1)&&(this.group.due()&&(await this.group.rekey(),await this.announce()),!!this.ready())){if(this.receipts.length){let e=JSON.stringify({kind:"seen",id:D(N(8)),from:this.group.me,t:Date.now(),l:this.receipts.splice(0,25)});this.send({type:"seen",...await this.group.seal(e)})}if(this.queued.length&&Date.now()>=(this.nextSend??0)){let e=this.queued.shift(),r={type:"msg",...await this.group.seal(Et(this.group.me,e.text))};for(this.own.set(r.h,{...e,frame:r,targets:this.present.filter(s=>s!==this.group.me),confirmed:new Set,at:Date.now()});this.own.size>100;)this.own.delete(this.own.keys().next().value);this.send(r),this.nextSend=Date.now()+6100}for(let[e,r]of this.seen)Date.now()-r>12e5&&this.seen.delete(e);for(;this.seen.size>4096;)this.seen.delete(this.seen.keys().next().value)}}async retryPending(){for(let e of this.own.values())!e.echo&&Date.now()-e.at>8e3&&(e.retries??0)<3&&(this.send(e.frame),e.at=Date.now(),e.retries=(e.retries??0)+1),!e.echo&&(e.retries??0)>=3&&this.emit("delivery",{id:e.id,text:"\u4E2D\u7EE7\u63D0\u4EA4\u672A\u786E\u8BA4\uFF0C\u8BF7\u6838\u5B9E\u540E\u91CD\u53D1"})}resetRelay(){for(let e of this.own.values())(e.confirmed.sizenew WebSocket(d,u)}){Object.assign(this,{relays:e,roomId:r,protocols:s,onactive:o,onmessage:n,onnotice:i,onfatal:a,ondisconnect:l,createSocket:c}),this.backoff=1e3,this.lastIndex=-1}start(){this.connect(0),this.probeTimer=setInterval(()=>{!this.stopped&&this.active?.ready&&this.active.index>0&&!this.probe&&this.connect(0,!0)},6e4),this.heartbeat=setInterval(()=>{let e=this.active;if(e?.ready){if(Date.now()-e.lastPong>65e3){this.fail(e);return}if(Date.now()-e.lastPing>=25e3){e.lastPing=Date.now();try{e.ws.send(JSON.stringify({type:"ping"}))}catch{this.fail(e)}}}},1e3)}connect(e,r=!1){if(this.stopped)return;let s={index:e,probe:r,frames:[],ready:!1,retired:!1};r?this.probe=s:this.pending=s;try{s.ws=this.createSocket(`${this.relays[e]}/hr/${this.roomId}`,this.protocols(e))}catch{this.fail(s);return}s.timeout=setTimeout(()=>this.fail(s),1e4),s.ws.onerror=()=>{!s.retired&&!s.probe&&this.onnotice("WSS \u63E1\u624B\u5931\u8D25\u53EF\u80FD\u662F\u7F51\u7EDC\u3001Origin\u3001\u9274\u6743\u6216\u8BC1\u4E66\u4FE1\u4EFB\u95EE\u9898\u3002\u6B63\u5728\u6309\u4F18\u5148\u7EA7\u91CD\u8BD5\uFF1B\u8BF7\u4F7F\u7528\u6709\u6548\u8BC1\u4E66\uFF0C\u52FF\u5173\u95ED\u8BC1\u4E66\u6821\u9A8C\u3002")},s.ws.onclose=o=>this.fail(s,o.code),s.ws.onmessage=o=>{if(s.retired||this.stopped||typeof o.data!="string"||o.data.length>7e3)return;let n;try{n=JSON.parse(o.data)}catch{return}if(!(!n||typeof n!="object")){if(s.ready){if(this.active!==s)return;n.type==="pong"&&(s.lastPong=Date.now()),this.onmessage(n);return}if(s.frames.length>=50){this.fail(s);return}s.frames.push(n),n.type==="hello"&&!s.activating&&Number.isInteger(n.you)&&n.you>=1&&n.you<=25&&typeof n.resume=="string"&&/^[0-9a-f]{32}$/.test(n.resume)&&(s.activating=!0,this.activate(s).catch(()=>this.fail(s)))}}}async activate(e){let r=this.active,s=this.lastIndex!==-1&&this.lastIndex!==e.index;if(this.active=e,this.probe===e&&(this.probe=null),this.pending===e&&(this.pending=null),r&&r!==e&&this.retire(r),await this.onactive(e.ws,e.index,s),!(e.retired||this.stopped||this.active!==e)){clearTimeout(e.timeout),this.lastIndex=e.index,this.backoff=1e3,e.ready=!0,e.lastPong=e.lastPing=Date.now();for(let o of e.frames)this.onmessage(o);e.frames=[]}}retire(e){if(!(!e||e.retired)){e.retired=!0,clearTimeout(e.timeout);try{e.ws?.close(4e3)}catch{}}}fail(e,r){if(!(e.retired||this.stopped)){if(this.retire(e),this.probe===e){this.probe=null;return}if(this.pending===e&&(this.pending=null),this.active===e&&(this.active=null,this.ondisconnect()),this.relays.length===1&&[4e3,4001,4002,4005,4006,4007,4008,1008,1009].includes(r)){this.stop(),this.onfatal();return}this.retire(this.probe),this.probe=null,clearTimeout(this.retry),this.retry=setTimeout(()=>this.connect((e.index+1)%this.relays.length),this.backoff),this.backoff=Math.min(this.backoff*2,3e4),r!==1006&&this.onnotice("\u8FDE\u63A5\u4E2D\u65AD\uFF0C\u6B63\u5728\u6309\u5217\u8868\u987A\u5E8F\u5207\u6362\u4E2D\u7EE7\uFF1B\u4E0D\u540C\u4E2D\u7EE7\u4E0A\u7684\u6210\u5458\u6682\u65F6\u65E0\u6CD5\u4E92\u901A\u3002")}}stop(){this.stopped=!0,clearTimeout(this.retry),clearInterval(this.probeTimer),clearInterval(this.heartbeat),this.retire(this.active),this.retire(this.pending),this.retire(this.probe)}};function $t(t){let e=c=>document.getElementById(c),r=c=>{e("status").textContent=c};e("preview-help").hidden=!0;let s=0,o=!1,n=0,i,a=new Map;function l(c,d){if((c==="notice"||c==="fatal")&&r(d),c==="relay-change"&&(e("safety").textContent="\u4E2D\u7EE7\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u9A8C\u8BC1\u6210\u5458\uFF1B\u8BF7\u91CD\u65B0\u6838\u5BF9\u5B89\u5168\u7801\u3002"),c==="presence"&&(e("presence").textContent=`\u5728\u7EBF ${d} \u4EBA\uFF08\u4E2D\u7EE7\u62A5\u544A\uFF09`),c==="ready"&&(r("\u5DF2\u5EFA\u7ACB\u7B7E\u540D\u6DF7\u5408\u52A0\u5BC6\u8FDE\u63A5"),e("safety").textContent=`\u5B89\u5168\u7801\uFF1A${d}\uFF0C\u8BF7\u901A\u8FC7\u72EC\u7ACB\u6E20\u9053\u6838\u5BF9\u3002`),c==="message"){let u=document.createElement("li");u.textContent=`${d.from}\uFF1A${d.text}`;let v=document.createElement("small");u.append(v),e("messages").append(u),a.set(d.id,v),a.size>200&&(a.delete(a.keys().next().value),e("messages").firstChild.remove())}c==="delivery"&&a.has(d.id)&&(a.get(d.id).textContent=d.text)}if(!globalThis.crypto?.subtle||!globalThis.WebSocket||!globalThis.TextEncoder)r("\u6B64\u9884\u89C8\u73AF\u5883\u4E0D\u652F\u6301\u6240\u9700\u811A\u672C\u6216\u52A0\u5BC6 API\uFF0C\u8BF7\u4F7F\u7528\u652F\u6301\u7684\u6D4F\u89C8\u5668\u6253\u5F00\uFF1BiOS\u3001\u5FAE\u4FE1\u548C QQ \u672C\u5730\u9884\u89C8\u9700\u5B9E\u6D4B\u3002"),e("enter").disabled=!0;else if(t.version)try{G(t),r(t.encrypted?"\u8BF7\u8F93\u5165\u53E3\u4EE4\u89E3\u9501\uFF1B\u89E3\u9501\u524D\u4E0D\u4F1A\u8FDE\u63A5\u4E2D\u7EE7\u3002":"\u65E0\u53E3\u4EE4\u6587\u4EF6\uFF1A\u70B9\u51FB\u8FDB\u5165\u623F\u95F4\u3002"),e("password").disabled=!1,e("enter").disabled=!1}catch{r("\u914D\u7F6E\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u6587\u4EF6\u3002"),e("enter").disabled=!0}else r("\u65E0\u79D8\u5BC6\u6A21\u677F\uFF1A\u8BF7\u4F7F\u7528\u751F\u6210\u5668\u521B\u5EFA\u623F\u95F4\u6587\u4EF6\u3002"),e("enter").disabled=!0;e("unlock").addEventListener("submit",async c=>{if(c.preventDefault(),!(o||Date.now()["pt2",`a${D(i.d.access)}`,...i.resume&&u.lastIndex===v?[`r${i.resume}`]:[]],onactive:(v,m,y)=>i.run(()=>{y&&i.resetRelay(),i.ws=v,e("relay-status").textContent=`\u5F53\u524D\u4E2D\u7EE7\uFF1A${t.relays[m]}\uFF08\u4F18\u5148\u7EA7 ${m+1}/${t.relays.length}\uFF09`,r(y?"\u5DF2\u5207\u6362\u4E2D\u7EE7\uFF0C\u6B63\u5728\u91CD\u65B0\u9A8C\u8BC1\u6210\u5458\uFF1B\u4E0D\u540C\u4E2D\u7EE7\u4E0A\u7684\u6210\u5458\u6682\u65F6\u65E0\u6CD5\u4E92\u901A\u3002":"\u5DF2\u8FDE\u63A5\u4E2D\u7EE7\uFF0C\u6B63\u5728\u9A8C\u8BC1\u6210\u5458\u5BC6\u94A5\u2026")}),onmessage:v=>{let m=i.ws;i.run(()=>{if(m===i.ws)return i.incoming(v)})},ondisconnect:()=>{i.ws=null,e("relay-status").textContent="\u5F53\u524D\u4E2D\u7EE7\uFF1A\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u6309\u4F18\u5148\u7EA7\u91CD\u8BD5",l("presence",0)},onnotice:r,onfatal:()=>{i.halt(),r("\u8FDE\u63A5\u88AB\u62D2\u7EDD\u6216\u4F1A\u8BDD\u7ED3\u675F\uFF0C\u8BF7\u68C0\u67E5\u623F\u95F4\u6587\u4EF6\u3001\u4E2D\u7EE7\u914D\u7F6E\u6216\u4EBA\u6570\u9650\u5236\u540E\u91CD\u65B0\u6253\u5F00\u3002")}});i.connection=u,u.start(),i.tick=setInterval(()=>i.run(async()=>{i.ws?.readyState===1&&(Date.now()-(i.lastAnnounce??0)>1e4&&(await i.announce(!0),i.lastAnnounce=Date.now()),await i.flush(),await i.retryPending())}),1600)}catch{s++,n=Date.now()+Math.min(3e4,1e3*2**Math.min(s,5)),e("password").value="",r("\u89E3\u9501\u5931\u8D25\uFF1A\u53E3\u4EE4\u6216\u6587\u4EF6\u4E0D\u6B63\u786E\u3002\u7A0D\u540E\u518D\u8BD5\u3002\u672C\u673A\u8282\u6D41\u4E0D\u80FD\u963B\u6B62\u79BB\u7EBF\u731C\u6D4B\u3002")}finally{o=!1,setTimeout(()=>{e("enter").disabled=!1},Math.max(0,n-Date.now()))}}}),e("chat").addEventListener("submit",c=>{c.preventDefault(),i?.queue(e("text").value)?e("text").value="":r("\u6D88\u606F\u4E3A\u7A7A\u3001\u8FC7\u957F\u6216\u961F\u5217\u5DF2\u6EE1\u3002")}),addEventListener("pagehide",()=>i?.halt())}var Mr=t=>JSON.stringify(t).replace(/[<>/\u2028\u2029]/g,e=>`\\u${e.charCodeAt(0).toString(16).padStart(4,"0")}`),Hr=t=>btoa(String.fromCharCode(...new TextEncoder().encode(t))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");function _e(t){return G(t),"#hr1="+Hr(JSON.stringify(t))}function Kt(t){let e=t.version?`${t.relays[0].replace(/^ws/,"http")}/hr.html${_e(t)}`:"";return`

      \u82E5\u6B64\u63D0\u793A\u4E00\u76F4\u663E\u793A\uFF0C\u5F53\u524D\u6587\u4EF6\u9884\u89C8\u672A\u80FD\u8FD0\u884C\u804A\u5929\u811A\u672C\u3002iOS\u300C\u6587\u4EF6\u300D\u3001\u5FAE\u4FE1\u6216 QQ \u7684\u9884\u89C8\u53EF\u80FD\u65E0\u6CD5\u4F7F\u7528\u6B64\u623F\u95F4\uFF1B\u8F93\u5165\u53E3\u4EE4\u4E0D\u4F1A\u751F\u6548\u3002\u8BF7\u5728 Safari \u7B49\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u623F\u95F4\u7F51\u9875\u94FE\u63A5\u3002

      ${e?`

      \u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u540C\u4E00\u623F\u95F4\uFF08\u82E5\u65E0\u6CD5\u8DF3\u8F6C\uFF0C\u8BF7\u590D\u5236\u94FE\u63A5\u5230 Safari \u5730\u5740\u680F\uFF09\u3002

      \u94FE\u63A5\u5305\u542B\u52A0\u5165\u623F\u95F4\u7684\u80FD\u529B\uFF0C\u8BF7\u8C28\u614E\u5206\u53D1\u3002\u7F51\u9875\u4EE3\u7801\u53EF\u8BFB\u53D6\u94FE\u63A5\u4E2D\u7684\u623F\u95F4\u914D\u7F6E\uFF0C\u5176\u5B89\u5168\u6027\u4E0D\u7B49\u540C\u4E8E\u9A8C\u8BC1\u8FC7\u7684\u79BB\u7EBF\u6587\u4EF6\uFF1B\u4E2D\u7EE7\u987B\u63D0\u4F9B /hr.html \u7F51\u9875\u5165\u53E3\u3002

      `:""}
      `}function Ot(t){if(typeof t!="string"||!/^#hr1=[A-Za-z0-9_-]+$/.test(t)||t.length>8192)throw Error("\u623F\u95F4\u94FE\u63A5\u683C\u5F0F\u6216\u957F\u5EA6\u65E0\u6548");let e=t.slice(5).replace(/-/g,"+").replace(/_/g,"/"),r=Uint8Array.from(atob(e+"=".repeat((4-e.length%4)%4)),s=>s.charCodeAt(0));if(r.length>4096)throw Error("\u623F\u95F4\u914D\u7F6E\u8FC7\u957F");return G(JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(r)))}var Le=async t=>btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t)))));async function Ct(t,e,r,s,o,n){if(G(e),!e.relays.every(d=>r.map(Ae).includes(d)))throw Error("\u4E2D\u7EE7\u6E90\u4E0D\u5728\u660E\u786E\u767D\u540D\u5355\u5185");if(t.length>2e5)throw Error("\u6A21\u677F\u8FC7\u957F");if(await Le(t)!==n)throw Error("\u5B8C\u6574\u6A21\u677F\u54C8\u5E0C\u4E0D\u7B26");let i=[...t.matchAll(/]*)>([\s\S]*?)<\/script>/g)],a=[...t.matchAll(/

      加密聊天房间

      正在启动……如果一直停在这里,请看下面的说明。

      ${previewHelp(config)}
      ${RETARGET}

        ${MAKER}

        HTML Room 开源项目:${repoLink}

        \n${comment ? '\n' : ''}`; + return { html, csp }; +} + +// Same room (version, roomId, master or wrapped master untouched), new relays. +// Relays are public routing data: not part of the roomId derivation or the +// password-wrap AAD, so no password is needed and nothing secret changes. +export function withRelays(config, input, custom = config.custom === true) { + validate(config); + const { custom: _, ...rest } = config; config = custom ? { ...rest, custom: true } : rest; + // Moving between classic (v2) and decentralized (v3) relays only changes the + // version tag: the key material and roomId stay the same. + const relays = relayList(input); + return validate({ ...config, version: versionFor(relays), relays }); +} + +const digest = async s => btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))))); +// own: { script, style, comment } — the exact bytes of the running page. +export async function retargetHtml(config, input, own, custom) { + const next = withRelays(config, input, custom); + return { config: next, html: await htmlFor(next, own) }; +} + +export function configFromRoomHtml(html) { + if (typeof html !== 'string' || html.length > 400000) throw Error('不是有效的房间文件'); + const m = html.match(/',``); + return template.replace(marker,``).replace(previewHelp({}),previewHelp(config)).replace('',``); } diff --git a/html-room/web.js b/html-room/web.js deleted file mode 100644 index e7f5f0a..0000000 --- a/html-room/web.js +++ /dev/null @@ -1,49 +0,0 @@ -// Adapted from Claude's hr.js maker/Fragment entry; cryptography and chat -// use the canonical protocol modules shared with standalone files. -import { boot } from './client.js'; -import { makeConfig, relayList, hex, random } from './crypto.js'; -import { configFromHash, fragmentFor, fillTemplate } from './transport.js'; -const fragment=location.hash; -if(fragment)history.replaceState(null,'',location.pathname+location.search); -const el=id=>document.getElementById(id); -const allowedRelays=PT_DEFAULT_RELAYS.length ? PT_DEFAULT_RELAYS : [location.origin.replace(/^http/,'ws')]; -let downloadUrl; -function fail(text){el('mk-error').textContent=text;} -function openFragment(fragment){ - if(location.hash)history.replaceState(null,'',location.pathname+location.search); - if(!el('room-panel').hidden){el('status').textContent='请在新窗口打开其他房间链接,当前会话不会自动切换。';return;} - el('maker').hidden=true;el('room-panel').hidden=false; - try{ - const config=configFromHash(fragment); - if(!config.relays.every(relay=>allowedRelays.includes(relay)))throw Error('网页入口仅允许预设中继,请用房间文件连接其他中继。'); - boot(config); - }catch{el('status').textContent='房间链接无效,或中继与本站不匹配。请重新索取链接或使用验证过的房间文件。';el('enter').disabled=true;} -} -addEventListener('hashchange',()=>{if(location.hash)openFragment(location.hash);}); -if(fragment)openFragment(fragment); -else{ - el('mk-relay').value=allowedRelays.join(', '); - el('mk-gen').addEventListener('click',()=>{el('mk-pass').value=hex(random(32));}); - el('maker').addEventListener('submit',async event=>{ - event.preventDefault();el('mk-make').disabled=true;fail('');el('mk-out').hidden=true; - try{ - const relays=relayList(el('mk-relay').value),password=el('mk-pass').value; - if(password && new TextEncoder().encode(password).length>1024)throw Error('口令过长'); - // Submitted canonical sources form the explicit file generation allowlist. - const config=await makeConfig(relays,password||undefined); - const response=await fetch('hr-template.html',{credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer'}); - if(!response.ok)throw Error('模板不可用'); - const html=await fillTemplate(await response.text(),config,relays,PT_SCRIPT_HASH,PT_STYLE_HASH,PT_TEMPLATE_HASH); - if(downloadUrl)URL.revokeObjectURL(downloadUrl); - downloadUrl=URL.createObjectURL(new Blob([html],{type:'text/html'})); - el('mk-download').href=downloadUrl;el('mk-out').hidden=false; - el('mk-pass').value=''; - if(relays.every(relay=>allowedRelays.includes(relay))){el('mk-link').value=location.origin+location.pathname+fragmentFor(config);el('mk-link-row').hidden=false;} - else{el('mk-link').value='';el('mk-link-row').hidden=true;} - fail(password?'文件已包装。请通过独立渠道传递口令;仍须防范离线猜测。':'此文件包含完整房间密钥,请勿通过未加密的社交软件直接发送。'); - }catch{fail('生成失败。请检查中继地址、环境和模板完整性。');} - finally{el('mk-make').disabled=false;} - }); - el('mk-copy').addEventListener('click',()=>navigator.clipboard?.writeText(el('mk-link').value).then(()=>fail('已复制。链接包含加入能力,请谨慎分发。'),()=>fail('无法自动复制,请手动复制链接。'))); -} -addEventListener('pagehide',()=>{if(downloadUrl)URL.revokeObjectURL(downloadUrl);}); diff --git a/package-lock.json b/package-lock.json index 76030a8..dd4bf5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", "ws": "^8.18.0" }, "devDependencies": { + "aedes": "1.2.0", "esbuild": "0.28.2", "playwright": "1.64.1", "wrangler": "^4.149.0" @@ -715,9 +718,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -735,9 +735,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -755,9 +752,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -775,9 +769,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -795,9 +786,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -815,9 +803,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -835,9 +820,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -855,9 +837,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -875,9 +854,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -901,9 +877,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -927,9 +900,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -953,9 +923,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -979,9 +946,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1005,9 +969,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1031,9 +992,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1057,9 +1015,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1200,6 +1155,33 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1249,6 +1231,136 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", + "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/aedes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-1.2.0.tgz", + "integrity": "sha512-fCgK7TzmP2zGYS2iI/RE1tjHAIIqq5MjeOx3bicd89aTWTac+wiaywwIWhQQK6CXS+ml7ssGz8NntKYL5jPaZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^4.0.1", + "aedes-persistence": "^11.0.0", + "mqemitter": "^7.1.0", + "mqtt-packet": "^9.0.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + }, + "peerDependencies": { + "aedes-persistence-level": "^9.1.2", + "aedes-persistence-mongodb": "^9.3.1", + "aedes-persistence-redis": "^11.2.1" + }, + "peerDependenciesMeta": { + "aedes-persistence-level": { + "optional": true + }, + "aedes-persistence-mongodb": { + "optional": true + }, + "aedes-persistence-redis": { + "optional": true + } + } + }, + "node_modules/aedes-packet": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-4.0.1.tgz", + "integrity": "sha512-PLjKJElXEYB66RzHhJCw0SrRLfSmGRbbg2mtO5t/6FAbFp8vC0cG5ErhvPvfa9iKBrKZwoluWLgn8doBU8sRAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mqtt-packet": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/aedes-persistence": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-11.0.0.tgz", + "integrity": "sha512-6ufVYbC9qrYC9te/jdVX1nXKSRJpUOi3XGLYWfmHo3ijrieZOX6k2/35LFZaX1E2VL2lV9xtq2NLNalPciuxeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^4.0.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1256,6 +1368,31 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1270,6 +1407,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1332,6 +1487,37 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1347,6 +1533,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1397,6 +1611,39 @@ } } }, + "node_modules/mqemitter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-7.1.0.tgz", + "integrity": "sha512-GnBDNz3lxmllW201ne0mrmdy5tPOTnc79jjVcsfUa2LG2pUGeyGWVeiae6ZysfC/64XrYOqCKRAQYrB7pGyBVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -1440,6 +1687,82 @@ "node": ">=20" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1503,6 +1826,16 @@ } } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -1534,6 +1867,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -1622,6 +1962,16 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index eb47bc6..4ba8b05 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,25 @@ "build:pages": "node scripts/build-html-room.mjs pages", "build:check": "node scripts/build-html-room.mjs template --check", "test": "node --test", - "test:browser": "node browser-tests/htmlroom.mjs && node browser-tests/html-room-preview.mjs && node browser-tests/multi-relay.mjs", + "test:browser": "node browser-tests/htmlroom.mjs && node browser-tests/html-room-preview.mjs && node browser-tests/multi-relay.mjs && node browser-tests/retarget.mjs && node browser-tests/mesh.mjs && node browser-tests/custom-relay.mjs && node browser-tests/generations.mjs && node browser-tests/file-server.mjs", + "test:retarget": "node browser-tests/retarget.mjs", "test:failover": "node browser-tests/multi-relay.mjs", "test:pages": "npm run build:pages && node browser-tests/pages.mjs", "test:preview": "node browser-tests/html-room-preview.mjs", "test:worker": "HR_WORKER_TEST=1 node --test test/hr-worker.test.mjs", "html-room:generate": "node scripts/build-html-room.mjs", "html-room:check": "node scripts/check-html-room.mjs", - "deploy": "npm run build:check && wrangler deploy" + "deploy": "npm run build:check && wrangler deploy", + "test:mesh": "node browser-tests/mesh.mjs", + "relay:go": "cd relay/go && go run . " }, "dependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", "ws": "^8.18.0" }, "devDependencies": { + "aedes": "1.2.0", "esbuild": "0.28.2", "playwright": "1.64.1", "wrangler": "^4.149.0" diff --git a/relay/go/Dockerfile b/relay/go/Dockerfile new file mode 100644 index 0000000..c6b9e23 --- /dev/null +++ b/relay/go/Dockerfile @@ -0,0 +1,10 @@ +# docker build -t htmlroom-relay relay/go && docker run -p 8080:8080 htmlroom-relay +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /htmlroom-relay . +FROM scratch +COPY --from=build /htmlroom-relay /htmlroom-relay +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/htmlroom-relay"] diff --git a/relay/go/go.mod b/relay/go/go.mod new file mode 100644 index 0000000..28a1826 --- /dev/null +++ b/relay/go/go.mod @@ -0,0 +1,3 @@ +module github.com/Freecode100Year/htmlroom/relay/go + +go 1.22 diff --git a/relay/go/main.go b/relay/go/main.go new file mode 100644 index 0000000..b7e7c31 --- /dev/null +++ b/relay/go/main.go @@ -0,0 +1,424 @@ +// htmlroom-relay: a broadcast relay for decentralized (version 3) HTML Room +// rooms, in one dependency-free binary for Windows, macOS, Linux, BSD and +// routers (OpenWrt mips/arm). +// +// It forwards each text frame on /bc/ to the other connections on the +// same topic. Frames are sealed by the members with a key the relay never sees; +// the relay keeps nothing on disk and logs no topics or addresses. +// +// htmlroom-relay listen on :8080 (put HTTPS in front) +// htmlroom-relay -tls-cert c -tls-key k -listen :443 +// htmlroom-relay -tunnel no domain, no open port: a free +// Cloudflare quick tunnel gives a wss:// URL +package main + +import ( + "bufio" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" + "sync" + "syscall" + "time" + "unicode/utf8" +) + +const version = "1.0.0" + +var ( + listen = flag.String("listen", ":8080", "address to listen on") + tlsCert = flag.String("tls-cert", "", "TLS certificate file (serve wss:// directly)") + tlsKey = flag.String("tls-key", "", "TLS key file") + allowOrigin = flag.String("allow-origin", "", "comma-separated Origins browsers may connect from (default: any; data is end-to-end encrypted)") + maxConns = flag.Int("max-conns", 2000, "maximum simultaneous connections") + maxPeers = flag.Int("max-peers", 50, "maximum connections per topic") + maxFrame = flag.Int("max-frame", 16000, "maximum frame size in bytes") + ratePer10s = flag.Int("rate", 60, "frames per connection per 10 seconds") + tunnel = flag.Bool("tunnel", false, "start a Cloudflare quick tunnel (needs cloudflared) and print the public relay address") + cloudflared = flag.String("cloudflared", "cloudflared", "path to the cloudflared binary") + showVersion = flag.Bool("version", false, "print version and exit") +) + +// Topic: the path after /bc/ (or the whole path, for clients configured with +// a bare host). The HTML Room client uses 32 hex characters. +var topicPath = regexp.MustCompile(`^/(?:bc/)?([A-Za-z0-9_-]{8,128})$`) + +// out is never closed (senders hold no lock against close); done signals the end. +type conn struct { + nc net.Conn + out chan []byte + done chan struct{} + once sync.Once + window int64 + count int +} + +func (c *conn) close() { c.once.Do(func() { close(c.done); c.nc.Close() }) } + +type hub struct { + mu sync.Mutex + topics map[string]map[*conn]bool + total int +} + +func (h *hub) join(topic string, c *conn) bool { + h.mu.Lock() + defer h.mu.Unlock() + peers := h.topics[topic] + if h.total >= *maxConns || len(peers) >= *maxPeers { + return false + } + if peers == nil { + peers = map[*conn]bool{} + h.topics[topic] = peers + } + peers[c] = true + h.total++ + return true +} + +func (h *hub) leave(topic string, c *conn) { + h.mu.Lock() + defer h.mu.Unlock() + if peers := h.topics[topic]; peers[c] { + delete(peers, c) + h.total-- + if len(peers) == 0 { + delete(h.topics, topic) + } + } +} + +func (h *hub) forward(topic string, from *conn, frame []byte) { + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.topics[topic] { + if c == from { + continue + } + select { + case c.out <- frame: + case <-c.done: + default: // a reader this slow is dropped rather than holding up the room + go c.close() + } + } +} + +func originAllowed(origin string) bool { + if *allowOrigin == "" || origin == "" || origin == "null" { + return true + } + for _, o := range strings.Split(*allowOrigin, ",") { + if strings.TrimSpace(o) == origin { + return true + } + } + return false +} + +func headerHas(r *http.Request, name, token string) bool { + for _, v := range r.Header.Values(name) { + for _, p := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(p), token) { + return true + } + } + } + return false +} + +const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +func (h *hub) serveHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + if r.URL.Path == "/healthz" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"version":%q,"kind":"bc"}`, version) + return + } + m := topicPath.FindStringSubmatch(r.URL.Path) + if m == nil { + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "HTML Room broadcast relay. Use it in a room as: bc+wss:///bc/") + return + } + http.NotFound(w, r) + return + } + key := r.Header.Get("Sec-WebSocket-Key") + if r.Method != http.MethodGet || !headerHas(r, "Connection", "upgrade") || !headerHas(r, "Upgrade", "websocket") || key == "" || r.Header.Get("Sec-WebSocket-Version") != "13" { + http.Error(w, "websocket only", http.StatusUpgradeRequired) + return + } + if !originAllowed(r.Header.Get("Origin")) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "cannot upgrade", http.StatusInternalServerError) + return + } + nc, rw, err := hj.Hijack() + if err != nil { + return + } + sum := sha1.Sum([]byte(key + wsGUID)) + fmt.Fprintf(rw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", base64.StdEncoding.EncodeToString(sum[:])) + if rw.Flush() != nil { + nc.Close() + return + } + c := &conn{nc: nc, out: make(chan []byte, 256), done: make(chan struct{})} + topic := m[1] + if !h.join(topic, c) { + writeFrame(nc, 0x8, closePayload(1013, "full")) + nc.Close() + return + } + go c.writeLoop() + h.readLoop(topic, c, rw.Reader) + h.leave(topic, c) + c.close() +} + +func closePayload(code uint16, reason string) []byte { + b := make([]byte, 2, 2+len(reason)) + binary.BigEndian.PutUint16(b, code) + return append(b, reason...) +} + +// Server frames are unmasked, single-fragment. +func writeFrame(w io.Writer, op byte, payload []byte) error { + head := []byte{0x80 | op} + switch n := len(payload); { + case n < 126: + head = append(head, byte(n)) + case n < 65536: + head = append(head, 126, byte(n>>8), byte(n)) + default: + head = append(head, 127, 0, 0, 0, 0, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + } + _, err := w.Write(append(head, payload...)) + return err +} + +// Queued items are [opcode, payload...]. +func (c *conn) writeLoop() { + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + for { + select { + case <-c.done: + return + case msg := <-c.out: + c.nc.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if writeFrame(c.nc, msg[0], msg[1:]) != nil { + go c.close() + return + } + case <-ping.C: + c.nc.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if writeFrame(c.nc, 0x9, nil) != nil { + go c.close() + return + } + } + } +} + +func (c *conn) send(op byte, payload []byte) { + select { + case c.out <- append([]byte{op}, payload...): + case <-c.done: + default: + } +} + +var errProtocol = errors.New("protocol") + +func (h *hub) readLoop(topic string, c *conn, r *bufio.Reader) { + var msg []byte + var msgOp byte + for { + c.nc.SetReadDeadline(time.Now().Add(75 * time.Second)) + fin, op, payload, err := readFrame(r, *maxFrame) + if err != nil { + if errors.Is(err, errProtocol) { + c.send(0x8, closePayload(1009, "bad frame")) + time.Sleep(50 * time.Millisecond) + } + return + } + switch op { + case 0x8: + c.send(0x8, closePayload(1000, "")) + time.Sleep(50 * time.Millisecond) + return + case 0x9: + c.send(0xA, payload) + continue + case 0xA: + continue + case 0x1: + msg, msgOp = append(msg[:0], payload...), op + case 0x0: + if msgOp == 0 { + return + } + msg = append(msg, payload...) + default: // binary or reserved: this relay carries text only + c.send(0x8, closePayload(1003, "text only")) + time.Sleep(50 * time.Millisecond) + return + } + if len(msg) > *maxFrame { + c.send(0x8, closePayload(1009, "too big")) + time.Sleep(50 * time.Millisecond) + return + } + if !fin { + continue + } + msgOp = 0 + if !utf8.Valid(msg) { + return + } + window := time.Now().Unix() / 10 + if c.window != window { + c.window, c.count = window, 0 + } + if c.count++; c.count > *ratePer10s { + c.send(0x8, closePayload(1008, "too fast")) + time.Sleep(50 * time.Millisecond) + return + } + h.forward(topic, c, append([]byte{0x1}, msg...)) + } +} + +func readFrame(r *bufio.Reader, limit int) (fin bool, op byte, payload []byte, err error) { + var head [2]byte + if _, err = io.ReadFull(r, head[:]); err != nil { + return + } + fin, op = head[0]&0x80 != 0, head[0]&0x0f + if head[0]&0x70 != 0 || head[1]&0x80 == 0 { // no extensions; clients must mask + return false, 0, nil, errProtocol + } + n := uint64(head[1] & 0x7f) + switch n { + case 126: + var b [2]byte + if _, err = io.ReadFull(r, b[:]); err != nil { + return + } + n = uint64(binary.BigEndian.Uint16(b[:])) + case 127: + var b [8]byte + if _, err = io.ReadFull(r, b[:]); err != nil { + return + } + n = binary.BigEndian.Uint64(b[:]) + } + if n > uint64(limit) || (op >= 0x8 && (n > 125 || !fin)) { + return false, 0, nil, errProtocol + } + var mask [4]byte + if _, err = io.ReadFull(r, mask[:]); err != nil { + return + } + payload = make([]byte, n) + if _, err = io.ReadFull(r, payload); err != nil { + return + } + for i := range payload { + payload[i] ^= mask[i%4] + } + return +} + +var tunnelURL = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) + +// startTunnel runs cloudflared and reports the relay address it was given. +func startTunnel(ctx context.Context, port string) { + cmd := exec.CommandContext(ctx, *cloudflared, "tunnel", "--no-autoupdate", "--url", "http://127.0.0.1:"+port) + stderr, err := cmd.StderrPipe() + if err == nil { + err = cmd.Start() + } + if err != nil { + log.Printf("无法启动 cloudflared(%v)。请先安装:https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/", err) + return + } + go func() { + s := bufio.NewScanner(stderr) + found := false + for s.Scan() { + if u := tunnelURL.FindString(s.Text()); u != "" && !found { + found = true + host := strings.TrimPrefix(u, "https://") + fmt.Printf("\n公网中继已就绪(临时地址,重启后会变化):\n\n bc+wss://%s/bc/\n\n在房间生成器或房间文件的「更换中继」中填写上面这一行。\n\n", host) + } + } + cmd.Wait() + if ctx.Err() == nil { + log.Printf("cloudflared 已退出,公网地址失效") + } + }() +} + +func main() { + flag.Parse() + if *showVersion { + fmt.Println(version) + return + } + h := &hub{topics: map[string]map[*conn]bool{}} + srv := &http.Server{Addr: *listen, Handler: http.HandlerFunc(h.serveHTTP), ReadHeaderTimeout: 10 * time.Second, ErrorLog: log.New(io.Discard, "", 0)} + ln, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatalf("无法监听 %s:%v", *listen, err) + } + _, port, _ := net.SplitHostPort(ln.Addr().String()) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + scheme := "ws" + if *tlsCert != "" { + scheme = "wss" + } + log.Printf("HTML Room broadcast relay %s listening on %s (%s://:%s/bc/)", version, ln.Addr(), scheme, port) + if *tunnel { + startTunnel(ctx, port) + } + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + srv.Shutdown(shutdown) + }() + if *tlsCert != "" { + err = srv.ServeTLS(ln, *tlsCert, *tlsKey) + } else { + err = srv.Serve(ln) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} diff --git a/scripts/build-html-room.mjs b/scripts/build-html-room.mjs index 24d7b09..a2406a2 100644 --- a/scripts/build-html-room.mjs +++ b/scripts/build-html-room.mjs @@ -2,32 +2,50 @@ import { build } from 'esbuild'; import { createHash } from 'node:crypto'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { makeConfig, relayOrigin, relayList, validate } from '../html-room/crypto.js'; -import { escapeConfig, previewHelp } from '../html-room/transport.js'; +import { makeConfig, relayEntry, relayList, validate, validateBlank, CUSTOM_SOURCES } from '../html-room/crypto.js'; +import { escapeConfig } from '../html-room/transport.js'; +import { roomHtml, withRelays, configFromRoomHtml, serverComment, SERVER_FORBIDDEN } from '../html-room/template.js'; export { escapeConfig }; const root=fileURLToPath(new URL('../',import.meta.url)); const hash=s=>createHash('sha256').update(s).digest('base64'); export async function render(config, allowed=[]) { - if(config.version){validate(config);if(!config.relays.every(relay=>allowed.map(relayOrigin).includes(relay)))throw Error('中继源不在明确白名单内');} + if(config.version){validate(config);if(!config.relays.every(relay=>allowed.map(relayEntry).includes(relay)))throw Error('中继源不在明确白名单内');} + else validateBlank(config); const result=await build({entryPoints:[root+'html-room/file.js'],bundle:true,write:false,format:'iife',target:['es2020'],minify:true,legalComments:'none'}); - const script=result.outputFiles[0].text.trimEnd(); - if(/<\/script/i.test(script))throw Error('可执行脚本含不安全结束标签'); + // The script embeds the room HTML (template.js) to re-emit itself; tag openers + // occur only inside string literals, where \x3C is the same character. + // Likewise comment delimiters, which would end the file's HTML or Node comments + // early; they too occur only inside literals (see html-room/template.js). + const script=result.outputFiles[0].text.trimEnd().replace(/<(\\?\/?)(script|style)/gi,'\\x3C$1$2') + .replace(/