diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index 3c5818f56a..2c0417a00a 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -26,6 +26,8 @@ export const AFFINE_PRO_LICENSE_AES_KEY: string | undefined | null export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null +export declare function buildPublicRootDoc(rootDocBin: Buffer, docMetas: Array): Buffer + export interface Chunk { index: number content: string @@ -120,6 +122,11 @@ export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocCon export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise +export interface PublicDocMetaInput { + id: string + title?: string +} + export declare function readAllDocIdsFromRootDoc(docBin: Buffer, includeTrash?: boolean | undefined | null): Array /** diff --git a/packages/backend/native/src/doc.rs b/packages/backend/native/src/doc.rs index 743f25ae60..6dca32cc90 100644 --- a/packages/backend/native/src/doc.rs +++ b/packages/backend/native/src/doc.rs @@ -54,6 +54,12 @@ impl From for NativeWorkspaceDocContent { } } +#[napi(object)] +pub struct PublicDocMetaInput { + pub id: String, + pub title: Option, +} + #[napi(object)] pub struct NativeBlockInfo { pub block_id: String, @@ -254,6 +260,19 @@ pub fn add_doc_to_root_doc(root_doc_bin: Buffer, doc_id: String, title: Option) -> Result { + let metas = doc_metas + .iter() + .map(|meta| (meta.id.as_str(), meta.title.as_deref())) + .collect::>(); + let result = map_napi_err( + doc_parser::build_public_root_doc(&root_doc_bin, &metas), + Status::GenericFailure, + )?; + Ok(Buffer::from(result)) +} + /// Updates a document title in the workspace root doc's meta.pages array. /// /// # Arguments diff --git a/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts b/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts index 31a1529f9b..44ca79ffbb 100644 --- a/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts +++ b/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts @@ -11,6 +11,7 @@ import { JobQueue } from '../../base'; import { ConfigModule } from '../../base/config'; import { AuthService } from '../../core/auth'; import { DocReader } from '../../core/doc'; +import { ContextCategories, DocRole, WorkspaceRole } from '../../models'; import { CopilotContextService } from '../../plugins/copilot/context'; import { CopilotEmbeddingJob, @@ -25,7 +26,7 @@ import { OpenAIProvider, } from '../../plugins/copilot/providers'; import { CopilotStorage } from '../../plugins/copilot/storage'; -import { MockCopilotProvider } from '../mocks'; +import { MockCopilotProvider, Mockers } from '../mocks'; import { acceptInviteById, createTestingApp, @@ -36,6 +37,7 @@ import { TestUser, } from '../utils'; import { + addContextCategory, addContextDoc, addContextFile, array2sse, @@ -60,6 +62,7 @@ import { getPinnedSessions, getWorkspaceSessions, listContext, + listContextCategories, listContextDocAndFiles, matchFiles, matchWorkspaceDocs, @@ -1040,6 +1043,114 @@ test('should be able to manage context', async t => { } }); +test('should reject context reads from another user', async t => { + const { app, context, jobs, u1 } = t.context; + + const u2 = await app.signupV1(); + await app.switchUser(u1); + + const { id: workspaceId } = await createWorkspace(app); + const sessionId = await createCopilotSession( + app, + workspaceId, + randomUUID(), + textPromptName + ); + + Sinon.stub(context, 'embeddingClient').get(() => new MockEmbeddingClient()); + Sinon.stub(jobs, 'embeddingClient').get(() => new MockEmbeddingClient()); + + const contextId = await createCopilotContext(app, workspaceId, sessionId); + await addContextFile(app, contextId, 'sample.txt', Buffer.from('test file')); + + await app.switchUser(u2); + + await t.throwsAsync( + app.gql(` + query { + currentUser { + copilot { + contexts(contextId: "${contextId}") { + id + } + } + } + } + `) + ); + await t.throwsAsync(matchFiles(app, contextId, 'test', 1)); +}); + +test('should skip unauthorized docs when adding context category', async t => { + const { app, context, jobs, u1 } = t.context; + + const member = await app.signupV1(); + await app.switchUser(u1); + + const { id: workspaceId } = await createWorkspace(app); + await app.create(Mockers.WorkspaceUser, { + workspaceId, + userId: member.id, + type: WorkspaceRole.Collaborator, + }); + + const readableSnapshot = await app.create(Mockers.DocSnapshot, { + workspaceId, + user: u1, + }); + const hiddenSnapshot = await app.create(Mockers.DocSnapshot, { + workspaceId, + user: u1, + }); + + await app.create(Mockers.DocMeta, { + workspaceId, + docId: readableSnapshot.id, + title: 'readable-doc', + }); + await app.create(Mockers.DocMeta, { + workspaceId, + docId: hiddenSnapshot.id, + title: 'hidden-doc', + defaultRole: DocRole.None, + }); + + Sinon.stub(context, 'embeddingClient').get(() => new MockEmbeddingClient()); + Sinon.stub(jobs, 'embeddingClient').get(() => new MockEmbeddingClient()); + + await app.switchUser(member); + const sessionId = await createCopilotSession( + app, + workspaceId, + randomUUID(), + textPromptName + ); + const contextId = await createCopilotContext(app, workspaceId, sessionId); + const category = await addContextCategory( + app, + contextId, + ContextCategories.Collection, + 'fav', + [readableSnapshot.id, hiddenSnapshot.id] + ); + + t.deepEqual( + category.docs.map(doc => doc.id), + [readableSnapshot.id] + ); + + const ret = await listContextCategories( + app, + workspaceId, + sessionId, + contextId + ); + t.deepEqual( + ret?.collections?.[0]?.docs.map(doc => doc.id), + [readableSnapshot.id] + ); +}); + test('should be able to transcript', async t => { const { app } = t.context; diff --git a/packages/backend/server/src/__tests__/e2e/doc/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/doc/resolver.spec.ts index 70e1e4ea47..9ef6bd71b2 100644 --- a/packages/backend/server/src/__tests__/e2e/doc/resolver.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/doc/resolver.spec.ts @@ -3,9 +3,11 @@ import { randomUUID } from 'node:crypto'; import { getRecentlyUpdatedDocsQuery, getWorkspacePageByIdQuery, + type GraphQLQuery, publishPageMutation, } from '@affine/graphql'; +import { DocRole, WorkspaceRole } from '../../../models'; import { Mockers } from '../../mocks'; import { app, e2e } from '../test'; @@ -152,3 +154,97 @@ e2e('should get doc with title and null summary', async t => { t.is(result.workspace.doc.title, doc.title); t.is(result.workspace.doc.summary, null); }); + +e2e('should require owner or admin to query workspace docs', async t => { + const owner = await app.signup(); + const member = await app.createUser(); + await app.login(member); + + await app.switchUser(owner); + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + type: WorkspaceRole.Collaborator, + }); + + const docSnapshot = await app.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user: owner, + }); + await app.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId: docSnapshot.id, + title: 'private-doc', + defaultRole: DocRole.None, + }); + + await app.switchUser(member); + await t.throwsAsync( + app.gql({ + query: { + id: 'workspaceDocsPermissionCheck', + op: 'workspaceDocsPermissionCheck', + query: ` + query { + workspace(id: "${workspace.id}") { + docs(pagination: { first: 10 }) { + totalCount + } + } + } + `, + } satisfies GraphQLQuery, + variables: undefined, + }) + ); +}); + +e2e('should require Doc.Read to query workspace page meta', async t => { + const owner = await app.signup(); + const member = await app.createUser(); + await app.login(member); + + await app.switchUser(owner); + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + type: WorkspaceRole.Collaborator, + }); + + const docSnapshot = await app.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user: owner, + }); + const doc = await app.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId: docSnapshot.id, + title: 'private-doc', + defaultRole: DocRole.None, + }); + + await app.switchUser(member); + await t.throwsAsync( + app.gql({ + query: { + id: 'workspacePageMetaPermissionCheck', + op: 'workspacePageMetaPermissionCheck', + query: ` + query { + workspace(id: "${workspace.id}") { + pageMeta(pageId: "${doc.docId}") { + createdAt + } + } + } + `, + } satisfies GraphQLQuery, + variables: undefined, + }) + ); +}); diff --git a/packages/backend/server/src/__tests__/sync/gateway.spec.ts b/packages/backend/server/src/__tests__/sync/gateway.spec.ts index b67a974518..f7a3844178 100644 --- a/packages/backend/server/src/__tests__/sync/gateway.spec.ts +++ b/packages/backend/server/src/__tests__/sync/gateway.spec.ts @@ -4,6 +4,12 @@ import { io, type Socket as SocketIOClient } from 'socket.io-client'; import { Doc, encodeStateAsUpdate } from 'yjs'; import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS } from '../../base'; +import { + DocRole, + Models, + WorkspaceMemberStatus, + WorkspaceRole, +} from '../../models'; import { createTestingApp, TestingApp } from '../utils'; type WebsocketResponse = @@ -25,6 +31,16 @@ function unwrapResponse(t: ExecutionContext, res: WebsocketResponse): T { throw new Error(`Websocket error: ${res.error.name}: ${res.error.message}`); } +function getErrorResponse( + t: ExecutionContext, + res: WebsocketResponse +): { name: string; message: string } { + if ('error' in res) return res.error; + + t.log(res); + throw new Error(`Expected websocket error response, got data instead`); +} + async function withTimeout( promise: Promise, timeoutMs: number, @@ -129,7 +145,7 @@ function expectNoEvent( } async function login(app: TestingApp) { - const user = await app.createUser('u1@affine.pro'); + const user = await app.createUser(); const res = await app .POST('/api/auth/sign-in') .send({ email: user.email, password: user.password }) @@ -519,3 +535,66 @@ test('active users metric should dedupe multiple sockets for one user', async t await Promise.all([waitForDisconnect(first), waitForDisconnect(second)]); } }); + +test('workspace sync delete-doc should enforce doc permissions', async t => { + const db = app.get(PrismaClient); + const models = app.get(Models); + const { user: owner } = await login(app); + const { user: collaborator, cookieHeader } = await login(app); + const workspace = await models.workspace.create(owner.id); + const docId = 'private-doc'; + + await models.workspaceUser.set( + workspace.id, + collaborator.id, + WorkspaceRole.Collaborator, + { + status: WorkspaceMemberStatus.Accepted, + } + ); + await models.doc.setDefaultRole(workspace.id, docId, DocRole.None); + await db.snapshot.create({ + data: { + id: docId, + workspaceId: workspace.id, + blob: Buffer.from([1, 1]), + state: Buffer.from([1, 1]), + createdAt: new Date(), + updatedAt: new Date(), + createdBy: owner.id, + updatedBy: owner.id, + }, + }); + + const socket = createClient(url, cookieHeader); + + try { + await waitForConnect(socket); + + const join = unwrapResponse( + t, + await emitWithAck<{ clientId: string; success: boolean }>( + socket, + 'space:join', + { + spaceType: 'workspace', + spaceId: workspace.id, + clientVersion: '0.26.0', + } + ) + ); + t.true(join.success); + + const error = getErrorResponse( + t, + await emitWithAck(socket, 'space:delete-doc', { + spaceType: 'workspace', + spaceId: workspace.id, + docId, + }) + ); + t.true(error.message.includes('Doc.Delete')); + } finally { + socket.disconnect(); + } +}); diff --git a/packages/backend/server/src/__tests__/utils/copilot.ts b/packages/backend/server/src/__tests__/utils/copilot.ts index 9902aad13c..7ab766ea4c 100644 --- a/packages/backend/server/src/__tests__/utils/copilot.ts +++ b/packages/backend/server/src/__tests__/utils/copilot.ts @@ -1,3 +1,4 @@ +import { ContextCategories } from '../../models'; import { PromptConfig, PromptMessage } from '../../plugins/copilot/providers'; import { NodeExecutorType } from '../../plugins/copilot/workflow/executor'; import { @@ -318,6 +319,33 @@ export async function addContextDoc( return res.addContextDoc; } +export async function addContextCategory( + app: TestingApp, + contextId: string, + type: ContextCategories, + categoryId: string, + docs: string[] +): Promise<{ type: string; id: string; docs: { id: string }[] }> { + const graphqlType = + type === ContextCategories.Collection ? 'Collection' : 'Tag'; + const res = await app.gql( + ` + mutation addContextCategory($options: AddContextCategoryInput!) { + addContextCategory(options: $options) { + type + id + docs { + id + } + } + } + `, + { options: { contextId, type: graphqlType, categoryId, docs } } + ); + + return res.addContextCategory; +} + export async function removeContextDoc( app: TestingApp, contextId: string, @@ -390,6 +418,50 @@ export async function listContextDocAndFiles( return { docs, files }; } +export async function listContextCategories( + app: TestingApp, + workspaceId: string, + sessionId: string, + contextId: string +): Promise< + | { + collections: { + type: string; + id: string; + docs: { + id: string; + status: string; + createdAt: number; + }[]; + }[]; + } + | undefined +> { + const res = await app.gql(` + query { + currentUser { + copilot(workspaceId: "${workspaceId}") { + contexts(sessionId: "${sessionId}", contextId: "${contextId}") { + collections { + type + id + docs { + id + status + createdAt + } + } + } + } + } + } + `); + + const { collections } = res.currentUser?.copilot?.contexts?.[0] || {}; + + return { collections }; +} + export async function submitAudioTranscription( app: TestingApp, workspaceId: string, diff --git a/packages/backend/server/src/__tests__/workspace.e2e.ts b/packages/backend/server/src/__tests__/workspace.e2e.ts index 0ecc7cb2db..703a411b8b 100644 --- a/packages/backend/server/src/__tests__/workspace.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace.e2e.ts @@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; +import { addDocToRootDoc, readAllDocIdsFromRootDoc } from '../native'; import { acceptInviteById, createTestingApp, @@ -58,10 +59,15 @@ test('should be able to publish workspace', async t => { }); test('should visit public page', async t => { - const { app } = t.context; - await app.signupV1('u1@affine.pro'); + const { app, client } = t.context; + const user = await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); + const rootDoc = addDocToRootDoc(Buffer.from([0, 0]), 'doc1', 'doc1'); + await client.snapshot.update({ + where: { workspaceId_id: { workspaceId: workspace.id, id: workspace.id } }, + data: { blob: rootDoc }, + }); const share = await publishDoc(app, workspace.id, 'doc1'); t.is(share.id, 'doc1', 'failed to share doc'); @@ -78,18 +84,32 @@ test('should visit public page', async t => { `/api/workspaces/${workspace.id}/docs/${workspace.id}` ); t.is(resp1.statusCode, 200, 'failed to get root doc with u1 token'); + + await app.logout(); const resp2 = await app.GET( `/api/workspaces/${workspace.id}/docs/${workspace.id}` ); - t.is(resp2.statusCode, 200, 'failed to get root doc with public pages'); + t.is(resp2.statusCode, 403, 'legacy root doc should not be public'); + + const respPublicRoot = await app.GET( + `/api/workspaces/${workspace.id}/public-docs/doc1/root-doc` + ); + t.is( + respPublicRoot.statusCode, + 200, + 'failed to get filtered public root doc' + ); + t.deepEqual(readAllDocIdsFromRootDoc(respPublicRoot.body, false), ['doc1']); const resp3 = await app.GET(`/api/workspaces/${workspace.id}/docs/doc1`); // 404 because we don't put the page doc to server - t.is(resp3.statusCode, 404, 'failed to get shared doc with u1 token'); + t.is(resp3.statusCode, 404, 'failed to get shared doc without snapshot'); const resp4 = await app.GET(`/api/workspaces/${workspace.id}/docs/doc1`); // 404 because we don't put the page doc to server t.is(resp4.statusCode, 404, 'should not get shared doc without token'); + await app.login(user); + const revoke = await revokePublicDoc(app, workspace.id, 'doc1'); t.false(revoke.public, 'failed to revoke doc'); const docs2 = await getWorkspacePublicDocs(app, workspace.id); diff --git a/packages/backend/server/src/__tests__/workspace/controller.spec.ts b/packages/backend/server/src/__tests__/workspace/controller.spec.ts index a68d44bd23..9cc28456b6 100644 --- a/packages/backend/server/src/__tests__/workspace/controller.spec.ts +++ b/packages/backend/server/src/__tests__/workspace/controller.spec.ts @@ -4,10 +4,17 @@ import { HttpStatus } from '@nestjs/common'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; +import supertest from 'supertest'; +import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs'; import { PgWorkspaceDocStorageAdapter } from '../../core/doc'; import { WorkspaceBlobStorage } from '../../core/storage'; import { Models, PublicDocMode, WorkspaceRole } from '../../models'; +import { + addDocToRootDoc, + mergeUpdatesInApplyWay, + readAllDocIdsFromRootDoc, +} from '../../native'; import { createTestingApp, TestingApp, TestUser } from '../utils'; const test = ava as TestFn<{ @@ -217,6 +224,81 @@ test('should be able to get doc', async t => { t.deepEqual(res.body, Buffer.from([0, 0])); }); +test('should not expose legacy root doc for private workspace with public pages', async t => { + const { app } = t.context; + + const res = await app.GET('/api/workspaces/private/docs/private'); + + t.is(res.status, HttpStatus.FORBIDDEN); +}); + +test('should expose filtered public root doc for shared page', async t => { + const { app, workspace: doc } = t.context; + + let root = addDocToRootDoc(Buffer.from([0, 0]), 'public', 'Public Doc'); + const privateUpdate = addDocToRootDoc(root, 'private-page', 'Private Doc'); + root = mergeUpdatesInApplyWay([root, privateUpdate]); + + doc.getDoc.resolves({ + spaceId: 'private', + docId: 'private', + bin: root, + timestamp: Date.now(), + }); + + const res = await app.GET( + '/api/workspaces/private/public-docs/public/root-doc' + ); + + t.is(res.status, HttpStatus.OK); + const body = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body); + t.deepEqual(readAllDocIdsFromRootDoc(body, false), ['public']); + + const ydoc = new YDoc({ guid: 'private' }); + t.notThrows(() => + applyUpdate( + ydoc, + new Uint8Array(body.buffer, body.byteOffset, body.byteLength) + ) + ); + const pages = (ydoc.getMap('meta') as YMap).get('pages') as + | { toArray: () => Array<{ get: (key: string) => unknown }> } + | undefined; + t.deepEqual( + pages?.toArray().map(page => page.get('id')), + ['public'] + ); +}); + +test('should expose public doc publish mode through HEAD route', async t => { + const { app } = t.context; + + const res = await supertest(app.getHttpServer()).head( + '/api/workspaces/private/public-docs/public' + ); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('publish-mode'), 'page'); +}); + +test('should expose public doc binary through public route', async t => { + const { app, workspace: doc } = t.context; + + doc.getDoc.resolves({ + spaceId: 'private', + docId: 'public', + bin: Buffer.from([1, 2, 3]), + timestamp: Date.now(), + }); + + const res = await app.GET('/api/workspaces/private/public-docs/public'); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('content-type'), 'application/octet-stream'); + t.is(res.get('publish-mode'), 'page'); + t.deepEqual(res.body, Buffer.from([1, 2, 3])); +}); + test('should record doc view when reading doc', async t => { const { app, workspace: doc, models } = t.context; diff --git a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts index b76fd06894..b206481be0 100644 --- a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts +++ b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { User, Workspace } from '@prisma/client'; -import ava, { TestFn } from 'ava'; +import ava, { ExecutionContext, TestFn } from 'ava'; import Sinon from 'sinon'; import { Doc as YDoc } from 'yjs'; @@ -11,12 +11,14 @@ import { Flavor } from '../../../env'; import { Models } from '../../../models'; import { DocReader, PgWorkspaceDocStorageAdapter } from '../../doc'; -const test = ava as TestFn<{ +interface Context { models: Models; app: TestingApp; adapter: PgWorkspaceDocStorageAdapter; docReader: DocReader; -}>; +} + +const test = ava as TestFn; test.before(async t => { // @ts-expect-error testing @@ -49,10 +51,11 @@ test.after.always(async t => { await t.context.app.close(); }); -test('should render page success', async t => { +async function createDoc( + adapter: PgWorkspaceDocStorageAdapter, + content: string +) { const docId = randomUUID(); - const { app, adapter, models } = t.context; - const doc = new YDoc(); const text = doc.getText('content'); const updates: Buffer[] = []; @@ -61,11 +64,15 @@ test('should render page success', async t => { updates.push(Buffer.from(update)); }); - text.insert(0, 'hello'); - text.insert(5, 'world'); - text.insert(5, ' '); - + text.insert(0, content); await adapter.pushDocUpdates(workspace.id, docId, updates, user.id); + return docId; +} + +test('should render page success', async t => { + const { app, adapter, models } = t.context; + const docId = await createDoc(adapter, 'hello world'); + await models.doc.publish(workspace.id, docId); await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200); @@ -73,19 +80,8 @@ test('should render page success', async t => { }); test('should record page view when rendering shared page', async t => { - const docId = randomUUID(); const { app, adapter, models, docReader } = t.context; - - const doc = new YDoc(); - const text = doc.getText('content'); - const updates: Buffer[] = []; - - doc.on('update', update => { - updates.push(Buffer.from(update)); - }); - - text.insert(0, 'analytics'); - await adapter.pushDocUpdates(workspace.id, docId, updates, user.id); + const docId = await createDoc(adapter, 'analytics'); await models.doc.publish(workspace.id, docId); const docContent = Sinon.stub(docReader, 'getDocContent').resolves({ @@ -110,46 +106,124 @@ test('should record page view when rendering shared page', async t => { record.restore(); }); -test('should return markdown content and skip page view when accept is text/markdown', async t => { - const docId = randomUUID(); - const { app, adapter, models, docReader } = t.context; +const policyCases: Array<{ + title: string; + content: string; + expectedStatus: number; + setup: ( + models: Models, + docId: string, + docReader: DocReader + ) => Promise<{ + markdown?: Sinon.SinonStub; + docContent?: Sinon.SinonStub; + record?: Sinon.SinonStub; + }>; + request: (app: TestingApp, docId: string) => ReturnType; + assert: ( + t: ExecutionContext, + res: Awaited>, + stubs: { + markdown?: Sinon.SinonStub; + docContent?: Sinon.SinonStub; + record?: Sinon.SinonStub; + }, + docId: string + ) => void; +}> = [ + { + title: + 'should return markdown content and skip page view when accept is text/markdown', + content: 'markdown', + expectedStatus: 200, + setup: async (models, docId, docReader) => { + await models.doc.publish(workspace.id, docId); + return { + markdown: Sinon.stub(docReader, 'getDocMarkdown').resolves({ + title: 'markdown-doc', + markdown: '# markdown-doc', + knownUnsupportedBlocks: [], + unknownBlocks: [], + }), + docContent: Sinon.stub(docReader, 'getDocContent'), + record: Sinon.stub( + models.workspaceAnalytics, + 'recordDocView' + ).resolves(), + }; + }, + request: (app, docId) => + app + .GET(`/workspace/${workspace.id}/${docId}`) + .set('accept', 'text/markdown'), + assert: (t, res, stubs, docId) => { + t.true(stubs.markdown?.calledOnceWithExactly(workspace.id, docId, false)); + t.is(res.text, '# markdown-doc'); + t.true( + (res.headers['content-type'] as string).startsWith('text/markdown') + ); + t.true(stubs.docContent?.notCalled); + t.true(stubs.record?.notCalled); + }, + }, + { + title: + 'should not return markdown for private page even if workspace preview is enabled', + content: 'private markdown', + expectedStatus: 404, + setup: async (models, _docId, docReader) => { + await models.workspace.update(workspace.id, { + enableUrlPreview: true, + }); + return { + markdown: Sinon.stub(docReader, 'getDocMarkdown'), + }; + }, + request: (app, docId) => + app + .GET(`/workspace/${workspace.id}/${docId}`) + .set('accept', 'text/markdown'), + assert: (t, _res, stubs) => { + t.true(stubs.markdown?.notCalled); + }, + }, + { + title: 'should not render shared page when workspace sharing is disabled', + content: 'shared but disabled', + expectedStatus: 200, + setup: async (models, docId, docReader) => { + await models.doc.publish(workspace.id, docId); + await models.workspace.update(workspace.id, { + enableSharing: false, + enableUrlPreview: true, + }); + return { + docContent: Sinon.stub(docReader, 'getDocContent'), + }; + }, + request: (app, docId) => app.GET(`/workspace/${workspace.id}/${docId}`), + assert: (t, res, stubs) => { + t.true(stubs.docContent?.notCalled); + t.is(res.headers['x-robots-tag'], 'noindex'); + }, + }, +]; - const doc = new YDoc(); - const text = doc.getText('content'); - const updates: Buffer[] = []; +for (const policyCase of policyCases) { + test(policyCase.title, async t => { + const { app, adapter, models, docReader } = t.context; + const docId = await createDoc(adapter, policyCase.content); + const stubs = await policyCase.setup(models, docId, docReader); - doc.on('update', update => { - updates.push(Buffer.from(update)); + try { + const res = await policyCase + .request(app, docId) + .expect(policyCase.expectedStatus); + policyCase.assert(t, res, stubs, docId); + } finally { + stubs.markdown?.restore(); + stubs.docContent?.restore(); + stubs.record?.restore(); + } }); - - text.insert(0, 'markdown'); - await adapter.pushDocUpdates(workspace.id, docId, updates, user.id); - await models.doc.publish(workspace.id, docId); - - const markdown = Sinon.stub(docReader, 'getDocMarkdown').resolves({ - title: 'markdown-doc', - markdown: '# markdown-doc', - knownUnsupportedBlocks: [], - unknownBlocks: [], - }); - const docContent = Sinon.stub(docReader, 'getDocContent'); - const record = Sinon.stub( - models.workspaceAnalytics, - 'recordDocView' - ).resolves(); - - const res = await app - .GET(`/workspace/${workspace.id}/${docId}`) - .set('accept', 'text/markdown') - .expect(200); - - t.true(markdown.calledOnceWithExactly(workspace.id, docId, false)); - t.is(res.text, '# markdown-doc'); - t.true((res.headers['content-type'] as string).startsWith('text/markdown')); - t.true(docContent.notCalled); - t.true(record.notCalled); - - markdown.restore(); - docContent.restore(); - record.restore(); -}); +} diff --git a/packages/backend/server/src/core/doc-renderer/controller.ts b/packages/backend/server/src/core/doc-renderer/controller.ts index 662e83c86d..69f0da2bac 100644 --- a/packages/backend/server/src/core/doc-renderer/controller.ts +++ b/packages/backend/server/src/core/doc-renderer/controller.ts @@ -11,6 +11,7 @@ import { Models } from '../../models'; import { htmlSanitize } from '../../native'; import { Public } from '../auth'; import { DocReader } from '../doc'; +import { WorkspacePolicyService } from '../permission'; interface RenderOptions { title: string; @@ -59,7 +60,8 @@ export class DocRendererController { constructor( private readonly doc: DocReader, private readonly models: Models, - private readonly config: Config + private readonly config: Config, + private readonly policy: WorkspacePolicyService ) { this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static')); this.mobileAssets = this.readHtmlAssets( @@ -74,21 +76,6 @@ export class DocRendererController { .digest('hex'); } - private async allowDocPreview(workspaceId: string, docId: string) { - const allowSharing = await this.models.workspace.allowSharing(workspaceId); - if (!allowSharing) return false; - - let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId); - - if (!allowUrlPreview) { - // if page is private, but workspace url preview is on - allowUrlPreview = - await this.models.workspace.allowUrlPreview(workspaceId); - } - - return allowUrlPreview; - } - @Public() @Get('/*path') async render(@Req() req: Request, @Res() res: Response) { @@ -112,8 +99,11 @@ export class DocRendererController { req.accepts().some(t => markdownType.has(t.toLowerCase())) ) { try { - const allowPreview = await this.allowDocPreview(workspaceId, sub); - if (!allowPreview) { + const canReadMarkdown = await this.policy.canReadSharedDoc( + workspaceId, + sub + ); + if (!canReadMarkdown) { res.status(404).end(); return; } @@ -172,7 +162,7 @@ export class DocRendererController { workspaceId: string, docId: string ): Promise { - if (await this.allowDocPreview(workspaceId, docId)) { + if (await this.policy.canPreviewDoc(workspaceId, docId)) { return this.doc.getDocContent(workspaceId, docId); } @@ -182,24 +172,18 @@ export class DocRendererController { private async getWorkspaceContent( workspaceId: string ): Promise { - const allowSharing = await this.models.workspace.allowSharing(workspaceId); - if (!allowSharing) { - return null; - } + const canPreviewWorkspace = + await this.policy.canPreviewWorkspace(workspaceId); + if (!canPreviewWorkspace) return null; - const allowUrlPreview = - await this.models.workspace.allowUrlPreview(workspaceId); + const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); - if (allowUrlPreview) { - const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); - - if (workspaceContent) { - return { - title: workspaceContent.name, - summary: '', - avatar: workspaceContent.avatarUrl, - }; - } + if (workspaceContent) { + return { + title: workspaceContent.name, + summary: '', + avatar: workspaceContent.avatarUrl, + }; } return null; diff --git a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts index 21a592e3cb..1b7a682361 100644 --- a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts @@ -21,6 +21,7 @@ let ac: DocAccessController; let policy: WorkspacePolicyService; let user: User; let ws: Workspace; +let underReviewUserId: string; test.before(async () => { module = await createTestingModule({ imports: [PermissionModule] }); @@ -39,111 +40,145 @@ test.after.always(async () => { await module.close(); }); -test('should get null role', async t => { - const role = await ac.getRole({ - workspaceId: 'ws1', - docId: 'doc1', - userId: 'u1', +const roleCases: Array<{ + title: string; + setup?: () => Promise; + resource: () => { + workspaceId: string; + docId: string; + userId: string; + allowLocal?: boolean; + }; + expectedRole: DocRole | null; +}> = [ + { + title: 'should get null role', + resource: () => ({ + workspaceId: 'ws1', + docId: 'doc1', + userId: 'u1', + }), + expectedRole: null, + }, + { + title: 'should return null if workspace role is not accepted', + setup: async () => { + const u2 = await models.user.create({ + email: `${randomUUID()}@affine.pro`, + }); + underReviewUserId = u2.id; + await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, { + status: WorkspaceMemberStatus.UnderReview, + }); + }, + resource: () => ({ + workspaceId: ws.id, + docId: 'doc1', + userId: underReviewUserId, + }), + expectedRole: null, + }, + { + title: + 'should return [Owner] role if workspace is not found but local is allowed', + resource: () => ({ + workspaceId: 'ws1', + docId: 'doc1', + userId: 'u1', + allowLocal: true, + }), + expectedRole: DocRole.Owner, + }, + { + title: 'should fallback to [External] if workspace is public', + setup: async () => { + await models.workspace.update(ws.id, { + public: true, + }); + }, + resource: () => ({ + workspaceId: ws.id, + docId: 'doc1', + userId: 'random-user-id', + }), + expectedRole: DocRole.External, + }, + { + title: 'should return null even if workspace has other public doc', + setup: async () => { + await models.doc.publish(ws.id, 'doc1'); + }, + resource: () => ({ + workspaceId: ws.id, + docId: 'doc2', + userId: 'random-user-id', + }), + expectedRole: null, + }, + { + title: 'should return [External] if doc is public', + setup: async () => { + await models.doc.publish(ws.id, 'doc1'); + }, + resource: () => ({ + workspaceId: ws.id, + docId: 'doc1', + userId: 'random-user-id', + }), + expectedRole: DocRole.External, + }, + { + title: 'should return null if doc role is [None]', + setup: async () => { + await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None); + await models.workspaceUser.set( + ws.id, + user.id, + WorkspaceRole.Collaborator, + { + status: WorkspaceMemberStatus.Accepted, + } + ); + }, + resource: () => ({ + workspaceId: ws.id, + docId: 'doc1', + userId: user.id, + }), + expectedRole: null, + }, + { + title: 'should return [External] if doc role is [None] but doc is public', + setup: async () => { + await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None); + await models.workspaceUser.set( + ws.id, + user.id, + WorkspaceRole.Collaborator, + { + status: WorkspaceMemberStatus.Accepted, + } + ); + await models.doc.publish(ws.id, 'doc1'); + }, + resource: () => ({ + workspaceId: ws.id, + docId: 'doc1', + userId: 'random-user-id', + }), + expectedRole: DocRole.External, + }, +]; + +for (const roleCase of roleCases) { + test(roleCase.title, async t => { + await roleCase.setup?.(); + const resource = roleCase.resource(); + const role = await ac.getRole(resource); + + t.is(role, roleCase.expectedRole); }); - - t.is(role, null); -}); - -test('should return null if workspace role is not accepted', async t => { - const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` }); - await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, { - status: WorkspaceMemberStatus.UnderReview, - }); - - const role = await ac.getRole({ - workspaceId: ws.id, - docId: 'doc1', - userId: u2.id, - }); - - t.is(role, null); -}); - -test('should return [Owner] role if workspace is not found but local is allowed', async t => { - const role = await ac.getRole({ - workspaceId: 'ws1', - docId: 'doc1', - userId: 'u1', - allowLocal: true, - }); - - t.is(role, DocRole.Owner); -}); - -test('should fallback to [External] if workspace is public', async t => { - await models.workspace.update(ws.id, { - public: true, - }); - - const role = await ac.getRole({ - workspaceId: ws.id, - docId: 'doc1', - userId: 'random-user-id', - }); - - t.is(role, DocRole.External); -}); - -test('should return null even if workspace has other public doc', async t => { - await models.doc.publish(ws.id, 'doc1'); - - const role = await ac.getRole({ - workspaceId: ws.id, - docId: 'doc2', - userId: 'random-user-id', - }); - - t.is(role, null); -}); - -test('should return [External] if doc is public', async t => { - await models.doc.publish(ws.id, 'doc1'); - - const role = await ac.getRole({ - workspaceId: ws.id, - docId: 'doc1', - userId: 'random-user-id', - }); - - t.is(role, DocRole.External); -}); - -test('should return null if doc role is [None]', async t => { - await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None); - - await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, { - status: WorkspaceMemberStatus.Accepted, - }); - - const role = await ac.getRole({ - workspaceId: ws.id, - docId: 'doc1', - userId: user.id, - }); - - t.is(role, null); -}); - -test('should return [External] if doc role is [None] but doc is public', async t => { - await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None); - await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, { - status: WorkspaceMemberStatus.Accepted, - }); - await models.doc.publish(ws.id, 'doc1'); - - const role = await ac.getRole({ - workspaceId: ws.id, - docId: 'doc1', - userId: 'random-user-id', - }); - - t.is(role, DocRole.External); -}); +} test('should return mapped permissions', async t => { const { permissions } = await ac.role({ @@ -155,6 +190,66 @@ test('should return mapped permissions', async t => { t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner)); }); +test('should deny publish permission when workspace sharing is disabled', async t => { + await models.workspace.update(ws.id, { + enableSharing: false, + }); + + const { permissions } = await ac.role({ + workspaceId: ws.id, + docId: 'doc1', + userId: user.id, + }); + + t.false(permissions['Doc.Publish']); + t.true(permissions['Doc.Read']); +}); + +test('should deny publish assert when workspace sharing is disabled', async t => { + await models.workspace.update(ws.id, { + enableSharing: false, + }); + + await t.throwsAsync( + ac.assert( + { + workspaceId: ws.id, + docId: 'doc1', + userId: user.id, + }, + 'Doc.Publish' + ) + ); + await t.notThrowsAsync( + ac.assert( + { + workspaceId: ws.id, + docId: 'doc1', + userId: user.id, + }, + 'Doc.Read' + ) + ); +}); + +test('should deny external read assert when sharing is disabled even if doc is public', async t => { + await models.doc.publish(ws.id, 'doc1'); + await models.workspace.update(ws.id, { + enableSharing: false, + }); + + await t.throwsAsync( + ac.assert( + { + workspaceId: ws.id, + docId: 'doc1', + userId: 'random-user-id', + }, + 'Doc.Read' + ) + ); +}); + test('should assert action', async t => { await t.notThrowsAsync( ac.assert( diff --git a/packages/backend/server/src/core/permission/__tests__/policy.spec.ts b/packages/backend/server/src/core/permission/__tests__/policy.spec.ts index 9f713900b3..c6262bea32 100644 --- a/packages/backend/server/src/core/permission/__tests__/policy.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/policy.spec.ts @@ -7,7 +7,11 @@ import { createTestingModule, type TestingModule, } from '../../../__tests__/utils'; -import { SpaceAccessDenied } from '../../../base'; +import { + DocActionDenied, + OwnerCanNotLeaveWorkspace, + SpaceAccessDenied, +} from '../../../base'; import { Models, User, @@ -145,6 +149,49 @@ test('should deny blob uploads when user no longer has write access', async t => ); }); +test('should deny publish through policy when workspace sharing is disabled', async t => { + await t.context.models.workspace.update(workspace.id, { + enableSharing: false, + }); + + await t.throwsAsync( + t.context.policy.assertCanPublishDoc(owner.id, workspace.id, 'doc1'), + { instanceOf: DocActionDenied } + ); + await t.notThrowsAsync( + t.context.policy.assertCanUnpublishDoc(owner.id, workspace.id, 'doc1') + ); +}); + +test('should allow managers to revoke invite links in readonly workspace', async t => { + await addAcceptedMembers(t.context.models, workspace.id, 10); + await t.context.policy.reconcileWorkspaceQuotaState(workspace.id); + + await t.notThrowsAsync( + t.context.policy.assertCanManageInviteLink(owner.id, workspace.id) + ); +}); + +test('should apply leave workspace policy by role', async t => { + const collaborator = await t.context.models.user.create({ + email: `${randomUUID()}@affine.pro`, + }); + await t.context.models.workspaceUser.set( + workspace.id, + collaborator.id, + WorkspaceRole.Collaborator, + { status: WorkspaceMemberStatus.Accepted } + ); + + await t.throwsAsync( + t.context.policy.assertCanLeaveWorkspace(owner.id, workspace.id), + { instanceOf: OwnerCanNotLeaveWorkspace } + ); + await t.notThrowsAsync( + t.context.policy.assertCanLeaveWorkspace(collaborator.id, workspace.id) + ); +}); + test('should enter readonly mode when fallback owner storage quota overflows', async t => { const quota = Sinon.stub( Reflect.get(t.context.policy, 'quota') as QuotaService, diff --git a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts index 002520d87d..d6d65bd949 100644 --- a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts @@ -21,6 +21,7 @@ let ac: WorkspaceAccessController; let policy: WorkspacePolicyService; let user: User; let ws: Workspace; +let underReviewUserId: string; test.before(async () => { module = await createTestingModule({ imports: [PermissionModule] }); @@ -39,90 +40,114 @@ test.after.always(async () => { await module.close(); }); -test('should get null role', async t => { - const role = await ac.getRole({ - workspaceId: 'ws1', - userId: 'u1', +const roleCases: Array<{ + title: string; + setup?: () => Promise; + resource: () => { + workspaceId: string; + userId: string; + allowLocal?: boolean; + }; + expectedRole: WorkspaceRole | null; +}> = [ + { + title: 'should get null role', + resource: () => ({ + workspaceId: 'ws1', + userId: 'u1', + }), + expectedRole: null, + }, + { + title: 'should return null if role is not accepted', + setup: async () => { + const u2 = await models.user.create({ + email: `${randomUUID()}@affine.pro`, + }); + underReviewUserId = u2.id; + await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, { + status: WorkspaceMemberStatus.UnderReview, + }); + }, + resource: () => ({ + workspaceId: ws.id, + userId: underReviewUserId, + }), + expectedRole: null, + }, + { + title: + 'should return [Owner] role if workspace is not found but local is allowed', + resource: () => ({ + workspaceId: 'ws1', + userId: 'u1', + allowLocal: true, + }), + expectedRole: WorkspaceRole.Owner, + }, + { + title: 'should fallback to [External] if workspace is public', + setup: async () => { + await models.workspace.update(ws.id, { + public: true, + }); + }, + resource: () => ({ + workspaceId: ws.id, + userId: 'random-user-id', + }), + expectedRole: WorkspaceRole.External, + }, + { + title: 'should return null if workspace is public but sharing disabled', + setup: async () => { + await models.workspace.update(ws.id, { + public: true, + enableSharing: false, + }); + }, + resource: () => ({ + workspaceId: ws.id, + userId: 'random-user-id', + }), + expectedRole: null, + }, + { + title: 'should return null even workspace has public doc', + setup: async () => { + await models.doc.publish(ws.id, 'doc1'); + }, + resource: () => ({ + workspaceId: ws.id, + userId: 'random-user-id', + }), + expectedRole: null, + }, + { + title: + 'should return null even workspace has public doc when sharing disabled', + setup: async () => { + await models.doc.publish(ws.id, 'doc1'); + await models.workspace.update(ws.id, { enableSharing: false }); + }, + resource: () => ({ + workspaceId: ws.id, + userId: 'random-user-id', + }), + expectedRole: null, + }, +]; + +for (const roleCase of roleCases) { + test(roleCase.title, async t => { + await roleCase.setup?.(); + const role = await ac.getRole(roleCase.resource()); + + t.is(role, roleCase.expectedRole); }); +} - t.is(role, null); -}); - -test('should return null if role is not accepted', async t => { - const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` }); - await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, { - status: WorkspaceMemberStatus.UnderReview, - }); - - const role = await ac.getRole({ - workspaceId: ws.id, - userId: u2.id, - }); - - t.is(role, null); -}); - -test('should return [Owner] role if workspace is not found but local is allowed', async t => { - const role = await ac.getRole({ - workspaceId: 'ws1', - userId: 'u1', - allowLocal: true, - }); - - t.is(role, WorkspaceRole.Owner); -}); - -test('should fallback to [External] if workspace is public', async t => { - await models.workspace.update(ws.id, { - public: true, - }); - - const role = await ac.getRole({ - workspaceId: ws.id, - userId: 'random-user-id', - }); - - t.is(role, WorkspaceRole.External); -}); - -test('should return null if workspace is public but sharing disabled', async t => { - await models.workspace.update(ws.id, { - public: true, - enableSharing: false, - }); - - const role = await ac.getRole({ - workspaceId: ws.id, - userId: 'random-user-id', - }); - - t.is(role, null); -}); - -test('should return null even workspace has public doc', async t => { - await models.doc.publish(ws.id, 'doc1'); - - const role = await ac.getRole({ - workspaceId: ws.id, - userId: 'random-user-id', - }); - - t.is(role, null); -}); - -test('should return null even workspace has public doc when sharing disabled', async t => { - await models.doc.publish(ws.id, 'doc1'); - await models.workspace.update(ws.id, { enableSharing: false }); - - const role = await ac.getRole({ - workspaceId: ws.id, - userId: 'random-user-id', - }); - - t.is(role, null); -}); - -test('should return mapped external permission for workspace has public docs', async t => { +test('should return mapped null permission even workspace has public docs', async t => { await models.doc.publish(ws.id, 'doc1'); const { permissions } = await ac.role({ @@ -130,9 +155,35 @@ test('should return mapped external permission for workspace has public docs', a userId: 'random-user-id', }); - t.deepEqual( - permissions, - mapWorkspaceRoleToPermissions(WorkspaceRole.External) + t.deepEqual(permissions, mapWorkspaceRoleToPermissions(null)); +}); + +test('should deny external read assert even workspace has public docs', async t => { + await models.doc.publish(ws.id, 'doc1'); + + await t.throwsAsync( + ac.assert( + { + workspaceId: ws.id, + userId: 'random-user-id', + }, + 'Workspace.Read' + ) + ); +}); + +test('should deny external read assert when sharing disabled even if workspace has public docs', async t => { + await models.doc.publish(ws.id, 'doc1'); + await models.workspace.update(ws.id, { enableSharing: false }); + + await t.throwsAsync( + ac.assert( + { + workspaceId: ws.id, + userId: 'random-user-id', + }, + 'Workspace.Read' + ) ); }); diff --git a/packages/backend/server/src/core/permission/doc.ts b/packages/backend/server/src/core/permission/doc.ts index 579cc613ab..011a1bb0be 100644 --- a/packages/backend/server/src/core/permission/doc.ts +++ b/packages/backend/server/src/core/permission/doc.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DocActionDenied } from '../../base'; -import { Models } from '../../models'; import { AccessController, getAccessController } from './controller'; import { WorkspacePolicyService } from './policy'; import type { Resource } from './resource'; @@ -16,10 +15,7 @@ import { WorkspaceAccessController } from './workspace'; @Injectable() export class DocAccessController extends AccessController<'doc'> { protected readonly type = 'doc'; - constructor( - private readonly models: Models, - private readonly policy: WorkspacePolicyService - ) { + constructor(private readonly policy: WorkspacePolicyService) { super(); } @@ -29,7 +25,7 @@ export class DocAccessController extends AccessController<'doc'> { resource.workspaceId, mapDocRoleToPermissions(role) ); - const sharingAllowed = await this.models.workspace.allowSharing( + const sharingAllowed = await this.policy.canPublishDoc( resource.workspaceId ); if (!sharingAllowed) { diff --git a/packages/backend/server/src/core/permission/policy.ts b/packages/backend/server/src/core/permission/policy.ts index faeb534d5c..b2b05c302d 100644 --- a/packages/backend/server/src/core/permission/policy.ts +++ b/packages/backend/server/src/core/permission/policy.ts @@ -1,7 +1,12 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; -import { DocActionDenied, OnEvent, SpaceAccessDenied } from '../../base'; +import { + DocActionDenied, + OnEvent, + OwnerCanNotLeaveWorkspace, + SpaceAccessDenied, +} from '../../base'; import { Models, WorkspaceRole } from '../../models'; import { QuotaService } from '../quota/service'; import { getAccessController } from './controller'; @@ -164,6 +169,57 @@ export class WorkspacePolicyService { return true; } + async isSharingEnabled(workspaceId: string) { + return await this.models.workspace.allowSharing(workspaceId); + } + + async canReadWorkspaceByPublicFlag(workspaceId: string) { + const workspace = await this.models.workspace.get(workspaceId); + return !!workspace?.public && (workspace.enableSharing ?? true); + } + + async canReadWorkspaceBySharedDocs(workspaceId: string) { + const [sharingEnabled, hasPublicDocs] = await Promise.all([ + this.isSharingEnabled(workspaceId), + this.models.doc.hasPublic(workspaceId), + ]); + + return sharingEnabled && hasPublicDocs; + } + + async canReadSharedDoc(workspaceId: string, docId: string) { + const [sharingEnabled, isPublicDoc] = await Promise.all([ + this.isSharingEnabled(workspaceId), + this.models.doc.isPublic(workspaceId, docId), + ]); + + return sharingEnabled && isPublicDoc; + } + + async canPreviewDoc(workspaceId: string, docId: string) { + const [sharingEnabled, canReadSharedDoc, allowUrlPreview] = + await Promise.all([ + this.isSharingEnabled(workspaceId), + this.canReadSharedDoc(workspaceId, docId), + this.models.workspace.allowUrlPreview(workspaceId), + ]); + + return sharingEnabled && (canReadSharedDoc || allowUrlPreview); + } + + async canPreviewWorkspace(workspaceId: string) { + const [sharingEnabled, allowUrlPreview] = await Promise.all([ + this.isSharingEnabled(workspaceId), + this.models.workspace.allowUrlPreview(workspaceId), + ]); + + return sharingEnabled && allowUrlPreview; + } + + async canPublishDoc(workspaceId: string) { + return await this.isSharingEnabled(workspaceId); + } + async applyWorkspacePermissions( workspaceId: string, permissions: WorkspaceActionPermissions @@ -299,10 +355,6 @@ export class WorkspacePolicyService { ); } - async assertCanPublishDoc(workspaceId: string, docId: string) { - await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish'); - } - @Transactional() async handleTeamPlanCanceled(workspaceId: string) { await this.models.workspaceUser.deleteNonAccepted(workspaceId); @@ -319,6 +371,43 @@ export class WorkspacePolicyService { await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish'); } + async assertCanPublishDoc( + userId: string, + workspaceId: string, + docId: string + ) { + await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish'); + await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish'); + + if (!(await this.canPublishDoc(workspaceId))) { + throw new DocActionDenied({ + action: 'Doc.Publish', + docId, + spaceId: workspaceId, + }); + } + } + + async assertCanManageInviteLink(userId: string, workspaceId: string) { + await this.assertWorkspaceRoleAction( + userId, + workspaceId, + 'Workspace.Users.Manage' + ); + } + + async assertCanLeaveWorkspace(userId: string, workspaceId: string) { + const role = await this.models.workspaceUser.getActive(workspaceId, userId); + + if (!role) { + throw new SpaceAccessDenied({ spaceId: workspaceId }); + } + + if (role.type === WorkspaceRole.Owner) { + throw new OwnerCanNotLeaveWorkspace(); + } + } + @OnEvent('workspace.members.updated') async onWorkspaceMembersUpdated({ workspaceId, diff --git a/packages/backend/server/src/core/permission/workspace.ts b/packages/backend/server/src/core/permission/workspace.ts index e0cf50b0ff..b89c59293c 100644 --- a/packages/backend/server/src/core/permission/workspace.ts +++ b/packages/backend/server/src/core/permission/workspace.ts @@ -26,18 +26,7 @@ export class WorkspaceAccessController extends AccessController<'ws'> { } async role(resource: Resource<'ws'>) { - let role = await this.getRole(resource); - - // NOTE(@forehalo): special case for public page - // Currently, we can not only load binary of a public Doc to render in a shared page, - // so we need to ensure anyone has basic 'read' permission to a workspace that has public pages. - if ( - !role && - (await this.models.workspace.allowSharing(resource.workspaceId)) && - (await this.models.doc.hasPublic(resource.workspaceId)) - ) { - role = WorkspaceRole.External; - } + const role = await this.getRole(resource); return { role, @@ -103,7 +92,7 @@ export class WorkspaceAccessController extends AccessController<'ws'> { } const workspaceRole = await this.getRole(payload); - const sharingAllowed = await this.models.workspace.allowSharing( + const sharingAllowed = await this.policy.isSharingEnabled( payload.workspaceId ); if ( @@ -210,7 +199,9 @@ export class WorkspaceAccessController extends AccessController<'ws'> { } if (ws.public) { - const sharingAllowed = await this.models.workspace.allowSharing(ws.id); + const sharingAllowed = await this.policy.canReadWorkspaceByPublicFlag( + ws.id + ); return sharingAllowed ? WorkspaceRole.External : null; } diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index 5bc14effae..52619d6604 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -40,7 +40,11 @@ import { PgWorkspaceDocStorageAdapter, } from '../doc'; import { applyUpdatesWithNative } from '../doc/merge-updates'; -import { AccessController, WorkspaceAction } from '../permission'; +import { + AccessController, + type DocAction, + WorkspaceAction, +} from '../permission'; import { DocID } from '../utils/doc'; const SubscribeMessage = (event: string) => @@ -304,6 +308,23 @@ export class SpaceSyncGateway setImmediate(() => client.disconnect()); } + private async assertDocActionAllowed( + spaceType: SpaceType, + userId: string, + spaceId: string, + docId: string, + action: DocAction + ) { + if (spaceType === SpaceType.Userspace) { + if (spaceId !== userId) { + throw new SpaceAccessDenied({ spaceId }); + } + return; + } + + await this.ac.user(userId).doc(spaceId, docId).assert(action); + } + handleConnection(client: Socket) { this.connectionCount++; this.logger.debug(`New connection, total: ${this.connectionCount}`); @@ -607,9 +628,17 @@ export class SpaceSyncGateway @SubscribeMessage('space:delete-doc') async onDeleteSpaceDoc( @ConnectedSocket() client: Socket, + @CurrentUser() user: CurrentUser, @MessageBody() { spaceType, spaceId, docId }: DeleteDocMessage ) { const adapter = this.selectAdapter(client, spaceType); + await this.assertDocActionAllowed( + spaceType, + user.id, + spaceId, + docId, + 'Doc.Delete' + ); await adapter.delete(spaceId, docId); } diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index 78c92bb045..be8938c35c 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { Controller, Get, + Head, Logger, Param, Query, @@ -16,16 +17,19 @@ import { BlobNotFound, CallMetric, CommentAttachmentNotFound, + DocActionDenied, DocHistoryNotFound, DocNotFound, getRequestTrackerId, InvalidHistoryTimestamp, + SpaceAccessDenied, } from '../../base'; import { DocMode, Models, PublicDocMode } from '../../models'; +import { buildPublicRootDoc } from '../../native'; import { CurrentUser, Public } from '../auth'; import { PgWorkspaceDocStorageAdapter } from '../doc'; import { DocReader } from '../doc/reader'; -import { AccessController } from '../permission'; +import { AccessController, WorkspacePolicyService } from '../permission'; import { CommentAttachmentStorage, WorkspaceBlobStorage } from '../storage'; import { DocID } from '../utils/doc'; @@ -36,6 +40,7 @@ export class WorkspacesController { private readonly storage: WorkspaceBlobStorage, private readonly commentAttachmentStorage: CommentAttachmentStorage, private readonly ac: AccessController, + private readonly workspacePolicy: WorkspacePolicyService, private readonly workspace: PgWorkspaceDocStorageAdapter, private readonly docReader: DocReader, private readonly models: Models @@ -48,6 +53,48 @@ export class WorkspacesController { .digest('hex'); } + private async assertCanReadPublicDoc( + userId: string, + workspaceId: string, + docId: string + ) { + const canReadSharedDoc = await this.ac + .user(userId) + .doc(workspaceId, docId) + .can('Doc.Read'); + if (!canReadSharedDoc) { + throw new DocActionDenied({ + docId, + spaceId: workspaceId, + action: 'Doc.Read', + }); + } + } + + private async getPublishModeHeader(workspaceId: string, docId: string) { + const docMeta = await this.models.doc.getMeta(workspaceId, docId, { + select: { + mode: true, + }, + }); + return docMeta?.mode === PublicDocMode.Edgeless + ? DocMode.edgeless + : DocMode.page; + } + + private async getDocBinaryOrThrow(workspaceId: string, docId: string) { + const binResponse = await this.docReader.getDoc(workspaceId, docId); + + if (!binResponse) { + throw new DocNotFound({ + spaceId: workspaceId, + docId, + }); + } + + return binResponse; + } + // get workspace blob // // NOTE: because graphql can't represent a File, so we have to use REST API to get blob @@ -61,10 +108,15 @@ export class WorkspacesController { @Query('redirect') redirect: string | undefined, @Res() res: Response ) { - await this.ac + const canReadWorkspace = await this.ac .user(user?.id ?? 'anonymous') .workspace(workspaceId) - .assert('Workspace.Read'); + .can('Workspace.Read'); + const canReadSharedWorkspaceBlobs = + await this.workspacePolicy.canReadWorkspaceBySharedDocs(workspaceId); + if (!canReadWorkspace && !canReadSharedWorkspaceBlobs) { + throw new SpaceAccessDenied({ spaceId: workspaceId }); + } const { body, metadata, redirectUrl } = await this.storage.get( workspaceId, name, @@ -186,6 +238,94 @@ export class WorkspacesController { res.send(binResponse.bin); } + @Public() + @Head('/:id/public-docs/:docId') + @CallMetric('controllers', 'workspace_head_public_doc') + async headPublicDoc( + @CurrentUser() user: CurrentUser | undefined, + @Param('id') workspaceId: string, + @Param('docId') docId: string, + @Res() res: Response + ) { + await this.assertCanReadPublicDoc( + user?.id ?? 'anonymous', + workspaceId, + docId + ); + const publishPageMode = await this.getPublishModeHeader(workspaceId, docId); + + res.setHeader('publish-mode', publishPageMode); + res.status(200).end(); + } + + @Public() + @Get('/:id/public-docs/:docId') + @CallMetric('controllers', 'workspace_get_public_doc') + async publicDoc( + @CurrentUser() user: CurrentUser | undefined, + @Req() req: Request, + @Param('id') workspaceId: string, + @Param('docId') docId: string, + @Res() res: Response + ) { + await this.assertCanReadPublicDoc( + user?.id ?? 'anonymous', + workspaceId, + docId + ); + + const binResponse = await this.getDocBinaryOrThrow(workspaceId, docId); + + void this.models.workspaceAnalytics + .recordDocView({ + workspaceId, + docId, + userId: user?.id, + visitorId: this.buildVisitorId(req, workspaceId, docId), + isGuest: !user, + }) + .catch(error => { + this.logger.warn( + `Failed to record doc view: ${workspaceId}/${docId}`, + error as Error + ); + }); + + res.setHeader( + 'publish-mode', + await this.getPublishModeHeader(workspaceId, docId) + ); + res.setHeader('content-type', 'application/octet-stream'); + res.send(binResponse.bin); + } + + @Public() + @Get('/:id/public-docs/:docId/root-doc') + @CallMetric('controllers', 'workspace_get_public_root_doc') + async publicRootDoc( + @CurrentUser() user: CurrentUser | undefined, + @Param('id') workspaceId: string, + @Param('docId') docId: string, + @Res() res: Response + ) { + await this.assertCanReadPublicDoc( + user?.id ?? 'anonymous', + workspaceId, + docId + ); + + const rootDoc = await this.getDocBinaryOrThrow(workspaceId, workspaceId); + + const publicDocs = await this.models.doc.findPublics(workspaceId); + const publicRootDoc = buildPublicRootDoc( + Buffer.from(rootDoc.bin), + publicDocs.map(doc => ({ id: doc.docId, title: doc.title ?? undefined })) + ); + + res.setHeader('content-type', 'application/octet-stream'); + res.send(publicRootDoc); + } + @Get('/:id/docs/:guid/histories/:timestamp') @CallMetric('controllers', 'workspace_get_history') async history( diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index e16b4e204e..17b0abd009 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -307,9 +307,12 @@ export class WorkspaceDocResolver { deprecationReason: 'use [WorkspaceType.doc] instead', }) async pageMeta( + @CurrentUser() me: CurrentUser, @Parent() workspace: WorkspaceType, @Args('pageId') pageId: string ) { + await this.ac.user(me.id).doc(workspace.id, pageId).assert('Doc.Read'); + const metadata = await this.models.doc.getAuthors(workspace.id, pageId); if (!metadata) { throw new DocNotFound({ spaceId: workspace.id, docId: pageId }); @@ -333,9 +336,15 @@ export class WorkspaceDocResolver { @ResolveField(() => PaginatedDocType) async docs( + @CurrentUser() me: CurrentUser, @Parent() workspace: WorkspaceType, @Args('pagination', PaginationInput.decode) pagination: PaginationInput ): Promise { + await this.ac + .user(me.id) + .workspace(workspace.id) + .assert('Workspace.Users.Manage'); + const [count, rows] = await this.models.doc.paginateDocInfo( workspace.id, pagination @@ -414,7 +423,7 @@ export class WorkspaceDocResolver { throw new ExpectToPublishDoc(); } - await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); + await this.policy.assertCanPublishDoc(user.id, workspaceId, docId); const doc = await this.models.doc.publish(workspaceId, docId, mode); diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index 4d26083e8f..0e7916eda6 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -25,10 +25,8 @@ import { mapAnyError, MemberNotFoundInSpace, NoMoreSeat, - OwnerCanNotLeaveWorkspace, QueryTooLong, RequestMutex, - SpaceAccessDenied, Throttle, TooManyRequest, URLHelper, @@ -309,11 +307,7 @@ export class WorkspaceMemberResolver { @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { - await this.policy.assertWorkspaceRoleAction( - user.id, - workspaceId, - 'Workspace.Users.Manage' - ); + await this.policy.assertCanManageInviteLink(user.id, workspaceId); const cacheId = `workspace:inviteLink:${workspaceId}`; return await this.cache.delete(cacheId); @@ -560,17 +554,7 @@ export class WorkspaceMemberResolver { }) _workspaceName?: string ) { - const role = await this.models.workspaceUser.getActive( - workspaceId, - user.id - ); - if (!role) { - throw new SpaceAccessDenied({ spaceId: workspaceId }); - } - - if (role.type === WorkspaceRole.Owner) { - throw new OwnerCanNotLeaveWorkspace(); - } + await this.policy.assertCanLeaveWorkspace(user.id, workspaceId); await this.models.workspaceUser.delete(workspaceId, user.id); this.event.emit('workspace.members.leave', { diff --git a/packages/backend/server/src/models/copilot-context.ts b/packages/backend/server/src/models/copilot-context.ts index 9b0f3ce0cc..4678bbdc95 100644 --- a/packages/backend/server/src/models/copilot-context.ts +++ b/packages/backend/server/src/models/copilot-context.ts @@ -59,6 +59,22 @@ export class CopilotContextModel extends BaseModel { return row; } + async getAccessInfo(id: string) { + return await this.db.aiContext.findFirst({ + where: { id }, + select: { + id: true, + sessionId: true, + session: { + select: { + userId: true, + workspaceId: true, + }, + }, + }, + }); + } + async getConfig(id: string) { const row = await this.get(id); if (row) { diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index 0565c191bf..ce65aee6d3 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -55,6 +55,7 @@ export const AFFINE_PRO_LICENSE_AES_KEY = export const createDocWithMarkdown = serverNativeModule.createDocWithMarkdown; export const updateDocWithMarkdown = serverNativeModule.updateDocWithMarkdown; export const addDocToRootDoc = serverNativeModule.addDocToRootDoc; +export const buildPublicRootDoc = serverNativeModule.buildPublicRootDoc; export const updateDocTitle = serverNativeModule.updateDocTitle; export const updateDocProperties = serverNativeModule.updateDocProperties; export const updateRootDocMetaTitle = serverNativeModule.updateRootDocMetaTitle; diff --git a/packages/backend/server/src/plugins/copilot/context/resolver.ts b/packages/backend/server/src/plugins/copilot/context/resolver.ts index 0fbcb3996b..93d9618209 100644 --- a/packages/backend/server/src/plugins/copilot/context/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/context/resolver.ts @@ -59,6 +59,30 @@ import { CopilotStorage } from '../storage'; import { getSignal, MAX_EMBEDDABLE_SIZE, readStream } from '../utils'; import { CopilotContextService } from './service'; +async function assertAccess( + ac: AccessController, + userId: string, + workspaceId: string +) { + await ac + .user(userId) + .workspace(workspaceId) + .allowLocal() + .assert('Workspace.Copilot'); +} + +async function getSession( + context: CopilotContextService, + ac: AccessController, + userId: string, + contextId: string, + options: { workspaceId?: string; sessionId?: string } = {} +) { + const session = await context.getOwnedContext(userId, contextId, options); + await assertAccess(ac, userId, session.workspaceId); + return session; +} + @InputType() class AddContextCategoryInput { @Field(() => String) @@ -310,8 +334,12 @@ export class CopilotContextRootResolver { } if (contextId) { - const context = await this.context.get(contextId); - if (context) return [context]; + return [ + await getSession(this.context, this.ac, user.id, contextId, { + sessionId, + workspaceId: copilot.workspaceId || undefined, + }), + ]; } else if (sessionId) { await this.checkChatSession( user, @@ -515,6 +543,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_category_add') async addContextCategory( + @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => AddContextCategoryInput }) options: AddContextCategoryInput ): Promise { @@ -523,18 +552,38 @@ export class CopilotContextResolver { if (!lock) { throw new TooManyRequest('Server is busy'); } - const session = await this.context.get(options.contextId); + const session = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { + const docs = options.docs?.length + ? ( + await Promise.all( + options.docs.map(async docId => + (await this.ac + .user(user.id) + .doc(session.workspaceId, docId) + .can('Doc.Read')) + ? docId + : null + ) + ) + ).filter((docId): docId is string => !!docId) + : []; + const records = await session.addCategoryRecord( options.type, options.categoryId, - options.docs || [] + docs ); - if (options.docs) { + if (docs.length) { await this.jobs.addDocEmbeddingQueue( - options.docs.map(docId => ({ + docs.map(docId => ({ workspaceId: session.workspaceId, docId, })), @@ -556,6 +605,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_category_remove') async removeContextCategory( + @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => RemoveContextCategoryInput }) options: RemoveContextCategoryInput ): Promise { @@ -564,7 +614,12 @@ export class CopilotContextResolver { if (!lock) { throw new TooManyRequest('Server is busy'); } - const session = await this.context.get(options.contextId); + const session = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { return await session.removeCategoryRecord( @@ -584,6 +639,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_doc_add') async addContextDoc( + @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => AddContextDocInput }) options: AddContextDocInput ): Promise { @@ -592,9 +648,18 @@ export class CopilotContextResolver { if (!lock) { throw new TooManyRequest('Server is busy'); } - const session = await this.context.get(options.contextId); + const session = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { + await this.ac + .user(user.id) + .doc(session.workspaceId, options.docId) + .assert('Doc.Read'); const record = await session.addDocRecord(options.docId); await this.jobs.addDocEmbeddingQueue( @@ -616,6 +681,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_doc_remove') async removeContextDoc( + @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => RemoveContextDocInput }) options: RemoveContextDocInput ): Promise { @@ -624,7 +690,12 @@ export class CopilotContextResolver { if (!lock) { throw new TooManyRequest('Server is busy'); } - const session = await this.context.get(options.contextId); + const session = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { return await session.removeDocRecord(options.docId); @@ -664,18 +735,13 @@ export class CopilotContextResolver { throw new BlobQuotaExceeded(); } - const session = await this.context.get(contextId); + const session = await getSession(this.context, this.ac, user.id, contextId); try { const buffer = await readStream(content.createReadStream()); const blobId = createHash('sha256').update(buffer).digest('base64url'); const { filename, mimetype } = content; - await this.ac - .user(user.id) - .workspace(session.workspaceId) - .allowLocal() - .assert('Workspace.Copilot'); await this.policy.assertCanUploadBlob(user.id, session.workspaceId); await this.storage.put(user.id, session.workspaceId, blobId, buffer); const file = await session.addFile( @@ -708,6 +774,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_file_remove') async removeContextFile( + @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => RemoveContextFileInput }) options: RemoveContextFileInput ): Promise { @@ -720,7 +787,12 @@ export class CopilotContextResolver { if (!lock) { throw new TooManyRequest('Server is busy'); } - const session = await this.context.get(options.contextId); + const session = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { return await session.removeFile(options.fileId); @@ -751,13 +823,12 @@ export class CopilotContextResolver { throw new TooManyRequest('Server is busy'); } - const contextSession = await this.context.get(options.contextId); - - await this.ac - .user(user.id) - .workspace(contextSession.workspaceId) - .allowLocal() - .assert('Workspace.Copilot'); + const contextSession = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { const blob = await contextSession.addBlobRecord(options.blobId); @@ -791,6 +862,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_blob_remove') async removeContextBlob( + @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => RemoveContextBlobInput }) options: RemoveContextBlobInput ): Promise { @@ -804,7 +876,12 @@ export class CopilotContextResolver { throw new TooManyRequest('Server is busy'); } - const contextSession = await this.context.get(options.contextId); + const contextSession = await getSession( + this.context, + this.ac, + user.id, + options.contextId + ); try { return await contextSession.removeBlobRecord(options.blobId); @@ -821,6 +898,7 @@ export class CopilotContextResolver { }) @CallMetric('ai', 'context_file_remove') async matchFiles( + @CurrentUser() user: CurrentUser, @Context() ctx: { req: Request }, @Parent() context: CopilotContextType, @Args('content') content: string, @@ -837,6 +915,7 @@ export class CopilotContextResolver { try { if (!context.id) { + await assertAccess(this.ac, user.id, context.workspaceId); return await this.context.matchWorkspaceFiles( context.workspaceId, content, @@ -846,7 +925,13 @@ export class CopilotContextResolver { ); } - const session = await this.context.get(context.id); + const session = await getSession( + this.context, + this.ac, + user.id, + context.id, + { workspaceId: context.workspaceId } + ); return await session.matchFiles( content, limit, @@ -899,11 +984,7 @@ export class CopilotContextResolver { } try { - await this.ac - .user(user.id) - .workspace(context.workspaceId) - .allowLocal() - .assert('Workspace.Copilot'); + await assertAccess(this.ac, user.id, context.workspaceId); const allowEmbedding = await this.models.workspace.allowEmbedding( context.workspaceId ); @@ -921,15 +1002,13 @@ export class CopilotContextResolver { ); } - const session = await this.context.get(context.id); - if (session.workspaceId !== context.workspaceId) { - throw new CopilotFailedToMatchContext({ - contextId: context.id, - // don't record the large content - content: content.slice(0, 512), - message: 'context not in the same workspace', - }); - } + const session = await getSession( + this.context, + this.ac, + user.id, + context.id, + { workspaceId: context.workspaceId } + ); const chunks = await session.matchWorkspaceDocs( content, limit, diff --git a/packages/backend/server/src/plugins/copilot/context/service.ts b/packages/backend/server/src/plugins/copilot/context/service.ts index be65d7963e..904af3df8f 100644 --- a/packages/backend/server/src/plugins/copilot/context/service.ts +++ b/packages/backend/server/src/plugins/copilot/context/service.ts @@ -141,6 +141,26 @@ export class CopilotContextService implements OnApplicationBootstrap { throw new CopilotInvalidContext({ contextId: id }); } + async getOwnedContext( + userId: string, + contextId: string, + options: { workspaceId?: string; sessionId?: string } = {} + ): Promise { + const accessInfo = + await this.models.copilotContext.getAccessInfo(contextId); + if ( + !accessInfo || + accessInfo.session.userId !== userId || + (options.workspaceId && + accessInfo.session.workspaceId !== options.workspaceId) || + (options.sessionId && accessInfo.sessionId !== options.sessionId) + ) { + throw new CopilotInvalidContext({ contextId }); + } + + return await this.get(contextId); + } + async getBySessionId(sessionId: string): Promise { const existsContext = await this.models.copilotContext.getBySessionId(sessionId); diff --git a/packages/backend/server/src/plugins/copilot/workspace/resolver.ts b/packages/backend/server/src/plugins/copilot/workspace/resolver.ts index 765b13af75..936d931386 100644 --- a/packages/backend/server/src/plugins/copilot/workspace/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/workspace/resolver.ts @@ -24,10 +24,7 @@ import { UserFriendlyError, } from '../../../base'; import { CurrentUser } from '../../../core/auth'; -import { - AccessController, - WorkspacePolicyService, -} from '../../../core/permission'; +import { AccessController } from '../../../core/permission'; import { WorkspaceType } from '../../../core/workspaces'; import { COPILOT_LOCKER } from '../resolver'; import { MAX_EMBEDDABLE_SIZE } from '../utils'; @@ -75,7 +72,6 @@ export class CopilotWorkspaceEmbeddingConfigResolver { constructor( private readonly ac: AccessController, private readonly mutex: Mutex, - private readonly policy: WorkspacePolicyService, private readonly copilotWorkspace: CopilotWorkspaceService ) {} @@ -219,11 +215,10 @@ export class CopilotWorkspaceEmbeddingConfigResolver { @Args('fileId', { type: () => String }) fileId: string ): Promise { - await this.policy.assertWorkspaceRoleAction( - user.id, - workspaceId, - 'Workspace.Settings.Update' - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Settings.Update'); return await this.copilotWorkspace.removeFile(workspaceId, fileId); } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 8cd7b1461e..7536fd2122 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -695,7 +695,7 @@ type CopilotWorkspaceIgnoredDocTypeEdge { } input CreateChatMessageInput { - attachments: [String!] + attachments: [String!] @deprecated(reason: "use blobs") blob: Upload blobs: [Upload!] content: String @@ -718,7 +718,7 @@ input CreateChatSessionInput { input CreateCheckoutSessionInput { args: JSONObject coupon: String - idempotencyKey: String + idempotencyKey: String @deprecated(reason: "not required anymore") plan: SubscriptionPlan = Pro recurring: SubscriptionRecurring = Yearly successCallbackLink: String! diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 5f5c18978f..8f36015cf1 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -832,6 +832,7 @@ export interface CopilotWorkspaceIgnoredDocTypeEdge { } export interface CreateChatMessageInput { + /** @deprecated use blobs */ attachments?: InputMaybe>; blob?: InputMaybe; blobs?: InputMaybe>; @@ -853,6 +854,7 @@ export interface CreateChatSessionInput { export interface CreateCheckoutSessionInput { args?: InputMaybe; coupon?: InputMaybe; + /** @deprecated not required anymore */ idempotencyKey?: InputMaybe; plan?: InputMaybe; recurring?: InputMaybe; diff --git a/packages/common/native/src/doc_parser/mod.rs b/packages/common/native/src/doc_parser/mod.rs index 3e03ef3cc6..df2b0a2e51 100644 --- a/packages/common/native/src/doc_parser/mod.rs +++ b/packages/common/native/src/doc_parser/mod.rs @@ -17,5 +17,6 @@ pub use read::{ parse_doc_from_binary, parse_doc_to_markdown, parse_page_doc, parse_workspace_doc, }; pub use write::{ - add_doc_to_root_doc, build_full_doc, update_doc, update_doc_properties, update_doc_title, update_root_doc_meta_title, + add_doc_to_root_doc, build_full_doc, build_public_root_doc, update_doc, update_doc_properties, update_doc_title, + update_root_doc_meta_title, }; diff --git a/packages/common/native/src/doc_parser/write/mod.rs b/packages/common/native/src/doc_parser/write/mod.rs index c631d53a04..17fe2ae00f 100644 --- a/packages/common/native/src/doc_parser/write/mod.rs +++ b/packages/common/native/src/doc_parser/write/mod.rs @@ -8,7 +8,7 @@ mod update; pub use create::build_full_doc; pub use doc_meta::{update_doc_title, update_root_doc_meta_title}; pub use doc_properties::update_doc_properties; -pub use root_doc::add_doc_to_root_doc; +pub use root_doc::{add_doc_to_root_doc, build_public_root_doc}; pub use update::update_doc; use y_octo::{Any, Doc, Map, Value}; diff --git a/packages/common/native/src/doc_parser/write/root_doc.rs b/packages/common/native/src/doc_parser/write/root_doc.rs index 3afa338631..cf94c3582b 100644 --- a/packages/common/native/src/doc_parser/write/root_doc.rs +++ b/packages/common/native/src/doc_parser/write/root_doc.rs @@ -1,8 +1,19 @@ +use std::collections::HashSet; + use y_octo::Array; use super::*; const DEFAULT_DOC_TITLE: &str = "Untitled"; +const SIMPLE_PAGE_META_KEYS: &[&str] = &[ + "id", + "title", + "createDate", + "updatedDate", + "trash", + "trashDate", + "headerImage", +]; fn any_to_value(doc: &Doc, any: Any) -> Result { match any { @@ -104,3 +115,177 @@ pub fn add_doc_to_root_doc(root_doc_bin: Vec, doc_id: &str, title: Option<&s // Encode only the changes (delta) since state_before Ok(doc.encode_state_as_update_v1(&state_before)?) } + +fn insert_page_stub(doc: &Doc, pages: &mut Array, doc_id: &str, title: Option<&str>) -> Result<(), ParseError> { + let page_map = doc.create_map()?; + let idx = pages.len(); + pages.insert(idx, Value::Map(page_map))?; + + if let Some(mut inserted_page) = pages.get(idx).and_then(|v| v.to_map()) { + inserted_page.insert("id".to_string(), Any::String(doc_id.to_string()))?; + inserted_page.insert( + "title".to_string(), + Any::String(title.unwrap_or(DEFAULT_DOC_TITLE).to_string()), + )?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + inserted_page.insert("createDate".to_string(), Any::Float64((timestamp as f64).into()))?; + + let tags = doc.create_array()?; + inserted_page.insert("tags".to_string(), Value::Array(tags))?; + } + + Ok(()) +} + +fn insert_simple_array(doc: &Doc, values: &[Any]) -> Result { + let mut array = doc.create_array()?; + for value in values { + match value { + Any::Array(_) | Any::Object(_) => continue, + _ => array.push(Value::Any(value.clone()))?, + } + } + Ok(Value::Array(array)) +} + +fn insert_page_from_any(doc: &Doc, pages: &mut Array, page: Any) -> Result, ParseError> { + let Any::Object(page) = page else { + return Ok(None); + }; + + let Some(page_id) = page.get("id").and_then(|value| match value { + Any::String(value) => Some(value.clone()), + _ => None, + }) else { + return Ok(None); + }; + + let page_map = doc.create_map()?; + let idx = pages.len(); + pages.insert(idx, Value::Map(page_map))?; + + let Some(mut inserted_page) = pages.get(idx).and_then(|v| v.to_map()) else { + return Ok(None); + }; + + for key in SIMPLE_PAGE_META_KEYS { + let Some(value) = page.get(*key) else { + continue; + }; + match value { + Any::Array(values) => { + inserted_page.insert((*key).to_string(), insert_simple_array(doc, values)?)?; + } + Any::Object(_) => continue, + _ => { + inserted_page.insert((*key).to_string(), Value::Any(value.clone()))?; + } + } + } + + if let Some(Any::Array(tags)) = page.get("tags") { + inserted_page.insert("tags".to_string(), insert_simple_array(doc, tags)?)?; + } + + Ok(Some(page_id)) +} + +pub fn build_public_root_doc(root_doc_bin: &[u8], doc_metas: &[(&str, Option<&str>)]) -> Result, ParseError> { + let source = load_doc_or_new(root_doc_bin)?; + let public_doc_ids = doc_metas + .iter() + .map(|(doc_id, _title)| (*doc_id).to_string()) + .collect::>(); + + let doc = Doc::default(); + let mut meta = doc.get_or_create_map("meta")?; + let mut pages = ensure_pages_array(&doc, &mut meta)?; + let mut copied = HashSet::new(); + + if let Ok(source_meta) = source.get_map("meta") { + let source_pages_value = source_meta.get("pages"); + + if let Some(source_pages) = source_pages_value.as_ref().and_then(|value| value.to_array()) { + for page_val in source_pages.iter() { + let Some(page) = page_val.to_map() else { + continue; + }; + let Some(page_id) = get_string(&page, "id") else { + continue; + }; + if !public_doc_ids.contains(&page_id) { + continue; + } + + let page_object = Any::Object( + page + .iter() + .filter_map(|(key, value)| value.to_any().map(|any| (key.to_string(), any))) + .collect(), + ); + if let Some(inserted_page_id) = insert_page_from_any(&doc, &mut pages, page_object)? { + copied.insert(inserted_page_id); + } + } + } else if let Some(Any::Array(entries)) = source_pages_value.and_then(|value| value.to_any()) { + for entry in entries { + let Any::Object(page) = entry.clone() else { + continue; + }; + let Some(Any::String(page_id)) = page.get("id") else { + continue; + }; + if !public_doc_ids.contains(page_id) { + continue; + } + + if let Some(inserted_page_id) = insert_page_from_any(&doc, &mut pages, entry)? { + copied.insert(inserted_page_id); + } + } + } + } + + for (doc_id, title) in doc_metas { + if copied.contains(*doc_id) { + continue; + } + insert_page_stub(&doc, &mut pages, doc_id, *title)?; + } + + Ok(doc.encode_update_v1()?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doc_parser::get_doc_ids_from_binary; + + #[test] + fn test_build_public_root_doc_filters_private_pages() { + let root = add_doc_to_root_doc(Vec::new(), "public-doc", Some("Public")).expect("create public entry"); + let update = add_doc_to_root_doc(root.clone(), "private-doc", Some("Private")).expect("create private entry"); + + let mut merged = load_doc_or_new(&root).expect("load root"); + merged + .apply_update_from_binary_v1(&update) + .expect("apply second update"); + let merged_bin = merged.encode_update_v1().expect("encode merged"); + + let public_root = build_public_root_doc( + &merged_bin, + &[("public-doc", Some("Public")), ("missing-public-doc", Some("Fallback"))], + ) + .expect("build public root"); + + let doc_ids = get_doc_ids_from_binary(public_root, false).expect("read public root doc ids"); + assert_eq!( + doc_ids, + vec!["public-doc".to_string(), "missing-public-doc".to_string()] + ); + } +} diff --git a/packages/common/nbstore/src/impls/cloud/doc-static.ts b/packages/common/nbstore/src/impls/cloud/doc-static.ts index 0381b5a3fa..f774885031 100644 --- a/packages/common/nbstore/src/impls/cloud/doc-static.ts +++ b/packages/common/nbstore/src/impls/cloud/doc-static.ts @@ -10,8 +10,12 @@ import { HttpConnection } from './http'; interface CloudDocStorageOptions extends DocStorageOptions { serverBaseUrl: string; + publicRootDocId?: string; } +const isShareModePrivateSystemDoc = (docId: string) => + docId.startsWith('db$') || docId.startsWith('userdata$'); + export class StaticCloudDocStorage extends DocStorageBase { static readonly identifier = 'StaticCloudDocStorage'; @@ -46,15 +50,26 @@ export class StaticCloudDocStorage extends DocStorageBase { try { - const arrayBuffer = await this.connection.fetchArrayBuffer( - `/api/workspaces/${this.spaceId}/docs/${docId}`, - { - priority: 'high', - headers: { - Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer - }, - } - ); + if ( + this.options.publicRootDocId && + docId !== this.spaceId && + isShareModePrivateSystemDoc(docId) + ) { + return null; + } + + const path = + docId === this.spaceId && this.options.publicRootDocId + ? `/api/workspaces/${this.spaceId}/public-docs/${this.options.publicRootDocId}/root-doc` + : this.options.publicRootDocId + ? `/api/workspaces/${this.spaceId}/public-docs/${docId}` + : `/api/workspaces/${this.spaceId}/docs/${docId}`; + const arrayBuffer = await this.connection.fetchArrayBuffer(path, { + priority: 'high', + headers: { + Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer + }, + }); if (!arrayBuffer) { return null; } diff --git a/packages/frontend/core/src/__tests__/share-page.spec.ts b/packages/frontend/core/src/__tests__/share-page.spec.ts index 0fd9c3c32f..213f180bd4 100644 --- a/packages/frontend/core/src/__tests__/share-page.spec.ts +++ b/packages/frontend/core/src/__tests__/share-page.spec.ts @@ -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); + }); +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx index da6e6d8af3..075443ac10 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx @@ -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 => { + 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(null); const [editor, setEditor] = useState(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 ; } + if (loadFailed) { + return ; + } + if (!workspace || !page || !editor || !currentPublishMode) { return null; } diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts index 23cc88ab25..4cd73f2dbb 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts @@ -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 => { 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; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx index df567ef20e..dd1df7ee4b 100644 --- a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx @@ -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 (