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

This commit is contained in:
DarkSky
2026-04-06 17:16:34 +08:00
committed by GitHub
parent 64149d909a
commit 193ec14ad3
35 changed files with 1784 additions and 409 deletions
@@ -1,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', {