Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ flowchart TD

**1. 创建房间** 打开[创建页面](https://freecode100year.github.io/htmlroom/),直接点「创建房间」。可选:
- **房间标题**:最多 60 字,显示在浏览器标签页和页面顶部;不填就是「聚众加密聊天房间」。
- **口令**:相当于门上再加一把锁。建议点「随机生成口令」。
- **口令**:相当于门上再加一把锁。建议点「随机生成口令」:会生成一串好念好抄的口令(如 `K7QM-3XRP-9DTA-HWVE-N4BC`),直接显示出来,点「复制口令」即可复制;房间建好后口令仍会显示在结果里。

**2. 发给朋友** 下载 `index.html` 发给朋友,或复制房间链接。设了口令的话,**口令请换一种方式告诉对方**(比如文件用微信发,口令打电话说)。

Expand Down
2 changes: 1 addition & 1 deletion browser-tests/htmlroom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ try{
for(const c of [a,b])assert.deepEqual(await c.cookies(),[]);
for(const p of [maker,file])assert.deepEqual(await p.evaluate(async()=>[localStorage.length,sessionStorage.length,(await indexedDB.databases()).length]),[0,0,0]);
const bad=await a.newPage();let connected=0;bad.on('websocket',()=>connected++);await bad.goto(base+'/hr.html#hr1=invalid');await bad.getByText(/房间链接打不开/).waitFor();assert.equal(connected,0);
const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.equal((await wrapped.locator('#mk-pass').inputValue()).length,64);await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'});
const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.match(await wrapped.locator('#mk-pass').inputValue(),/^[A-HJKMNP-Z2-9]{4}(-[A-HJKMNP-Z2-9]{4}){4}$/);assert.equal(await wrapped.locator('#mk-pass').getAttribute('type'),'text');await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'});
const encoded=new URL(await wrapped.locator('#mk-link').inputValue()).hash.slice(5);const protectedConfig=JSON.parse(Buffer.from(encoded,'base64url').toString());assert.equal(protectedConfig.encrypted,true);assert.equal('master'in protectedConfig,false);
console.log(JSON.stringify({suite:'integrated hosted maker/Fragment + file interop',checks:9,result:'pass',browser:browser.version(),consoleErrors:0,thirdPartyRequests:0}));
}finally{await browser?.close();server?.kill();await rm(dir,{recursive:true,force:true});}
68 changes: 68 additions & 0 deletions browser-tests/password-ui.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// The generated password is readable and copyable, survives creating the room,
// and opens it; the room page can show what is typed. Both on the site and in
// a local file (where the copy may fall back to select-and-copy).
import { chromium } from 'playwright';
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 { configFromRoomHtml } from '../html-room/template.js';
import { unlock } from '../html-room/crypto.js';
import { nodeRelay } from '../test/fixtures/mesh-relays.mjs';

const FORMAT = /^[A-HJKMNP-Z2-9]{4}(-[A-HJKMNP-Z2-9]{4}){4}$/;
const dir = await mkdtemp(tmpdir() + '/hr-pw-');
const relay = await nodeRelay();
let browser;
try {
browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
const errors = []; const watch = p => { p.on('pageerror', e => errors.push(e.message)); return p; };
const page = watch(await ctx.newPage());
await page.goto(`http://127.0.0.1:${relay.port}/`);
await page.locator('#mk-gen').click();
const pw = await page.locator('#mk-pass').inputValue();
assert.match(pw, FORMAT, 'readable groups, no look-alike characters');
assert.equal(await page.locator('#mk-pass').getAttribute('type'), 'text', 'shown after generating');
assert.equal(await page.locator('#mk-show').textContent(), '隐藏');
await page.locator('#mk-copy-pass').click();
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), pw, 'copied to the clipboard');
await page.locator('#mk-show').click();
assert.equal(await page.locator('#mk-pass').getAttribute('type'), 'password');
await page.locator('#mk-show').click();
await page.locator('#mk-make').click(); await page.locator('#mk-out').waitFor({ state: 'visible' });
assert.equal(await page.locator('#mk-pass-row').isVisible(), true, 'the password stays readable with the result');
assert.equal(await page.locator('#mk-pass-out').inputValue(), pw);
await page.evaluate(() => navigator.clipboard.writeText('-'));
await page.locator('#mk-pass-copy').click();
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), pw);
const dl = page.waitForEvent('download'); await page.locator('#mk-download').click();
const roomPath = dir + '/room.html'; await (await dl).saveAs(roomPath);
const config = configFromRoomHtml(await readFile(roomPath, 'utf8'));
assert.equal(config.encrypted, true);
assert.equal((await unlock(config, pw)).roomId, config.roomId, 'the shown password opens the room');

