mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 04:21:40 +08:00
fix(server): sync permission check (#15123)
fix #15121 #### PR Dependency Tree * **PR #15123** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Security Improvements** * Enforced document-level `Doc.Read`/`Doc.Update` checks for key sync websocket operations, including filtering workspace doc timestamp results to only readable documents. * Improved remote permission handling: once a remote denies access, syncing stops for the affected document and retry behavior is suppressed. * **Improvements** * `delete-doc` now relies on server acknowledgment and returns an explicit `{ success: true }`. * Websocket acknowledgment errors are now normalized for consistent error details. * **Tests** * Expanded permission-denied and websocket error-handling coverage, including timestamp filtering and no-retry behavior after permission denial. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -38,6 +38,7 @@ type Job =
|
||||
interface Status {
|
||||
docs: Set<string>;
|
||||
connectedDocs: Set<string>;
|
||||
docErrors: Map<string, string>;
|
||||
jobDocQueue: AsyncPriorityQueue;
|
||||
jobMap: Map<string, Job[]>;
|
||||
remoteClocks: ClockMap;
|
||||
@@ -78,9 +79,12 @@ function createJobErrorCatcher<
|
||||
await fn(docId, ...args);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw new Error(
|
||||
const wrapped = new Error(
|
||||
`Error in job "${k}": ${err.stack || err.message}`
|
||||
);
|
||||
wrapped.name = err.name;
|
||||
(wrapped as Error & { cause?: unknown }).cause = err;
|
||||
throw wrapped;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -91,6 +95,14 @@ function createJobErrorCatcher<
|
||||
) as Jobs;
|
||||
}
|
||||
|
||||
function isRemotePermissionError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const name = error.name.toUpperCase();
|
||||
return name === 'DOC_ACTION_DENIED' || name === 'SPACE_ACCESS_DENIED';
|
||||
}
|
||||
|
||||
function isEqualUint8Arrays(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
@@ -155,6 +167,7 @@ export class DocSyncPeer {
|
||||
private status: Status = {
|
||||
docs: new Set<string>(),
|
||||
connectedDocs: new Set<string>(),
|
||||
docErrors: new Map<string, string>(),
|
||||
jobDocQueue: new AsyncPriorityQueue(),
|
||||
jobMap: new Map(),
|
||||
remoteClocks: new ClockMap(new Map()),
|
||||
@@ -165,6 +178,14 @@ export class DocSyncPeer {
|
||||
};
|
||||
private readonly statusUpdatedSubject$ = new Subject<string | true>();
|
||||
|
||||
private get currentErrorMessage() {
|
||||
return (
|
||||
this.status.errorMessage ??
|
||||
this.status.docErrors.values().next().value ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
peerState$ = new Observable<PeerState>(subscribe => {
|
||||
const next = () => {
|
||||
if (this.status.skipped) {
|
||||
@@ -182,7 +203,7 @@ export class DocSyncPeer {
|
||||
syncing: this.status.docs.size,
|
||||
synced: false,
|
||||
retrying: this.status.retrying,
|
||||
errorMessage: this.status.errorMessage,
|
||||
errorMessage: this.currentErrorMessage,
|
||||
});
|
||||
} else {
|
||||
const syncing = this.status.jobMap.size;
|
||||
@@ -190,8 +211,8 @@ export class DocSyncPeer {
|
||||
total: this.status.docs.size,
|
||||
syncing: syncing,
|
||||
retrying: this.status.retrying,
|
||||
errorMessage: this.status.errorMessage,
|
||||
synced: syncing === 0,
|
||||
errorMessage: this.currentErrorMessage,
|
||||
synced: syncing === 0 && this.status.docErrors.size === 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -211,6 +232,7 @@ export class DocSyncPeer {
|
||||
docState$(docId: string) {
|
||||
return new Observable<PeerDocState>(subscribe => {
|
||||
const next = () => {
|
||||
const docErrorMessage = this.status.docErrors.get(docId) ?? null;
|
||||
if (this.status.skipped) {
|
||||
subscribe.next({
|
||||
syncing: false,
|
||||
@@ -218,14 +240,16 @@ export class DocSyncPeer {
|
||||
retrying: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
subscribe.next({
|
||||
syncing:
|
||||
!this.status.connectedDocs.has(docId) ||
|
||||
this.status.jobMap.has(docId),
|
||||
synced: !this.status.jobMap.has(docId),
|
||||
!docErrorMessage &&
|
||||
(!this.status.connectedDocs.has(docId) ||
|
||||
this.status.jobMap.has(docId)),
|
||||
synced: !docErrorMessage && !this.status.jobMap.has(docId),
|
||||
retrying: this.status.retrying,
|
||||
errorMessage: this.status.errorMessage,
|
||||
errorMessage: docErrorMessage ?? this.status.errorMessage,
|
||||
});
|
||||
};
|
||||
next();
|
||||
@@ -469,6 +493,9 @@ export class DocSyncPeer {
|
||||
|
||||
private readonly actions = {
|
||||
updateRemoteClock: (docId: string, remoteClock: Date) => {
|
||||
if (this.status.docErrors.has(docId)) {
|
||||
return;
|
||||
}
|
||||
this.status.remoteClocks.setIfBigger(docId, remoteClock);
|
||||
this.statusUpdatedSubject$.next(docId);
|
||||
},
|
||||
@@ -494,6 +521,10 @@ export class DocSyncPeer {
|
||||
update: Uint8Array;
|
||||
clock: Date;
|
||||
}) => {
|
||||
if (this.status.docErrors.has(docId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// try add doc for new doc
|
||||
this.actions.addDoc(docId);
|
||||
|
||||
@@ -514,6 +545,10 @@ export class DocSyncPeer {
|
||||
update: Uint8Array;
|
||||
remoteClock: Date;
|
||||
}) => {
|
||||
if (this.status.docErrors.has(docId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// try add doc for new doc
|
||||
this.actions.addDoc(docId);
|
||||
this.actions.updateRemoteClock(docId, remoteClock);
|
||||
@@ -530,33 +565,45 @@ export class DocSyncPeer {
|
||||
|
||||
async mainLoop(signal?: AbortSignal) {
|
||||
while (true) {
|
||||
let shouldRetry = true;
|
||||
try {
|
||||
await this.retryLoop(signal);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
console.warn('Sync error, retry in 5s', err);
|
||||
shouldRetry = !isRemotePermissionError(err);
|
||||
console.warn(
|
||||
shouldRetry
|
||||
? 'Sync error, retry in 5s'
|
||||
: 'Sync stopped due to remote permission error',
|
||||
err
|
||||
);
|
||||
this.status.errorMessage =
|
||||
err instanceof Error ? err.message : `${err}`;
|
||||
this.status.retrying = shouldRetry;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
} finally {
|
||||
// reset all status
|
||||
this.status = {
|
||||
docs: new Set(),
|
||||
connectedDocs: new Set(),
|
||||
docErrors: new Map(),
|
||||
jobDocQueue: new AsyncPriorityQueue(),
|
||||
jobMap: new Map(),
|
||||
remoteClocks: new ClockMap(new Map()),
|
||||
syncing: false,
|
||||
skipped: false,
|
||||
// tell ui to show retrying status
|
||||
retrying: true,
|
||||
retrying: shouldRetry,
|
||||
// error message from last retry
|
||||
errorMessage: this.status.errorMessage,
|
||||
};
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
if (!shouldRetry) {
|
||||
return;
|
||||
}
|
||||
// wait for 5s before next retry
|
||||
await Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
@@ -725,29 +772,53 @@ export class DocSyncPeer {
|
||||
|
||||
const connect = remove(jobs, j => j.type === 'connect');
|
||||
if (connect && connect.length > 0) {
|
||||
await this.jobs.connect(docId, signal);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.connect(docId, signal)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const pullAndPush = remove(jobs, j => j.type === 'pullAndPush');
|
||||
if (pullAndPush && pullAndPush.length > 0) {
|
||||
await this.jobs.pullAndPush(docId, signal);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.pullAndPush(docId, signal)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const pull = remove(jobs, j => j.type === 'pull');
|
||||
if (pull && pull.length > 0) {
|
||||
await this.jobs.pull(docId, signal);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.pull(docId, signal)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const push = remove(jobs, j => j.type === 'push');
|
||||
if (push && push.length > 0) {
|
||||
await this.jobs.push(
|
||||
docId,
|
||||
push as (Job & { type: 'push' })[],
|
||||
signal
|
||||
);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.push(
|
||||
docId,
|
||||
push as (Job & { type: 'push' })[],
|
||||
signal
|
||||
)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -771,7 +842,40 @@ export class DocSyncPeer {
|
||||
}
|
||||
}
|
||||
|
||||
private async runRemoteDocJob(docId: string, job: () => Promise<void>) {
|
||||
try {
|
||||
await job();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isRemotePermissionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('Sync skipped for doc due to remote permission error', {
|
||||
docId,
|
||||
error,
|
||||
});
|
||||
this.status.docErrors.set(docId, message);
|
||||
this.status.connectedDocs.delete(docId);
|
||||
this.status.jobMap.delete(docId);
|
||||
this.statusUpdatedSubject$.next(docId);
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(job: Job) {
|
||||
if (
|
||||
this.status.docErrors.has(job.docId) &&
|
||||
(job.type === 'connect' ||
|
||||
job.type === 'push' ||
|
||||
job.type === 'pull' ||
|
||||
job.type === 'pullAndPush')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = this.prioritySettings.get(job.docId) ?? 0;
|
||||
this.status.jobDocQueue.push(job.docId, priority);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user