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

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "user_feature_gates" (
"id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"feature" VARCHAR NOT NULL,
"reason" VARCHAR NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_feature_gates_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "user_feature_gates" ADD CONSTRAINT "user_feature_gates_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,6 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"] binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["metrics", "tracing"] previewFeatures = ["metrics", "tracing"]
} }
@@ -48,10 +48,22 @@ model User {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
/// Not available if user signed up through OAuth providers /// Not available if user signed up through OAuth providers
password String? @db.VarChar password String? @db.VarChar
features UserFeatureGates[]
@@map("users") @@map("users")
} }
model UserFeatureGates {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
feature String @db.VarChar
reason String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_feature_gates")
}
model Account { model Account {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @map("user_id") userId String @map("user_id")

View File

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

View File

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

View File

@@ -1,16 +1,10 @@
import { randomUUID } from 'node:crypto';
import { PrismaAdapter } from '@auth/prisma-adapter'; 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 { verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { assign, omit } from 'lodash-es'; import { assign, omit } from 'lodash-es';
import { nanoid } from 'nanoid';
import { NextAuthOptions } from 'next-auth'; import { NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials'; import Credentials from 'next-auth/providers/credentials';
import Email, { import Email from 'next-auth/providers/email';
type SendVerificationRequestParams,
} from 'next-auth/providers/email';
import Github from 'next-auth/providers/github'; import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google'; import Google from 'next-auth/providers/google';
@@ -20,7 +14,12 @@ import { SessionService } from '../../session';
import { NewFeaturesKind } from '../users/types'; import { NewFeaturesKind } from '../users/types';
import { isStaff } from '../users/utils'; import { isStaff } from '../users/utils';
import { MailService } from './mailer'; import { MailService } from './mailer';
import { getUtcTimestamp, UserClaim } from './service'; import {
decode,
encode,
sendVerificationRequest,
SendVerificationRequestParams,
} from './utils';
export const NextAuthOptionsProvide = Symbol('NextAuthOptions'); export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
@@ -78,44 +77,8 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
}, },
}, },
from: config.auth.email.sender, from: config.auth.email.sender,
async sendVerificationRequest(params: SendVerificationRequestParams) { sendVerificationRequest: (params: SendVerificationRequestParams) =>
const { identifier, url, provider } = params; sendVerificationRequest(config, logger, mailer, session, 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`);
}
},
}), }),
], ],
// @ts-expect-error Third part library type mismatch // @ts-expect-error Third part library type mismatch
@@ -200,66 +163,9 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
} }
nextAuthOptions.jwt = { nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) => { encode: async ({ token, maxAge }) =>
if (!token?.email) { encode(config, prisma, token, maxAge),
throw new BadRequestException('Missing email in jwt token'); decode: async ({ token }) => decode(config, 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,
};
},
}; };
nextAuthOptions.secret ??= config.auth.nextAuthSecret; nextAuthOptions.secret ??= config.auth.nextAuthSecret;

View File

@@ -23,7 +23,7 @@ import { AuthHandler } from 'next-auth/core';
import { Config } from '../../config'; import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics'; import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { AuthThrottlerGuard, Throttle } from '../../throttler';
import { NextAuthOptionsProvide } from './next-auth-options'; import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service'; import { AuthService } from './service';
@@ -47,7 +47,7 @@ export class NextAuthController {
this.callbackSession = nextAuthOptions.callbacks!.session; this.callbackSession = nextAuthOptions.callbacks!.session;
} }
@UseGuards(CloudThrottlerGuard) @UseGuards(AuthThrottlerGuard)
@Throttle(60, 60) @Throttle(60, 60)
@All('*') @All('*')
async auth( async auth(
@@ -197,7 +197,7 @@ export class NextAuthController {
throw new BadRequestException(`Invalid new session data`); throw new BadRequestException(`Invalid new session data`);
} }
if (password) { if (password) {
const user = await this.getUserFromRequest(req); const user = await this.verifyUserFromRequest(req);
const { password: userPassword } = user; const { password: userPassword } = user;
if (!oldPassword) { if (!oldPassword) {
if (userPassword) { if (userPassword) {
@@ -223,7 +223,7 @@ export class NextAuthController {
} }
return user; return user;
} else { } else {
const user = await this.getUserFromRequest(req); const user = await this.verifyUserFromRequest(req);
return this.prisma.user.update({ return this.prisma.user.update({
where: { where: {
id: user.id, 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; const token = req.headers.authorization;
if (!token) { if (!token) {
const session = await AuthHandler({ 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 { StorageModule } from '../storage';
import { UserResolver } from './resolver'; import { UserResolver } from './resolver';
import { UsersService } from './users';
@Module({ @Module({
imports: [StorageModule], imports: [StorageModule],
providers: [UserResolver], providers: [UserResolver, UsersService],
}) })
export class UsersModule {} export class UsersModule {}
export { UserType } from './resolver'; 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 // @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types'; import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth/guard'; import { Auth, CurrentUser, Public } from '../auth/guard';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types'; import { NewFeaturesKind } from './types';
import { UsersService } from './users';
import { isStaff } from './utils'; import { isStaff } from './utils';
registerEnumType(NewFeaturesKind, { registerEnumType(NewFeaturesKind, {
@@ -83,7 +83,7 @@ export class UserResolver {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly storage: StorageService, private readonly storage: StorageService,
private readonly config: Config private readonly users: UsersService
) {} ) {}
@Throttle(10, 60) @Throttle(10, 60)
@@ -92,9 +92,7 @@ export class UserResolver {
description: 'Get current user', description: 'Get current user',
}) })
async currentUser(@CurrentUser() user: UserType) { async currentUser(@CurrentUser() user: UserType) {
const storedUser = await this.prisma.user.findUnique({ const storedUser = await this.users.findUserById(user.id);
where: { id: user.id },
});
if (!storedUser) { if (!storedUser) {
throw new BadRequestException(`User ${user.id} not found in db`); throw new BadRequestException(`User ${user.id} not found in db`);
} }
@@ -117,27 +115,14 @@ export class UserResolver {
}) })
@Public() @Public()
async user(@Args('email') email: string) { async user(@Args('email') email: string) {
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) { if (!(await this.users.canEarlyAccess(email))) {
const hasEarlyAccess = await this.prisma.newFeaturesWaitingList return new HttpException(
.findUnique({ `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
where: { email, type: NewFeaturesKind.EarlyAccess }, 401
}) );
.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
);
}
} }
// TODO: need to limit a user can only get another user witch is in the same workspace // TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.prisma.user const user = await this.users.findUserByEmail(email);
.findUnique({
where: { email },
})
.catch(() => {
return null;
});
if (user?.password) { if (user?.password) {
const userResponse: UserType = user; const userResponse: UserType = user;
userResponse.hasPassword = true; userResponse.hasPassword = true;
@@ -155,7 +140,7 @@ export class UserResolver {
@Args({ name: 'avatar', type: () => GraphQLUpload }) @Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload avatar: FileUpload
) { ) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.users.findUserById(id);
if (!user) { if (!user) {
throw new BadRequestException(`User ${id} not found`); throw new BadRequestException(`User ${id} not found`);
} }
@@ -169,19 +154,8 @@ export class UserResolver {
@Throttle(10, 60) @Throttle(10, 60)
@Mutation(() => DeleteAccount) @Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> { async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
await this.prisma.user.delete({ await this.users.deleteUser(user.id);
where: { return { success: true };
id: user.id,
},
});
await this.prisma.session.deleteMany({
where: {
userId: user.id,
},
});
return {
success: true,
};
} }
@Throttle(10, 60) @Throttle(10, 60)
@@ -194,7 +168,7 @@ export class UserResolver {
type: NewFeaturesKind, type: NewFeaturesKind,
@Args('email') email: string @Args('email') email: string
): Promise<AddToNewFeaturesWaitingList> { ): Promise<AddToNewFeaturesWaitingList> {
if (!user.email.endsWith('@toeverything.info')) { if (!isStaff(user.email)) {
throw new ForbiddenException('You are not allowed to do this'); throw new ForbiddenException('You are not allowed to do this');
} }
await this.prisma.newFeaturesWaitingList.create({ 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 { Module } from '@nestjs/common';
import { DocModule } from '../doc'; import { DocModule } from '../doc';
import { UsersService } from '../users';
import { WorkspacesController } from './controller'; import { WorkspacesController } from './controller';
import { PermissionService } from './permission'; import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver'; import { WorkspaceResolver } from './resolver';
@@ -8,7 +9,7 @@ import { WorkspaceResolver } from './resolver';
@Module({ @Module({
imports: [DocModule.forFeature()], imports: [DocModule.forFeature()],
controllers: [WorkspacesController], controllers: [WorkspacesController],
providers: [WorkspaceResolver, PermissionService], providers: [WorkspaceResolver, PermissionService, UsersService],
exports: [PermissionService], exports: [PermissionService],
}) })
export class WorkspaceModule {} export class WorkspaceModule {}

View File

@@ -21,6 +21,30 @@ export class PermissionService {
return data?.type as Permission; 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> { async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
if (user) { if (user) {
return await this.tryCheck(ws, user); return await this.tryCheck(ws, user);
@@ -157,6 +181,18 @@ export class PermissionService {
.then(p => p.id); .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) { async acceptById(ws: string, id: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({ const result = await this.prisma.userWorkspacePermission.updateMany({
where: { where: {

View File

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

View File

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

View File

@@ -386,6 +386,27 @@ async function collectAllBlobSizes(
return res.body.data.collectAllBlobSizes.size; 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( async function setBlob(
app: INestApplication, app: INestApplication,
token: string, token: string,
@@ -480,6 +501,7 @@ async function getInviteInfo(
export { export {
acceptInvite, acceptInvite,
acceptInviteById, acceptInviteById,
checkBlobSize,
collectAllBlobSizes, collectAllBlobSizes,
collectBlobSizes, collectBlobSizes,
createTestApp, createTestApp,

View File

@@ -8,6 +8,7 @@ import request from 'supertest';
import { AppModule } from '../app'; import { AppModule } from '../app';
import { import {
checkBlobSize,
collectAllBlobSizes, collectAllBlobSizes,
collectBlobSizes, collectBlobSizes,
createWorkspace, createWorkspace,
@@ -126,4 +127,20 @@ test('should calc all blobs size', async t => {
const size = await collectAllBlobSizes(app, u1.token.token); const size = await collectAllBlobSizes(app, u1.token.token);
t.is(size, 8, 'failed to collect all blob sizes'); 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 }; export { Throttle };

View File

@@ -215,7 +215,7 @@ export const gqlFetcherFactory = (endpoint: string) => {
return gqlFetch; return gqlFetch;
}; };
export const fetchWithTraceReport = ( export const fetchWithTraceReport = async (
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit, init?: RequestInit,
traceOptions?: { event: string } traceOptions?: { event: string }
@@ -241,21 +241,20 @@ export const fetchWithTraceReport = (
return fetch(input, init); return fetch(input, init);
} }
return fetch(input, init) try {
.then(response => { const response = await fetch(input, init);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
traceReporter!.cacheTrace(traceId, spanId, startTime, { traceReporter!.cacheTrace(traceId, spanId, startTime, {
requestId, requestId,
...(event ? { event } : {}), ...(event ? { event } : {}),
});
return response;
})
.catch(err => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
traceReporter!.uploadTrace(traceId, spanId, startTime, {
requestId,
...(event ? { event } : {}),
});
return Promise.reject(err);
}); });
return response;
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
traceReporter!.uploadTrace(traceId, spanId, startTime, {
requestId,
...(event ? { event } : {}),
});
return await Promise.reject(err);
}
}; };

View File

@@ -0,0 +1,5 @@
query checkBlobSizes($workspaceId: String!, $size: Float!) {
checkBlobSize(workspaceId: $workspaceId, size: $size) {
size
}
}

View File

@@ -7,6 +7,19 @@ export interface GraphQLQuery {
containsFile?: boolean; containsFile?: boolean;
} }
export const checkBlobSizesQuery = {
id: 'checkBlobSizesQuery' as const,
operationName: 'checkBlobSizes',
definitionName: 'checkBlobSize',
containsFile: false,
query: `
query checkBlobSizes($workspaceId: String!, $size: Float!) {
checkBlobSize(workspaceId: $workspaceId, size: $size) {
size
}
}`,
};
export const deleteBlobMutation = { export const deleteBlobMutation = {
id: 'deleteBlobMutation' as const, id: 'deleteBlobMutation' as const,
operationName: 'deleteBlob', operationName: 'deleteBlob',

View File

@@ -50,6 +50,16 @@ export interface UpdateWorkspaceInput {
public: InputMaybe<Scalars['Boolean']['input']>; public: InputMaybe<Scalars['Boolean']['input']>;
} }
export type CheckBlobSizesQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
size: Scalars['Float']['input'];
}>;
export type CheckBlobSizesQuery = {
__typename?: 'Query';
checkBlobSize: { __typename?: 'WorkspaceBlobSizes'; size: number };
};
export type DeleteBlobMutationVariables = Exact<{ export type DeleteBlobMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
hash: Scalars['String']['input']; hash: Scalars['String']['input'];
@@ -448,6 +458,11 @@ export type AcceptInviteByWorkspaceIdMutation = {
}; };
export type Queries = export type Queries =
| {
name: 'checkBlobSizesQuery';
variables: CheckBlobSizesQueryVariables;
response: CheckBlobSizesQuery;
}
| { | {
name: 'listBlobsQuery'; name: 'listBlobsQuery';
variables: ListBlobsQueryVariables; variables: ListBlobsQueryVariables;

View File

@@ -1,13 +1,13 @@
import { import {
checkBlobSizesQuery,
deleteBlobMutation, deleteBlobMutation,
fetchWithTraceReport, fetchWithTraceReport,
listBlobsQuery, listBlobsQuery,
setBlobMutation, setBlobMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { fetcher } from '@affine/workspace/affine/gql';
import type { BlobStorage } from '@blocksuite/store'; import type { BlobStorage } from '@blocksuite/store';
import { fetcher } from '../affine/gql';
export const createCloudBlobStorage = (workspaceId: string): BlobStorage => { export const createCloudBlobStorage = (workspaceId: string): BlobStorage => {
return { return {
crud: { crud: {
@@ -18,6 +18,20 @@ export const createCloudBlobStorage = (workspaceId: string): BlobStorage => {
).then(res => res.blob()); ).then(res => res.blob());
}, },
set: async (key, value) => { set: async (key, value) => {
const {
checkBlobSize: { size },
} = await fetcher({
query: checkBlobSizesQuery,
variables: {
workspaceId,
size: value.size,
},
});
if (size <= 0) {
throw new Error('Blob size limit exceeded');
}
const result = await fetcher({ const result = await fetcher({
query: setBlobMutation, query: setBlobMutation,
variables: { variables: {