// The room page: show what is typed, then enter with the copied password.
const room = watch(await ctx.newPage()); await room.goto(pathToFileURL(roomPath).href);
await room.locator('#password').fill(pw);
assert.equal(await room.locator('#password').getAttribute('type'), 'password');
await room.locator('#pw-show').click();
assert.equal(await room.locator('#password').getAttribute('type'), 'text');
await room.locator('#enter').click();
await room.locator('#relay-status').filter({ hasText: relay.url }).waitFor({ timeout: 15000 });

// A local creator without clipboard permission: copy falls back, and the text is left selected.
const blankPath = dir + '/creator.html'; await writeFile(blankPath, (await render({}, [])).html);
const local = watch(await (await browser.newContext()).newPage()); await local.goto(pathToFileURL(blankPath).href);
await local.locator('#mk-gen').click();
const pw2 = await local.locator('#mk-pass').inputValue();
assert.match(pw2, FORMAT); assert.notEqual(pw2, pw);
await local.locator('#mk-copy-pass').click();
await local.locator('#mk-error').filter({ hasText: /口令已复制|口令已选中/ }).waitFor();
assert.equal(await local.evaluate(() => getSelection().toString() || document.activeElement.value.slice(document.activeElement.selectionStart, document.activeElement.selectionEnd)), pw2, 'selected for copying by hand');
assert.deepEqual(errors, []);
console.log('PASS: generated password readable and copyable, kept on the result, opens the room; show/hide on both forms; local-file copy fallback');
} finally {
await browser?.close(); await relay.stop(); await rm(dir, { recursive: true, force: true });
}
2 changes: 1 addition & 1 deletion dist/_headers
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
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'
Content-Security-Policy: default-src 'none'; script-src 'sha256-aNnjLm65Zqk0gxUuDbkzb8IA4rm24tWrD4YbtohyyZ8='; style-src 'sha256-kDQ4uvzydd/bYRgj2CH45MTiA02XvOWw5A9uc4FMls8='; 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
Expand Down
8 changes: 4 additions & 4 deletions dist/hr-template.html

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions dist/hr.html

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions docs/html-room/release-integrity.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
{
"version": 2,
"stage": "room-title-htmlroom",
"stage": "visible-password-htmlroom",
"esbuild": "0.28.2",
"artifacts": {
"html-room-template.html": {
"bytes": 141314,
"sha256": "5a3f6fff21b29210b2744c91ab1e83cc67c668ee91e5008cc2e8e5b88edfbc70",
"bytes": 144598,
"sha256": "371e2db7d0a7b88a67d3999745f2041e7b96fac568d12e0e232765704eb1c347",
"csp": "([^"
}
},
"scriptHashBase64": "2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ=",
"scriptHashHex": "d89b50046435837472b0160cc0999c2b2f96155a8b2030d9c492df6089e189f4",
"styleHashBase64": "+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA=",
"scriptHashBase64": "aNnjLm65Zqk0gxUuDbkzb8IA4rm24tWrD4YbtohyyZ8=",
"scriptHashHex": "68d9e32e6eb966a93483152e0db9336fc200e2b9b6e2d5ab0f861bb68872c99f",
"styleHashBase64": "kDQ4uvzydd/bYRgj2CH45MTiA02XvOWw5A9uc4FMls8=",
"serverCommentSha256": "971a532473d0c7d039dd0617391a736bd4057fab488e2249d612188a340195fe"
}
2 changes: 2 additions & 0 deletions html-room/client.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { unlock, validate, hex, sessionRelays } from './crypto.js';
import { showToggle } from './ui.js';
import { Session } from './session.js';
import { RelayConnection } from './relay-connection.js';
import { MeshConnection } from './mesh.js';
Expand All @@ -21,6 +22,7 @@ if(!globalThis.crypto?.subtle || !globalThis.WebSocket || !globalThis.TextEncode
else if(config.version){
try{validate(config);status(config.encrypted?'请输入口令,然后点「进入房间」。':'点「进入房间」开始聊天。');el('password').disabled=false;el('enter').disabled=false;}catch{status('房间文件有问题,请让对方重新发一份。');el('enter').disabled=true;}
}else{status('这是空白模板,请先在创建页面里创建房间。');el('enter').disabled=true;}
showToggle(el('password'),el('pw-show'));
const custom=allowCustom && config.version && config.custom===true;
if(custom){el('custom-relay').hidden=false;el('relay-input').value=config.relays.join(' ');}
el('unlock').addEventListener('submit',async e=>{e.preventDefault();if(busy||Date.now()<nextUnlock)return;
Expand Down
10 changes: 10 additions & 0 deletions html-room/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ export function bytes(s, n) {
return Uint8Array.from(s.match(/../g), h => parseInt(h, 16));
}
export const random = n => crypto.getRandomValues(new Uint8Array(n));
// A random password people can read out and type: 20 characters from 31 that
// are hard to confuse (no I, L, O, 0, 1), in groups of four. About 99 bits.
const PW_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function readablePassword(groups = 5) {
const out = [];
while (out.length < groups * 4) {
for (const b of random(32)) if (b < 248 && out.length < groups * 4) out.push(PW_ALPHABET[b % 31]); // 248 = 8 × 31: no bias
}
return out.join('').match(/.{4}/g).join('-');
}
export async function derive(master) {
const raw = typeof master === 'string' ? bytes(master, 32) : master;
if (!(raw instanceof Uint8Array) || raw.length !== 32) throw Error('房间密钥长度错误');
Expand Down
20 changes: 16 additions & 4 deletions html-room/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
// 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, DEFAULT_TITLE } from './crypto.js';
import { makeConfig, relayList, validate, validateBlank, readablePassword, DEFAULT_TITLE } from './crypto.js';
import { copyText, showToggle } from './ui.js';
import { configFromHash, fragmentFor } from './transport.js';
import { retargetHtml, htmlFor, withRelays, configFromRoomHtml } from './template.js';

Expand Down Expand Up @@ -51,7 +52,15 @@ function enableMaker(defaults, { open, siteTitle }) {
if (open) { el('new-room').open = true; el('new-room').querySelector('summary').hidden = true; }
el('mk-relay').value = defaults.join(' ');
if (!defaults.length) el('mk-advanced').open = true;
el('mk-gen').addEventListener('click', () => { el('mk-pass').value = hex(random(32)); });
const pass = showToggle(el('mk-pass'), el('mk-show'));
el('mk-pass').addEventListener('input', () => { el('mk-copy-pass').hidden = !el('mk-pass').value; });
// A generated password is shown, so it can be read out and copied.
el('mk-gen').addEventListener('click', () => {
el('mk-pass').value = readablePassword(); pass.show(true); el('mk-copy-pass').hidden = false;
fail('已生成口令,显示在上面。可以点「复制口令」。');
});
el('mk-copy-pass').addEventListener('click', () => copyText(el('mk-pass')).then(ok => fail(ok ? '口令已复制。请换一种方式告诉朋友,别和文件一起发。' : '复制失败:口令已选中,请手动复制。')));
el('mk-pass-copy').addEventListener('click', () => copyText(el('mk-pass-out')).then(ok => fail(ok ? '口令已复制。请换一种方式告诉朋友,别和文件一起发。' : '复制失败:口令已选中,请手动复制。')));
// Classic relays of this project also forward decentralized rooms on /bc/.
el('mk-mesh').addEventListener('click', () => {
const list = el('mk-relay').value.trim().split(/[\s,]+/).filter(Boolean).map(r => /^wss?:\/\//.test(r) ? `bc+${r.replace(/\/+$/, '')}/bc/` : r);
Expand All @@ -65,15 +74,18 @@ function enableMaker(defaults, { open, siteTitle }) {
if (password && new TextEncoder().encode(password).length > 1024) throw Error('口令太长了');
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 = '';
el('mk-out').hidden = false;
// The password stays readable with the result: losing it loses the room.
el('mk-pass-out').value = password; el('mk-pass-row').hidden = !password;
el('mk-pass').value = ''; pass.show(false); el('mk-copy-pass').hidden = true;
// A link only works on a hosted creator, which can open any room.
if (hosted) { el('mk-link').value = location.origin + location.pathname + fragmentFor(next); el('mk-link-row').hidden = false; }
else { el('mk-link').value = ''; el('mk-link-row').hidden = true; }
fail(password ? '✅ 房间建好了。口令请换一种方式告诉朋友(比如打电话),别和文件一起发。' : '✅ 房间建好了。文件就是钥匙,请只发给要聊天的人。');
} catch (e) { fail(cjk(e) ? `创建失败:${e.message}` : '创建失败,请检查中继地址后再试。'); el('mk-advanced').open = true; }
finally { el('mk-make').disabled = false; }
});
el('mk-copy').addEventListener('click', () => navigator.clipboard?.writeText(el('mk-link').value).then(() => fail('已复制。链接就是钥匙,请只发给要聊天的人。'), () => fail('复制失败,请手动选中链接复制。')));
el('mk-copy').addEventListener('click', () => copyText(el('mk-link')).then(ok => fail(ok ? '已复制。链接就是钥匙,请只发给要聊天的人。' : '复制失败:链接已选中,请手动复制。')));
el('cv-make').addEventListener('click', async () => {
const say = t => { el('cv-error').textContent = t; }; el('cv-download').hidden = true; el('cv-make').disabled = true; say('');
try {
Expand Down
2 changes: 1 addition & 1 deletion html-room/style.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading