mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
feat(server): synthetic root doc (#14794)
This commit is contained in:
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user