mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
@@ -59,6 +59,7 @@ test('should update workspace', async t => {
|
||||
const data = {
|
||||
public: true,
|
||||
enableAi: true,
|
||||
enableSharing: false,
|
||||
enableUrlPreview: true,
|
||||
enableDocEmbedding: false,
|
||||
};
|
||||
|
||||
@@ -85,6 +85,26 @@ export async function updateWorkspace(
|
||||
return res.updateWorkspace.public;
|
||||
}
|
||||
|
||||
export async function setWorkspaceSharing(
|
||||
app: TestingApp,
|
||||
workspaceId: string,
|
||||
enableSharing: boolean
|
||||
) {
|
||||
const res = await app.gql(
|
||||
`
|
||||
mutation {
|
||||
updateWorkspace(
|
||||
input: { id: "${workspaceId}", enableSharing: ${enableSharing} }
|
||||
) {
|
||||
enableSharing
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
return res.updateWorkspace.enableSharing as boolean;
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
app: TestingApp,
|
||||
workspaceId: string
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
inviteUser,
|
||||
publishDoc,
|
||||
revokePublicDoc,
|
||||
setWorkspaceSharing,
|
||||
TestingApp,
|
||||
updateWorkspace,
|
||||
} from './utils';
|
||||
@@ -180,4 +181,17 @@ test('should be able to get public workspace doc', async t => {
|
||||
.type('application/octet-stream');
|
||||
|
||||
t.deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc');
|
||||
|
||||
const disabled = await setWorkspaceSharing(app, workspace.id, false);
|
||||
t.false(disabled, 'failed to disable workspace sharing');
|
||||
|
||||
// owner should still be able to access
|
||||
await app
|
||||
.GET(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.expect(200);
|
||||
|
||||
await app.logout();
|
||||
await app
|
||||
.GET(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,11 @@ export class DocRendererController {
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
|
||||
if (!allowSharing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
|
||||
|
||||
if (!allowUrlPreview) {
|
||||
@@ -118,6 +123,11 @@ export class DocRendererController {
|
||||
private async getWorkspaceContent(
|
||||
workspaceId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
|
||||
if (!allowSharing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowUrlPreview =
|
||||
await this.models.workspace.allowUrlPreview(workspaceId);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ let ws: Workspace;
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = new DocAccessController();
|
||||
ac = module.get(DocAccessController);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
|
||||
@@ -80,6 +80,20 @@ test('should fallback to [External] if workspace is public', async t => {
|
||||
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');
|
||||
|
||||
@@ -91,6 +105,18 @@ test('should return null even workspace has public doc', async t => {
|
||||
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 => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
@@ -105,6 +131,24 @@ test('should return mapped external permission for workspace has public docs', a
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject external doc roles when sharing disabled', async t => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const [docRole] = await ac.docRoles(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
['doc1']
|
||||
);
|
||||
|
||||
t.is(docRole.role, null);
|
||||
t.false(docRole.permissions['Doc.Read']);
|
||||
});
|
||||
|
||||
test('should return mapped permissions', async t => {
|
||||
const { permissions } = await ac.role({
|
||||
workspaceId: ws.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DocActionDenied } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { AccessController, getAccessController } from './controller';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
@@ -14,14 +15,21 @@ import { WorkspaceAccessController } from './workspace';
|
||||
@Injectable()
|
||||
export class DocAccessController extends AccessController<'doc'> {
|
||||
protected readonly type = 'doc';
|
||||
constructor(private readonly models: Models) {
|
||||
super();
|
||||
}
|
||||
|
||||
async role(resource: Resource<'doc'>) {
|
||||
const role = await this.getRole(resource);
|
||||
const permissions = mapDocRoleToPermissions(role);
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(
|
||||
resource.workspaceId
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
permissions['Doc.Publish'] = false;
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
permissions: mapDocRoleToPermissions(role),
|
||||
};
|
||||
return { role, permissions };
|
||||
}
|
||||
|
||||
async can(resource: Resource<'doc'>, action: DocAction) {
|
||||
|
||||
@@ -27,7 +27,11 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
// 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.doc.hasPublic(resource.workspaceId))) {
|
||||
if (
|
||||
!role &&
|
||||
(await this.models.workspace.allowSharing(resource.workspaceId)) &&
|
||||
(await this.models.doc.hasPublic(resource.workspaceId))
|
||||
) {
|
||||
role = WorkspaceRole.External;
|
||||
}
|
||||
|
||||
@@ -92,6 +96,15 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
}
|
||||
|
||||
const workspaceRole = await this.getRole(payload);
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(
|
||||
payload.workspaceId
|
||||
);
|
||||
if (
|
||||
!sharingAllowed &&
|
||||
(workspaceRole === null || workspaceRole === WorkspaceRole.External)
|
||||
) {
|
||||
return docIds.map(() => null);
|
||||
}
|
||||
|
||||
const userRoles = await this.models.docUser.findMany(
|
||||
payload.workspaceId,
|
||||
@@ -190,7 +203,8 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
}
|
||||
|
||||
if (ws.public) {
|
||||
return WorkspaceRole.External;
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(ws.id);
|
||||
return sharingAllowed ? WorkspaceRole.External : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -56,6 +56,21 @@ class ListWorkspaceInput {
|
||||
|
||||
@Field(() => AdminWorkspaceSort, { nullable: true })
|
||||
orderBy?: AdminWorkspaceSort;
|
||||
|
||||
@Field({ nullable: true })
|
||||
public?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
enableAi?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
enableSharing?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
enableUrlPreview?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
enableDocEmbedding?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -111,6 +126,9 @@ export class AdminWorkspace {
|
||||
@Field()
|
||||
enableAi!: boolean;
|
||||
|
||||
@Field()
|
||||
enableSharing!: boolean;
|
||||
|
||||
@Field()
|
||||
enableUrlPreview!: boolean;
|
||||
|
||||
@@ -150,6 +168,7 @@ class AdminUpdateWorkspaceInput extends PartialType(
|
||||
PickType(AdminWorkspace, [
|
||||
'public',
|
||||
'enableAi',
|
||||
'enableSharing',
|
||||
'enableUrlPreview',
|
||||
'enableDocEmbedding',
|
||||
'name',
|
||||
@@ -183,6 +202,13 @@ export class AdminWorkspaceResolver {
|
||||
keyword: filter.keyword,
|
||||
features: filter.features,
|
||||
order: this.mapSort(filter.orderBy),
|
||||
flags: {
|
||||
public: filter.public ?? undefined,
|
||||
enableAi: filter.enableAi ?? undefined,
|
||||
enableSharing: filter.enableSharing ?? undefined,
|
||||
enableUrlPreview: filter.enableUrlPreview ?? undefined,
|
||||
enableDocEmbedding: filter.enableDocEmbedding ?? undefined,
|
||||
},
|
||||
includeTotal: false,
|
||||
});
|
||||
return rows;
|
||||
@@ -196,6 +222,13 @@ export class AdminWorkspaceResolver {
|
||||
const total = await this.models.workspace.adminCountWorkspaces({
|
||||
keyword: filter.keyword,
|
||||
features: filter.features,
|
||||
flags: {
|
||||
public: filter.public ?? undefined,
|
||||
enableAi: filter.enableAi ?? undefined,
|
||||
enableSharing: filter.enableSharing ?? undefined,
|
||||
enableUrlPreview: filter.enableUrlPreview ?? undefined,
|
||||
enableDocEmbedding: filter.enableDocEmbedding ?? undefined,
|
||||
},
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ export class WorkspaceType extends WorkspaceFeatureType {
|
||||
@Field({ description: 'Enable AI' })
|
||||
enableAi!: boolean;
|
||||
|
||||
@Field({ description: 'Enable workspace sharing' })
|
||||
enableSharing!: boolean;
|
||||
|
||||
@Field({ description: 'Enable url previous when sharing' })
|
||||
enableUrlPreview!: boolean;
|
||||
|
||||
@@ -130,7 +133,13 @@ export class InvitationType {
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput extends PickType(
|
||||
PartialType(WorkspaceType),
|
||||
['public', 'enableAi', 'enableUrlPreview', 'enableDocEmbedding'],
|
||||
[
|
||||
'public',
|
||||
'enableAi',
|
||||
'enableSharing',
|
||||
'enableUrlPreview',
|
||||
'enableDocEmbedding',
|
||||
],
|
||||
InputType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
|
||||
@@ -14,6 +14,7 @@ type RawWorkspaceSummary = {
|
||||
name: string | null;
|
||||
avatarKey: string | null;
|
||||
enableAi: boolean;
|
||||
enableSharing: boolean;
|
||||
enableUrlPreview: boolean;
|
||||
enableDocEmbedding: boolean;
|
||||
memberCount: bigint | number | null;
|
||||
@@ -36,6 +37,7 @@ export type AdminWorkspaceSummary = {
|
||||
name: string | null;
|
||||
avatarKey: string | null;
|
||||
enableAi: boolean;
|
||||
enableSharing: boolean;
|
||||
enableUrlPreview: boolean;
|
||||
enableDocEmbedding: boolean;
|
||||
memberCount: number;
|
||||
@@ -67,6 +69,7 @@ export type UpdateWorkspaceInput = Pick<
|
||||
Partial<Workspace>,
|
||||
| 'public'
|
||||
| 'enableAi'
|
||||
| 'enableSharing'
|
||||
| 'enableUrlPreview'
|
||||
| 'enableDocEmbedding'
|
||||
| 'name'
|
||||
@@ -169,6 +172,11 @@ export class WorkspaceModel extends BaseModel {
|
||||
return workspace?.enableUrlPreview ?? false;
|
||||
}
|
||||
|
||||
async allowSharing(workspaceId: string) {
|
||||
const workspace = await this.get(workspaceId);
|
||||
return workspace?.enableSharing ?? true;
|
||||
}
|
||||
|
||||
async allowEmbedding(workspaceId: string) {
|
||||
const workspace = await this.get(workspaceId);
|
||||
return workspace?.enableDocEmbedding ?? false;
|
||||
@@ -185,6 +193,13 @@ export class WorkspaceModel extends BaseModel {
|
||||
first: number;
|
||||
keyword?: string | null;
|
||||
features?: WorkspaceFeatureName[] | null;
|
||||
flags?: {
|
||||
public?: boolean;
|
||||
enableAi?: boolean;
|
||||
enableSharing?: boolean;
|
||||
enableUrlPreview?: boolean;
|
||||
enableDocEmbedding?: boolean;
|
||||
};
|
||||
order?:
|
||||
| 'createdAt'
|
||||
| 'snapshotSize'
|
||||
@@ -197,9 +212,10 @@ export class WorkspaceModel extends BaseModel {
|
||||
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
||||
const keyword = options.keyword?.trim();
|
||||
const features = options.features ?? [];
|
||||
const flags = options.flags ?? {};
|
||||
const includeTotal = options.includeTotal ?? true;
|
||||
const total = includeTotal
|
||||
? await this.adminCountWorkspaces({ keyword, features })
|
||||
? await this.adminCountWorkspaces({ keyword, features, flags })
|
||||
: 0;
|
||||
if (includeTotal && total === 0) {
|
||||
return { rows: [], total: 0 };
|
||||
@@ -251,6 +267,7 @@ export class WorkspaceModel extends BaseModel {
|
||||
w.name,
|
||||
w.avatar_key AS "avatarKey",
|
||||
w.enable_ai AS "enableAi",
|
||||
w.enable_sharing AS "enableSharing",
|
||||
w.enable_url_preview AS "enableUrlPreview",
|
||||
w.enable_doc_embedding AS "enableDocEmbedding",
|
||||
o.owner_id AS "ownerId",
|
||||
@@ -283,6 +300,14 @@ export class WorkspaceModel extends BaseModel {
|
||||
`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
${
|
||||
this.buildAdminFlagWhere(flags).length
|
||||
? Prisma.sql`AND ${Prisma.join(
|
||||
this.buildAdminFlagWhere(flags),
|
||||
' AND '
|
||||
)}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${groupAndHaving}
|
||||
)
|
||||
SELECT f.*,
|
||||
@@ -307,6 +332,7 @@ export class WorkspaceModel extends BaseModel {
|
||||
name: row.name,
|
||||
avatarKey: row.avatarKey,
|
||||
enableAi: row.enableAi,
|
||||
enableSharing: row.enableSharing,
|
||||
enableUrlPreview: row.enableUrlPreview,
|
||||
enableDocEmbedding: row.enableDocEmbedding,
|
||||
memberCount: Number(row.memberCount ?? 0),
|
||||
@@ -332,9 +358,17 @@ export class WorkspaceModel extends BaseModel {
|
||||
async adminCountWorkspaces(options: {
|
||||
keyword?: string | null;
|
||||
features?: WorkspaceFeatureName[] | null;
|
||||
flags?: {
|
||||
public?: boolean;
|
||||
enableAi?: boolean;
|
||||
enableSharing?: boolean;
|
||||
enableUrlPreview?: boolean;
|
||||
enableDocEmbedding?: boolean;
|
||||
};
|
||||
}) {
|
||||
const keyword = options.keyword?.trim();
|
||||
const features = options.features ?? [];
|
||||
const flags = options.flags ?? {};
|
||||
|
||||
const featuresHaving =
|
||||
features.length > 0
|
||||
@@ -393,6 +427,14 @@ export class WorkspaceModel extends BaseModel {
|
||||
`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
${
|
||||
this.buildAdminFlagWhere(flags).length
|
||||
? Prisma.sql`AND ${Prisma.join(
|
||||
this.buildAdminFlagWhere(flags),
|
||||
' AND '
|
||||
)}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${groupAndHaving}
|
||||
)
|
||||
SELECT COUNT(*) AS total FROM filtered
|
||||
@@ -401,6 +443,36 @@ export class WorkspaceModel extends BaseModel {
|
||||
return row?.total ? Number(row.total) : 0;
|
||||
}
|
||||
|
||||
private buildAdminFlagWhere(flags: {
|
||||
public?: boolean;
|
||||
enableAi?: boolean;
|
||||
enableSharing?: boolean;
|
||||
enableUrlPreview?: boolean;
|
||||
enableDocEmbedding?: boolean;
|
||||
}) {
|
||||
const conditions: Prisma.Sql[] = [];
|
||||
if (flags.public !== undefined) {
|
||||
conditions.push(Prisma.sql`w.public = ${flags.public}`);
|
||||
}
|
||||
if (flags.enableAi !== undefined) {
|
||||
conditions.push(Prisma.sql`w.enable_ai = ${flags.enableAi}`);
|
||||
}
|
||||
if (flags.enableSharing !== undefined) {
|
||||
conditions.push(Prisma.sql`w.enable_sharing = ${flags.enableSharing}`);
|
||||
}
|
||||
if (flags.enableUrlPreview !== undefined) {
|
||||
conditions.push(
|
||||
Prisma.sql`w.enable_url_preview = ${flags.enableUrlPreview}`
|
||||
);
|
||||
}
|
||||
if (flags.enableDocEmbedding !== undefined) {
|
||||
conditions.push(
|
||||
Prisma.sql`w.enable_doc_embedding = ${flags.enableDocEmbedding}`
|
||||
);
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
|
||||
private buildAdminOrder(
|
||||
order?:
|
||||
| 'createdAt'
|
||||
|
||||
@@ -35,6 +35,7 @@ input AdminUpdateWorkspaceInput {
|
||||
avatarKey: String
|
||||
enableAi: Boolean
|
||||
enableDocEmbedding: Boolean
|
||||
enableSharing: Boolean
|
||||
enableUrlPreview: Boolean
|
||||
features: [FeatureType!]
|
||||
id: String!
|
||||
@@ -49,6 +50,7 @@ type AdminWorkspace {
|
||||
createdAt: DateTime!
|
||||
enableAi: Boolean!
|
||||
enableDocEmbedding: Boolean!
|
||||
enableSharing: Boolean!
|
||||
enableUrlPreview: Boolean!
|
||||
features: [FeatureType!]!
|
||||
id: String!
|
||||
@@ -1226,10 +1228,15 @@ input ListUserInput {
|
||||
}
|
||||
|
||||
input ListWorkspaceInput {
|
||||
enableAi: Boolean
|
||||
enableDocEmbedding: Boolean
|
||||
enableSharing: Boolean
|
||||
enableUrlPreview: Boolean
|
||||
features: [FeatureType!]
|
||||
first: Int! = 20
|
||||
keyword: String
|
||||
orderBy: AdminWorkspaceSort
|
||||
public: Boolean
|
||||
skip: Int! = 0
|
||||
}
|
||||
|
||||
@@ -2207,6 +2214,9 @@ input UpdateWorkspaceInput {
|
||||
"""Enable doc embedding"""
|
||||
enableDocEmbedding: Boolean
|
||||
|
||||
"""Enable workspace sharing"""
|
||||
enableSharing: Boolean
|
||||
|
||||
"""Enable url previous when sharing"""
|
||||
enableUrlPreview: Boolean
|
||||
id: ID!
|
||||
@@ -2432,6 +2442,9 @@ type WorkspaceType {
|
||||
"""Enable doc embedding"""
|
||||
enableDocEmbedding: Boolean!
|
||||
|
||||
"""Enable workspace sharing"""
|
||||
enableSharing: Boolean!
|
||||
|
||||
"""Enable url previous when sharing"""
|
||||
enableUrlPreview: Boolean!
|
||||
histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]!
|
||||
|
||||
Reference in New Issue
Block a user