Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ac11e1d7 | ||
|
|
f2e118c966 | ||
|
|
d77ca1f113 |
No files matched your search
@@ -55,6 +55,9 @@ public static class WebDropApp
|
||||
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
|
||||
options.Limits, options.ShareDraftTtlSeconds));
|
||||
builder.Services.AddHostedService<SweepWorker>();
|
||||
// JSON(如 crypto 状态)与前端静态资源压缩后再传;认证走请求头而非 cookie,开启 HTTPS 压缩不会带来 BREACH 风险。
|
||||
// 默认 MIME 列表不含 text/event-stream 与 octet-stream,日志流和密文不受影响
|
||||
builder.Services.AddResponseCompression(o => o.EnableForHttps = true);
|
||||
|
||||
if (options.CorsOrigins.Length > 0)
|
||||
{
|
||||
@@ -99,6 +102,7 @@ public static class WebDropApp
|
||||
});
|
||||
}
|
||||
|
||||
app.UseResponseCompression();
|
||||
app.Use(SecurityHeaders);
|
||||
app.Use(HandleErrors);
|
||||
if (options.CorsOrigins.Length > 0) app.UseCors();
|
||||
|
||||
@@ -106,14 +106,17 @@ async function syncVault(): Promise<void> {
|
||||
|
||||
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise<AccountSession> {
|
||||
const profile = validateVault(await openJson(remote.vault, key));
|
||||
const deviceSigning = await ensureDeviceSigning(identity.username, identity.token, profile.keys.signing);
|
||||
// 设备签名密钥与个人资料互不依赖,同时请求,冷启动少等一个往返
|
||||
const [deviceSigning, personal] = await Promise.all([
|
||||
ensureDeviceSigning(identity.username, identity.token, profile.keys.signing),
|
||||
loadPersonal(identity.token).catch((error: unknown) => {
|
||||
if (!(error instanceof Error && error.message === '网络连接失败')) throw error;
|
||||
return cachedPersonal;
|
||||
}),
|
||||
]);
|
||||
clearResourceSessions(); setResourceScope(identity.username);
|
||||
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning };
|
||||
try { session.personal = await loadPersonal(identity.token); }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error && error.message === '网络连接失败')) throw error;
|
||||
session.personal = cachedPersonal;
|
||||
}
|
||||
session.personal = personal;
|
||||
account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal);
|
||||
await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes()));
|
||||
setResourceChangeListener(scheduleSync);
|
||||
@@ -135,7 +138,8 @@ export function restoreAccount(): Promise<void> {
|
||||
try {
|
||||
const session = await activate(saved, saved.key, remote, true, saved.personal);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
await syncVault().catch(() => {});
|
||||
// 保险库已在 activate 里并入本机资源,完整同步(约 4 次往返)放到后台,不挡住打开页面
|
||||
void syncVault().catch(() => {});
|
||||
} catch (error) { await logoutAccount(); throw error; }
|
||||
})().catch((error: unknown) => { restoration = undefined; throw error; });
|
||||
return restoration;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { apiFetch } from './http';
|
||||
import { accountPublicKey, accountToken, requireAccount } from './accountSession';
|
||||
import { decryptInvitation, encryptInvitation } from './accountCrypto';
|
||||
import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto';
|
||||
import { loadWorkspace } from './workspaceStructure';
|
||||
import { cachedWorkspace, loadWorkspace } from './workspaceStructure';
|
||||
import { pinSigningRoot } from './accountPersistence';
|
||||
import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning';
|
||||
|
||||
@@ -13,12 +13,17 @@ const objectKeys = new Map<string, CryptoKey>();
|
||||
const states = new Map<string, CryptoState>();
|
||||
const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`;
|
||||
const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`;
|
||||
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); }
|
||||
const loaded = new Map<string, DataKeys>();
|
||||
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); loaded.clear(); }
|
||||
export function attachDataKeys(session: ChannelSession, resource: SavedResource): void {
|
||||
if (resource.kind !== 'channel') return;
|
||||
session.objectKeyResolver = async itemId => {
|
||||
const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached;
|
||||
const { keys } = await readDataKeys(resource, session);
|
||||
// 内存里已有这个对象和它的数据域密钥就直接用;否则才重新拉(每次都拉整份 crypto 状态,历史越长越慢)
|
||||
const { keys } = await cachedKeys(resource, session, data => {
|
||||
const known = data.state.objects.find(o => o.itemId === itemId);
|
||||
return Boolean(known && data.keys[domainId(known.channelId, known.domain, known.epoch)]);
|
||||
});
|
||||
const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId);
|
||||
if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域');
|
||||
const key = keys[domainId(object.channelId, object.domain, object.epoch)];
|
||||
@@ -35,6 +40,11 @@ export function readDataKeys(resource: SavedResource, session: ChannelSession):
|
||||
if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); }
|
||||
return task;
|
||||
}
|
||||
/** 内存里的结果满足 enough 就复用,不满足再请求 */
|
||||
async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> {
|
||||
const have = loaded.get(resource.id);
|
||||
return have && enough(have) ? have : readDataKeys(resource, session);
|
||||
}
|
||||
async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
|
||||
const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise<CryptoState>);
|
||||
states.set(resource.id, state);
|
||||
@@ -49,7 +59,9 @@ async function loadDataKeys(resource: SavedResource, session: ChannelSession): P
|
||||
keys[id] = value.key;
|
||||
}
|
||||
if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys });
|
||||
return { state, keys };
|
||||
const result = { state, keys };
|
||||
loaded.set(resource.id, result);
|
||||
return result;
|
||||
}
|
||||
async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise<DomainEnvelope> {
|
||||
const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise<PublicKeyBody>)).public_key) as JsonWebKey;
|
||||
@@ -102,7 +114,8 @@ export async function initializeChannelKeys(resource: SavedResource, session: Ch
|
||||
export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number }
|
||||
export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise<PreparedObject> {
|
||||
const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes);
|
||||
const { structure } = await loadWorkspace(resource.id); let channel = structure.channels.find(c => c.id === channelId);
|
||||
let channel = (await cachedWorkspace(resource.id)).structure.channels.find(c => c.id === channelId);
|
||||
if (!channel) channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId);
|
||||
if (!channel) throw new Error('请先创建频道');
|
||||
if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) {
|
||||
await initializeChannelKeys(resource, session, channelId);
|
||||
@@ -111,7 +124,7 @@ export async function prepareObject(resource: SavedResource, session: ChannelSes
|
||||
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
|
||||
let keyEnvelope = '';
|
||||
if (resource.mode === 'enc') {
|
||||
const { keys } = await readDataKeys(resource, session); const key = keys[domainId(channelId, domain, epoch)];
|
||||
const { keys } = await cachedKeys(resource, session, data => Boolean(data.keys[domainId(channelId, domain, epoch)])); const key = keys[domainId(channelId, domain, epoch)];
|
||||
if (!key) throw new Error('未获得该频道数据域的访问授权');
|
||||
const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope;
|
||||
objectKeys.set(`${resource.id}:${itemId}`, created.key);
|
||||
|
||||
@@ -21,8 +21,10 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
// Device sessions can be revoked independently of account membership; rejoin idempotently.
|
||||
const joinedAt = freshlyJoined.get(id); freshlyJoined.delete(id);
|
||||
if (joinedAt === undefined || Date.now() - joinedAt > 60_000)
|
||||
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
const join = () => apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
// 已保存的成员重连只是确认身份:与首屏数据同时发出,失败就由下面的重连循环再来一次。首次加入(needsJoin)仍然先 join
|
||||
const joining = joinedAt !== undefined && Date.now() - joinedAt <= 60_000 ? undefined
|
||||
: resource.needsJoin ? await join().then(() => undefined) : join().then(() => undefined);
|
||||
if (resource.needsJoin) {
|
||||
if (session.can('owner')) await channelMetadata(resource, session);
|
||||
resource.needsJoin = false;
|
||||
@@ -49,7 +51,7 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
if (!initialized) {
|
||||
[stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session)]);
|
||||
[stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session), joining]);
|
||||
if (signal.aborted) break;
|
||||
await flushOutbox(resource, session);
|
||||
initialized = true;
|
||||
|
||||
@@ -24,6 +24,10 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
|
||||
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
||||
return body;
|
||||
}
|
||||
/** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */
|
||||
export async function cachedWorkspace(id: string): Promise<WorkspaceBody> {
|
||||
return current.get(id) ?? loadWorkspace(id);
|
||||
}
|
||||
export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
|
||||
|
||||
@@ -24,7 +24,7 @@ interface ChannelsState {
|
||||
const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)];
|
||||
let hydration: Promise<void> | undefined;
|
||||
|
||||
export const useChannels = create<ChannelsState>()((set) => ({
|
||||
export const useChannels = create<ChannelsState>()((set, get) => ({
|
||||
list: [],
|
||||
hydrated: false,
|
||||
pendingLink: null,
|
||||
@@ -41,6 +41,8 @@ export const useChannels = create<ChannelsState>()((set) => ({
|
||||
return c;
|
||||
},
|
||||
hydrate: () => {
|
||||
// 账号刷新已经列过一次,别再请求一遍
|
||||
if (get().hydrated) return Promise.resolve();
|
||||
hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
|
||||
return hydration;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user