refactor(server): rate limit and permission (#4198)

Co-authored-by: LongYinan <lynweklm@gmail.com>
This commit is contained in:
DarkSky
2023-09-08 05:32:41 +08:00
committed by GitHub
parent 4c4bb65be8
commit f4340da478
25 changed files with 555 additions and 272 deletions

View File

@@ -187,8 +187,8 @@ export interface AFFiNEConfig {
path: string;
};
/**
* Free user storage quota
* @default 10 * 1024 * 1024 (10GB)
* default storage quota
* @default 10 * 1024 * 1024 * 1024 (10GB)
*/
quota: number;
};

View File

@@ -175,7 +175,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
fs: {
path: join(homedir(), '.affine-storage'),
},
quota: 10 * 1024 * 1024,
// 10GB
quota: 10 * 1024 * 1024 * 1024,
},
rateLimiter: {
ttl: 60,

View File

@@ -1,16 +1,10 @@
import { randomUUID } from 'node:crypto';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common';
import { FactoryProvider, Logger } from '@nestjs/common';
import { verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { assign, omit } from 'lodash-es';
import { nanoid } from 'nanoid';
import { NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Email, {
type SendVerificationRequestParams,
} from 'next-auth/providers/email';
import Email from 'next-auth/providers/email';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
@@ -20,7 +14,12 @@ import { SessionService } from '../../session';
import { NewFeaturesKind } from '../users/types';
import { isStaff } from '../users/utils';
import { MailService } from './mailer';
import { getUtcTimestamp, UserClaim } from './service';
import {
decode,
encode,
sendVerificationRequest,
SendVerificationRequestParams,
} from './utils';
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
@@ -78,44 +77,8 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
},
},
from: config.auth.email.sender,
async sendVerificationRequest(params: SendVerificationRequestParams) {
const { identifier, url, provider } = params;
const urlWithToken = new URL(url);
const callbackUrl =
urlWithToken.searchParams.get('callbackUrl') || '';
if (!callbackUrl) {
throw new Error('callbackUrl is not set');
} else {
const newCallbackUrl = new URL(callbackUrl, config.origin);
const token = nanoid();
await session.set(token, identifier);
newCallbackUrl.searchParams.set('token', token);
urlWithToken.searchParams.set(
'callbackUrl',
newCallbackUrl.toString()
);
}
const result = await mailer.sendSignInEmail(
urlWithToken.toString(),
{
to: identifier,
from: provider.from,
}
);
logger.log(
`send verification email success: ${result.accepted.join(', ')}`
);
const failed = result.rejected
.concat(result.pending)
.filter(Boolean);
if (failed.length) {
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
}
},
sendVerificationRequest: (params: SendVerificationRequestParams) =>
sendVerificationRequest(config, logger, mailer, session, params),
}),
],
// @ts-expect-error Third part library type mismatch
@@ -200,66 +163,9 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
}
nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
picture: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
hasPassword: Boolean(user.password),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
},
decode: async ({ token }) => {
if (!token) {
return null;
}
const { name, email, emailVerified, id, picture, hasPassword } = (
await jwtVerify(token, config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [config.serverId],
leeway: config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as Omit<UserClaim, 'avatarUrl'> & {
picture: string | undefined;
};
return {
name,
email,
emailVerified,
picture,
sub: id,
id,
hasPassword,
};
},
encode: async ({ token, maxAge }) =>
encode(config, prisma, token, maxAge),
decode: async ({ token }) => decode(config, token),
};
nextAuthOptions.secret ??= config.auth.nextAuthSecret;

View File

@@ -23,7 +23,7 @@ import { AuthHandler } from 'next-auth/core';
import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { AuthThrottlerGuard, Throttle } from '../../throttler';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
@@ -47,7 +47,7 @@ export class NextAuthController {
this.callbackSession = nextAuthOptions.callbacks!.session;
}
@UseGuards(CloudThrottlerGuard)
@UseGuards(AuthThrottlerGuard)
@Throttle(60, 60)
@All('*')
async auth(
@@ -197,7 +197,7 @@ export class NextAuthController {
throw new BadRequestException(`Invalid new session data`);
}
if (password) {
const user = await this.getUserFromRequest(req);
const user = await this.verifyUserFromRequest(req);
const { password: userPassword } = user;
if (!oldPassword) {
if (userPassword) {
@@ -223,7 +223,7 @@ export class NextAuthController {
}
return user;
} else {
const user = await this.getUserFromRequest(req);
const user = await this.verifyUserFromRequest(req);
return this.prisma.user.update({
where: {
id: user.id,
@@ -233,7 +233,7 @@ export class NextAuthController {
}
}
private async getUserFromRequest(req: Request): Promise<User> {
private async verifyUserFromRequest(req: Request): Promise<User> {
const token = req.headers.authorization;
if (!token) {
const session = await AuthHandler({

View File

@@ -0,0 +1,3 @@
export { jwtDecode as decode, jwtEncode as encode } from './jwt';
export { sendVerificationRequest } from './send-mail';
export type { SendVerificationRequestParams } from 'next-auth/providers/email';

View File

@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { JWT } from 'next-auth/jwt';
import { Config } from '../../../config';
import { PrismaService } from '../../../prisma';
import { getUtcTimestamp, UserClaim } from '../service';
export const jwtEncode = async (
config: Config,
prisma: PrismaService,
token: JWT | undefined,
maxAge: number | undefined
) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
picture: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
hasPassword: Boolean(user.password),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
};
export const jwtDecode = async (config: Config, token: string | undefined) => {
if (!token) {
return null;
}
const { name, email, emailVerified, id, picture, hasPassword } = (
await jwtVerify(token, config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [config.serverId],
leeway: config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as Omit<UserClaim, 'avatarUrl'> & {
picture: string | undefined;
};
return {
name,
email,
emailVerified,
picture,
sub: id,
id,
hasPassword,
};
};

View File

@@ -0,0 +1,41 @@
import { Logger } from '@nestjs/common';
import { nanoid } from 'nanoid';
import type { SendVerificationRequestParams } from 'next-auth/providers/email';
import { Config } from '../../../config';
import { SessionService } from '../../../session';
import { MailService } from '../mailer';
export async function sendVerificationRequest(
config: Config,
logger: Logger,
mailer: MailService,
session: SessionService,
params: SendVerificationRequestParams
) {
const { identifier, url, provider } = params;
const urlWithToken = new URL(url);
const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || '';
if (!callbackUrl) {
throw new Error('callbackUrl is not set');
} else {
const newCallbackUrl = new URL(callbackUrl, config.origin);
const token = nanoid();
await session.set(token, identifier);
newCallbackUrl.searchParams.set('token', token);
urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString());
}
const result = await mailer.sendSignInEmail(urlWithToken.toString(), {
to: identifier,
from: provider.from,
});
logger.log(`send verification email success: ${result.accepted.join(', ')}`);
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
}
}

View File

@@ -0,0 +1,42 @@
type FeatureEarlyAccessPreview = {
whitelist: RegExp[];
};
type FeatureStorageLimit = {
storageQuota: number;
};
type UserFeatureGate = {
earlyAccessPreview: FeatureEarlyAccessPreview;
freeUser: FeatureStorageLimit;
proUser: FeatureStorageLimit;
};
const UserLevel = {
freeUser: {
storageQuota: 10 * 1024 * 1024 * 1024,
},
proUser: {
storageQuota: 100 * 1024 * 1024 * 1024,
},
} satisfies Pick<UserFeatureGate, 'freeUser' | 'proUser'>;
export function getStorageQuota(features: string[]) {
for (const feature of features) {
if (feature in UserLevel) {
return UserLevel[feature as keyof typeof UserLevel].storageQuota;
}
}
return null;
}
const UserType = {
earlyAccessPreview: {
whitelist: [/@toeverything\.info$/],
},
} satisfies Pick<UserFeatureGate, 'earlyAccessPreview'>;
export const FeatureGates = {
...UserType,
...UserLevel,
} satisfies UserFeatureGate;

View File

@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { StorageModule } from '../storage';
import { UserResolver } from './resolver';
import { UsersService } from './users';
@Module({
imports: [StorageModule],
providers: [UserResolver],
providers: [UserResolver, UsersService],
})
export class UsersModule {}
export { UserType } from './resolver';
export { UsersService } from './users';

View File

@@ -18,13 +18,13 @@ import type { User } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth/guard';
import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types';
import { UsersService } from './users';
import { isStaff } from './utils';
registerEnumType(NewFeaturesKind, {
@@ -83,7 +83,7 @@ export class UserResolver {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly config: Config
private readonly users: UsersService
) {}
@Throttle(10, 60)
@@ -92,9 +92,7 @@ export class UserResolver {
description: 'Get current user',
})
async currentUser(@CurrentUser() user: UserType) {
const storedUser = await this.prisma.user.findUnique({
where: { id: user.id },
});
const storedUser = await this.users.findUserById(user.id);
if (!storedUser) {
throw new BadRequestException(`User ${user.id} not found in db`);
}
@@ -117,27 +115,14 @@ export class UserResolver {
})
@Public()
async user(@Args('email') email: string) {
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
const hasEarlyAccess = await this.prisma.newFeaturesWaitingList
.findUnique({
where: { email, type: NewFeaturesKind.EarlyAccess },
})
.catch(() => false);
if (!hasEarlyAccess) {
return new HttpException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
401
);
}
if (!(await this.users.canEarlyAccess(email))) {
return new HttpException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
401
);
}
// TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.prisma.user
.findUnique({
where: { email },
})
.catch(() => {
return null;
});
const user = await this.users.findUserByEmail(email);
if (user?.password) {
const userResponse: UserType = user;
userResponse.hasPassword = true;
@@ -155,7 +140,7 @@ export class UserResolver {
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
const user = await this.prisma.user.findUnique({ where: { id } });
const user = await this.users.findUserById(id);
if (!user) {
throw new BadRequestException(`User ${id} not found`);
}
@@ -169,19 +154,8 @@ export class UserResolver {
@Throttle(10, 60)
@Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
await this.prisma.user.delete({
where: {
id: user.id,
},
});
await this.prisma.session.deleteMany({
where: {
userId: user.id,
},
});
return {
success: true,
};
await this.users.deleteUser(user.id);
return { success: true };
}
@Throttle(10, 60)
@@ -194,7 +168,7 @@ export class UserResolver {
type: NewFeaturesKind,
@Args('email') email: string
): Promise<AddToNewFeaturesWaitingList> {
if (!user.email.endsWith('@toeverything.info')) {
if (!isStaff(user.email)) {
throw new ForbiddenException('You are not allowed to do this');
}
await this.prisma.newFeaturesWaitingList.create({

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { getStorageQuota } from './gates';
import { NewFeaturesKind } from './types';
import { isStaff } from './utils';
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly config: Config
) {}
async canEarlyAccess(email: string) {
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
return this.prisma.newFeaturesWaitingList
.findUnique({
where: { email, type: NewFeaturesKind.EarlyAccess },
})
.catch(() => false);
} else {
return true;
}
}
async getStorageQuotaById(id: string) {
const features = await this.prisma.user
.findUnique({
where: { id },
select: {
features: {
select: {
feature: true,
},
},
},
})
.then(user => user?.features.map(f => f.feature) ?? []);
return getStorageQuota(features) || this.config.objectStorage.quota;
}
async findUserByEmail(email: string) {
return this.prisma.user
.findUnique({
where: { email },
})
.catch(() => {
return null;
});
}
async findUserById(id: string) {
return this.prisma.user
.findUnique({
where: { id },
})
.catch(() => {
return null;
});
}
async deleteUser(id: string) {
return this.prisma.user.delete({ where: { id } });
}
}

View File

@@ -1,6 +1,7 @@
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';
@@ -8,7 +9,7 @@ import { WorkspaceResolver } from './resolver';
@Module({
imports: [DocModule.forFeature()],
controllers: [WorkspacesController],
providers: [WorkspaceResolver, PermissionService],
providers: [WorkspaceResolver, PermissionService, UsersService],
exports: [PermissionService],
})
export class WorkspaceModule {}

View File

@@ -21,6 +21,30 @@ export class PermissionService {
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) {
return await this.tryCheck(ws, user);
@@ -157,6 +181,18 @@ export class PermissionService {
.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: {

View File

@@ -9,6 +9,7 @@ import {
import {
Args,
Field,
Float,
ID,
InputType,
Int,
@@ -36,6 +37,7 @@ import type { FileUpload } from '../../types';
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';
@@ -139,7 +141,8 @@ export class WorkspaceResolver {
private readonly config: Config,
private readonly mailer: MailService,
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService,
private readonly permissions: PermissionService,
private readonly users: UsersService,
@Inject(StorageProvide) private readonly storage: Storage
) {}
@@ -156,7 +159,7 @@ export class WorkspaceResolver {
return workspace.permission;
}
const permission = await this.permissionProvider.get(workspace.id, user.id);
const permission = await this.permissions.get(workspace.id, user.id);
if (!permission) {
throw new ForbiddenException();
@@ -197,15 +200,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId: workspace.id,
type: Permission.Owner,
},
include: {
user: true,
},
});
const data = await this.permissions.getWorkspaceOwner(workspace.id);
return data.user;
}
@@ -241,15 +236,7 @@ export class WorkspaceResolver {
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
return data?.user?.id === user.id;
}
@@ -298,7 +285,7 @@ export class WorkspaceResolver {
description: 'Get workspace by id',
})
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id);
await this.permissions.check(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) {
@@ -367,7 +354,7 @@ export class WorkspaceResolver {
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissionProvider.check(id, user.id, Permission.Admin);
await this.permissions.check(id, user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
@@ -379,7 +366,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id, Permission.Owner);
await this.permissions.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
where: {
@@ -411,17 +398,13 @@ export class WorkspaceResolver {
@Args('permission', { type: () => Permission }) permission: Permission,
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
await this.permissions.check(workspaceId, user.id, Permission.Admin);
if (permission === Permission.Owner) {
throw new ForbiddenException('Cannot change owner');
}
const target = await this.prisma.user.findUnique({
where: {
email,
},
});
const target = await this.users.findUserByEmail(email);
if (target) {
const originRecord = await this.prisma.userWorkspacePermission.findFirst({
@@ -435,7 +418,7 @@ export class WorkspaceResolver {
return originRecord.id;
}
const inviteId = await this.permissionProvider.grant(
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
@@ -458,7 +441,7 @@ export class WorkspaceResolver {
return inviteId;
} else {
const user = await this.auth.createAnonymousUser(email);
const inviteId = await this.permissionProvider.grant(
const inviteId = await this.permissions.grant(
workspaceId,
user.id,
permission
@@ -488,17 +471,21 @@ export class WorkspaceResolver {
description: 'Update workspace',
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
const permission =
await this.prisma.userWorkspacePermission.findUniqueOrThrow({
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: permission.workspaceId,
workspaceId: permission.workspaceId,
id: workspaceId,
workspaceId,
},
});
@@ -507,32 +494,17 @@ export class WorkspaceResolver {
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,
},
});
const invitee = await this.prisma.userWorkspacePermission.findUniqueOrThrow(
{
where: {
id: inviteId,
workspaceId: permission.workspaceId,
},
include: {
user: true,
},
}
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(
permission.workspaceId,
workspaceId,
metaJSON.avatar
);
avatar = avatarBlob?.data.toString('base64') || '';
@@ -542,7 +514,7 @@ export class WorkspaceResolver {
workspace: {
name: metaJSON.name || '',
avatar: avatar || defaultWorkspaceAvatar,
id: permission.workspaceId,
id: workspaceId,
},
user: owner.user,
invitee: invitee.user,
@@ -555,9 +527,9 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
await this.permissions.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.revoke(workspaceId, userId);
return this.permissions.revoke(workspaceId, userId);
}
@Mutation(() => Boolean)
@@ -586,7 +558,7 @@ export class WorkspaceResolver {
});
}
return this.permissionProvider.acceptById(workspaceId, inviteId);
return this.permissions.acceptById(workspaceId, inviteId);
}
@Mutation(() => Boolean)
@@ -594,7 +566,7 @@ export class WorkspaceResolver {
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
return this.permissionProvider.accept(workspaceId, user.id);
return this.permissions.accept(workspaceId, user.id);
}
@Mutation(() => Boolean)
@@ -604,17 +576,9 @@ export class WorkspaceResolver {
@Args('workspaceName') workspaceName: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean
) {
await this.permissionProvider.check(workspaceId, user.id);
await this.permissions.check(workspaceId, user.id);
const owner = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner.user) {
throw new ForbiddenException(
@@ -629,7 +593,7 @@ export class WorkspaceResolver {
});
}
return this.permissionProvider.revoke(workspaceId, user.id);
return this.permissions.revoke(workspaceId, user.id);
}
@Mutation(() => Boolean)
@@ -638,9 +602,9 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
await this.permissions.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.grantPage(workspaceId, pageId);
return this.permissions.grantPage(workspaceId, pageId);
}
@Mutation(() => Boolean)
@@ -649,9 +613,9 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
await this.permissions.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.revokePage(workspaceId, pageId);
return this.permissions.revokePage(workspaceId, pageId);
}
@Query(() => [String], {
@@ -661,7 +625,7 @@ export class WorkspaceResolver {
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
await this.permissions.check(workspaceId, user.id);
return this.storage.listBlobs(workspaceId);
}
@@ -671,7 +635,7 @@ export class WorkspaceResolver {
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
await this.permissions.check(workspaceId, user.id);
return this.storage.blobsSize([workspaceId]).then(size => ({ size }));
}
@@ -699,6 +663,29 @@ export class WorkspaceResolver {
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,
@@ -706,36 +693,53 @@ export class WorkspaceResolver {
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Write);
const quota = this.config.objectStorage.quota;
const { size } = await this.collectAllBlobSizes(user);
await this.permissions.check(workspaceId, user.id, Permission.Write);
if (size > quota) {
this.logger.log(`storage size limit exceeded: ${size} > ${quota}`);
// 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', () => {
resolve(Buffer.concat(chunks));
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new ForbiddenException('storage size limit exceeded'));
} else {
resolve(buffer);
}
});
});
if (size + buffer.length > quota) {
this.logger.log(
`storage size limit exceeded after blob set: ${size} > ${
buffer.length > quota
}`
);
throw new ForbiddenException('storage size limit exceeded');
}
return this.storage.uploadBlob(workspaceId, buffer);
}
@@ -745,7 +749,7 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string,
@Args('hash') hash: string
) {
await this.permissionProvider.check(workspaceId, user.id);
await this.permissions.check(workspaceId, user.id);
return this.storage.deleteBlob(workspaceId, hash);
}

View File

@@ -158,6 +158,7 @@ type Query {
listBlobs(workspaceId: String!): [String!]!
collectBlobSizes(workspaceId: String!): WorkspaceBlobSizes!
collectAllBlobSizes: WorkspaceBlobSizes!
checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes!
"""Get current user"""
currentUser: UserType!

View File

@@ -386,6 +386,27 @@ async function collectAllBlobSizes(
return res.body.data.collectAllBlobSizes.size;
}
async function checkBlobSize(
app: INestApplication,
token: string,
workspaceId: string,
size: number
): Promise<number> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `query checkBlobSize($workspaceId: String!, $size: Float!) {
checkBlobSize(workspaceId: $workspaceId, size: $size) {
size
}
}`,
variables: { workspaceId, size },
})
.expect(200);
return res.body.data.checkBlobSize.size;
}
async function setBlob(
app: INestApplication,
token: string,
@@ -480,6 +501,7 @@ async function getInviteInfo(
export {
acceptInvite,
acceptInviteById,
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
createTestApp,

View File

@@ -8,6 +8,7 @@ import request from 'supertest';
import { AppModule } from '../app';
import {
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
createWorkspace,
@@ -126,4 +127,20 @@ test('should calc all blobs size', async t => {
const size = await collectAllBlobSizes(app, u1.token.token);
t.is(size, 8, 'failed to collect all blob sizes');
const size1 = await checkBlobSize(
app,
u1.token.token,
workspace1.id,
10 * 1024 * 1024 * 1024 - 8
);
t.is(size1, 0, 'failed to check blob size');
const size2 = await checkBlobSize(
app,
u1.token.token,
workspace1.id,
10 * 1024 * 1024 * 1024 - 7
);
t.is(size2, -1, 'failed to check blob size');
});

View File

@@ -54,4 +54,22 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
}
}
@Injectable()
export class AuthThrottlerGuard extends CloudThrottlerGuard {
override async handleRequest(
context: ExecutionContext,
limit: number,
ttl: number
): Promise<boolean> {
const { req } = this.getRequestResponse(context);
if (req?.url === '/api/auth/session') {
// relax throttle for session auto renew
return super.handleRequest(context, limit * 20, ttl);
}
return super.handleRequest(context, limit, ttl);
}
}
export { Throttle };