refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions

View File

@@ -0,0 +1,89 @@
import type { Storage } from '@affine/storage';
import {
Controller,
ForbiddenException,
Get,
Inject,
Logger,
NotFoundException,
Param,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import format from 'pretty-time';
import { StorageProvide } from '../../storage';
import { DocID } from '../../utils/doc';
import { Auth, CurrentUser, Publicable } from '../auth';
import { DocManager } from '../doc';
import { UserType } from '../users';
import { PermissionService } from './permission';
@Controller('/api/workspaces')
export class WorkspacesController {
private readonly logger = new Logger('WorkspacesController');
constructor(
@Inject(StorageProvide) private readonly storage: Storage,
private readonly permission: PermissionService,
private readonly docManager: DocManager
) {}
// get workspace blob
//
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
@Get('/:id/blobs/:name')
async blob(
@Param('id') workspaceId: string,
@Param('name') name: string,
@Res() res: Response
) {
const blob = await this.storage.getBlob(workspaceId, name);
if (!blob) {
throw new NotFoundException(
`Blob not found in workspace ${workspaceId}: ${name}`
);
}
res.setHeader('content-type', blob.contentType);
res.setHeader('last-modified', blob.lastModified);
res.setHeader('content-length', blob.size);
res.send(blob.data);
}
// get doc binary
@Get('/:id/docs/:guid')
@Auth()
@Publicable()
async doc(
@CurrentUser() user: UserType | undefined,
@Param('id') ws: string,
@Param('guid') guid: string,
@Res() res: Response
) {
const start = process.hrtime();
const docId = new DocID(guid, ws);
if (
// if a user has the permission
!(await this.permission.isAccessible(
docId.workspace,
docId.guid,
user?.id
))
) {
throw new ForbiddenException('Permission denied');
}
const update = await this.docManager.getBinary(docId.workspace, docId.guid);
if (!update) {
throw new NotFoundException('Doc not found');
}
res.setHeader('content-type', 'application/octet-stream');
res.send(update);
this.logger.debug(`workspaces doc api: ${format(process.hrtime(start))}`);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { DocModule } from '../doc';
import { UsersService } from '../users';
import { WorkspacesController } from './controller';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
imports: [DocModule.forFeature()],
controllers: [WorkspacesController],
providers: [WorkspaceResolver, PermissionService, UsersService],
exports: [PermissionService],
})
export class WorkspaceModule {}
export { InvitationType, WorkspaceType } from './resolver';

View File

@@ -0,0 +1,298 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma';
import { Permission } from './types';
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async get(ws: string, user: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
},
});
return data?.type as Permission;
}
async getWorkspaceOwner(workspaceId: string) {
return this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
}
async tryGetWorkspaceOwner(workspaceId: string) {
return this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
}
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
if (user) {
const hasPermission = await this.tryCheck(ws, user);
if (hasPermission) return true;
}
// check if this is a public workspace
const count = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
if (count > 0) {
return true;
}
// check whether this is a public subpage
const workspace = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: ws,
userId: null,
},
});
const subpages = workspace
.map(ws => ws.subPageId)
.filter((v): v is string => !!v);
if (subpages.length > 0 && ws === id) {
// rootDoc is always accessible when there is a public subpage
return true;
} else {
// check if this is a public subpage
return subpages.some(subpage => id === subpage);
}
}
async check(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
if (!(await this.tryCheck(ws, user, permission))) {
throw new ForbiddenException('Permission denied');
}
}
async tryCheck(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
if (data > 0) {
return true;
}
}
const data = await this.prisma.userWorkspacePermission.count({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
type: {
gte: permission,
},
},
});
return data > 0;
}
async grant(
ws: string,
user: string,
permission: Permission = Permission.Read
): Promise<string> {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
},
});
if (data) {
const [p] = await this.prisma.$transaction(
[
this.prisma.userWorkspacePermission.update({
where: {
id: data.id,
},
data: {
type: permission,
},
}),
// If the new permission is owner, we need to revoke old owner
permission === Permission.Owner
? this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
type: Permission.Owner,
userId: {
not: user,
},
},
data: {
type: Permission.Admin,
},
})
: null,
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p.id;
}
return this.prisma.userWorkspacePermission
.create({
data: {
workspaceId: ws,
subPageId: null,
userId: user,
type: permission,
},
})
.then(p => p.id);
}
async getInvitationById(inviteId: string, workspaceId: string) {
return this.prisma.userWorkspacePermission.findUniqueOrThrow({
where: {
id: inviteId,
workspaceId,
},
include: {
user: true,
},
});
}
async acceptById(ws: string, id: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({
where: {
id,
workspaceId: ws,
},
data: {
accepted: true,
},
});
return result.count > 0;
}
async accept(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: false,
},
data: {
accepted: true,
},
});
return result.count > 0;
}
async revoke(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
async isPageAccessible(ws: string, page: string, user?: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
},
});
return data?.accepted || false;
}
async grantPage(
ws: string,
page: string,
user?: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
},
});
if (data) {
return data.accepted;
}
return this.prisma.userWorkspacePermission
.create({
data: {
workspaceId: ws,
subPageId: page,
userId: user,
// if provide user id, user need to accept the invitation
accepted: user ? false : true,
type: permission,
},
})
.then(ret => ret.accepted);
}
async revokePage(ws: string, page: string, user?: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
}

