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

This commit is contained in:
DarkSky
2026-04-06 17:16:34 +08:00
committed by GitHub
parent 64149d909a
commit 193ec14ad3
35 changed files with 1784 additions and 409 deletions
+7
View File
@@ -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<PublicDocMetaInput>): 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<Buffer>
export interface PublicDocMetaInput {
id: string
title?: string
}
export declare function readAllDocIdsFromRootDoc(docBin: Buffer, includeTrash?: boolean | undefined | null): Array<string>
/**
+19
View File
@@ -54,6 +54,12 @@ impl From<WorkspaceDocContent> for NativeWorkspaceDocContent {
}
}
#[napi(object)]
pub struct PublicDocMetaInput {
pub id: String,
pub title: Option<String>,
}
#[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<S
Ok(Buffer::from(result))
}
#[napi]
pub fn build_public_root_doc(root_doc_bin: Buffer, doc_metas: Vec<PublicDocMetaInput>) -> Result<Buffer> {
let metas = doc_metas
.iter()
.map(|meta| (meta.id.as_str(), meta.title.as_deref()))
.collect::<Vec<_>>();
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
@@ -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;
@@ -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,
})
);
});
@@ -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<T> =
@@ -25,6 +31,16 @@ function unwrapResponse<T>(t: ExecutionContext, res: WebsocketResponse<T>): T {
throw new Error(`Websocket error: ${res.error.name}: ${res.error.message}`);
}
function getErrorResponse<T>(
t: ExecutionContext,
res: WebsocketResponse<T>
): { 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<T>(
promise: Promise<T>,
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();
}
});
@@ -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,
@@ -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);
@@ -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<unknown>).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;
@@ -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<Context>;
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<TestingApp['GET']>;
assert: (
t: ExecutionContext<Context>,
res: Awaited<ReturnType<TestingApp['GET']>>,
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();
});
}
@@ -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<RenderOptions | null> {
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<RenderOptions | null> {
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;
@@ -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<void>;
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(
@@ -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,
@@ -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<void>;
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'
)
);
});
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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);
}
@@ -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(
@@ -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<PaginatedDocType> {
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);
@@ -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', {
@@ -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) {
+1
View File
@@ -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;
@@ -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<CopilotContextCategory> {
@@ -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<boolean> {
@@ -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<CopilotContextDoc> {
@@ -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<boolean> {
@@ -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<boolean> {
@@ -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<boolean> {
@@ -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,
@@ -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<ContextSession> {
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<ContextSession | null> {
const existsContext =
await this.models.copilotContext.getBySessionId(sessionId);
@@ -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<boolean> {
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);
}
+2 -2
View File
@@ -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!
+2
View File
@@ -832,6 +832,7 @@ export interface CopilotWorkspaceIgnoredDocTypeEdge {
}
export interface CreateChatMessageInput {
/** @deprecated use blobs */
attachments?: InputMaybe<Array<Scalars['String']['input']>>;
blob?: InputMaybe<Scalars['Upload']['input']>;
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
@@ -853,6 +854,7 @@ export interface CreateChatSessionInput {
export interface CreateCheckoutSessionInput {
args?: InputMaybe<Scalars['JSONObject']['input']>;
coupon?: InputMaybe<Scalars['String']['input']>;
/** @deprecated not required anymore */
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
plan?: InputMaybe<SubscriptionPlan>;
recurring?: InputMaybe<SubscriptionRecurring>;
+2 -1
View File
@@ -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,
};
@@ -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};
@@ -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<Value, ParseError> {
match any {
@@ -104,3 +115,177 @@ pub fn add_doc_to_root_doc(root_doc_bin: Vec<u8>, 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<Value, ParseError> {
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<Option<String>, 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<Vec<u8>, 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::<HashSet<_>>();
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()]
);
}
}
@@ -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<CloudDocStorageOptions> {
static readonly identifier = 'StaticCloudDocStorage';
@@ -46,15 +50,26 @@ export class StaticCloudDocStorage extends DocStorageBase<CloudDocStorageOptions
docId: string
): Promise<DocRecord | null> {
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;
}
@@ -1,9 +1,13 @@
import { UserFriendlyError } from '@affine/error';
import { TimeoutError } from 'rxjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
fetchSharedPublishMode,
getResolvedPublishMode,
getSearchWithMode,
isSharePagePermissionError,
isSharePageTimeoutError,
parsePublishMode,
} from '../desktop/pages/workspace/share/share-page.utils';
@@ -56,7 +60,7 @@ describe('fetchSharedPublishMode', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
new URL(
'/api/workspaces/workspace-id/docs/doc-id',
'/api/workspaces/workspace-id/public-docs/doc-id',
'https://app.affine.pro'
),
expect.objectContaining({ method: 'HEAD' })
@@ -95,3 +99,34 @@ describe('getSearchWithMode', () => {
);
});
});
describe('share page error helpers', () => {
test('recognizes permission errors only', () => {
const permissionError = new UserFriendlyError({
status: 403,
code: 'DOC_ACTION_DENIED',
type: 'DOC_ACTION_DENIED',
name: 'DOC_ACTION_DENIED',
message: 'forbidden',
});
expect(isSharePagePermissionError(permissionError)).toBe(true);
expect(isSharePagePermissionError(new TimeoutError())).toBe(false);
expect(isSharePagePermissionError(new Error('x'))).toBe(false);
});
test('recognizes timeout errors only', () => {
expect(isSharePageTimeoutError(new TimeoutError())).toBe(true);
expect(
isSharePageTimeoutError(
new UserFriendlyError({
status: 403,
code: 'DOC_ACTION_DENIED',
type: 'DOC_ACTION_DENIED',
name: 'DOC_ACTION_DENIED',
message: 'forbidden',
})
)
).toBe(false);
});
});
@@ -32,6 +32,7 @@ import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { filter, firstValueFrom, timeout } from 'rxjs';
import { PageNotFound } from '../../404';
import { ShareFooter } from './share-footer';
@@ -40,9 +41,24 @@ import * as styles from './share-page.css';
import {
fetchSharedPublishMode,
getResolvedPublishMode,
isSharePagePermissionError,
isSharePageTimeoutError,
} from './share-page.utils';
import { useSharedModeQuerySync } from './use-shared-mode-query-sync';
const waitForSharedDocRecord = async (
docsService: DocsService,
docId: string
): Promise<void> => {
if (docsService.list.doc$(docId).value) {
return;
}
await firstValueFrom(
docsService.list.doc$(docId).pipe(filter(Boolean), timeout(3000))
);
};
const useUpdateBasename = (workspace: Workspace | null) => {
const location = useLocation();
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
@@ -131,6 +147,7 @@ const SharePageInner = ({
const [page, setPage] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [noPermission, setNoPermission] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [fetchedPublishMode, setFetchedPublishMode] = useState<
DocMode | null | undefined
>(() => (publishMode === undefined ? undefined : null));
@@ -198,6 +215,7 @@ const SharePageInner = ({
name: 'StaticCloudDocStorage',
opts: {
id: workspaceId,
publicRootDocId: docId,
serverBaseUrl: serverService.server.baseUrl,
},
},
@@ -218,7 +236,10 @@ const SharePageInner = ({
sharedWorkspace.engine.doc
.waitForDocLoaded(sharedWorkspace.id)
.then(async () => {
const { doc } = sharedWorkspace.scope.get(DocsService).open(docId);
const docsService = sharedWorkspace.scope.get(DocsService);
await waitForSharedDocRecord(docsService, docId);
const { doc } = docsService.open(docId);
doc.blockSuiteDoc.load();
doc.blockSuiteDoc.readonly = true;
@@ -241,7 +262,17 @@ const SharePageInner = ({
})
.catch(err => {
console.error(err);
setNoPermission(true);
if (isSharePagePermissionError(err)) {
setNoPermission(true);
return;
}
if (isSharePageTimeoutError(err)) {
setLoadFailed(true);
return;
}
setLoadFailed(true);
});
}, [
docId,
@@ -308,6 +339,10 @@ const SharePageInner = ({
return <PageNotFound noPermission />;
}
if (loadFailed) {
return <PageNotFound />;
}
if (!workspace || !page || !editor || !currentPublishMode) {
return null;
}
@@ -1,4 +1,6 @@
import { UserFriendlyError } from '@affine/error';
import { type DocMode, DocModes } from '@blocksuite/affine/model';
import { TimeoutError } from 'rxjs';
export const getResolvedPublishMode = (
queryMode: DocMode | null,
@@ -35,7 +37,7 @@ export const fetchSharedPublishMode = async ({
signal?: AbortSignal;
}): Promise<DocMode | null> => {
const url = new URL(
`/api/workspaces/${encodeURIComponent(workspaceId)}/docs/${encodeURIComponent(docId)}`,
`/api/workspaces/${encodeURIComponent(workspaceId)}/public-docs/${encodeURIComponent(docId)}`,
serverBaseUrl
);
const headers = {
@@ -71,3 +73,12 @@ export const getSearchWithMode = (search: string, mode: DocMode) => {
const nextSearch = searchParams.toString();
return nextSearch ? `?${nextSearch}` : '';
};
export const isSharePagePermissionError = (error: unknown) =>
error instanceof UserFriendlyError &&
(error.isStatus(403) ||
error.name === 'DOC_ACTION_DENIED' ||
error.name === 'SPACE_ACCESS_DENIED');
export const isSharePageTimeoutError = (error: unknown) =>
error instanceof TimeoutError;
@@ -1,5 +1,6 @@
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { EditorService } from '@affine/core/modules/editor';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { UserFriendlyError } from '@affine/error';
import { PublicDocMode } from '@affine/graphql';
@@ -19,11 +20,13 @@ import * as styles from './styles.css';
export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
const t = useI18n();
const editorService = useService(EditorService);
const shareInfoService = useService(ShareInfoService);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
const isRevalidating = useLiveData(
shareInfoService.shareInfo.isRevalidating$
);
const currentMode = useLiveData(editorService.editor.mode$);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
@@ -63,7 +66,9 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
}
try {
// TODO(@JimmFly): remove mode when we have a better way to handle it
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
await shareInfoService.shareInfo.enableShare(
currentMode === 'edgeless' ? PublicDocMode.Edgeless : PublicDocMode.Page
);
track.$.sharePanel.$.createShareLink();
notify.success({
title:
@@ -84,7 +89,7 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
message: err.message,
});
}
}, [isSharedPage, shareInfoService.shareInfo, t]);
}, [currentMode, isSharedPage, shareInfoService.shareInfo, t]);
return (
<div className={styles.rowContainerStyle}>