From f60a82efb0608c2d57473c0f4b166e8d6469e900 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:45:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20room=20titles=20anyone=20can=20set=20(d?= =?UTF-8?q?efault=20=E8=81=9A=E4=BC=97=E5=8A=A0=E5=AF=86=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=88=BF=E9=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optional config title (1-60 characters, normalized, no control or bidi characters) is written, HTML-escaped, into and the page heading. Set it when creating a room; any holder can change or clear it when regenerating the file (same key and password). CLI room/retarget take --title. The title is outside every key derivation and is part of the verified file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCUwNSXQdqfDSugmFo4qmM --- README.md | 1 + browser-tests/generations.mjs | 23 ++++++++-- dist/_headers | 2 +- dist/hr-template.html | 6 +-- dist/hr.html | 6 +-- docs/html-room/protocol.md | 4 ++ docs/html-room/release-integrity.json | 10 ++--- html-room/crypto.js | 25 +++++++++-- html-room/file.js | 19 ++++----- html-room/template.js | 21 ++++++---- html-room/transport.js | 6 ++- scripts/build-html-room.mjs | 13 +++--- test/title.test.mjs | 60 +++++++++++++++++++++++++++ 13 files changed, 149 insertions(+), 47 deletions(-) create mode 100644 test/title.test.mjs diff --git a/README.md b/README.md index 9bbf074..f1d7c4a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ flowchart TD **第 1 步:创建房间** 打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。 如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。 +还可以给房间起个名字(「房间标题」,最多 60 字),它会显示在浏览器标签页和页面顶部;不填就是「聚众加密聊天房间」。持有房间文件的人以后也能在「生成新文件」里改名,房间和口令不变。 **第 2 步:发给朋友** 点「创建房间」后,你可以: diff --git a/browser-tests/generations.mjs b/browser-tests/generations.mjs index 9cbf974..2d428ca 100644 --- a/browser-tests/generations.mjs +++ b/browser-tests/generations.mjs @@ -23,8 +23,9 @@ try { const offline = await browser.newContext({ acceptDownloads: true }); await offline.setOffline(true); const errors = [], requests = []; const watch = p => { p.on('pageerror', e => errors.push(e.message)); p.on('request', r => { if (!/^(file|blob|data):/.test(r.url())) requests.push(r.url()); }); return p; }; - const create = async (page, { relays, password, expand }) => { + const create = async (page, { relays, password, expand, title }) => { if (expand) await page.locator('#new-room > summary').click(); + if (title) await page.locator('#mk-title').fill(title); if (relays) { if (!(await page.locator('#mk-relay').isVisible())) await page.locator('#mk-advanced > summary').click(); await page.locator('#mk-relay').fill(relays); } if (password) await page.locator('#mk-pass').fill(password); await page.locator('#mk-make').click(); @@ -42,11 +43,24 @@ try { assert.equal(await g0.locator('#maker').isVisible(), true); assert.equal(await g0.locator('#unlock').isVisible(), false); assert.equal(await g0.locator('#mk-relay').inputValue(), ''); - const a = await create(g0, { relays: relay.url, password: 'first pass' }); - assert.equal(a.config.custom, true); + assert.equal(await g0.title(), '聚众加密聊天房间'); + const a = await create(g0, { relays: relay.url, password: 'first pass', title: '周末读书会 <b>' }); + assert.equal(a.config.custom, true); assert.equal(a.config.title, '周末读书会 <b>'); // Generation 1: room A creates room B with a different password. const g1 = watch(await offline.newPage()); await g1.goto(pathToFileURL(a.path).href); + assert.equal(await g1.title(), '周末读书会 <b>', 'the title shows as text, not markup'); + assert.equal(await g1.locator('h1').first().textContent(), '周末读书会 <b>'); + // Any holder can rename the room by regenerating the file (same key, same password). + await g1.locator('#retarget > summary').click(); + assert.equal(await g1.locator('#rt-title').inputValue(), '周末读书会 <b>'); + await g1.locator('#rt-title').fill('读书会第二季'); await g1.locator('#rt-make').click(); + const rdl = g1.waitForEvent('download'); await g1.locator('#rt-download').click(); + const renamedPath = dir + '/renamed.html'; await (await rdl).saveAs(renamedPath); + const renamed = await readFile(renamedPath, 'utf8'); await checkHtml(renamed, blank.scriptHash); + const rc = configFromRoomHtml(renamed); + assert.equal(rc.title, '读书会第二季'); assert.equal(rc.roomId, a.config.roomId); assert.equal(rc.ciphertext, a.config.ciphertext); + await g1.locator('#retarget > summary').click(); assert.equal(await g1.locator('#unlock').isVisible(), true); assert.equal(await g1.locator('#maker').isVisible(), false, 'folded in a room file'); await g1.locator('#new-room > summary').click(); @@ -54,6 +68,7 @@ try { assert.equal(await g1.locator('#mk-relay').inputValue(), relay.url, 'starts from this room\'s relays'); const b = await create(g1, { password: 'second pass' }); assert.notEqual(b.config.roomId, a.config.roomId, 'a new room, not a copy'); + assert.equal(b.config.title, undefined, 'a new room starts with the default title unless one is given'); assert.equal((await unlock(b.config, 'second pass')).roomId, b.config.roomId); await assert.rejects(unlock(b.config, 'first pass')); @@ -83,7 +98,7 @@ try { const again = watch(await offline.newPage()); await again.goto(pathToFileURL(selfPath).href); assert.equal(await again.locator('#mk-relay').inputValue(), relay.url); assert.deepEqual(errors, []); - console.log('PASS: blank creator -> room A -> room B (new key, new password) -> room C, all offline and byte-identical to release builds; chat in B; site saves itself as an offline creator'); + console.log('PASS: titled room A, renamed by a holder; blank creator -> room A -> room B (new key, new password) -> room C, all offline and byte-identical to release builds; chat in B; site saves itself as an offline creator'); } finally { await browser?.close(); await relay.stop(); await rm(dir, { recursive: true, force: true }); } diff --git a/dist/_headers b/dist/_headers index 8b6c6ab..91c1b09 100644 --- a/dist/_headers +++ b/dist/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'sha256-t+UQwWWn39gEDqed1s6sSnhEiZjNGlRobivE0cpid5M='; style-src 'sha256-+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA='; connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' + Content-Security-Policy: default-src 'none'; script-src 'sha256-2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ='; style-src 'sha256-+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA='; connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' Cache-Control: no-store NEL: {"max_age":0} Referrer-Policy: no-referrer diff --git a/dist/hr-template.html b/dist/hr-template.html index 30a645a..621aaca 100644 --- a/dist/hr-template.html +++ b/dist/hr-template.html @@ -28,8 +28,8 @@ `)}});for(let f of["exit","SIGINT","SIGTERM"])process.once(f,()=>c.kill())}let Z=!1;for(let t of["SIGTERM","SIGINT"])process.on(t,()=>{Z&&process.exit(0),Z=!0,m.close();for(let c of S)c.close(1012,"restart");setTimeout(()=>process.exit(0),S.size?500:0)});return m}typeof require<"u"&&require.main===module&&ke((0,L.readFileSync)(__filename,"utf8"),require("node:path").basename(__filename));0&&(module.exports={serve}); /* --> -<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-t+UQwWWn39gEDqed1s6sSnhEiZjNGlRobivE0cpid5M='; style-src 'sha256-+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA='; connect-src wss: ws://localhost:* ws://127.0.0.1:*; base-uri 'none'; form-action 'none'"><meta name="referrer" content="no-referrer"><title>加密聊天房间

加密聊天房间

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

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

    +`:""}`,csp:i}}function Gr(e,t,r=e.custom===!0,n=e.title){tt(e);let s=ee(n),{custom:o,title:i,...c}=e;e={...c,...r&&{custom:!0},...s&&{title:s}};let a=Tt(t);return tt({...e,version:re(a),relays:a})}var Vo=async e=>btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e)))));async function Go(e,t,r,n,s){let o=Gr(e,t,n,s);return{config:o,html:await we(o,r)}}function zo(e){if(typeof e!="string"||e.length>4e5)throw Error("\u4E0D\u662F\u6709\u6548\u7684\u623F\u95F4\u6587\u4EF6");let t=e.match(/\x3Cscript id="pt-room-config" type="application\/json">([\s\S]*?)\x3C\/script>/);if(!t)throw Error("\u4E0D\u662F\u6709\u6548\u7684\u623F\u95F4\u6587\u4EF6");return tt(JSON.parse(t[1]))}async function we(e,{script:t,style:r,comment:n=""}){if(typeof t!="string"||typeof r!="string"||/\x3C\/script/i.test(t)||/\x3C\/style/i.test(r))throw Error("\u811A\u672C\u6216\u6837\u5F0F\u4E0D\u53EF\u7528");return e.version?tt(e):ve(e),Wi(e,t,r,await Vo(t),await Vo(r),n).html}var Zo=document.currentScript?.textContent,Xo=document.querySelector("style")?.textContent,Ji=document.firstChild?.nodeType===Node.COMMENT_NODE?document.firstChild.data:"",nr={script:Zo,style:Xo,comment:Ji},A=e=>document.getElementById(e),Xr=/^https?:$/.test(location.protocol),Fr=e=>/[一-鿿]/.test(e?.message??""),or=new Map;function sr(e,t){or.has(e)&&URL.revokeObjectURL(or.get(e));let r=URL.createObjectURL(new Blob([t],{type:"text/html"}));or.set(e,r),A(e).href=r,A(e).hidden=!1}addEventListener("pagehide",()=>{for(let e of or.values())URL.revokeObjectURL(e)});var ut;try{ut=JSON.parse(A("pt-room-config").textContent)}catch{ut=null}function Qi(e){let t=r=>{A("rt-status").textContent=r};A("retarget").hidden=!1,A("rt-relay").value=e.relays.join(" "),A("rt-title").value=e.title??"",A("rt-make").addEventListener("click",async()=>{A("rt-download").hidden=!0,A("rt-make").disabled=!0,t("");try{let r=await Go(e,A("rt-relay").value,nr,void 0,A("rt-title").value);if(JSON.stringify(r.config.relays)===JSON.stringify(e.relays)&&r.config.title===e.title){t("\u4E2D\u7EE7\u548C\u6807\u9898\u90FD\u548C\u73B0\u5728\u4E00\u6837\uFF0C\u4E0D\u7528\u751F\u6210\u3002");return}sr("rt-download",r.html),t(`\u65B0\u6587\u4EF6\u300C${r.config.title??It}\u300D\u5DF2\u751F\u6210\uFF08\u4E2D\u7EE7\uFF1A${r.config.relays.join("\u3001")}\uFF09\u3002\u53E3\u4EE4\u4E0D\u53D8\u3002\u8BF7\u628A\u5B83\u53D1\u7ED9\u6240\u6709\u4EBA\uFF0C\u5927\u5BB6\u90FD\u7528\u65B0\u6587\u4EF6\u6253\u5F00\uFF0C\u518D\u6838\u5BF9\u4E00\u6B21\u5B89\u5168\u7801\u3002`)}catch(r){t(Fr(r)?`\u751F\u6210\u5931\u8D25\uFF1A${r.message}\u3002`:"\u751F\u6210\u5931\u8D25\uFF1A\u5730\u5740\u683C\u5F0F\u4E0D\u5BF9\u3002\u4F8B\u5B50\uFF1Awss://relay.example.com")}finally{A("rt-make").disabled=!1}})}function zr(e,{open:t,siteTitle:r}){let n=s=>{A("mk-error").textContent=s};A("new-room").hidden=!1,t&&(A("new-room").open=!0,A("new-room").querySelector("summary").hidden=!0),A("mk-relay").value=e.join(" "),e.length||(A("mk-advanced").open=!0),A("mk-gen").addEventListener("click",()=>{A("mk-pass").value=G(Q(32))}),A("mk-mesh").addEventListener("click",()=>{let s=A("mk-relay").value.trim().split(/[\s,]+/).filter(Boolean).map(o=>/^wss?:\/\//.test(o)?`bc+${o.replace(/\/+$/,"")}/bc/`:o);A("mk-relay").value=s.join(" "),n(s.length?"\u5DF2\u6362\u6210\u53BB\u4E2D\u5FC3\u5316\u4E2D\u7EE7\u3002\u8FD8\u53EF\u4EE5\u5728\u540E\u9762\u52A0\u4E0A nostr+wss://\u2026 \u6216 mqtt+wss://\u2026 \u5F53\u5907\u4EFD\u3002":"\u5148\u586B\u4E00\u4E2A\u4E2D\u7EE7\u5730\u5740\uFF0C\u4F8B\u5982 bc+wss://relay.example.com")}),A("maker").addEventListener("submit",async s=>{s.preventDefault(),A("mk-make").disabled=!0,n(""),A("mk-out").hidden=!0;try{let o=Tt(A("mk-relay").value),i=A("mk-pass").value;if(i&&new TextEncoder().encode(i).length>1024)throw Error("\u53E3\u4EE4\u592A\u957F\u4E86");let c=await sn(o,i||void 0,{custom:A("mk-custom").checked,title:A("mk-title").value});sr("mk-download",await we(c,nr)),A("mk-out").hidden=!1,A("mk-pass").value="",Xr?(A("mk-link").value=location.origin+location.pathname+Vr(c),A("mk-link-row").hidden=!1):(A("mk-link").value="",A("mk-link-row").hidden=!0),n(i?"\u2705 \u623F\u95F4\u5EFA\u597D\u4E86\u3002\u53E3\u4EE4\u8BF7\u6362\u4E00\u79CD\u65B9\u5F0F\u544A\u8BC9\u670B\u53CB\uFF08\u6BD4\u5982\u6253\u7535\u8BDD\uFF09\uFF0C\u522B\u548C\u6587\u4EF6\u4E00\u8D77\u53D1\u3002":"\u2705 \u623F\u95F4\u5EFA\u597D\u4E86\u3002\u6587\u4EF6\u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u53EA\u53D1\u7ED9\u8981\u804A\u5929\u7684\u4EBA\u3002")}catch(o){n(Fr(o)?`\u521B\u5EFA\u5931\u8D25\uFF1A${o.message}`:"\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u4E2D\u7EE7\u5730\u5740\u540E\u518D\u8BD5\u3002"),A("mk-advanced").open=!0}finally{A("mk-make").disabled=!1}}),A("mk-copy").addEventListener("click",()=>navigator.clipboard?.writeText(A("mk-link").value).then(()=>n("\u5DF2\u590D\u5236\u3002\u94FE\u63A5\u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u53EA\u53D1\u7ED9\u8981\u804A\u5929\u7684\u4EBA\u3002"),()=>n("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u4E2D\u94FE\u63A5\u590D\u5236\u3002"))),A("cv-make").addEventListener("click",async()=>{let s=o=>{A("cv-error").textContent=o};A("cv-download").hidden=!0,A("cv-make").disabled=!0,s("");try{let o=A("cv-file").files?.[0];if(!o)throw Error("\u8BF7\u5148\u9009\u62E9\u623F\u95F4\u6587\u4EF6");if(o.size>4e5)throw Error("\u8FD9\u4E2A\u6587\u4EF6\u592A\u5927\u4E86\uFF0C\u4E0D\u662F\u623F\u95F4\u6587\u4EF6");let i;try{i=zo(await o.text())}catch{throw Error("\u8BFB\u4E0D\u51FA\u623F\u95F4\u4FE1\u606F\u3002\u5982\u679C\u662F\u5F88\u65E9\u7684\u65E7\u7248\u6587\u4EF6\uFF0C\u8BF7\u91CD\u65B0\u521B\u5EFA\u623F\u95F4")}let c=Gr(i,A("cv-relay").value,A("cv-custom").checked);sr("cv-download",await we(c,nr)),s(`\u65B0\u6587\u4EF6\u5DF2\u751F\u6210\uFF08\u4E2D\u7EE7\uFF1A${c.relays.join("\u3001")}\uFF09\u3002${c.encrypted?"\u53E3\u4EE4\u4E0D\u53D8\u3002":""}\u8BF7\u628A\u5B83\u53D1\u7ED9\u6240\u6709\u4EBA\uFF0C\u5927\u5BB6\u90FD\u7528\u65B0\u6587\u4EF6\u6253\u5F00\uFF0C\u518D\u6838\u5BF9\u4E00\u6B21\u5B89\u5168\u7801\u3002`)}catch(o){s(Fr(o)?o.message:"\u751F\u6210\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u4E2D\u7EE7\u5730\u5740\u3002")}finally{A("cv-make").disabled=!1}}),Xr&&we({...e.length&&{relays:e},...r&&{title:r}},nr).then(s=>{sr("mk-self",s),A("mk-self-row").hidden=!1}).catch(()=>{})}function tc(){for(let e of["risk","status","preview-help","unlock","retarget","relay-status","presence","safety","messages","chat"])A(e).hidden=!0;document.querySelector("h1").hidden=!0}function ec(e){history.replaceState(null,"",location.pathname+location.search);let t;try{t=Po(e)}catch{return A("status").hidden=!1,A("status").textContent="\u623F\u95F4\u94FE\u63A5\u6253\u4E0D\u5F00\uFF1A\u94FE\u63A5\u4E0D\u5B8C\u6574\u6216\u5DF2\u635F\u574F\u3002\u8BF7\u8BA9\u5BF9\u65B9\u91CD\u53D1\u94FE\u63A5\uFF0C\u6216\u76F4\u63A5\u7528\u623F\u95F4\u6587\u4EF6\u3002",A("enter").disabled=!0,!1}return Pr(t),!0}var Zr=Zo&&Xo;try{if(ut?.version)tt(ut),Pr(ut),Zr&&(Qi(ut),zr(ut.relays,{open:!1}));else if(ut){ve(ut);let e=ut.relays??(Xr?[location.origin.replace(/^http/,"ws")]:[]);location.hash.startsWith("#hr1=")?(A("new-room").hidden=!0,ec(location.hash)&&Zr&&zr(e,{open:!1})):(tc(),Zr&&zr(e,{open:!0,siteTitle:ut.title}),addEventListener("hashchange",()=>{location.hash.startsWith("#hr1=")&&location.reload()}))}else throw Error("bad config")}catch{A("status").hidden=!1,A("status").textContent="\u623F\u95F4\u6587\u4EF6\u6709\u95EE\u9898\uFF0C\u8BF7\u8BA9\u5BF9\u65B9\u91CD\u65B0\u53D1\u4E00\u4EFD\u3002",A("enter").disabled=!0}})(); diff --git a/dist/hr.html b/dist/hr.html index 30a645a..621aaca 100644 --- a/dist/hr.html +++ b/dist/hr.html @@ -28,8 +28,8 @@ `)}});for(let f of["exit","SIGINT","SIGTERM"])process.once(f,()=>c.kill())}let Z=!1;for(let t of["SIGTERM","SIGINT"])process.on(t,()=>{Z&&process.exit(0),Z=!0,m.close();for(let c of S)c.close(1012,"restart");setTimeout(()=>process.exit(0),S.size?500:0)});return m}typeof require<"u"&&require.main===module&&ke((0,L.readFileSync)(__filename,"utf8"),require("node:path").basename(__filename));0&&(module.exports={serve}); /* --> -加密聊天房间

    加密聊天房间

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

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

      +`:""}`,csp:i}}function Gr(e,t,r=e.custom===!0,n=e.title){tt(e);let s=ee(n),{custom:o,title:i,...c}=e;e={...c,...r&&{custom:!0},...s&&{title:s}};let a=Tt(t);return tt({...e,version:re(a),relays:a})}var Vo=async e=>btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e)))));async function Go(e,t,r,n,s){let o=Gr(e,t,n,s);return{config:o,html:await we(o,r)}}function zo(e){if(typeof e!="string"||e.length>4e5)throw Error("\u4E0D\u662F\u6709\u6548\u7684\u623F\u95F4\u6587\u4EF6");let t=e.match(/\x3Cscript id="pt-room-config" type="application\/json">([\s\S]*?)\x3C\/script>/);if(!t)throw Error("\u4E0D\u662F\u6709\u6548\u7684\u623F\u95F4\u6587\u4EF6");return tt(JSON.parse(t[1]))}async function we(e,{script:t,style:r,comment:n=""}){if(typeof t!="string"||typeof r!="string"||/\x3C\/script/i.test(t)||/\x3C\/style/i.test(r))throw Error("\u811A\u672C\u6216\u6837\u5F0F\u4E0D\u53EF\u7528");return e.version?tt(e):ve(e),Wi(e,t,r,await Vo(t),await Vo(r),n).html}var Zo=document.currentScript?.textContent,Xo=document.querySelector("style")?.textContent,Ji=document.firstChild?.nodeType===Node.COMMENT_NODE?document.firstChild.data:"",nr={script:Zo,style:Xo,comment:Ji},A=e=>document.getElementById(e),Xr=/^https?:$/.test(location.protocol),Fr=e=>/[一-鿿]/.test(e?.message??""),or=new Map;function sr(e,t){or.has(e)&&URL.revokeObjectURL(or.get(e));let r=URL.createObjectURL(new Blob([t],{type:"text/html"}));or.set(e,r),A(e).href=r,A(e).hidden=!1}addEventListener("pagehide",()=>{for(let e of or.values())URL.revokeObjectURL(e)});var ut;try{ut=JSON.parse(A("pt-room-config").textContent)}catch{ut=null}function Qi(e){let t=r=>{A("rt-status").textContent=r};A("retarget").hidden=!1,A("rt-relay").value=e.relays.join(" "),A("rt-title").value=e.title??"",A("rt-make").addEventListener("click",async()=>{A("rt-download").hidden=!0,A("rt-make").disabled=!0,t("");try{let r=await Go(e,A("rt-relay").value,nr,void 0,A("rt-title").value);if(JSON.stringify(r.config.relays)===JSON.stringify(e.relays)&&r.config.title===e.title){t("\u4E2D\u7EE7\u548C\u6807\u9898\u90FD\u548C\u73B0\u5728\u4E00\u6837\uFF0C\u4E0D\u7528\u751F\u6210\u3002");return}sr("rt-download",r.html),t(`\u65B0\u6587\u4EF6\u300C${r.config.title??It}\u300D\u5DF2\u751F\u6210\uFF08\u4E2D\u7EE7\uFF1A${r.config.relays.join("\u3001")}\uFF09\u3002\u53E3\u4EE4\u4E0D\u53D8\u3002\u8BF7\u628A\u5B83\u53D1\u7ED9\u6240\u6709\u4EBA\uFF0C\u5927\u5BB6\u90FD\u7528\u65B0\u6587\u4EF6\u6253\u5F00\uFF0C\u518D\u6838\u5BF9\u4E00\u6B21\u5B89\u5168\u7801\u3002`)}catch(r){t(Fr(r)?`\u751F\u6210\u5931\u8D25\uFF1A${r.message}\u3002`:"\u751F\u6210\u5931\u8D25\uFF1A\u5730\u5740\u683C\u5F0F\u4E0D\u5BF9\u3002\u4F8B\u5B50\uFF1Awss://relay.example.com")}finally{A("rt-make").disabled=!1}})}function zr(e,{open:t,siteTitle:r}){let n=s=>{A("mk-error").textContent=s};A("new-room").hidden=!1,t&&(A("new-room").open=!0,A("new-room").querySelector("summary").hidden=!0),A("mk-relay").value=e.join(" "),e.length||(A("mk-advanced").open=!0),A("mk-gen").addEventListener("click",()=>{A("mk-pass").value=G(Q(32))}),A("mk-mesh").addEventListener("click",()=>{let s=A("mk-relay").value.trim().split(/[\s,]+/).filter(Boolean).map(o=>/^wss?:\/\//.test(o)?`bc+${o.replace(/\/+$/,"")}/bc/`:o);A("mk-relay").value=s.join(" "),n(s.length?"\u5DF2\u6362\u6210\u53BB\u4E2D\u5FC3\u5316\u4E2D\u7EE7\u3002\u8FD8\u53EF\u4EE5\u5728\u540E\u9762\u52A0\u4E0A nostr+wss://\u2026 \u6216 mqtt+wss://\u2026 \u5F53\u5907\u4EFD\u3002":"\u5148\u586B\u4E00\u4E2A\u4E2D\u7EE7\u5730\u5740\uFF0C\u4F8B\u5982 bc+wss://relay.example.com")}),A("maker").addEventListener("submit",async s=>{s.preventDefault(),A("mk-make").disabled=!0,n(""),A("mk-out").hidden=!0;try{let o=Tt(A("mk-relay").value),i=A("mk-pass").value;if(i&&new TextEncoder().encode(i).length>1024)throw Error("\u53E3\u4EE4\u592A\u957F\u4E86");let c=await sn(o,i||void 0,{custom:A("mk-custom").checked,title:A("mk-title").value});sr("mk-download",await we(c,nr)),A("mk-out").hidden=!1,A("mk-pass").value="",Xr?(A("mk-link").value=location.origin+location.pathname+Vr(c),A("mk-link-row").hidden=!1):(A("mk-link").value="",A("mk-link-row").hidden=!0),n(i?"\u2705 \u623F\u95F4\u5EFA\u597D\u4E86\u3002\u53E3\u4EE4\u8BF7\u6362\u4E00\u79CD\u65B9\u5F0F\u544A\u8BC9\u670B\u53CB\uFF08\u6BD4\u5982\u6253\u7535\u8BDD\uFF09\uFF0C\u522B\u548C\u6587\u4EF6\u4E00\u8D77\u53D1\u3002":"\u2705 \u623F\u95F4\u5EFA\u597D\u4E86\u3002\u6587\u4EF6\u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u53EA\u53D1\u7ED9\u8981\u804A\u5929\u7684\u4EBA\u3002")}catch(o){n(Fr(o)?`\u521B\u5EFA\u5931\u8D25\uFF1A${o.message}`:"\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u4E2D\u7EE7\u5730\u5740\u540E\u518D\u8BD5\u3002"),A("mk-advanced").open=!0}finally{A("mk-make").disabled=!1}}),A("mk-copy").addEventListener("click",()=>navigator.clipboard?.writeText(A("mk-link").value).then(()=>n("\u5DF2\u590D\u5236\u3002\u94FE\u63A5\u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u53EA\u53D1\u7ED9\u8981\u804A\u5929\u7684\u4EBA\u3002"),()=>n("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u4E2D\u94FE\u63A5\u590D\u5236\u3002"))),A("cv-make").addEventListener("click",async()=>{let s=o=>{A("cv-error").textContent=o};A("cv-download").hidden=!0,A("cv-make").disabled=!0,s("");try{let o=A("cv-file").files?.[0];if(!o)throw Error("\u8BF7\u5148\u9009\u62E9\u623F\u95F4\u6587\u4EF6");if(o.size>4e5)throw Error("\u8FD9\u4E2A\u6587\u4EF6\u592A\u5927\u4E86\uFF0C\u4E0D\u662F\u623F\u95F4\u6587\u4EF6");let i;try{i=zo(await o.text())}catch{throw Error("\u8BFB\u4E0D\u51FA\u623F\u95F4\u4FE1\u606F\u3002\u5982\u679C\u662F\u5F88\u65E9\u7684\u65E7\u7248\u6587\u4EF6\uFF0C\u8BF7\u91CD\u65B0\u521B\u5EFA\u623F\u95F4")}let c=Gr(i,A("cv-relay").value,A("cv-custom").checked);sr("cv-download",await we(c,nr)),s(`\u65B0\u6587\u4EF6\u5DF2\u751F\u6210\uFF08\u4E2D\u7EE7\uFF1A${c.relays.join("\u3001")}\uFF09\u3002${c.encrypted?"\u53E3\u4EE4\u4E0D\u53D8\u3002":""}\u8BF7\u628A\u5B83\u53D1\u7ED9\u6240\u6709\u4EBA\uFF0C\u5927\u5BB6\u90FD\u7528\u65B0\u6587\u4EF6\u6253\u5F00\uFF0C\u518D\u6838\u5BF9\u4E00\u6B21\u5B89\u5168\u7801\u3002`)}catch(o){s(Fr(o)?o.message:"\u751F\u6210\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u4E2D\u7EE7\u5730\u5740\u3002")}finally{A("cv-make").disabled=!1}}),Xr&&we({...e.length&&{relays:e},...r&&{title:r}},nr).then(s=>{sr("mk-self",s),A("mk-self-row").hidden=!1}).catch(()=>{})}function tc(){for(let e of["risk","status","preview-help","unlock","retarget","relay-status","presence","safety","messages","chat"])A(e).hidden=!0;document.querySelector("h1").hidden=!0}function ec(e){history.replaceState(null,"",location.pathname+location.search);let t;try{t=Po(e)}catch{return A("status").hidden=!1,A("status").textContent="\u623F\u95F4\u94FE\u63A5\u6253\u4E0D\u5F00\uFF1A\u94FE\u63A5\u4E0D\u5B8C\u6574\u6216\u5DF2\u635F\u574F\u3002\u8BF7\u8BA9\u5BF9\u65B9\u91CD\u53D1\u94FE\u63A5\uFF0C\u6216\u76F4\u63A5\u7528\u623F\u95F4\u6587\u4EF6\u3002",A("enter").disabled=!0,!1}return Pr(t),!0}var Zr=Zo&&Xo;try{if(ut?.version)tt(ut),Pr(ut),Zr&&(Qi(ut),zr(ut.relays,{open:!1}));else if(ut){ve(ut);let e=ut.relays??(Xr?[location.origin.replace(/^http/,"ws")]:[]);location.hash.startsWith("#hr1=")?(A("new-room").hidden=!0,ec(location.hash)&&Zr&&zr(e,{open:!1})):(tc(),Zr&&zr(e,{open:!0,siteTitle:ut.title}),addEventListener("hashchange",()=>{location.hash.startsWith("#hr1=")&&location.reload()}))}else throw Error("bad config")}catch{A("status").hidden=!1,A("status").textContent="\u623F\u95F4\u6587\u4EF6\u6709\u95EE\u9898\uFF0C\u8BF7\u8BA9\u5BF9\u65B9\u91CD\u65B0\u53D1\u4E00\u4EFD\u3002",A("enter").disabled=!0}})(); diff --git a/docs/html-room/protocol.md b/docs/html-room/protocol.md index d46a90e..6e99898 100644 --- a/docs/html-room/protocol.md +++ b/docs/html-room/protocol.md @@ -69,3 +69,7 @@ V2/V3 配置可选字段 `custom`,只能为 `true`(缺省表示不允许) 另外修正了 `lib/group.js`:第一次得知某成员的 ML-KEM 公钥时不再清空已建立的配对密钥(只在公钥变化时重置)。定向投递下,号码大的一方可能先拿到配对密钥、后看到对方公钥,旧逻辑会让这一对永远等待。 中继限流相应调整:每连接 10 秒 100 帧(原 60)、密钥帧 60(原 40)、每地址每分钟入房 120(原 60)、`/bc/` 每连接 10 秒 100 帧。实测(单进程模拟全部客户端):经典中继 50 人同时进房约 7 秒全部就绪,平均每人约 265 KB;去中心化(`/bc/` 只能广播)20 人约 12 秒、每人约 450 KB,30 人约 2 MB,50 人可完成但代价按 n³ 增长,建议去中心化房间控制在 30 人左右。 + +## 房间标题(title) + +配置可选字段 `title`:去掉首尾空白、连续空白合并为一个空格后的 1–60 个字符,不含控制字符和双向文本控制符;等于默认标题「聚众加密聊天房间」时省略。它写入 `` 和页面首个 `<h1>`(按 HTML 文本转义 `& < > " '`),不参与任何密钥派生或 AAD。新建房间时可选填;「生成新文件」、转换器和 CLI(`room --title`、`retarget --title`)可保留、修改或清除(空字符串)。空白文件也可带 `title`,作为创建器页面和离线版的名称。 diff --git a/docs/html-room/release-integrity.json b/docs/html-room/release-integrity.json index 9a868b2..31038a1 100644 --- a/docs/html-room/release-integrity.json +++ b/docs/html-room/release-integrity.json @@ -1,16 +1,16 @@ { "version": 2, - "stage": "fifty-members-htmlroom", + "stage": "room-title-htmlroom", "esbuild": "0.28.2", "artifacts": { "html-room-template.html": { - "bytes": 139626, - "sha256": "2fd303b6275ff62aec19746e94c718548d025f8795d409d0b5068d070fc3e713", + "bytes": 141314, + "sha256": "5a3f6fff21b29210b2744c91ab1e83cc67c668ee91e5008cc2e8e5b88edfbc70", "csp": "([^" } }, - "scriptHashBase64": "t+UQwWWn39gEDqed1s6sSnhEiZjNGlRobivE0cpid5M=", - "scriptHashHex": "b7e510c165a7dfd8040ea79dd6ceac4a78448998cd1a54686e2bc4d1ca627793", + "scriptHashBase64": "2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ=", + "scriptHashHex": "d89b50046435837472b0160cc0999c2b2f96155a8b2030d9c492df6089e189f4", "styleHashBase64": "+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA=", "serverCommentSha256": "971a532473d0c7d039dd0617391a736bd4057fab488e2249d612188a340195fe" } diff --git a/html-room/crypto.js b/html-room/crypto.js index 50b8b37..5a9a4f4 100644 --- a/html-room/crypto.js +++ b/html-room/crypto.js @@ -72,10 +72,25 @@ export const CUSTOM_SOURCES = ['wss:', 'ws://localhost:*', 'ws://127.0.0.1:*']; export const connectSources = config => !config.version ? CUSTOM_SOURCES : [...(config.custom ? CUSTOM_SOURCES : []), ...config.relays.map(cspSource)]; // Blank files may carry default relays for the generator: {} or {relays:[...]}. export function validateBlank(c) { - if (!c || typeof c !== 'object' || Array.isArray(c) || c.version !== undefined || Object.keys(c).some(k => k !== 'relays')) throw Error('空白文件配置错误'); + if (!c || typeof c !== 'object' || Array.isArray(c) || c.version !== undefined || Object.keys(c).some(k => !['relays','title'].includes(k))) throw Error('空白文件配置错误'); + if (c.title !== undefined && titleOf(c.title) !== c.title) throw Error('标题格式错误'); if (c.relays !== undefined && JSON.stringify(relayList(c.relays)) !== JSON.stringify(c.relays)) throw Error('默认中继格式错误'); return c; } +// A room's title: shown in <title> and as the page heading. Anyone holding +// the file can choose it (when creating, or in a regenerated file); it is +// public routing-free text, escaped wherever it is written into HTML. +export const DEFAULT_TITLE = '聚众加密聊天房间'; +export const MAX_TITLE = 60; +export function titleOf(input) { + if (input === undefined || input === null) return undefined; + if (typeof input !== 'string') throw Error('标题格式错误'); + const t = input.replace(/\s+/g, ' ').trim(); + if (!t || t === DEFAULT_TITLE) return undefined; + if (Array.from(t).length > MAX_TITLE) throw Error(`标题最多 ${MAX_TITLE} 个字`); + if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(t)) throw Error('标题含不支持的字符'); + return t; +} export const versionFor = relays => relays.some(r => relayKind(r) !== 'hr') ? 3 : 2; const KDF = { name:'PBKDF2', hash:'SHA-256', iterations:310000 }; const aad = roomId => enc.encode(`private-talk/html-room/v1/wrap|${roomId}`); @@ -91,7 +106,8 @@ export function validate(c) { // custom: true lets whoever opens the file connect through relays they type // in (its CSP then admits any wss: origin); absent means the listed relays only. if (c.custom !== undefined && c.custom !== true) throw Error('配置字段错误'); - const common=['version','relays','roomId','encrypted','custom']; + if (c.title !== undefined && titleOf(c.title) !== c.title) throw Error('标题格式错误'); + const common=['version','relays','roomId','encrypted','custom','title']; if (c.encrypted === true) { const allowed=[...common,'salt','iv','ciphertext','kdf']; if (Object.keys(c).some(k=>!allowed.includes(k)) || !c.kdf || Object.keys(c.kdf).length !== 3 || Object.entries(KDF).some(([k,v])=>c.kdf[k]!==v)) throw Error('不支持的 KDF 配置'); @@ -102,7 +118,7 @@ export function validate(c) { } return c; } -export async function makeConfig(relay, pass, { custom = false } = {}) { +export async function makeConfig(relay, pass, { custom = false, title } = {}) { const relays=relayList(relay); const master=random(32), d=await derive(master), c={version:versionFor(relays),relays,roomId:d.roomId,encrypted:!!pass}; let out; @@ -112,7 +128,8 @@ export async function makeConfig(relay, pass, { custom = false } = {}) { const ciphertext=await crypto.subtle.encrypt({name:'AES-GCM',iv,additionalData:aad(c.roomId)},key,master); out={...c,salt:hex(salt),iv:hex(iv),ciphertext:hex(ciphertext),kdf:{...KDF}}; } - return custom ? {...out,custom:true} : out; + const t = titleOf(title); + return {...out, ...(custom && {custom:true}), ...(t && {title:t})}; } // The relays this visit uses: the file's own, or ones typed into a file that // allows it. Only the routing changes; key material and roomId stay. diff --git a/html-room/file.js b/html-room/file.js index c38b3ee..3672def 100644 --- a/html-room/file.js +++ b/html-room/file.js @@ -6,7 +6,7 @@ // exact script and style bytes this page runs with (html-room/template.js), // so they are byte-identical to a release build and pass verify-room-file. import { boot } from './client.js'; -import { makeConfig, relayList, validate, validateBlank, hex, random } from './crypto.js'; +import { makeConfig, relayList, validate, validateBlank, hex, random, DEFAULT_TITLE } from './crypto.js'; import { configFromHash, fragmentFor } from './transport.js'; import { retargetHtml, htmlFor, withRelays, configFromRoomHtml } from './template.js'; @@ -31,21 +31,21 @@ try { config = JSON.parse(el('pt-room-config').textContent); } catch { config = function enableRetarget(config) { const say = t => { el('rt-status').textContent = t; }; - el('retarget').hidden = false; el('rt-relay').value = config.relays.join(' '); + el('retarget').hidden = false; el('rt-relay').value = config.relays.join(' '); el('rt-title').value = config.title ?? ''; el('rt-make').addEventListener('click', async () => { el('rt-download').hidden = true; el('rt-make').disabled = true; say(''); try { - const r = await retargetHtml(config, el('rt-relay').value, own); - if (JSON.stringify(r.config.relays) === JSON.stringify(config.relays)) { say('地址和现在一样,不用换。'); return; } + const r = await retargetHtml(config, el('rt-relay').value, own, undefined, el('rt-title').value); + if (JSON.stringify(r.config.relays) === JSON.stringify(config.relays) && r.config.title === config.title) { say('中继和标题都和现在一样,不用生成。'); return; } offer('rt-download', r.html); - say(`新文件已生成(中继:${r.config.relays.join('、')})。口令不变。请把它发给所有人,大家都用新文件打开,再核对一次安全码。`); + say(`新文件「${r.config.title ?? DEFAULT_TITLE}」已生成(中继:${r.config.relays.join('、')})。口令不变。请把它发给所有人,大家都用新文件打开,再核对一次安全码。`); } catch (e) { say(cjk(e) ? `生成失败:${e.message}。` : '生成失败:地址格式不对。例子:wss://relay.example.com'); } finally { el('rt-make').disabled = false; } }); } // The creator. defaults: relays to start from. -function enableMaker(defaults, { open }) { +function enableMaker(defaults, { open, siteTitle }) { const fail = t => { el('mk-error').textContent = t; }; el('new-room').hidden = false; if (open) { el('new-room').open = true; el('new-room').querySelector('summary').hidden = true; } @@ -63,7 +63,7 @@ function enableMaker(defaults, { open }) { try { const relays = relayList(el('mk-relay').value), password = el('mk-pass').value; if (password && new TextEncoder().encode(password).length > 1024) throw Error('口令太长了'); - const next = await makeConfig(relays, password || undefined, { custom: el('mk-custom').checked }); + const next = await makeConfig(relays, password || undefined, { custom: el('mk-custom').checked, title: el('mk-title').value }); offer('mk-download', await htmlFor(next, own)); el('mk-out').hidden = false; el('mk-pass').value = ''; // A link only works on a hosted creator, which can open any room. @@ -87,13 +87,12 @@ function enableMaker(defaults, { open }) { finally { el('cv-make').disabled = false; } }); // On the site: the page itself, saved as a blank creator, works offline. - if (hosted) htmlFor(defaults.length ? { relays: defaults } : {}, own).then(html => { offer('mk-self', html); el('mk-self-row').hidden = false; }).catch(() => {}); + if (hosted) htmlFor({ ...(defaults.length && { relays: defaults }), ...(siteTitle && { title: siteTitle }) }, own).then(html => { offer('mk-self', html); el('mk-self-row').hidden = false; }).catch(() => {}); } function creatorOnly() { for (const id of ['risk', 'status', 'preview-help', 'unlock', 'retarget', 'relay-status', 'presence', 'safety', 'messages', 'chat']) el(id).hidden = true; document.querySelector('h1').hidden = true; - document.title = 'HTML Room · 加密聊天室'; } function openLink(hash) { @@ -119,7 +118,7 @@ try { if (openLink(location.hash) && ready) enableMaker(defaults, { open: false }); } else { creatorOnly(); - if (ready) enableMaker(defaults, { open: true }); + if (ready) enableMaker(defaults, { open: true, siteTitle: config.title }); addEventListener('hashchange', () => { if (location.hash.startsWith('#hr1=')) location.reload(); }); } } else throw Error('bad config'); diff --git a/html-room/template.js b/html-room/template.js index 0c2c22b..80b23d1 100644 --- a/html-room/template.js +++ b/html-room/template.js @@ -2,7 +2,8 @@ // Node; a room file uses the same function to re-emit itself with another // relay list, so a re-targeted file is byte-identical to a fresh build and // passes scripts/verify-room-file.mjs. -import { validate, validateBlank, relayList, versionFor, connectSources } from './crypto.js'; +import { validate, validateBlank, relayList, versionFor, connectSources, titleOf, DEFAULT_TITLE, MAX_TITLE } from './crypto.js'; +const esc = s => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); import { escapeConfig, previewHelp } from './transport.js'; export const cspFor = (config, scriptHash, styleHash) => @@ -12,11 +13,11 @@ export const REPO = 'https://github.com/Freecode100Year/htmlroom.git'; export const repoLink = `<a href="${REPO}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">github.com/Freecode100Year/htmlroom</a>`; // Hidden until file.js enables it, so the copy embedded in the web entry stays inert. -const RETARGET = `<details id="retarget" hidden><summary>生成换了中继的新文件(发给大家)</summary><p>中继是帮大家传消息的服务器,它看不到聊天内容。填上新地址,会生成一份新的房间文件:还是同一个房间,口令也不变。把新文件发给所有人,大家打开就自动用新中继。</p><label for="rt-relay">新的中继地址(多个用空格隔开)</label><p class="hint">例如 <code>wss://relay.example.com</code>;去中心化中继写成 <code>bc+wss://…</code>、<code>nostr+wss://…</code> 或 <code>mqtt+wss://…/mqtt</code>。</p><input id="rt-relay" maxlength="1200" autocomplete="off" spellcheck="false"><button id="rt-make" type="button">生成新文件</button><p id="rt-status" role="status"></p><a id="rt-download" download="index.html" hidden>下载新的房间文件</a></details>`; +const RETARGET = `<details id="retarget" hidden><summary>生成新文件:换中继或改标题(发给大家)</summary><p>中继是帮大家传消息的服务器,它看不到聊天内容。填上新地址,会生成一份新的房间文件:还是同一个房间,口令也不变。把新文件发给所有人,大家打开就自动用新中继。</p><label for="rt-title">房间标题(可改,最多 ${MAX_TITLE} 字)</label><input id="rt-title" maxlength="${MAX_TITLE}" autocomplete="off" placeholder="${DEFAULT_TITLE}"><label for="rt-relay">新的中继地址(多个用空格隔开)</label><p class="hint">例如 <code>wss://relay.example.com</code>;去中心化中继写成 <code>bc+wss://…</code>、<code>nostr+wss://…</code> 或 <code>mqtt+wss://…/mqtt</code>。</p><input id="rt-relay" maxlength="1200" autocomplete="off" spellcheck="false"><button id="rt-make" type="button">生成新文件</button><p id="rt-status" role="status"></p><a id="rt-download" download="index.html" hidden>下载新的房间文件</a></details>`; // The creator: open in a blank file (the offline generator and the hosted // site), folded away in a room file. Everything happens in this page, offline. -const MAKER = `<details id="new-room" hidden><summary>创建一个新房间(新的钥匙,可设新口令)</summary><form id="maker"><h1>创建加密聊天室</h1><p>点一下就能建好一个房间,把文件发给朋友,打开就能聊。全程在这个页面里完成,不用联网,也不经过任何网站。</p><label for="mk-pass">口令(可选,相当于门上再加一把锁)</label><input id="mk-pass" type="password" maxlength="1024" autocomplete="off"><button id="mk-gen" type="button">随机生成口令</button><details id="mk-advanced"><summary>中继服务器</summary><p class="hint">中继负责帮大家传消息,看不到内容。经典 <code>wss://…</code> 最多 3 个,坏了会自动换下一个;去中心化 <code>bc+wss://…</code>、<code>nostr+wss://…</code>、<code>mqtt+wss://…/mqtt</code> 最多 5 个,同时使用。两类不能混用。</p><label for="mk-relay">中继地址(多个用空格隔开)</label><input id="mk-relay" required maxlength="1200" spellcheck="false" placeholder="wss://relay.example.com"><button id="mk-mesh" type="button">改用去中心化中继</button><label class="check"><input id="mk-custom" type="checkbox" checked> 允许在房间文件里自定义中继</label><p class="hint">打开时,拿到文件的人进房前可以改填任意 <code>wss://</code> 中继。关掉更严格:文件只能连上面这些中继。</p></details><button id="mk-make">创建房间</button><p id="mk-error" role="status"></p><section id="mk-out" hidden><a id="mk-download" download="index.html">下载房间文件(index.html)</a><section id="mk-link-row" hidden><p>或者复制房间链接发给朋友。微信、QQ 里打不开文件时,用链接更方便。</p><input id="mk-link" readonly aria-label="房间链接"><button id="mk-copy" type="button">复制链接</button></section></section><details id="cv"><summary>已经有房间文件?换一个中继服务器</summary><p>选择房间文件,填上新的中继地址,会生成同一个房间的新文件,口令不变。文件只在这个页面里读取,不会上传。</p><label for="cv-file">房间文件</label><input id="cv-file" type="file" accept=".html,text/html"><label for="cv-relay">新的中继地址(多个用空格隔开)</label><input id="cv-relay" maxlength="1200" autocomplete="off" spellcheck="false" placeholder="wss://relay.example.com"><label class="check"><input id="cv-custom" type="checkbox" checked> 允许在新文件里自定义中继</label><button id="cv-make" type="button">生成新文件</button><p id="cv-error" role="status"></p><a id="cv-download" download="index.html" hidden>下载新的房间文件</a></details><p id="mk-self-row" class="hint" hidden>这个页面本身就是创建器:<a id="mk-self" download="index.html">下载离线版(index.html)</a>,以后不联网也能创建房间。</p></form></details>`; +const MAKER = `<details id="new-room" hidden><summary>创建一个新房间(新的钥匙,可设新口令)</summary><form id="maker"><h1>创建加密聊天室</h1><p>点一下就能建好一个房间,把文件发给朋友,打开就能聊。全程在这个页面里完成,不用联网,也不经过任何网站。</p><label for="mk-title">房间标题(可选,最多 ${MAX_TITLE} 字)</label><input id="mk-title" maxlength="${MAX_TITLE}" autocomplete="off" placeholder="${DEFAULT_TITLE}"><label for="mk-pass">口令(可选,相当于门上再加一把锁)</label><input id="mk-pass" type="password" maxlength="1024" autocomplete="off"><button id="mk-gen" type="button">随机生成口令</button><details id="mk-advanced"><summary>中继服务器</summary><p class="hint">中继负责帮大家传消息,看不到内容。经典 <code>wss://…</code> 最多 3 个,坏了会自动换下一个;去中心化 <code>bc+wss://…</code>、<code>nostr+wss://…</code>、<code>mqtt+wss://…/mqtt</code> 最多 5 个,同时使用。两类不能混用。</p><label for="mk-relay">中继地址(多个用空格隔开)</label><input id="mk-relay" required maxlength="1200" spellcheck="false" placeholder="wss://relay.example.com"><button id="mk-mesh" type="button">改用去中心化中继</button><label class="check"><input id="mk-custom" type="checkbox" checked> 允许在房间文件里自定义中继</label><p class="hint">打开时,拿到文件的人进房前可以改填任意 <code>wss://</code> 中继。关掉更严格:文件只能连上面这些中继。</p></details><button id="mk-make">创建房间</button><p id="mk-error" role="status"></p><section id="mk-out" hidden><a id="mk-download" download="index.html">下载房间文件(index.html)</a><section id="mk-link-row" hidden><p>或者复制房间链接发给朋友。微信、QQ 里打不开文件时,用链接更方便。</p><input id="mk-link" readonly aria-label="房间链接"><button id="mk-copy" type="button">复制链接</button></section></section><details id="cv"><summary>已经有房间文件?换一个中继服务器</summary><p>选择房间文件,填上新的中继地址,会生成同一个房间的新文件,口令不变。文件只在这个页面里读取,不会上传。</p><label for="cv-file">房间文件</label><input id="cv-file" type="file" accept=".html,text/html"><label for="cv-relay">新的中继地址(多个用空格隔开)</label><input id="cv-relay" maxlength="1200" autocomplete="off" spellcheck="false" placeholder="wss://relay.example.com"><label class="check"><input id="cv-custom" type="checkbox" checked> 允许在新文件里自定义中继</label><button id="cv-make" type="button">生成新文件</button><p id="cv-error" role="status"></p><a id="cv-download" download="index.html" hidden>下载新的房间文件</a></details><p id="mk-self-row" class="hint" hidden>这个页面本身就是创建器:<a id="mk-self" download="index.html">下载离线版(index.html)</a>,以后不联网也能创建房间。</p></form></details>`; // The file is also a Node script. This comment, before <!doctype html>, holds // the relay (html-room/serve.js) for `node index.html`; browsers skip it. To @@ -27,17 +28,19 @@ export const SERVER_FORBIDDEN = /\*\/|-->|<!--|--!>|<\/?(?:script|style)/i; export function roomHtml(config, script, style, scriptHash, styleHash, comment = '') { if (comment && SERVER_FORBIDDEN.test(comment.replace(/\/\*\n$/, ''))) throw Error('服务端代码含不安全序列'); - const csp = cspFor(config, scriptHash, styleHash); - const html=`${comment ? `<!--${comment}-->\n` : ''}<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><meta name="referrer" content="no-referrer"><title>加密聊天房间

      加密聊天房间

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

      ${previewHelp(config)}
      ${RETARGET}

        ${MAKER}\n${comment ? '\n' : ''}`; + const csp = cspFor(config, scriptHash, styleHash), title = esc(config.title ?? DEFAULT_TITLE); + const html=`${comment ? `\n` : ''}${title}

        ${title}

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

        ${previewHelp(config)}
        ${RETARGET}

          ${MAKER}\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) { +// title: undefined keeps the file's; '' or the default removes it. +export function withRelays(config, input, custom = config.custom === true, title = config.title) { validate(config); - const { custom: _, ...rest } = config; config = custom ? { ...rest, custom: true } : rest; + const t = titleOf(title); + const { custom: _, title: __, ...rest } = config; config = { ...rest, ...(custom && { custom: true }), ...(t && { title: t }) }; // 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); @@ -46,8 +49,8 @@ export function withRelays(config, input, custom = config.custom === true) { 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); +export async function retargetHtml(config, input, own, custom, title) { + const next = withRelays(config, input, custom, title); return { config: next, html: await htmlFor(next, own) }; } diff --git a/html-room/transport.js b/html-room/transport.js index c3de2d0..332c703 100644 --- a/html-room/transport.js +++ b/html-room/transport.js @@ -1,4 +1,5 @@ -import { validate, validateBlank, relayEntry, relayKind, relayUrl, connectSources } from './crypto.js'; +import { validate, validateBlank, relayEntry, relayKind, relayUrl, connectSources, DEFAULT_TITLE } from './crypto.js'; +const escText = s => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); // The page that can open this room from a link: a classic relay's /hr.html, or // for a decentralized room whose relays are all bc paths on one server, that // server's page. Other relay mixes have no web entry; use the file. @@ -33,5 +34,6 @@ export async function fillTemplate(template,config,allowed,scriptHash,styleHash, const csp=c=>`default-src 'none'; script-src 'sha256-${scriptHash}'; style-src 'sha256-${styleHash}'; connect-src ${connectSources(c).join(' ')}; base-uri 'none'; form-action 'none'`; const marker=``; if(!template.includes(marker))throw Error('模板 CSP 无效'); - return template.replace(marker,``).replace(previewHelp({}),previewHelp(config)).replace('',``); + const title=escText(config.title ?? DEFAULT_TITLE); + return template.replace(marker,``).replace(`${DEFAULT_TITLE}`,`${title}`).replace(`

          ${DEFAULT_TITLE}

          `,`

          ${title}

          `).replace(previewHelp({}),previewHelp(config)).replace('',``); } diff --git a/scripts/build-html-room.mjs b/scripts/build-html-room.mjs index a2406a2..02f853a 100644 --- a/scripts/build-html-room.mjs +++ b/scripts/build-html-room.mjs @@ -68,30 +68,31 @@ if(process.argv[1]===fileURLToPath(import.meta.url)){ if(args[0]==='retarget'){ const flags=args.slice(1); let custom;for(const f of ['--strict','--custom'])if(flags.includes(f)){custom=f==='--custom';flags.splice(flags.indexOf(f),1);} - if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--in','--out','--relay'].includes(value)) || flags.filter(x=>x==='--in').length!==1 || flags.filter(x=>x==='--out').length!==1)throw Error('用法:retarget --in 旧房间.html --out 新房间.html --relay wss://新中继 [--relay wss://备用中继]'); + if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--in','--out','--relay','--title'].includes(value)) || flags.filter(x=>x==='--in').length!==1 || flags.filter(x=>x==='--out').length!==1)throw Error('用法:retarget --in 旧房间.html --out 新房间.html --relay wss://新中继 [--relay wss://备用中继]'); const getAll=name=>flags.filter((value,i)=>i%2===1&&flags[i-1]===name); const relays=getAll('--relay');if(!relays.length)throw Error('retarget 需要至少一个 --relay'); // Old files are rebuilt with the current template: the room secret is kept, the code is upgraded. - const config=withRelays(configFromRoomHtml(await readFile(getAll('--in')[0],'utf8')),relays,custom); + const [newTitle]=getAll('--title'); + const config=withRelays(configFromRoomHtml(await readFile(getAll('--in')[0],'utf8')),relays,custom,newTitle); const r=await render(config,config.relays); await writeFile(getAll('--out')[0],r.html,{mode:0o600,flag:'wx'}); console.log(JSON.stringify({bytes:r.bytes,relays:config.relays,roomId:config.roomId,encrypted:config.encrypted}));process.exit(0); } - let output,relay,allowed,strict=true; + let output,relay,allowed,strict=true,title; if(args[0]==='room'){ strict=false; const flags=args.slice(1); strict=flags.includes('--strict');if(strict)flags.splice(flags.indexOf('--strict'),1); - if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--out','--relay','--allow-relay'].includes(value)) || flags.filter(x=>x==='--out').length!==1)throw Error('room 仅支持一个 --out、可重复的 --relay、--allow-relay 和 --strict(不允许在文件里自定义中继);口令请用 HR_PASSWORD_STDIN'); + if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--out','--relay','--allow-relay','--title'].includes(value)) || flags.filter(x=>x==='--out').length!==1 || flags.filter(x=>x==='--title').length>1)throw Error('room 仅支持一个 --out、可重复的 --relay、--allow-relay、一个 --title 和 --strict(不允许在文件里自定义中继);口令请用 HR_PASSWORD_STDIN'); const getAll=name=>flags.filter((value,i)=>i%2===1&&flags[i-1]===name); - [output]=getAll('--out');relay=getAll('--relay');allowed=getAll('--allow-relay'); + [output]=getAll('--out');relay=getAll('--relay');allowed=getAll('--allow-relay');[title]=getAll('--title'); if(!output||!relay.length||!allowed.length)throw Error('room 需要 --out、--relay 与明确 --allow-relay'); }else{[output,relay,...allowed]=args;} if(!output)throw Error('用法:node scripts/build-html-room.mjs out/index.html wss://relay.example wss://relay.example (从标准输入可选读取口令,HR_PASSWORD_STDIN=1)'); let pass; if(process.env.HR_PASSWORD_STDIN==='1'){let s='';for await(const chunk of process.stdin){s+=chunk;if(Buffer.byteLength(s)>4096)throw Error('输入过长');}pass=s.replace(/\r?\n$/,'');if(!pass)throw Error('口令不能为空');} // room: custom relays allowed unless --strict; the legacy positional form stays strict. - const config=relay?await makeConfig(relay,pass,{custom:!strict}):{}; + const config=relay?await makeConfig(relay,pass,{custom:!strict,title}):{}; const r=await render(config,allowed); await mkdir(new URL('../out/',import.meta.url),{recursive:true}); await writeFile(output,r.html,{mode:0o600,flag:'wx'}); diff --git a/test/title.test.mjs b/test/title.test.mjs new file mode 100644 index 0000000..16acce4 --- /dev/null +++ b/test/title.test.mjs @@ -0,0 +1,60 @@ +// Room titles: anyone holding the file may choose one; it is escaped in HTML, +// bounded, kept by conversions, and part of the verified file. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { makeConfig, validate, titleOf, DEFAULT_TITLE, unlock } from '../html-room/crypto.js'; +import { withRelays, configFromRoomHtml } from '../html-room/template.js'; +import { fillTemplate } from '../html-room/transport.js'; +import { render } from '../scripts/build-html-room.mjs'; +import { checkHtml } from '../scripts/check-html-room.mjs'; +import { createHash } from 'node:crypto'; + +const head = html => html.split(' & "x"'; + const c = await makeConfig('wss://r.example', 'pw', { title: evil }); + assert.equal(c.title, evil); + const r = await render(c, c.relays); + assert.ok(head(r.html).includes('</title><script>alert(1)</script> & "x"')); + assert.equal([...r.html.matchAll(/]/g)].length, 2, 'no extra script element'); + await checkHtml(r.html, r.scriptHash); + assert.equal(configFromRoomHtml(r.html).title, evil); + assert.equal((await unlock(c, 'pw')).roomId, c.roomId, 'title is not part of the key'); + const t = await render({}, []), templateHash = createHash('sha256').update(t.html).digest('base64'); + assert.equal(await fillTemplate(t.html, c, c.relays, t.scriptHash, t.styleHash, templateHash), r.html, 'web path and build agree'); +}); + +test('titles are bounded and normalized', () => { + assert.equal(titleOf(' 周末 读书会 '), '周末 读书会'); + assert.equal(titleOf(''), undefined); assert.equal(titleOf(DEFAULT_TITLE), undefined); + assert.equal(titleOf('字'.repeat(60)), '字'.repeat(60)); + assert.throws(() => titleOf('字'.repeat(61))); + for (const bad of ['a\u0000b', 'a‮b', 'a⁦b', 'a\u0085b']) assert.throws(() => titleOf(bad), JSON.stringify(bad)); + assert.throws(() => validate({ version: 2, relays: ['wss://r.example'], roomId: 'a'.repeat(64), encrypted: false, master: 'b'.repeat(64), title: ' padded ' })); +}); + +test('conversions keep, change or clear the title', async () => { + const c = await makeConfig('wss://r.example', undefined, { title: '读书会', custom: true }); + assert.equal(withRelays(c, 'wss://s.example').title, '读书会'); + assert.equal(withRelays(c, 'wss://s.example', undefined, '新名字').title, '新名字'); + assert.equal(withRelays(c, 'wss://s.example', undefined, '').title, undefined); + assert.deepEqual(Object.keys(withRelays(c, 'wss://s.example')), Object.keys(c), 'same key order as a fresh build'); +}); + +test('CLI room and retarget take --title', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hr-title-')), out = join(dir, 'a.html'), out2 = join(dir, 'b.html'); + execFileSync(process.execPath, ['scripts/build-html-room.mjs', 'room', '--out', out, '--relay', 'wss://r.example', '--allow-relay', 'wss://r.example', '--title', '周五夜谈']); + assert.equal(configFromRoomHtml(readFileSync(out, 'utf8')).title, '周五夜谈'); + execFileSync(process.execPath, ['scripts/build-html-room.mjs', 'retarget', '--in', out, '--out', out2, '--relay', 'wss://s.example', '--title', '周六夜谈']); + assert.equal(configFromRoomHtml(readFileSync(out2, 'utf8')).title, '周六夜谈'); +});