View File

@@ -0,0 +1,838 @@
import type { Storage } from '@affine/storage';
import {
ForbiddenException,
Inject,
InternalServerErrorException,
Logger,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import {
Args,
Field,
Float,
ID,
InputType,
Int,
Mutation,
ObjectType,
OmitType,
Parent,
PartialType,
PickType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { User, Workspace } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types';
import { DocID } from '../../utils/doc';
import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer';
import { AuthService } from '../auth/service';
import { UsersService } from '../users';
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)
id!: string;
@Field({ description: 'is Public workspace' })
public!: boolean;
@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 WorkspaceBlobSizes {
@Field(() => Float)
size!: number;
}
@ObjectType()
export class InvitationType {
@Field({ description: 'Workspace information' })
workspace!: InvitationWorkspaceType;
@Field({ description: 'User information' })
user!: UserType;
@Field({ description: 'Invitee information' })
invitee!: UserType;
}
@InputType()
export class UpdateWorkspaceInput extends PickType(
PartialType(WorkspaceType),
['public'],
InputType
) {
@Field(() => ID)
id!: string;
}
/**
* Workspace resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
private readonly logger = new Logger('WorkspaceResolver');
constructor(
private readonly auth: AuthService,
private readonly mailer: MailService,
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly users: UsersService,
@Inject(StorageProvide) private readonly storage: Storage
) {}
@ResolveField(() => Permission, {
description: 'Permission of current signed in user in workspace',
complexity: 2,
})
async permission(
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
if ('permission' in workspace) {
return workspace.permission;
}
const permission = await this.permissions.get(workspace.id, user.id);
if (!permission) {
throw new ForbiddenException();
}
return permission;
}
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
memberCount(@Parent() workspace: WorkspaceType) {
return this.prisma.userWorkspacePermission.count({
where: {
workspaceId: workspace.id,
userId: {
not: null,
},
},
});
}
@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,
})
async owner(@Parent() workspace: WorkspaceType) {
const data = await this.permissions.getWorkspaceOwner(workspace.id);
return data.user;
}
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@Parent() workspace: WorkspaceType,
@Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number
) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: workspace.id,
userId: {
not: null,
},
},
skip,
take: take || 8,
orderBy: [
{
createdAt: 'asc',
},
{
type: 'desc',
},
],
include: {
user: true,
},
});
return data
.filter(({ user }) => !!user)
.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.permissions.tryGetWorkspaceOwner(workspaceId);
return data?.user?.id === user.id;
}
@Query(() => [WorkspaceType], {
description: 'Get all accessible workspaces for current user',
complexity: 2,
})
async workspaces(@CurrentUser() user: User) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
userId: user.id,
accepted: true,
},
include: {
workspace: true,
},
});
return data.map(({ workspace, type }) => {
return {
...workspace,
permission: type,
};
});
}
@Throttle({
default: {
limit: 10,
ttl: 30,
},
})
@Public()
@Query(() => WorkspaceType, {
description: 'Get public workspace by id',
})
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',
})
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissions.check(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) {
throw new NotFoundException("Workspace doesn't exist");
}
return workspace;
}
@Mutation(() => WorkspaceType, {
description: 'Create a new workspace',
})
async createWorkspace(
@CurrentUser() user: UserType,
@Args({ name: 'init', type: () => GraphQLUpload })
update: FileUpload
) {
// convert stream to buffer
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = update.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
});
stream.on('error', reject);
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
const workspace = await this.prisma.workspace.create({
data: {
public: false,
users: {
create: {
type: Permission.Owner,
user: {
connect: {
id: user.id,
},
},
accepted: true,
},
},
},
});
if (buffer.length) {
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
});
}
return workspace;
}
@Mutation(() => WorkspaceType, {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: UserType,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissions.check(id, user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
id,
},
data: updates,
});
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissions.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
where: {
id,
},
});
await this.prisma.$transaction([
this.prisma.update.deleteMany({
where: {
workspaceId: id,
},
}),
this.prisma.snapshot.deleteMany({
where: {
workspaceId: id,
},
}),
]);
return true;
}
@Mutation(() => String)
async invite(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('permission', { type: () => Permission }) permission: Permission,
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
) {
await this.permissions.check(workspaceId, user.id, Permission.Admin);
if (permission === Permission.Owner) {
throw new ForbiddenException('Cannot change owner');
}
const target = await this.users.findUserByEmail(email);
if (target) {
const originRecord = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
userId: target.id,
},
});
if (originRecord) {
return originRecord.id;
}
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
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 || '',
},
});
} catch (e) {
const ret = await this.permissions.revoke(workspaceId, target.id);
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new InternalServerErrorException(e);
}
}
return inviteId;
} else {
const user = await this.auth.createAnonymousUser(email);
const inviteId = await this.permissions.grant(
workspaceId,
user.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
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 || '',
},
});
} catch (e) {
const ret = await this.permissions.revoke(workspaceId, user.id);
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new InternalServerErrorException(e);
}
}
return inviteId;
}
}
@Throttle({
default: {
limit: 10,
ttl: 30,
},
})
@Public()
@Query(() => InvitationType, {
description: 'Update workspace',
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
const workspaceId = await this.prisma.userWorkspacePermission
.findUniqueOrThrow({
where: {
id: inviteId,
},
select: {
workspaceId: true,
},
})
.then(({ workspaceId }) => workspaceId);
const snapshot = await this.prisma.snapshot.findFirstOrThrow({
where: {
id: workspaceId,
workspaceId,
},
});
const doc = new Doc();
applyUpdate(doc, new Uint8Array(snapshot.blob));
const metaJSON = doc.getMap('meta').toJSON();
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const invitee = await this.permissions.getInvitationById(
inviteId,
workspaceId
);
let avatar = '';
if (metaJSON.avatar) {
const avatarBlob = await this.storage.getBlob(
workspaceId,
metaJSON.avatar
);
avatar = avatarBlob?.data.toString('base64') || '';
}
return {
workspace: {
name: metaJSON.name || '',
avatar: avatar || defaultWorkspaceAvatar,
id: workspaceId,
},
user: owner.user,
invitee: invitee.user,
};
}
@Mutation(() => Boolean)
async revoke(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
await this.permissions.check(workspaceId, user.id, Permission.Admin);
return this.permissions.revoke(workspaceId, userId);
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@Args('workspaceId') workspaceId: string,
@Args('inviteId') inviteId: string,
@Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean
) {
const {
invitee,
user: inviter,
workspace,
} = await this.getInviteInfo(inviteId);
if (!inviter || !invitee) {
throw new ForbiddenException(
`can not find inviter/invitee by inviteId: ${inviteId}`
);
}
if (sendAcceptMail) {
await this.mailer.sendAcceptedEmail(inviter.email, {
inviteeName: invitee.name,
workspaceName: workspace.name,
});
}
return this.permissions.acceptById(workspaceId, inviteId);
}
@Mutation(() => Boolean)
async acceptInvite(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
return this.permissions.accept(workspaceId, user.id);
}
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('workspaceName') workspaceName: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean
) {
await this.permissions.check(workspaceId, user.id);
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner.user) {
throw new ForbiddenException(
`can not find owner by workspaceId: ${workspaceId}`
);
}
if (sendLeaveMail) {
await this.mailer.sendLeaveWorkspaceEmail(owner.user.email, {
workspaceName,
inviteeName: user.name,
});
}
return this.permissions.revoke(workspaceId, user.id);
}
@Mutation(() => Boolean)
async sharePage(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new ForbiddenException('Expect page not to be workspace');
}
const userWorkspace = await this.prisma.userWorkspacePermission.findFirst({
where: {
userId: user.id,
workspaceId: docId.workspace,
},
});
if (!userWorkspace?.accepted) {
throw new ForbiddenException('Permission denied');
}
return this.permissions.grantPage(docId.workspace, docId.guid);
}
@Mutation(() => Boolean)
async revokePage(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new ForbiddenException('Expect page not to be workspace');
}
await this.permissions.check(docId.workspace, user.id, Permission.Admin);
return this.permissions.revokePage(docId.workspace, docId.guid);
}
@Query(() => [String], {
description: 'List blobs of workspace',
})
async listBlobs(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissions.check(workspaceId, user.id);
return this.storage.listBlobs(workspaceId);
}
@Query(() => WorkspaceBlobSizes)
async collectBlobSizes(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissions.check(workspaceId, user.id);
return this.storage.blobsSize([workspaceId]).then(size => ({ size }));
}
@Query(() => WorkspaceBlobSizes)
async collectAllBlobSizes(@CurrentUser() user: UserType) {
const workspaces = await this.prisma.userWorkspacePermission
.findMany({
where: {
userId: user.id,
accepted: true,
type: Permission.Owner,
},
select: {
workspace: {
select: {
id: true,
},
},
},
})
.then(data => data.map(({ workspace }) => workspace.id));
const size = await this.storage.blobsSize(workspaces);
return { size };
}
@Query(() => WorkspaceBlobSizes)
async checkBlobSize(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('size', { type: () => Float }) size: number
) {
const canWrite = await this.permissions.tryCheck(
workspaceId,
user.id,
Permission.Write
);
if (canWrite) {
const { user } = await this.permissions.getWorkspaceOwner(workspaceId);
if (user) {
const quota = await this.users.getStorageQuotaById(user.id);
const { size: currentSize } = await this.collectAllBlobSizes(user);
return { size: quota - (size + currentSize) };
}
}
return false;
}
@Mutation(() => String)
async setBlob(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
) {
await this.permissions.check(workspaceId, user.id, Permission.Write);
// quota was apply to owner's account
const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) return new NotFoundException('Workspace owner not found');
const quota = await this.users.getStorageQuotaById(owner.id);
const { size } = await this.collectAllBlobSizes(owner);
const checkExceeded = (recvSize: number) => {
if (size + recvSize > quota) {
this.logger.log(
`storage size limit exceeded: ${size + recvSize} > ${quota}`
);
return true;
} else {
return false;
}
};
if (checkExceeded(0)) {
throw new ForbiddenException('storage size limit exceeded');
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
// check size after receive each chunk to avoid unnecessary memory usage
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
if (checkExceeded(bufferSize)) {
reject(new ForbiddenException('storage size limit exceeded'));
}
});
stream.on('error', reject);
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new ForbiddenException('storage size limit exceeded'));
} else {
resolve(buffer);
}
});
});
return this.storage.uploadBlob(workspaceId, buffer);
}
@Mutation(() => Boolean)
async deleteBlob(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('hash') hash: string
) {
await this.permissions.check(workspaceId, user.id);
return this.storage.deleteBlob(workspaceId, hash);
}
}

View File

@@ -0,0 +1,6 @@
export enum Permission {
Read = 0,
Write = 1,
Admin = 10,
Owner = 99,
}

View File

@@ -0,0 +1,2 @@
export const defaultWorkspaceAvatar =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';