feat(web)!: sign in before joining a space and drop the stranger option

Opening a space link while signed out shows the sign-in page first and keeps
the link in memory. The channel settings no longer offer the stranger switch,
and the client supports API contract major 8.
This commit is contained in:
justme committed 2026-10-16 06:46:15 +08:00
1 parent c858bd2a09
commit 8233369cd4
16 files changed
+27 -30

No files matched your search

+1 -1
View File
@@ -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` 重建它,主线程只传链接、帧字节和事件。
-1
View File
@@ -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;
+4 -3
View File
@@ -45,7 +45,8 @@ let dataDir = '';
let base = '';
let accountToken = '';
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
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();
});
+2
View File
@@ -68,6 +68,8 @@ export function App() {
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
// 空间只接受登录账号:未登录先登录,链接留在内存里,登录后继续加入
else if (route.name === 'channel' && !username) screen = <AccountScreen />;
else if (saved) screen = <ChannelScreen id={route.id} />;
else if (pendingLink?.id === route.id) {
screen = (
+4 -3
View File
@@ -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);
});
+1 -1
View File
@@ -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-]+)*)?$/;
+1 -1
View File
@@ -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 };
}
-3
View File
@@ -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;
@@ -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' },
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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',
@@ -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 <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
return <SettingsForm {...props} info={info} />;
@@ -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
<label className={styles.field}><span>加入方式</span>
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
<option value="open">登录后持链接加入</option><option value="invite">仅受邀账号加入</option>
</select>
</label>
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
+2 -2
View File
@@ -1,6 +1,6 @@
import { createContext, useContext } from 'react';
import type { ArchiveEntry, Item } from '@/core-bridge/types';
import { GUEST_LABEL } from '@/lib/sender';
import { UNKNOWN_SENDER_LABEL } from '@/lib/sender';
import { useShareSession } from '@/store/shareSession';
import { useWorkspace } from '@/store/workspace';
@@ -26,7 +26,7 @@ export interface ItemSource {
export const channelSource: ItemSource = {
useItem: (id) => useWorkspace((s) => s.byId[id]),
fallbackSender: GUEST_LABEL,
fallbackSender: UNKNOWN_SENDER_LABEL,
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
listArchive: (id) => useWorkspace.getState().listArchive(id),
actions: {
+1 -1
View File
@@ -48,7 +48,7 @@ export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
</dd>
</dl>
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : preview?.allowGuests === false ? '这个空间需要登录账号后加入。' : '未登录用户将以陌生人设备身份加入。'}符合加入规则后可读取空间历史。</p>
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : '登录账号后即可加入。'}符合加入规则后可读取空间历史。</p>
{preview?.passwordRequired ? <label>空间加入密码<input type="password" autoComplete="off" maxLength={256} value={password} onChange={(event) => setPassword(event.target.value)} /></label> : null}
{phase === 'failed' ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={styles.row}>
+3 -3
View File
@@ -1,8 +1,8 @@
import type { SenderInfo } from '@/core-bridge/types';
/** 没有账号归属的内容(未登录访客)的显示名;设备只是账号的属性,不作为发送者展示 */
export const GUEST_LABEL = '访客';
/** 找不到作者信息时的显示名(频道只有登录账号,正常不会用到);设备只是账号的属性,不作为发送者展示 */
export const UNKNOWN_SENDER_LABEL = '未知成员';
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = GUEST_LABEL): string {
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = UNKNOWN_SENDER_LABEL): string {
return source?.author?.displayName || source?.author?.username || fallback;
}
+1 -1
View File
@@ -25,7 +25,7 @@ createRoot(document.getElementById('root')!).render(
</StrictMode>,
);
// Restore the account scope before loading any account/guest resources.
// Restore the account scope before loading any account resources.
void useAccount.getState().refresh().then(() => Promise.all([
useChannels.getState().hydrate(), useShares.getState().hydrate(),
]));