mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 23:26:30 +08:00
feat!: affine cloud support (#3813)
Co-authored-by: Hongtao Lye <codert.sn@gmail.com> Co-authored-by: liuyi <forehalo@gmail.com> Co-authored-by: LongYinan <lynweklm@gmail.com> Co-authored-by: X1a0t <405028157@qq.com> Co-authored-by: JimmFly <yangjinfei001@gmail.com> Co-authored-by: Peng Xiao <pengxiao@outlook.com> Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com> Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: Qi <474021214@qq.com> Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
OmitType,
|
||||
Parent,
|
||||
PartialType,
|
||||
PickType,
|
||||
@@ -19,20 +20,43 @@ import {
|
||||
import type { User, Workspace } from '@prisma/client';
|
||||
// @ts-expect-error graphql-upload is not typed
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { StorageProvide } from '../../storage';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { Auth, CurrentUser, Public } from '../auth';
|
||||
import { MailService } from '../auth/mailer';
|
||||
import { AuthService } from '../auth/service';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { PermissionService } from './permission';
|
||||
import { Permission } from './types';
|
||||
import { defaultWorkspaceAvatar } from './utils';
|
||||
|
||||
registerEnumType(Permission, {
|
||||
name: 'Permission',
|
||||
description: 'User permission in workspace',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class InviteUserType extends OmitType(
|
||||
PartialType(UserType),
|
||||
['id'],
|
||||
ObjectType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => Permission, { description: 'User permission in workspace' })
|
||||
permission!: Permission;
|
||||
|
||||
@Field({ description: 'Invite id' })
|
||||
inviteId!: string;
|
||||
|
||||
@Field({ description: 'User accepted' })
|
||||
accepted!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceType implements Partial<Workspace> {
|
||||
@Field(() => ID)
|
||||
@@ -43,6 +67,34 @@ export class WorkspaceType implements Partial<Workspace> {
|
||||
|
||||
@Field({ description: 'Workspace created date' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => [InviteUserType], {
|
||||
description: 'Members of workspace',
|
||||
})
|
||||
members!: InviteUserType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class InvitationWorkspaceType {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Workspace name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
// nullable: true,
|
||||
description: 'Base64 encoded avatar',
|
||||
})
|
||||
avatar!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class InvitationType {
|
||||
@Field({ description: 'Workspace information' })
|
||||
workspace!: InvitationWorkspaceType;
|
||||
@Field({ description: 'User information' })
|
||||
user!: UserType;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -59,6 +111,8 @@ export class UpdateWorkspaceInput extends PickType(
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionProvider: PermissionService,
|
||||
@Inject(StorageProvide) private readonly storage: Storage
|
||||
@@ -69,7 +123,7 @@ export class WorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async permission(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
@@ -99,6 +153,20 @@ export class WorkspaceResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [String], {
|
||||
description: 'Shared pages of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async sharedPages(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
return data.map(item => item.subPageId).filter(Boolean);
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
@@ -117,27 +185,46 @@ export class WorkspaceResolver {
|
||||
return data.user;
|
||||
}
|
||||
|
||||
@ResolveField(() => [UserType], {
|
||||
@ResolveField(() => [InviteUserType], {
|
||||
description: 'Members of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async members(
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
async members(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
accepted: true,
|
||||
userId: {
|
||||
not: user.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
return data.map(({ user }) => user);
|
||||
return data.map(({ id, accepted, type, user }) => ({
|
||||
...user,
|
||||
permission: type,
|
||||
inviteId: id,
|
||||
accepted,
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
description: 'Get is owner of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async isOwner(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
type: Permission.Owner,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.user?.id === user.id;
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceType], {
|
||||
@@ -163,6 +250,22 @@ export class WorkspaceResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get public workspace by id',
|
||||
})
|
||||
@Public()
|
||||
async publicWorkspace(@Args('id') id: string) {
|
||||
const workspace = await this.prisma.workspace.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (workspace?.public) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
@@ -181,7 +284,7 @@ export class WorkspaceResolver {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'init', type: () => GraphQLUpload })
|
||||
update: FileUpload
|
||||
) {
|
||||
@@ -215,7 +318,13 @@ export class WorkspaceResolver {
|
||||
},
|
||||
});
|
||||
|
||||
await this.storage.createWorkspace(workspace.id, buffer);
|
||||
await this.prisma.snapshot.create({
|
||||
data: {
|
||||
id: workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
blob: buffer,
|
||||
},
|
||||
});
|
||||
|
||||
return workspace;
|
||||
}
|
||||
@@ -224,11 +333,11 @@ export class WorkspaceResolver {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
await this.permissionProvider.check('id', user.id, Permission.Admin);
|
||||
await this.permissionProvider.check(id, user.id, Permission.Admin);
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: {
|
||||
@@ -239,7 +348,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
|
||||
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissionProvider.check(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
@@ -248,25 +357,30 @@ export class WorkspaceResolver {
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.userWorkspacePermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO:
|
||||
// delete all related data, like websocket connections, blobs, etc.
|
||||
await this.storage.deleteWorkspace(id);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.update.deleteMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
},
|
||||
}),
|
||||
this.prisma.snapshot.deleteMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@Mutation(() => String)
|
||||
async invite(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('email') email: string,
|
||||
@Args('permission', { type: () => Permission }) permission: Permission
|
||||
@Args('permission', { type: () => Permission }) permission: Permission,
|
||||
// TODO: add rate limit
|
||||
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
|
||||
|
||||
@@ -280,18 +394,122 @@ export class WorkspaceResolver {
|
||||
},
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException("User doesn't exist");
|
||||
if (target) {
|
||||
const originRecord = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (originRecord) {
|
||||
return originRecord.id;
|
||||
}
|
||||
|
||||
const inviteId = await this.permissionProvider.grant(
|
||||
workspaceId,
|
||||
target.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
return inviteId;
|
||||
} else {
|
||||
const user = await this.auth.createAnonymousUser(email);
|
||||
const inviteId = await this.permissionProvider.grant(
|
||||
workspaceId,
|
||||
user.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
return inviteId;
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Query(() => InvitationType, {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
||||
const permission =
|
||||
await this.prisma.userWorkspacePermission.findUniqueOrThrow({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await this.prisma.snapshot.findFirstOrThrow({
|
||||
where: {
|
||||
id: permission.workspaceId,
|
||||
workspaceId: permission.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const doc = new Doc();
|
||||
|
||||
applyUpdate(doc, new Uint8Array(snapshot.blob));
|
||||
const metaJSON = doc.getMap('meta').toJSON();
|
||||
|
||||
const owner = await this.prisma.userWorkspacePermission.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: permission.workspaceId,
|
||||
type: Permission.Owner,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
let avatar = '';
|
||||
|
||||
if (metaJSON.avatar) {
|
||||
const avatarBlob = await this.storage.getBlob(
|
||||
permission.workspaceId,
|
||||
metaJSON.avatar
|
||||
);
|
||||
avatar = avatarBlob?.data.toString('base64') || '';
|
||||
}
|
||||
|
||||
await this.permissionProvider.grant(workspaceId, target.id, permission);
|
||||
|
||||
return true;
|
||||
return {
|
||||
workspace: {
|
||||
name: metaJSON.name || '',
|
||||
avatar: avatar || defaultWorkspaceAvatar,
|
||||
id: permission.workspaceId,
|
||||
},
|
||||
user: owner.user,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revoke(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
@@ -300,9 +518,18 @@ export class WorkspaceResolver {
|
||||
return this.permissionProvider.revoke(workspaceId, userId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@Public()
|
||||
async acceptInviteById(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('inviteId') inviteId: string
|
||||
) {
|
||||
return this.permissionProvider.acceptById(workspaceId, inviteId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async acceptInvite(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
return this.permissionProvider.accept(workspaceId, user.id);
|
||||
@@ -310,7 +537,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async leaveWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
@@ -318,14 +545,48 @@ export class WorkspaceResolver {
|
||||
return this.permissionProvider.revoke(workspaceId, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sharePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
|
||||
|
||||
return this.permissionProvider.grantPage(workspaceId, pageId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
|
||||
|
||||
return this.permissionProvider.revokePage(workspaceId, pageId);
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
description: 'List blobs of workspace',
|
||||
})
|
||||
async listBlobs(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
|
||||
return this.storage.listBlobs(workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
async uploadBlob(
|
||||
@CurrentUser() user: User,
|
||||
async setBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args({ name: 'blob', type: () => GraphQLUpload })
|
||||
blob: FileUpload
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Write);
|
||||
|
||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const stream = blob.createReadStream();
|
||||
@@ -341,4 +602,15 @@ export class WorkspaceResolver {
|
||||
|
||||
return this.storage.uploadBlob(workspaceId, buffer);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('hash') hash: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
|
||||
return this.storage.deleteBlob(workspaceId, hash);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user