From d66c93fe9852c825a35ebb00c4a95a2e44b8c0a4 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 14:35:42 +0800 Subject: [PATCH] feat(web): avatar thumbnails with original preview, registration avatar, pure-JS crypto fallback for http [CI] --- web/package-lock.json | 42 +++++ web/package.json | 3 + web/src/core-bridge/account.ts | 6 +- web/src/core-bridge/api.ts | 2 + web/src/core-bridge/protocol/account.ts | 6 +- web/src/core-bridge/real.ts | 1 + web/src/core-bridge/subtleFallback.test.ts | 70 ++++++++ web/src/core-bridge/subtleFallback.ts | 161 ++++++++++++++++++ web/src/core-bridge/types.ts | 2 + web/src/core-bridge/worker.ts | 18 +- web/src/core-bridge/worker/account.ts | 20 ++- web/src/core-bridge/worker/accountProfile.ts | 8 +- web/src/core-bridge/worker/avatars.ts | 68 ++++++++ web/src/core-bridge/worker/channelEvents.ts | 4 +- .../core-bridge/worker/channelResources.ts | 18 +- web/src/core-bridge/worker/http.ts | 11 +- .../core-bridge/worker/workspaceStructure.ts | 25 ++- web/src/core-bridge/workerClient.ts | 12 +- .../channel/ChannelSettingsSheet.module.css | 8 + .../features/channel/ChannelSettingsSheet.tsx | 23 ++- web/src/features/channel/ChatMessage.tsx | 6 +- .../features/channel/MembersPane.module.css | 10 ++ web/src/features/channel/MembersPane.tsx | 11 +- .../features/common/AvatarPreview.module.css | 25 +++ web/src/features/common/AvatarPreview.tsx | 30 ++++ web/src/features/home/AccountPanel.module.css | 46 +++++ web/src/features/home/AccountPanel.tsx | 41 ++++- .../features/home/AccountSettings.module.css | 8 + web/src/features/home/AvatarEditor.tsx | 15 +- web/src/features/home/CreateSheet.tsx | 1 + web/src/features/home/ProfileSettings.tsx | 25 ++- web/src/features/home/avatarCrop.ts | 21 +++ web/src/features/home/avatarGif.ts | 14 +- web/src/main.tsx | 4 + web/src/store/account.ts | 28 ++- web/src/store/reduce.test.ts | 2 +- 36 files changed, 735 insertions(+), 60 deletions(-) create mode 100644 web/src/core-bridge/subtleFallback.test.ts create mode 100644 web/src/core-bridge/subtleFallback.ts create mode 100644 web/src/core-bridge/worker/avatars.ts create mode 100644 web/src/features/common/AvatarPreview.module.css create mode 100644 web/src/features/common/AvatarPreview.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 879fdae..28081af 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "@material/material-color-utilities": "^0.3.0", + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "@webdrop/core": "file:../packages/core", "cap-widget": "^0.1.58", "gifenc": "^1.0.3", @@ -311,6 +314,45 @@ "integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==", "license": "Apache-2.0" }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/web/package.json b/web/package.json index 0dc10a2..1716ca2 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,9 @@ }, "dependencies": { "@material/material-color-utilities": "^0.3.0", + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "@webdrop/core": "file:../packages/core", "cap-widget": "^0.1.58", "gifenc": "^1.0.3", diff --git a/web/src/core-bridge/account.ts b/web/src/core-bridge/account.ts index 93d0bc9..20fff28 100644 --- a/web/src/core-bridge/account.ts +++ b/web/src/core-bridge/account.ts @@ -1,6 +1,6 @@ import { callWorker } from './workerClient'; import type { AccountDeviceBody, InvitationBody } from './generated/api'; -import type { PersonalProfile } from './protocol/account'; +import type { AvatarChange, PersonalProfile } from './protocol/account'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; @@ -18,7 +18,9 @@ export const accountApi = { sync: () => callWorker('syncAccount', { apiBase: API_BASE }), invitations: () => callWorker('accountInvitations', { apiBase: API_BASE }), acceptInvitation: (id: string) => callWorker('acceptInvitation', { id, apiBase: API_BASE }), - updatePersonal: (displayName: string, avatar?: string) => callWorker('updatePersonal', { displayName, avatar, apiBase: API_BASE }), + /** 默认是有损的缩略图;full 为 true 取原图(预览时) */ + avatar: (username: string, version: number, full = false) => callWorker('getAvatar', { username, version, full, apiBase: API_BASE }), + updatePersonal: (displayName: string, avatar?: AvatarChange) => callWorker('updatePersonal', { displayName, avatar, apiBase: API_BASE }), devices: () => callWorker('accountDevices', { apiBase: API_BASE }), renameDevice: (id: string, name: string) => callWorker('renameAccountDevice', { id, name, apiBase: API_BASE }), revokeDevice: (id: string) => callWorker('revokeAccountDevice', { id, apiBase: API_BASE }), diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts index 4c8943b..9dbb0e6 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/core-bridge/api.ts @@ -12,6 +12,8 @@ export interface WorkspaceApi { createChannel(opts: CreateOptions, captchaToken?: string): Promise; /** 使用现有空间 settings 接口更新空间设置。 */ updateChannelSettings(patch: ChannelSettingsPatch): Promise; + /** 空间头像原图(信息里只有缩略图),预览时才下载 */ + spaceAvatar(): Promise; /** 通过链接加入:先 GET 空间信息预览,再 POST join */ joinChannel(link: ParsedChannelLink, password?: string): Promise; inviteUser(username: string): Promise; diff --git a/web/src/core-bridge/protocol/account.ts b/web/src/core-bridge/protocol/account.ts index 84238d0..02349a9 100644 --- a/web/src/core-bridge/protocol/account.ts +++ b/web/src/core-bridge/protocol/account.ts @@ -2,7 +2,8 @@ import type { AccountProfile } from '../generated/api'; export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } /** Account vault v2: credentials and cached metadata, encrypted before transport. */ -export type PersonalProfile = Pick & { avatar?: string }; +/** avatar 只在加密线程里有(发消息要带头像快照);界面这边通过 hasAvatar 判断,头像另取 */ +export type PersonalProfile = Pick & { avatar?: string; hasAvatar?: boolean }; export interface ResourceDelete { id: string; modifiedAt: number } export interface VaultResource { domainKeys?: Record; @@ -25,3 +26,6 @@ export interface VaultResource { state?: 'draft' | 'sealed'; } export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] } + +/** 资料里头像的修改:换成新的(原图 + 缩略图)、保持不变、或移除(不传) */ +export type AvatarChange = { avatar: string; thumb: string } | 'keep' | undefined; diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts index c822022..6074ef4 100644 --- a/web/src/core-bridge/real.ts +++ b/web/src/core-bridge/real.ts @@ -17,6 +17,7 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) = export const realApi: WorkspaceApi = { createChannel: (options: CreateOptions, captchaToken?: string) => callWorker('createChannel', { options, captchaToken, apiBase: API_BASE }), updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }), + spaceAvatar: () => callWorker('getSpaceAvatar', { id: currentChannelId(), apiBase: API_BASE }), joinChannel: (link: ParsedChannelLink, password?: string) => callWorker('joinChannel', { link, password, apiBase: API_BASE }), inviteUser: (username) => callWorker('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }), listInvitations: () => callWorker('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }), diff --git a/web/src/core-bridge/subtleFallback.test.ts b/web/src/core-bridge/subtleFallback.test.ts new file mode 100644 index 0000000..d33a4cf --- /dev/null +++ b/web/src/core-bridge/subtleFallback.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { fallbackSubtle as soft } from './subtleFallback'; + +const native = globalThis.crypto.subtle; +const text = (value: string) => new TextEncoder().encode(value); +const hex = (data: ArrayBuffer | Uint8Array) => Array.from(data instanceof Uint8Array ? data : new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0')).join(''); + +describe('crypto.subtle 的纯 JS 替代实现和原生逐字节一致', () => { + it('SHA-256、HMAC', async () => { + expect(hex(await soft.digest('SHA-256', text('hello')))).toBe(hex(await native.digest('SHA-256', text('hello')))); + const a = await soft.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const b = await native.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + expect(hex(await soft.sign('HMAC', a, text('m')))).toBe(hex(await native.sign('HMAC', b, text('m')))); + }); + + it('PBKDF2 和 HKDF', async () => { + const salt = text('salty'); + const pa = await soft.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']); + const pb = await native.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']); + expect(hex(await soft.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pa, 256))) + .toBe(hex(await native.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pb, 256))); + const ha = await soft.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']); + const hb = await native.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']); + expect(hex(await soft.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, ha, 256))) + .toBe(hex(await native.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, hb, 256))); + }); + + it('AES-GCM 互相能解开,带附加数据;篡改会失败', async () => { + const raw = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12)); + const a = await soft.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']); + const b = await native.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']); + const params = { name: 'AES-GCM', iv, additionalData: text('aad') }; + const sealed = await soft.encrypt(params, a, text('秘密内容')); + expect(hex(sealed)).toBe(hex(await native.encrypt(params, b, text('秘密内容')))); + expect(new TextDecoder().decode(await native.decrypt(params, b, sealed))).toBe('秘密内容'); + const tampered = new Uint8Array(sealed); tampered[0] = tampered[0]! ^ 1; + await expect(soft.decrypt(params, a, tampered)).rejects.toThrow(); + }); + + it('ECDSA:原生签的软件能验,软件签的原生能验', async () => { + const pair = await soft.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) as CryptoKeyPair; + const jwkPrivate = await soft.exportKey('jwk', pair.privateKey) as JsonWebKey; + const jwkPublic = await soft.exportKey('jwk', pair.publicKey) as JsonWebKey; + const nativePrivate = await native.importKey('jwk', jwkPrivate, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); + const nativePublic = await native.importKey('jwk', jwkPublic, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + const algorithm = { name: 'ECDSA', hash: 'SHA-256' }; + const fromSoft = await soft.sign(algorithm, pair.privateKey, text('消息')); + expect(fromSoft.byteLength).toBe(64); + expect(await native.verify(algorithm, nativePublic, fromSoft, text('消息'))).toBe(true); + const fromNative = await native.sign(algorithm, nativePrivate, text('消息')); + expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('消息'))).toBe(true); + expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('别的消息'))).toBe(false); + }); + + it('ECDH:两边算出同一个共享密钥,JWK 互通', async () => { + const ours = await soft.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) as CryptoKeyPair; + const theirs = await native.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const theirPublic = await native.exportKey('jwk', theirs.publicKey); + const ourPublic = await soft.exportKey('jwk', ours.publicKey) as JsonWebKey; + const soft1 = await soft.deriveBits({ name: 'ECDH', public: await soft.importKey('jwk', theirPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []) } as never, ours.privateKey, 256); + const nativePublic = await native.importKey('jwk', ourPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []); + const native1 = await native.deriveBits({ name: 'ECDH', public: nativePublic }, theirs.privateKey, 256); + expect(hex(soft1)).toBe(hex(native1)); + }); + + it('不可导出的密钥导出会失败', async () => { + const key = await soft.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt']) as CryptoKey; + await expect(soft.exportKey('raw', key)).rejects.toThrow(); + }); +}); diff --git a/web/src/core-bridge/subtleFallback.ts b/web/src/core-bridge/subtleFallback.ts new file mode 100644 index 0000000..dcb7c2c --- /dev/null +++ b/web/src/core-bridge/subtleFallback.ts @@ -0,0 +1,161 @@ +import { gcm } from '@noble/ciphers/aes.js'; +import { p256 } from '@noble/curves/nist.js'; +import { hkdf } from '@noble/hashes/hkdf.js'; +import { hmac } from '@noble/hashes/hmac.js'; +import { pbkdf2 } from '@noble/hashes/pbkdf2.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +/** + * 浏览器只在 https 或 localhost 下提供 crypto.subtle。通过局域网 IP 用 http 访问时它不存在, + * 注册、登录(密码派生、保险库加解密、签名)全都会失败。这里用纯 JS 实现这个项目用到的那一小部分接口, + * 只在 crypto.subtle 不存在时才安装;输出和原生实现逐字节一致(见 subtleFallback.test.ts),所以同一个账号在 https 和 http 下可以互通。 + * + * 只覆盖用到的算法:SHA-256、HMAC-SHA256、HKDF-SHA256、PBKDF2-SHA256、AES-256-GCM、ECDH P-256、ECDSA P-256(SHA-256)。 + * 纯 JS 比原生慢(PBKDF2 的 60 万次迭代要一两秒),而且 http 页面本身可以被网络上的人替换,所以这只是开发和局域网试用的退路。 + */ +type Bytes = Uint8Array; +interface FallbackKey { + readonly __fallback: true; + type: 'secret' | 'public' | 'private'; + extractable: boolean; + algorithm: { name: string; [key: string]: unknown }; + usages: string[]; + raw?: Bytes; + /** EC:私钥 d(32 字节)和公钥(65 字节,未压缩) */ + d?: Bytes; + point?: Bytes; +} + +const toBytes = (data: BufferSource): Bytes => (data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); +const fresh = (data: Bytes): ArrayBuffer => data.slice().buffer as ArrayBuffer; +const name = (algorithm: string | { name: string }): string => (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase(); +const b64u = (data: Bytes): string => btoa(String.fromCharCode(...data)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +const unb64u = (text: string): Bytes => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (char) => char.charCodeAt(0)); +const fail = (message: string, kind = 'OperationError'): never => { throw new DOMException(message, kind); }; +const asKey = (key: CryptoKey): FallbackKey => ((key as unknown as FallbackKey).__fallback ? (key as unknown as FallbackKey) : fail('密钥不是由这个环境创建的', 'InvalidAccessError')); +const wrap = (key: Omit): CryptoKey => ({ __fallback: true, ...key }) as unknown as CryptoKey; + +function ecKey(algorithmName: string, type: 'public' | 'private', point: Bytes, d: Bytes | undefined, extractable: boolean, usages: string[]): CryptoKey { + return wrap({ type, extractable, usages, algorithm: { name: algorithmName, namedCurve: 'P-256' }, point, ...(d ? { d } : {}) }); +} + +function ecPair(algorithmName: string, extractable: boolean, usages: string[]): CryptoKeyPair { + const d = p256.utils.randomSecretKey(); + const point = p256.getPublicKey(d, false); + const publicUsages = algorithmName === 'ECDSA' ? usages.filter((usage) => usage === 'verify') : []; + const privateUsages = usages.filter((usage) => usage !== 'verify'); + return { publicKey: ecKey(algorithmName, 'public', point, undefined, true, publicUsages), privateKey: ecKey(algorithmName, 'private', point, d, extractable, privateUsages) }; +} + +function importJwk(jwk: JsonWebKey, algorithm: { name: string; namedCurve?: string }, extractable: boolean, usages: string[]): CryptoKey { + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.x || !jwk.y) fail('只支持 P-256 的 EC 密钥', 'DataError'); + const point = new Uint8Array(65); + point[0] = 4; point.set(unb64u(jwk.x!), 1); point.set(unb64u(jwk.y!), 33); + const d = jwk.d ? unb64u(jwk.d) : undefined; + return ecKey(algorithm.name, d ? 'private' : 'public', point, d, extractable, usages); +} + +function exportJwk(key: FallbackKey): JsonWebKey { + if (!key.extractable) fail('这个密钥不允许导出', 'InvalidAccessError'); + if (!key.point) fail('只能导出 EC 密钥', 'NotSupportedError'); + const ops = key.type === 'private' ? key.usages : key.usages; + return { kty: 'EC', crv: 'P-256', ...(key.d ? { d: b64u(key.d) } : {}), x: b64u(key.point!.subarray(1, 33)), y: b64u(key.point!.subarray(33, 65)), ext: true, key_ops: ops }; +} + +const subtle = { + async digest(algorithm: string | { name: string }, data: BufferSource): Promise { + if (name(algorithm) !== 'SHA-256') fail('只支持 SHA-256', 'NotSupportedError'); + return fresh(sha256(toBytes(data))); + }, + + async importKey(format: string, keyData: BufferSource | JsonWebKey, algorithm: string | { name: string; namedCurve?: string; hash?: string }, extractable: boolean, usages: string[]): Promise { + const algo = name(algorithm); + if (format === 'jwk') return importJwk(keyData as JsonWebKey, typeof algorithm === 'string' ? { name: algorithm } : algorithm, extractable, usages); + if (format !== 'raw') fail('只支持 raw 和 jwk', 'NotSupportedError'); + const raw = toBytes(keyData as BufferSource).slice(); + if (algo === 'AES-GCM' && ![16, 24, 32].includes(raw.length)) fail('AES 密钥长度不对', 'DataError'); + return wrap({ type: 'secret', extractable, usages, algorithm: typeof algorithm === 'string' ? { name: algorithm } : { ...algorithm }, raw }); + }, + + async exportKey(format: string, key: CryptoKey): Promise { + const own = asKey(key); + if (format === 'jwk') return exportJwk(own); + if (!own.extractable) fail('这个密钥不允许导出', 'InvalidAccessError'); + return fresh(own.raw ?? own.point ?? new Uint8Array()); + }, + + async generateKey(algorithm: { name: string; length?: number; namedCurve?: string }, extractable: boolean, usages: string[]): Promise { + const algo = name(algorithm); + if (algo === 'AES-GCM') return wrap({ type: 'secret', extractable, usages, algorithm: { name: 'AES-GCM', length: algorithm.length ?? 256 }, raw: crypto.getRandomValues(new Uint8Array((algorithm.length ?? 256) / 8)) }); + if ((algo === 'ECDH' || algo === 'ECDSA') && algorithm.namedCurve === 'P-256') return ecPair(algo, extractable, usages); + return fail('不支持的算法', 'NotSupportedError'); + }, + + async sign(algorithm: string | { name: string }, key: CryptoKey, data: BufferSource): Promise { + const own = asKey(key); + const algo = name(algorithm); + if (algo === 'HMAC') return fresh(hmac(sha256, own.raw!, toBytes(data))); + if (algo === 'ECDSA' && own.d) return fresh(p256.sign(toBytes(data), own.d, { prehash: true, format: 'compact' })); + return fail('不支持的签名', 'NotSupportedError'); + }, + + async verify(algorithm: string | { name: string }, key: CryptoKey, signature: BufferSource, data: BufferSource): Promise { + const own = asKey(key); + const algo = name(algorithm); + if (algo === 'HMAC') { + const expected = hmac(sha256, own.raw!, toBytes(data)); const got = toBytes(signature); + return expected.length === got.length && expected.every((byte, index) => byte === got[index]); + } + if (algo === 'ECDSA' && own.point) { + try { return p256.verify(toBytes(signature), toBytes(data), own.point, { prehash: true, format: 'compact', lowS: false }); } catch { return false; } + } + return fail('不支持的验证', 'NotSupportedError'); + }, + + async encrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise { + if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError'); + return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).encrypt(toBytes(data))); + }, + + async decrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise { + if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError'); + try { return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).decrypt(toBytes(data))); } + catch { return fail('解密失败', 'OperationError'); } + }, + + async deriveBits(algorithm: { name: string; hash?: string; salt?: BufferSource; info?: BufferSource; iterations?: number; public?: CryptoKey }, key: CryptoKey, length: number): Promise { + const own = asKey(key); + const algo = name(algorithm); + if (algo === 'PBKDF2') return fresh(pbkdf2(sha256, own.raw!, toBytes(algorithm.salt!), { c: algorithm.iterations!, dkLen: length / 8 })); + if (algo === 'HKDF') return fresh(hkdf(sha256, own.raw!, toBytes(algorithm.salt!), toBytes(algorithm.info!), length / 8)); + if (algo === 'ECDH' && own.d) return fresh(p256.getSharedSecret(own.d, asKey(algorithm.public!).point!, true).subarray(1, 33).slice(0, length / 8)); + return fail('不支持的派生', 'NotSupportedError'); + }, + + async deriveKey(algorithm: { name: string; salt?: BufferSource; info?: BufferSource }, key: CryptoKey, derived: { name: string; length?: number }, extractable: boolean, usages: string[]): Promise { + const length = derived.length ?? 256; + const raw = new Uint8Array(await subtle.deriveBits(algorithm as never, key, length)); + return wrap({ type: 'secret', extractable, usages, algorithm: { name: derived.name, length }, raw }); + }, +}; + +/** randomUUID 同样只在安全上下文里有 */ +function randomUuid(): `${string}-${string}-${string}-${string}-${string}` { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** crypto.subtle、crypto.randomUUID 不存在时(http 的局域网访问)装上替代实现。已经有原生的就什么也不做 */ +/** force:即使有原生实现也用纯 JS 的(排查用,localStorage 里设 webdrop.softcrypto=1) */ +export function installSubtleFallback(force = false): boolean { + if (typeof crypto === 'undefined') return false; + if (force) { Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true }); return true; } + if (!crypto.randomUUID) Object.defineProperty(crypto, 'randomUUID', { value: randomUuid, configurable: true }); + if (crypto.subtle) return false; + Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true }); + return true; +} + +export { subtle as fallbackSubtle }; diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index c7513ac..b792642 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -55,6 +55,8 @@ export interface ChannelSettingsPatch { name?: string; /** 空字符串表示移除头像 */ avatar?: string; + /** 有损的小图,随头像一起上传 */ + avatarThumb?: string; maxMembers?: number; membersCanUpload?: boolean; ttlSeconds?: number; diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index c12a39d..83b7104 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -1,8 +1,9 @@ +import { installSubtleFallback } from './subtleFallback'; import { setApiBase, getServerInfo } from './worker/http'; import type { WorkspaceRoleRequest } from './generated/api'; import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys'; import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems'; -import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources'; +import { createChannel, forgetChannel, getSpaceAvatar, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources'; import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend'; import { cancelFileUpload } from './worker/uploadQueue'; import { stopChannel } from './worker/channelTransport'; @@ -15,6 +16,8 @@ import { serial } from './worker/serial'; import { errorMessage } from './errors'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; +import { fetchAvatar } from './worker/avatars'; +import { servePlaybackRange } from './worker/playbackStream'; import { refreshLoadedWorkspaces } from './worker/workspaceStructure'; import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; @@ -45,7 +48,9 @@ async function dispatch(operation: string, input: unknown): Promise { case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? '')); case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false); case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false); - case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); void refreshLoadedWorkspaces(); return accountStatus(); + case 'getAvatar': return fetchAvatar(String(payload.username ?? ''), Number(payload.version ?? 0), payload.full === true); + case 'getSpaceAvatar': return getSpaceAvatar(id); + case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), payload.avatar as Parameters[1]); void refreshLoadedWorkspaces(); return accountStatus(); case 'accountDevices': return listAccountDevices(); case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true; case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus(); @@ -127,6 +132,12 @@ function laneOf(operation: string, payload: Record): string | u scope.onmessage = (event) => { const request = event.data; + // 边下边播的取数请求:数据通过随消息转移来的端口直接回给 Service Worker,不走请求/回复 + if (request.operation === 'streamRange') { + const port = event.ports[0]; + if (port) void servePlaybackRange(port, request.payload as Parameters[1]); + return; + } const lane = laneOf(request.operation, request.payload as Record); const run = () => dispatch(request.operation, request.payload); const task = lane ? serial(lane, run) : run(); @@ -138,3 +149,6 @@ scope.onmessage = (event) => { }), ); }; + +// http 的局域网访问没有 crypto.subtle:装上纯 JS 的替代实现(原生存在时什么也不做) +installSubtleFallback(self.name.includes('softcrypto')); diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts index ee0e081..b820ba5 100644 --- a/web/src/core-bridge/worker/account.ts +++ b/web/src/core-bridge/worker/account.ts @@ -1,5 +1,5 @@ import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database'; -import { apiFetch } from './http'; +import { apiFetch, jsonBody } from './http'; import { clearResourceSessions, getSession } from './sessions'; import { setAccountIdentity, requireAccount } from './accountSession'; import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto'; @@ -11,6 +11,8 @@ import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api'; import { loadPersonal, personalProfile } from './accountProfile'; +import { primeAvatar } from './avatars'; +import type { AvatarChange } from '../protocol/account'; import type { AccountProfile, AccountProfileRequest } from '../generated/api'; import type { PersonalProfile } from '../protocol/account'; @@ -206,7 +208,8 @@ export async function unlockAuthenticatedAccount(identity: AccountSessionBody, k export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> { return { username: account?.username ?? null, revision: account?.revision ?? 0, remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(), - ...(account?.personal ? { personal: account.personal } : {}), + // 界面每隔几秒就读一次状态:不能每次都带着几 MB 的头像过 postMessage,头像由界面另外取 + ...(account?.personal ? { personal: { displayName: account.personal.displayName, modifiedAt: account.personal.modifiedAt, hasAvatar: account.personal.hasAvatar ?? Boolean(account.personal.avatar) } } : {}), ...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) }; } export async function logoutAccount(): Promise { @@ -230,13 +233,20 @@ export async function clearSavedLogin(): Promise { } await forgetAccount(); await forgetLoginFields(); } -export async function updatePersonal(displayName: string, avatar?: string): Promise { +export async function updatePersonal(displayName: string, change?: AvatarChange): Promise { if (!account) throw new Error('请先登录账号'); const session = account; - const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' }, - body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise); + const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true } + : change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false }; + const { body, headers: encoding } = await jsonBody(request); + const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise); stillActive(session); + const previous = session.personal?.avatar; session.personal = personalProfile(value); + // 本机刚设置的头像直接用,不用再下载;只改名字时沿用现有的 + const shown = change && change !== 'keep' ? change.thumb : change === 'keep' ? previous : undefined; + if (shown) { session.personal.avatar = shown; session.personal.hasAvatar = true; } + primeAvatar(value.username, value.modifiedAt, shown); setAccountIdentity(session, session.profile.keys.publicKey, session.personal); await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision }); } diff --git a/web/src/core-bridge/worker/accountProfile.ts b/web/src/core-bridge/worker/accountProfile.ts index d4107b8..3f5b8fe 100644 --- a/web/src/core-bridge/worker/accountProfile.ts +++ b/web/src/core-bridge/worker/accountProfile.ts @@ -1,12 +1,16 @@ import { apiFetch } from './http'; +import { fetchAvatar } from './avatars'; import type { AccountProfile } from '../generated/api'; import type { PersonalProfile } from '../protocol/account'; export function personalProfile(value: AccountProfile): PersonalProfile { - return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt }; + return { displayName: value.displayName, hasAvatar: value.hasAvatar || Boolean(value.avatar), ...(value.avatar ? { avatar: value.avatar } : {}), modifiedAt: value.modifiedAt }; } export async function loadPersonal(token: string): Promise { const headers = { 'X-WD-Account': token }; const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise); - return personalProfile(profile); + const personal = personalProfile(profile); + // 头像后台取,不挡住登录;取到后补进来,之后发消息的头像快照就有了 + if (personal.hasAvatar && !personal.avatar) void fetchAvatar(profile.username, profile.modifiedAt).then((avatar) => { if (avatar) personal.avatar = avatar; }); + return personal; } diff --git a/web/src/core-bridge/worker/avatars.ts b/web/src/core-bridge/worker/avatars.ts new file mode 100644 index 0000000..8f717fa --- /dev/null +++ b/web/src/core-bridge/worker/avatars.ts @@ -0,0 +1,68 @@ +import { apiFetch } from './http'; +import { accountToken } from './accountSession'; + +/** + * 头像不再随成员资料、空间结构一起下发(动图头像有几 MB,每次刷新结构都带上会把打开空间拖得很慢)。 + * 资料里只有「有没有头像」和修改时间,头像另取一次并缓存;修改时间变了才重新取, + * 服务端对头像带 ETag,浏览器重新打开时只回 304。 + */ +interface Entry { version: number; value?: string; loading?: Promise } +const cache = new Map(); +const waiting: (() => void)[] = []; +let running = 0; +const PARALLEL = 4; + +/** 已经取到的头像(没取到或版本不对返回 undefined) */ +export function cachedAvatar(username: string, version: number): string | undefined { + const entry = cache.get(username); + return entry && entry.version === version ? entry.value : undefined; +} + +/** 本机刚设置的头像,直接放进缓存,不用再下载一遍 */ +export function primeAvatar(username: string, version: number, value: string | undefined): void { + cache.set(username, { version, ...(value ? { value } : {}) }); +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +async function slot(task: () => Promise): Promise { + if (running >= PARALLEL) await new Promise((resolve) => waiting.push(resolve)); + running += 1; + try { return await task(); } + finally { running -= 1; waiting.shift()?.(); } +} + +async function download(username: string, token: string, full: boolean): Promise { + // no-cache:浏览器每次都带 ETag 去验证,没变化就是 304,不用重新下载 + const response = await apiFetch(`/accounts/users/${encodeURIComponent(username)}/avatar${full ? '?size=full' : ''}`, { headers: { 'X-WD-Account': token }, cache: 'no-cache' }); + return blobToDataUrl(await response.blob()); +} + +/** 默认取有损的缩略图(列表、聊天用,小,有内存缓存);full 取原图(预览时才用,不放进内存缓存) */ +export function fetchAvatar(username: string, version: number, full = false): Promise { + if (full) { + const token = accountToken(); + return token ? download(username, token, true).catch(() => undefined) : Promise.resolve(undefined); + } + const entry = cache.get(username); + if (entry && entry.version === version) return entry.loading ?? Promise.resolve(entry.value); + const token = accountToken(); + if (!token) return Promise.resolve(undefined); + const next: Entry = { version }; + next.loading = slot(async () => { + try { + const value = await download(username, token, false); + next.value = value; + return value; + } catch { cache.delete(username); return undefined; } + }); + cache.set(username, next); + return next.loading; +} diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index 9a9e62b..c3b5c57 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -3,6 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK import { getResource, cacheResource, type SavedResource } from '../database'; import { channelCache } from './resourceMetadata'; import { cachedPerson, loadWorkspace } from './workspaceStructure'; +import { cachedAvatar } from './avatars'; import { displayAvatar } from './senderSnapshot'; import type { FileItem, Item, SenderInfo, Snapshot } from '../types'; import { previewKindOf } from '@/lib/fileKind'; @@ -37,7 +38,8 @@ async function authorOf(resourceId: string, itemId: string): Promise response.json() as Promise); session.verifyChannelInfo(info); const updated = await cacheResource(resource, channelCache(info)); @@ -175,3 +177,13 @@ export async function forgetChannel(id: string): Promise { await removeResource(id); } + +/** 空间头像原图:信息里只带缩略图,预览时才下载原图 */ +export async function getSpaceAvatar(resourceId: string): Promise { + const { session } = await getSession(resourceId); + try { + const response = await apiFetch(`/channels/${resourceId}/avatar`, { headers: session.authHeaders('read'), cache: 'no-cache' }); + const blob = await response.blob(); + return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); + } catch { return undefined; } +} diff --git a/web/src/core-bridge/worker/http.ts b/web/src/core-bridge/worker/http.ts index 7c160cd..c1893da 100644 --- a/web/src/core-bridge/worker/http.ts +++ b/web/src/core-bridge/worker/http.ts @@ -47,7 +47,8 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise(path: string, headers: Record) return apiFetch(path, { headers }).then((response) => response.json() as Promise); } + +/** JSON 请求体,浏览器支持时用 gzip 压缩(头像是 base64,压缩后能省下约四分之一的上传量) */ +export async function jsonBody(value: unknown, compress = true): Promise<{ body: BodyInit; headers: Record }> { + const text = JSON.stringify(value); + if (!compress || typeof CompressionStream === 'undefined') return { body: text, headers: { 'Content-Type': 'application/json' } }; + const zipped = await new Response(new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'))).blob(); + return { body: zipped, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }; +} diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/core-bridge/worker/workspaceStructure.ts index 2644785..14a49ff 100644 --- a/web/src/core-bridge/worker/workspaceStructure.ts +++ b/web/src/core-bridge/worker/workspaceStructure.ts @@ -4,6 +4,7 @@ import { getResource, cacheResource } from '../database'; import { getSession } from './sessions'; import { apiFetch } from './http'; import { publishWorkspace } from './presentation'; +import { cachedAvatar, fetchAvatar } from './avatars'; import { displayAvatar } from './senderSnapshot'; const current = new Map(); @@ -19,11 +20,31 @@ async function publish(id: string, body: WorkspaceBody): Promise current.set(id, body); const resource = await getResource(id); if (resource) await cacheResource(resource, { workspaceMetadata: body }); - const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined }))); + await emit(id, body); if (current.get(id) !== body) return current.get(id) ?? body; - publishWorkspace(id, { t: 'structure', state: body.structure, people }); + void loadAvatars(id, body); return body; } + +/** 先把成员列表发出去(没有头像时结构很小,打开空间立刻可用),头像稍后逐个补上 */ +async function emit(id: string, body: WorkspaceBody): Promise { + const people = await Promise.all(body.people.map(async person => { + const full = person.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined; + return { ...person, avatar: full ? await displayAvatar(full) : undefined }; + })); + if (current.get(id) !== body) return; + publishWorkspace(id, { t: 'structure', state: body.structure, people }); +} +const republishing = new Set(); +async function loadAvatars(id: string, body: WorkspaceBody): Promise { + const missing = body.people.filter(person => person.hasAvatar && cachedAvatar(person.username, person.modifiedAt) === undefined); + if (!missing.length) return; + await Promise.all(missing.map(person => fetchAvatar(person.username, person.modifiedAt))); + const latest = current.get(id); + if (!latest || republishing.has(id)) return; + republishing.add(id); + try { await emit(id, latest); } finally { republishing.delete(id); } +} /** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */ export async function cachedWorkspace(id: string): Promise { return current.get(id) ?? loadWorkspace(id); diff --git a/web/src/core-bridge/workerClient.ts b/web/src/core-bridge/workerClient.ts index d5b120c..7c2a85d 100644 --- a/web/src/core-bridge/workerClient.ts +++ b/web/src/core-bridge/workerClient.ts @@ -8,8 +8,13 @@ let nextId = 1; const pending = new Map(); const listeners = new Map void>>(); +/** 排查用:localStorage 里设 webdrop.softcrypto=1,强制用纯 JS 的加密实现(模拟 http 局域网访问) */ +export function forcedSoftCrypto(): boolean { + try { return localStorage.getItem('webdrop.softcrypto') === '1'; } catch { return false; } +} + function getWorker(): Worker { - worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' }); + worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: forcedSoftCrypto() ? 'webdrop-crypto softcrypto' : 'webdrop-crypto' }); worker.onmessage = (message: MessageEvent) => { const reply = message.data; if ('requestId' in reply) { @@ -70,3 +75,8 @@ export function subscribeWorker(resourceId: string, liste } export type { BridgeEvent }; + +/** 直接给加密线程发消息(不等回复),可以带 MessagePort 之类要转移的对象 */ +export function postToWorker(message: { operation: string; payload: unknown }, transfer: Transferable[]): void { + getWorker().postMessage(message, transfer); +} diff --git a/web/src/features/channel/ChannelSettingsSheet.module.css b/web/src/features/channel/ChannelSettingsSheet.module.css index e7e1c16..712f40c 100644 --- a/web/src/features/channel/ChannelSettingsSheet.module.css +++ b/web/src/features/channel/ChannelSettingsSheet.module.css @@ -180,3 +180,11 @@ display: flex; gap: var(--space-2); } + +.avatarButton { + padding: 0; + border: 0; + border-radius: var(--radius-lg); + background: none; + cursor: zoom-in; +} diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index c109607..f60c1ee 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -3,7 +3,9 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types'; import { Button } from '@/ui/Button'; import { Icon, type IconName } from '@/ui/Icon'; import { Sheet } from '@/ui/Sheet'; -import { AvatarEditor } from '../home/AvatarEditor'; +import { api } from '@/core-bridge/instance'; +import { AvatarPreview } from '../common/AvatarPreview'; +import { AvatarEditor, type AvatarPick } from '../home/AvatarEditor'; import { checkAvatarSource } from '../home/avatarSource'; import { errorMessage } from '@/core-bridge/errors'; import { useToast } from '@/store/toast'; @@ -46,7 +48,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server const [joinPassword, setJoinPassword] = useState(''); const [clearPassword, setClearPassword] = useState(false); const [tab, setTab] = useState('basic'); - const [avatar, setAvatar] = useState(channel.avatar); + // 新选的头像(原图 + 缩略图)、是否移除;没动就保持现有的。界面里默认只有有损缩略图,原图预览时才下载 + const [pick, setPick] = useState(); + const [removed, setRemoved] = useState(false); + const [previewing, setPreviewing] = useState(false); + const avatar = pick ? pick.thumb : removed ? undefined : channel.avatar; const [editing, setEditing] = useState(); const fileInput = useRef(null); const spaceName = name.trim() || channel.name || '空间'; @@ -60,7 +66,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server setSaving(true); setFailed(false); try { - await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, + await onSave({ name: name.trim(), ...(pick ? { avatar: pick.avatar, avatarThumb: pick.thumb } : removed ? { avatar: '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, ...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) }); onClose(); } catch { @@ -76,11 +82,12 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); } } - const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload + const dirty = pick !== undefined || removed || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload || ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword; const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8); return ( + <>{previewing && avatar ? pick?.avatar ?? api.spaceAvatar()} onClose={() => setPreviewing(false)} /> : null}
@@ -88,16 +95,16 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server {item.label})}
- {editing ? setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} /> + {editing ? setEditing(undefined)} onDone={(next) => { setPick(next); setRemoved(false); setEditing(undefined); }} /> :
item.id === tab)?.label}> {tab === 'basic' ? <>
- {avatar ? 空间头像 : } + {avatar ? : }
{ void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
- {avatar && owner ? : null} + {avatar && owner ? : null}
PNG、JPEG、WebP 或 GIF 动图,所有成员可见
@@ -143,6 +150,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server ) :

只有所有者可以修改空间设置。

}
- + ); } diff --git a/web/src/features/channel/ChatMessage.tsx b/web/src/features/channel/ChatMessage.tsx index 1de1a60..33bf0fb 100644 --- a/web/src/features/channel/ChatMessage.tsx +++ b/web/src/features/channel/ChatMessage.tsx @@ -9,6 +9,8 @@ import { TransferProgress } from '@/features/item/TransferProgress'; import { Icon } from '@/ui/Icon'; import { Button } from '@/ui/Button'; import { ContextMenu, type MenuAction } from '@/ui/ContextMenu'; +import { accountApi } from '@/core-bridge/account'; +import { AvatarPreview } from '../common/AvatarPreview'; import { ChatAttachment, hasInlinePreview } from './ChatAttachment'; import { NameWithBadges } from './RoleBadges'; import styles from './ChatPane.module.css'; @@ -24,6 +26,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props const download = useDownload(); const [downloading, setDownloading] = useState(false); const [menu, setMenu] = useState<{ x: number; y: number }>(); + const [previewing, setPreviewing] = useState(false); if (!item) return null; const sender = item.author; const name = senderLabel(item); @@ -50,7 +53,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props return
{compact ? - : sender?.avatar ? {`${name} + : sender?.avatar ? : {Array.from(name)[0] ?? '?'}}
{!compact ?
{name}
: null} @@ -74,6 +77,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props : item.state.kind === 'incomplete' ? 发送未完成,等待续传。 : null}
{owner || self ?
; } diff --git a/web/src/features/channel/MembersPane.module.css b/web/src/features/channel/MembersPane.module.css index 6728c8a..63928a3 100644 --- a/web/src/features/channel/MembersPane.module.css +++ b/web/src/features/channel/MembersPane.module.css @@ -175,3 +175,13 @@ justify-content: flex-end; gap: var(--space-2); } + +.avatarButton { + display: block; + flex: none; + padding: 0; + border: 0; + border-radius: var(--radius-avatar); + background: none; + cursor: zoom-in; +} diff --git a/web/src/features/channel/MembersPane.tsx b/web/src/features/channel/MembersPane.tsx index 2e3cb46..1e12829 100644 --- a/web/src/features/channel/MembersPane.tsx +++ b/web/src/features/channel/MembersPane.tsx @@ -7,14 +7,16 @@ import { useToast } from '@/store/toast'; import { useWorkspace } from '@/store/workspace'; import { permissionsOf } from '@/lib/roles'; import { useAccount } from '@/store/account'; +import { accountApi } from '@/core-bridge/account'; import { Button } from '@/ui/Button'; +import { AvatarPreview } from '../common/AvatarPreview'; import { NameWithBadges } from './RoleBadges'; import styles from './MembersPane.module.css'; interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void } -function Avatar({ person, name }: { person?: SenderInfo; name: string }) { - return person?.avatar ? +function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) { + return person?.avatar ? : ; } @@ -36,6 +38,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { const [members, setMembers] = useState([]); const [pending, setPending] = useState([]); const [error, setError] = useState(''); + const [viewing, setViewing] = useState(); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { @@ -70,7 +73,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { const person = people[username]; const name = person?.displayName || username; return
  • - + setViewing(username)} /> {name} {detail || name !== username ? {[name !== username ? username : '', detail].filter(Boolean).join(' · ')} : null} {isOnline ? : null} @@ -83,7 +86,9 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined, false, removable(item), item.role === 'owner', item.online); + const viewed = viewing ? people[viewing] : undefined; return
    + {viewing && viewed?.avatar ? accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null}
    成员 {members.length}{maxMembers ? `/${maxMembers}` : ''} {owner ? : null} diff --git a/web/src/features/common/AvatarPreview.module.css b/web/src/features/common/AvatarPreview.module.css new file mode 100644 index 0000000..74ff8d4 --- /dev/null +++ b/web/src/features/common/AvatarPreview.module.css @@ -0,0 +1,25 @@ +.stage { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); +} + +.image { + display: block; + width: min(100%, 420px); + max-height: 60dvh; + border-radius: var(--radius-lg); + background: var(--surface-2); + object-fit: contain; +} + +.blur { + image-rendering: auto; +} + +.note { + margin: 0; + color: var(--text-dim); + font-size: 12px; +} diff --git a/web/src/features/common/AvatarPreview.tsx b/web/src/features/common/AvatarPreview.tsx new file mode 100644 index 0000000..27e92a0 --- /dev/null +++ b/web/src/features/common/AvatarPreview.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { Sheet } from '@/ui/Sheet'; +import styles from './AvatarPreview.module.css'; + +interface Props { + title: string; + /** 已经在界面上的有损缩略图:原图下载好之前先显示它 */ + thumb: string; + /** 取原图;没有单独的原图(或取不到)时返回 undefined,继续显示缩略图 */ + load: () => Promise; + onClose: () => void; +} + +/** 头像预览:默认分发的是有损缩略图,点开才下载无损的原图 */ +export function AvatarPreview({ title, thumb, load, onClose }: Props) { + const [full, setFull] = useState(); + const [done, setDone] = useState(false); + useEffect(() => { + let active = true; + void load().then((value) => { if (active) { setFull(value); setDone(true); } }).catch(() => { if (active) setDone(true); }); + return () => { active = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return +
    + {title} + {!done ?

    正在加载原图…

    : full ? null :

    没有更清晰的原图了

    } +
    +
    ; +} diff --git a/web/src/features/home/AccountPanel.module.css b/web/src/features/home/AccountPanel.module.css index e1ec478..bfa2ebe 100644 --- a/web/src/features/home/AccountPanel.module.css +++ b/web/src/features/home/AccountPanel.module.css @@ -214,3 +214,49 @@ } } + +.avatarRow { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.avatarPick { + display: grid; + flex: 0 0 56px; + place-items: center; + width: 56px; + height: 56px; + padding: 0; + overflow: hidden; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-avatar); + background: var(--surface-2); + color: var(--text-dim); + font-size: 20px; + cursor: pointer; +} + +.avatarPick img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatarText { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--text-dim); + font-size: 13px; +} + +.avatarClear { + padding: 0; + border: 0; + background: none; + color: var(--text-dim); + font: inherit; + text-decoration: underline; + cursor: pointer; +} diff --git a/web/src/features/home/AccountPanel.tsx b/web/src/features/home/AccountPanel.tsx index d9d2572..7850417 100644 --- a/web/src/features/home/AccountPanel.tsx +++ b/web/src/features/home/AccountPanel.tsx @@ -7,6 +7,10 @@ import { Button } from '@/ui/Button'; import styles from './AccountPanel.module.css'; import { navigate, channelPath } from '@/app/navigation'; import { passkeysAvailable } from '@/core-bridge/passkeys'; +import { useToast } from '@/store/toast'; +import { errorMessage } from '@/core-bridge/errors'; +import { AvatarEditor, type AvatarPick } from './AvatarEditor'; +import { checkAvatarSource } from './avatarSource'; /** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) { @@ -32,6 +36,12 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a const [remember, setRemember] = useState(true); const [saved, setSaved] = useState(false); const edited = useRef(false); + // 浏览器只在 https 或 localhost 下开放 crypto.subtle;用局域网 IP 走 http 访问时改用纯 JS 的替代实现(见 subtleFallback) + const insecure = typeof window !== 'undefined' && !window.isSecureContext; + // 注册时可以顺便选个头像(可选):在这里裁剪好,账号建好后一起设置 + const [pick, setPick] = useState(); + const [editing, setEditing] = useState(); + const fileInput = useRef(null); useEffect(() => { if (account) return; let active = true; @@ -46,12 +56,17 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a const mismatch = creating && confirm !== '' && password !== confirm; // 与服务端一致:ID 3–32 位,仅字母、数字、_、- const rules = [ - { text: 'ID 为 3–32 个字符', met: username.length >= 3 && username.length <= 32 }, - { text: 'ID 只能包含字母、数字、下划线 _ 和连字符 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) }, - { text: '密码至少 12 个字符', met: password.length >= 12 }, - { text: '两次输入的密码一致', met: confirm !== '' && password === confirm }, + { text: 'ID 3–32 位', met: username.length >= 3 && username.length <= 32 }, + { text: 'ID 仅限字母、数字、_ 和 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) }, + { text: '密码至少 12 位', met: password.length >= 12 }, + { text: '两次密码一致', met: confirm !== '' && password === confirm }, ]; const unmet = creating && rules.some((rule) => !rule.met); + async function chooseAvatar(file?: File) { + if (!file) return; + try { await checkAvatarSource(file); setEditing(file); } + catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); } + } async function clearSaved() { edited.current = true; try { await clearSavedLogin(); setSaved(false); setRemember(false); setUsername(''); setPassword(''); setMessage(account ? '本机保存已清除,当前页面仍可使用;刷新后需要重新登录。' : '已清除本机保存的账号和密码。'); } @@ -64,8 +79,8 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a if (creating && password !== confirm) { setMessage('两次输入的密码不一致'); return; } setMessage(''); try { - if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName); - setPassword(''); setConfirm(''); setDisplayName(''); + if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName, pick); + setPassword(''); setConfirm(''); setDisplayName(''); setPick(undefined); await Promise.all([refreshChannels(), refreshShares()]); setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。'); const pending = useChannels.getState().pendingLink; @@ -83,14 +98,24 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
    {account ? {account} : null}
    - {account ? null : ( + {account ? null : editing ? ( + setEditing(undefined)} onDone={(next) => { setPick(next); setEditing(undefined); }} /> + ) : (
    { void submit(event); }}> {variant === 'auth' && !unlockUsername ?
    : null} + {insecure ?

    当前是 http 连接,使用纯 JS 加密(登录和同步会慢一点)。仅建议在局域网试用,正式使用请用 https。

    : null} {unlockUsername ?

    已用Passkey确认账号,请输入账号密码解锁资源。

    : null} + {creating ?
    + { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} /> + + 头像(可选){pick ? : null} +
    : null} {creating ? : null} {creating ?