Files
WebDrop/packages/core/src/link.ts
T
2026-10-02 00:59:22 +08:00

93 lines
3.0 KiB
TypeScript

import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js';
import { fromBase64Url, toBase64Url, type Bytes } from './bytes.js';
import { LinkError } from './errors.js';
/**
* 频道链接(protocol §2):
* enc 所有者 /c/{id}#k=<K>&a=<A> 成员 /c/{id}#k=<K>
* raw 所有者 /c/{id}#a=<A>[&w=<W>] 成员 /c/{id}[#w=<W>]
*
* 密钥只放在 fragment 里,浏览器不会把它发给服务器。降级防御:带 k 就是 enc,否则是 raw。
*/
export interface ParsedLink {
/** 频道 id 的 base64url 文本。 */
id: string;
idBytes: Bytes;
/** 带 k 即 enc,否则 raw。客户端必须据此核对服务端报告的模式。 */
mode: ChannelMode;
role: 'owner' | 'member';
/** 频道密钥,仅 enc。 */
k?: Bytes;
/** 管理密钥,仅所有者链接。 */
a?: Bytes;
/** raw 模式成员的写密钥。 */
w?: Bytes;
}
const PATH = /(?:^|\/)c\/([A-Za-z0-9_-]{22})\/?$/;
export function parseLink(input: string | URL): ParsedLink {
let url: URL;
try {
url = typeof input === 'string' ? new URL(input) : input;
} catch {
throw new LinkError('不是合法的链接');
}
const match = PATH.exec(url.pathname);
if (!match) throw new LinkError('链接里没有频道 id');
const id = match[1] as string;
const idBytes = fromBase64Url(id, ID_SIZE);
if (!idBytes) throw new LinkError('频道 id 不合法');
// 密钥放在查询串里会随请求发给服务器,等于已经泄露,必须拒绝而不是悄悄当成 raw 链接
for (const name of ['k', 'a', 'w']) {
if (url.searchParams.has(name)) throw new LinkError('密钥必须放在 # 后面,放在 ? 后面会被发送给服务器');
}
const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash);
const key = (name: string): Bytes | undefined => {
const v = params.get(name);
if (v === null) return undefined;
const bytes = fromBase64Url(v, KEY_SIZE);
if (!bytes) throw new LinkError(`链接里的 ${name} 不合法`);
return bytes;
};
const k = key('k');
const a = key('a');
const w = key('w');
// enc 频道的写令牌由 K 派生,出现 w 说明链接被拼错或被篡改
if (k && w) throw new LinkError('enc 链接不应包含 w');
const link: ParsedLink = {
id,
idBytes,
mode: k ? 'enc' : 'raw',
role: a ? 'owner' : 'member',
};
if (k) link.k = k;
if (a) link.a = a;
if (w) link.w = w;
return link;
}
export interface LinkParts {
id: string;
k?: Uint8Array;
a?: Uint8Array;
w?: Uint8Array;
}
/** 生成链接。origin 如 "https://app.example.com",路径固定为 /c/{id}。 */
export function buildLink(origin: string, parts: LinkParts): string {
const params: string[] = [];
if (parts.k) params.push(`k=${toBase64Url(parts.k)}`);
if (parts.a) params.push(`a=${toBase64Url(parts.a)}`);
if (parts.w) params.push(`w=${toBase64Url(parts.w)}`);
const base = `${origin.replace(/\/+$/, '')}/c/${parts.id}`;
return params.length > 0 ? `${base}#${params.join('&')}` : base;
}