diff --git a/packages/core/README.md b/packages/core/README.md index 2b7956a..cdbea8a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -134,7 +134,7 @@ const { complete, nextIndex, meta } = await result; ## 必须注意的几点 -**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 +**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。 **2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。 diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ace17af..6e77968 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -13,7 +13,6 @@ export type AuthRole = 'read' | 'write' | 'owner'; /** 频道设置,字段名与 API 一致(snake_case)。 */ export interface ChannelSettings { join_policy?: 'open' | 'invite'; - allow_guests?: boolean; name?: string; max_members?: number; members_can_upload?: boolean; diff --git a/packages/core/test/e2e.test.ts b/packages/core/test/e2e.test.ts index eaa70a2..d0db17c 100644 --- a/packages/core/test/e2e.test.ts +++ b/packages/core/test/e2e.test.ts @@ -45,7 +45,8 @@ let dataDir = ''; let base = ''; let accountToken = ''; function headersFor(session: ChannelSession, role: AuthRole): Record { - return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) }; + // 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现 + return { ...session.authHeaders(role), 'X-WD-Account': accountToken }; } beforeAll(async () => { @@ -145,7 +146,7 @@ function subscribe(session: ChannelSession, role: AuthRole, until: (events: Item const h = headersFor(session, role); const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`]; if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`); - if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`); + protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`); const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols); ws.binaryType = 'arraybuffer'; @@ -416,7 +417,7 @@ suite('端到端(真实服务端)', () => { const member = await ChannelSession.open(owner.link(ORIGIN)); await joinChannel(member, 'read'); - const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, 'read') } }); + const res = await fetch(api(`/channels/${owner.id}/settings`), { method: 'PATCH', headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/json' }, body: '{}' }); expect(res.status).toBe(403); expect(() => headersFor(member, 'owner')).toThrow(); }); diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 3994671..48a7752 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -68,6 +68,8 @@ export function App() { else if (route.name === 'share' && waitingOnSavedShare) screen = null; else if (route.name === 'share') screen = ; else if (route.name === 'channel' && waitingOnSavedChannel) screen = null; + // 空间只接受登录账号:未登录先登录,链接留在内存里,登录后继续加入 + else if (route.name === 'channel' && !username) screen = ; else if (saved) screen = ; else if (pendingLink?.id === route.id) { screen = ( diff --git a/web/src/core-bridge/contract.test.ts b/web/src/core-bridge/contract.test.ts index 98bf1b4..cdb07da 100644 --- a/web/src/core-bridge/contract.test.ts +++ b/web/src/core-bridge/contract.test.ts @@ -3,9 +3,9 @@ import { supportsApiContract } from './contract'; describe('API contract compatibility', () => { it('accepts compatible SemVer releases in the supported major', () => { - expect(supportsApiContract('7.0.0')).toBe(true); - expect(supportsApiContract('7.8.2')).toBe(true); - expect(supportsApiContract('7.2.0-rc.1')).toBe(true); + expect(supportsApiContract('8.0.0')).toBe(true); + expect(supportsApiContract('8.8.2')).toBe(true); + expect(supportsApiContract('8.2.0-rc.1')).toBe(true); }); it('rejects a breaking major and malformed versions', () => { @@ -13,6 +13,7 @@ describe('API contract compatibility', () => { expect(supportsApiContract('4.0.0')).toBe(false); expect(supportsApiContract('5.0.0')).toBe(false); expect(supportsApiContract('6.1.0')).toBe(false); + expect(supportsApiContract('7.0.0')).toBe(false); expect(supportsApiContract('01.0.0')).toBe(false); expect(supportsApiContract(undefined)).toBe(false); }); diff --git a/web/src/core-bridge/contract.ts b/web/src/core-bridge/contract.ts index 124bf53..b288705 100644 --- a/web/src/core-bridge/contract.ts +++ b/web/src/core-bridge/contract.ts @@ -1,4 +1,4 @@ -export const SUPPORTED_API_CONTRACT_MAJOR = 7; +export const SUPPORTED_API_CONTRACT_MAJOR = 8; const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; diff --git a/web/src/core-bridge/settings.ts b/web/src/core-bridge/settings.ts index c803da6..3f4557b 100644 --- a/web/src/core-bridge/settings.ts +++ b/web/src/core-bridge/settings.ts @@ -4,5 +4,5 @@ import type { CreateOptions } from './types'; export function creationOptions(defaults: DefaultsBody): CreateOptions { const settings = defaults.channel; return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload, - fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests }; + fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy }; } diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index 4b339b1..0ab1844 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -8,7 +8,6 @@ export interface ChannelInfo { dataSource?: 'server' | 'cache'; creatorAccount?: string; joinPolicy?: 'open' | 'invite'; - allowGuests?: boolean; passwordRequired?: boolean; id: string; /** 服务端 channel settings 中的共享名称,加入者可见。 */ @@ -33,7 +32,6 @@ export interface ChannelInfo { /** 新建空间的选项(protocol §12 设置的子集) */ export interface CreateOptions { joinPolicy?: 'open' | 'invite'; - allowGuests?: boolean; joinPassword?: string; name?: string; mode: ChannelMode; @@ -51,7 +49,6 @@ export interface FileUploadOptions { export interface ChannelSettingsPatch { joinPolicy?: 'open' | 'invite'; - allowGuests?: boolean; joinPassword?: string; name?: string; maxMembers?: number; diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts index 7264d69..33135c2 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/core-bridge/worker/channelResources.ts @@ -34,7 +34,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin ttl_seconds: options.ttlSeconds, members_can_upload: options.membersCanUpload, file_max_downloads: options.fileMaxDownloads, - join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests, + join_policy: options.joinPolicy ?? defaults.join_policy, }, }); try { @@ -157,7 +157,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads; if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy; - if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests; if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword; const info = await apiFetch(`/channels/${resourceId}/settings`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, diff --git a/web/src/core-bridge/worker/http.test.ts b/web/src/core-bridge/worker/http.test.ts index a38ff24..d99d460 100644 --- a/web/src/core-bridge/worker/http.test.ts +++ b/web/src/core-bridge/worker/http.test.ts @@ -8,7 +8,7 @@ describe('API contract preflight', () => { }); it('blocks API requests when the server has a newer breaking major', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 })); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '9.0.0' }), { status: 200 })); vi.stubGlobal('fetch', fetchMock); setApiBase('http://api.test/api/v1'); @@ -19,7 +19,7 @@ describe('API contract preflight', () => { it('checks the contract once and then sends the API request', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts index d7a7256..8ab8293 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/core-bridge/worker/wire.ts @@ -8,7 +8,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn return { id: raw.id, creatorAccount: raw.creator_account ?? undefined, - joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required, + joinPolicy, passwordRequired: raw.password_required, name: raw.settings.name ?? undefined, mode: raw.mode, role: raw.can_manage_structure ? 'owner' : 'member', diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index 1915eb4..febc295 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -16,7 +16,7 @@ interface Props { export function ChannelSettingsSheet(props: Props) { const { info, error, load } = useServerSettings(); - if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined) + if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined) return

{error ?? '正在读取空间设置…'}

{error ? : null}
; return ; @@ -29,7 +29,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!); const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!); const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!); - const [allowGuests, setAllowGuests] = useState(channel.allowGuests!); const [joinPassword, setJoinPassword] = useState(''); const [clearPassword, setClearPassword] = useState(false); const [saving, setSaving] = useState(false); @@ -42,7 +41,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server setSaving(true); setFailed(false); try { - await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests, + await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, ...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) }); onClose(); } catch { @@ -87,10 +86,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server -