feat(server): synthetic root doc (#14794)

This commit is contained in:
DarkSky
2026-04-06 17:16:34 +08:00
committed by GitHub
parent 64149d909a
commit 193ec14ad3
35 changed files with 1784 additions and 409 deletions
@@ -1,9 +1,13 @@
import { UserFriendlyError } from '@affine/error';
import { TimeoutError } from 'rxjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
fetchSharedPublishMode,
getResolvedPublishMode,
getSearchWithMode,
isSharePagePermissionError,
isSharePageTimeoutError,
parsePublishMode,
} from '../desktop/pages/workspace/share/share-page.utils';
@@ -56,7 +60,7 @@ describe('fetchSharedPublishMode', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
new URL(
'/api/workspaces/workspace-id/docs/doc-id',
'/api/workspaces/workspace-id/public-docs/doc-id',
'https://app.affine.pro'
),
expect.objectContaining({ method: 'HEAD' })
@@ -95,3 +99,34 @@ describe('getSearchWithMode', () => {
);
});
});
describe('share page error helpers', () => {
test('recognizes permission errors only', () => {
const permissionError = new UserFriendlyError({
status: 403,
code: 'DOC_ACTION_DENIED',
type: 'DOC_ACTION_DENIED',
name: 'DOC_ACTION_DENIED',
message: 'forbidden',
});
expect(isSharePagePermissionError(permissionError)).toBe(true);
expect(isSharePagePermissionError(new TimeoutError())).toBe(false);
expect(isSharePagePermissionError(new Error('x'))).toBe(false);
});
test('recognizes timeout errors only', () => {
expect(isSharePageTimeoutError(new TimeoutError())).toBe(true);
expect(
isSharePageTimeoutError(
new UserFriendlyError({
status: 403,
code: 'DOC_ACTION_DENIED',
type: 'DOC_ACTION_DENIED',
name: 'DOC_ACTION_DENIED',
message: 'forbidden',
})
)
).toBe(false);
});
});
@@ -32,6 +32,7 @@ import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { filter, firstValueFrom, timeout } from 'rxjs';
import { PageNotFound } from '../../404';
import { ShareFooter } from './share-footer';
@@ -40,9 +41,24 @@ import * as styles from './share-page.css';
import {
fetchSharedPublishMode,
getResolvedPublishMode,
isSharePagePermissionError,
isSharePageTimeoutError,
} from './share-page.utils';
import { useSharedModeQuerySync } from './use-shared-mode-query-sync';
const waitForSharedDocRecord = async (
docsService: DocsService,
docId: string
): Promise<void> => {
if (docsService.list.doc$(docId).value) {
return;
}
await firstValueFrom(
docsService.list.doc$(docId).pipe(filter(Boolean), timeout(3000))
);
};
const useUpdateBasename = (workspace: Workspace | null) => {
const location = useLocation();
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
@@ -131,6 +147,7 @@ const SharePageInner = ({
const [page, setPage] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [noPermission, setNoPermission] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [fetchedPublishMode, setFetchedPublishMode] = useState<
DocMode | null | undefined
>(() => (publishMode === undefined ? undefined : null));
@@ -198,6 +215,7 @@ const SharePageInner = ({
name: 'StaticCloudDocStorage',
opts: {
id: workspaceId,
publicRootDocId: docId,
serverBaseUrl: serverService.server.baseUrl,
},
},
@@ -218,7 +236,10 @@ const SharePageInner = ({
sharedWorkspace.engine.doc
.waitForDocLoaded(sharedWorkspace.id)
.then(async () => {
const { doc } = sharedWorkspace.scope.get(DocsService).open(docId);
const docsService = sharedWorkspace.scope.get(DocsService);
await waitForSharedDocRecord(docsService, docId);
const { doc } = docsService.open(docId);
doc.blockSuiteDoc.load();
doc.blockSuiteDoc.readonly = true;
@@ -241,7 +262,17 @@ const SharePageInner = ({
})
.catch(err => {
console.error(err);
setNoPermission(true);
if (isSharePagePermissionError(err)) {
setNoPermission(true);
return;
}
if (isSharePageTimeoutError(err)) {
setLoadFailed(true);
return;
}
setLoadFailed(true);
});
}, [
docId,
@@ -308,6 +339,10 @@ const SharePageInner = ({
return <PageNotFound noPermission />;
}
if (loadFailed) {
return <PageNotFound />;
}
if (!workspace || !page || !editor || !currentPublishMode) {
return null;
}
@@ -1,4 +1,6 @@
import { UserFriendlyError } from '@affine/error';
import { type DocMode, DocModes } from '@blocksuite/affine/model';
import { TimeoutError } from 'rxjs';
export const getResolvedPublishMode = (
queryMode: DocMode | null,
@@ -35,7 +37,7 @@ export const fetchSharedPublishMode = async ({
signal?: AbortSignal;
}): Promise<DocMode | null> => {
const url = new URL(
`/api/workspaces/${encodeURIComponent(workspaceId)}/docs/${encodeURIComponent(docId)}`,
`/api/workspaces/${encodeURIComponent(workspaceId)}/public-docs/${encodeURIComponent(docId)}`,
serverBaseUrl
);
const headers = {
@@ -71,3 +73,12 @@ export const getSearchWithMode = (search: string, mode: DocMode) => {
const nextSearch = searchParams.toString();
return nextSearch ? `?${nextSearch}` : '';
};
export const isSharePagePermissionError = (error: unknown) =>
error instanceof UserFriendlyError &&
(error.isStatus(403) ||
error.name === 'DOC_ACTION_DENIED' ||
error.name === 'SPACE_ACCESS_DENIED');
export const isSharePageTimeoutError = (error: unknown) =>
error instanceof TimeoutError;
@@ -1,5 +1,6 @@
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { EditorService } from '@affine/core/modules/editor';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { UserFriendlyError } from '@affine/error';
import { PublicDocMode } from '@affine/graphql';
@@ -19,11 +20,13 @@ import * as styles from './styles.css';
export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
const t = useI18n();
const editorService = useService(EditorService);
const shareInfoService = useService(ShareInfoService);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
const isRevalidating = useLiveData(
shareInfoService.shareInfo.isRevalidating$
);
const currentMode = useLiveData(editorService.editor.mode$);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
@@ -63,7 +66,9 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
}
try {
// TODO(@JimmFly): remove mode when we have a better way to handle it
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
await shareInfoService.shareInfo.enableShare(
currentMode === 'edgeless' ? PublicDocMode.Edgeless : PublicDocMode.Page
);
track.$.sharePanel.$.createShareLink();
notify.success({
title:
@@ -84,7 +89,7 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
message: err.message,
});
}
}, [isSharedPage, shareInfoService.shareInfo, t]);
}, [currentMode, isSharedPage, shareInfoService.shareInfo, t]);
return (
<div className={styles.rowContainerStyle}>