feat(server): introduce user friendly server errors (#7111)

This commit is contained in:
liuyi
2024-06-17 11:30:58 +08:00
committed by GitHub
parent 5307a55f8a
commit 54fc1197ad
65 changed files with 3170 additions and 924 deletions

View File

@@ -1,7 +1,6 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Body,
Controller,
Get,
@@ -14,7 +13,16 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Config, Throttle, URLHelper } from '../../fundamentals';
import {
Config,
EarlyAccessRequired,
EmailTokenNotFound,
InternalServerError,
InvalidEmailToken,
SignUpForbidden,
Throttle,
URLHelper,
} from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -55,9 +63,7 @@ export class AuthController {
validators.assertValidEmail(credential.email);
const canSignIn = await this.auth.canSignIn(credential.email);
if (!canSignIn) {
throw new BadRequestException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
);
throw new EarlyAccessRequired();
}
if (credential.password) {
@@ -74,7 +80,7 @@ export class AuthController {
if (!user) {
const allowSignup = await this.config.runtime.fetch('auth/allowSignup');
if (!allowSignup) {
throw new BadRequestException('You are not allows to sign up.');
throw new SignUpForbidden();
}
}
@@ -84,7 +90,7 @@ export class AuthController {
);
if (result.rejected.length) {
throw new Error('Failed to send sign-in email.');
throw new InternalServerError('Failed to send sign-in email.');
}
res.status(HttpStatus.OK).send({
@@ -145,7 +151,7 @@ export class AuthController {
@Body() { email, token }: MagicLinkCredential
) {
if (!token || !email) {
throw new BadRequestException('Missing sign-in mail token');
throw new EmailTokenNotFound();
}
validators.assertValidEmail(email);
@@ -155,7 +161,7 @@ export class AuthController {
});
if (!valid) {
throw new BadRequestException('Invalid sign-in mail token');
throw new InvalidEmailToken();
}
const user = await this.user.fulfillUser(email, {

View File

@@ -3,15 +3,13 @@ import type {
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import {
Injectable,
SetMetadata,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { getRequestResponseFromContext } from '../../fundamentals';
import {
AuthenticationRequired,
getRequestResponseFromContext,
} from '../../fundamentals';
import { AuthService, parseAuthUserSeqNum } from './service';
function extractTokenFromHeader(authorization: string) {
@@ -84,7 +82,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
}
if (!req.user) {
throw new UnauthorizedException('You are not signed in.');
throw new AuthenticationRequired();
}
return true;

View File

@@ -1,4 +1,3 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import {
Args,
Field,
@@ -10,7 +9,18 @@ import {
Resolver,
} from '@nestjs/graphql';
import { Config, SkipThrottle, Throttle, URLHelper } from '../../fundamentals';
import {
ActionForbidden,
Config,
EmailAlreadyUsed,
EmailTokenNotFound,
EmailVerificationRequired,
InvalidEmailToken,
SameEmailProvided,
SkipThrottle,
Throttle,
URLHelper,
} from '../../fundamentals';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
@@ -62,7 +72,7 @@ export class AuthResolver {
@Parent() user: UserType
): Promise<ClientTokenType> {
if (user.id !== currentUser.id) {
throw new ForbiddenException('Invalid user');
throw new ActionForbidden();
}
const session = await this.auth.createUserSession(
@@ -102,7 +112,7 @@ export class AuthResolver {
);
if (!valid) {
throw new ForbiddenException('Invalid token');
throw new InvalidEmailToken();
}
await this.auth.changePassword(user.id, newPassword);
@@ -124,7 +134,7 @@ export class AuthResolver {
});
if (!valid) {
throw new ForbiddenException('Invalid token');
throw new InvalidEmailToken();
}
email = decodeURIComponent(email);
@@ -144,7 +154,7 @@ export class AuthResolver {
@Args('email', { nullable: true }) _email?: string
) {
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
throw new EmailVerificationRequired();
}
const token = await this.token.createToken(
@@ -166,7 +176,7 @@ export class AuthResolver {
@Args('email', { nullable: true }) _email?: string
) {
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
throw new EmailVerificationRequired();
}
const token = await this.token.createToken(
@@ -195,7 +205,7 @@ export class AuthResolver {
@Args('email', { nullable: true }) _email?: string
) {
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
throw new EmailVerificationRequired();
}
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
@@ -213,24 +223,26 @@ export class AuthResolver {
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
if (!token) {
throw new EmailTokenNotFound();
}
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
credential: user.id,
});
if (!valid) {
throw new ForbiddenException('Invalid token');
throw new InvalidEmailToken();
}
const hasRegistered = await this.user.findUserByEmail(email);
if (hasRegistered) {
if (hasRegistered.id !== user.id) {
throw new BadRequestException(`The email provided has been taken.`);
throw new EmailAlreadyUsed();
} else {
throw new BadRequestException(
`The email provided is the same as the current email.`
);
throw new SameEmailProvided();
}
}
@@ -264,7 +276,7 @@ export class AuthResolver {
@Args('token') token: string
) {
if (!token) {
throw new BadRequestException('Invalid token');
throw new EmailTokenNotFound();
}
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
@@ -272,7 +284,7 @@ export class AuthResolver {
});
if (!valid) {
throw new ForbiddenException('Invalid token');
throw new InvalidEmailToken();
}
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);

View File

@@ -1,16 +1,18 @@
import {
BadRequestException,
Injectable,
NotAcceptableException,
OnApplicationBootstrap,
} from '@nestjs/common';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import { Config, CryptoHelper, MailService } from '../../fundamentals';
import {
Config,
CryptoHelper,
EmailAlreadyUsed,
MailService,
WrongSignInCredentials,
WrongSignInMethod,
} from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
import { QuotaService } from '../quota/service';
import { QuotaType } from '../quota/types';
@@ -109,7 +111,7 @@ export class AuthService implements OnApplicationBootstrap {
const user = await this.user.findUserByEmail(email);
if (user) {
throw new BadRequestException('Email was taken');
throw new EmailAlreadyUsed();
}
const hashedPassword = await this.crypto.encryptPassword(password);
@@ -127,13 +129,11 @@ export class AuthService implements OnApplicationBootstrap {
const user = await this.user.findUserWithHashedPasswordByEmail(email);
if (!user) {
throw new NotAcceptableException('Invalid sign in credentials');
throw new WrongSignInCredentials();
}
if (!user.password) {
throw new NotAcceptableException(
'User Password is not set. Should login through email link.'
);
throw new WrongSignInMethod();
}
const passwordMatches = await this.crypto.verifyPassword(
@@ -142,7 +142,7 @@ export class AuthService implements OnApplicationBootstrap {
);
if (!passwordMatches) {
throw new NotAcceptableException('Invalid sign in credentials');
throw new WrongSignInCredentials();
}
return sessionUser(user);
@@ -382,27 +382,14 @@ export class AuthService implements OnApplicationBootstrap {
id: string,
newPassword: string
): Promise<Omit<User, 'password'>> {
const user = await this.user.findUserById(id);
if (!user) {
throw new BadRequestException('Invalid email');
}
const hashedPassword = await this.crypto.encryptPassword(newPassword);
return this.user.updateUser(user.id, { password: hashedPassword });
return this.user.updateUser(id, { password: hashedPassword });
}
async changeEmail(
id: string,
newEmail: string
): Promise<Omit<User, 'password'>> {
const user = await this.user.findUserById(id);
if (!user) {
throw new BadRequestException('Invalid email');
}
return this.user.updateUser(id, {
email: newEmail,
emailVerifiedAt: new Date(),

View File

@@ -3,10 +3,13 @@ import type {
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, UnauthorizedException, UseGuards } from '@nestjs/common';
import { Injectable, UseGuards } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { getRequestResponseFromContext } from '../../fundamentals';
import {
ActionForbidden,
getRequestResponseFromContext,
} from '../../fundamentals';
import { FeatureManagementService } from '../features';
@Injectable()
@@ -27,7 +30,7 @@ export class AdminGuard implements CanActivate, OnModuleInit {
}
if (!allow) {
throw new UnauthorizedException('Your operation is not allowed.');
throw new ActionForbidden();
}
return true;

View File

@@ -5,7 +5,14 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import type { EventPayload } from '../../fundamentals';
import { Config, metrics, OnEvent } from '../../fundamentals';
import {
Config,
DocHistoryNotFound,
DocNotFound,
metrics,
OnEvent,
WorkspaceNotFound,
} from '../../fundamentals';
import { QuotaService } from '../quota';
import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager';
@@ -191,7 +198,11 @@ export class DocHistoryManager {
});
if (!history) {
throw new Error('Given history not found');
throw new DocHistoryNotFound({
workspaceId,
docId: id,
timestamp: timestamp.getTime(),
});
}
const oldSnapshot = await this.db.snapshot.findUnique({
@@ -204,8 +215,7 @@ export class DocHistoryManager {
});
if (!oldSnapshot) {
// unreachable actually
throw new Error('Given Doc not found');
throw new DocNotFound({ workspaceId, docId: id });
}
// save old snapshot as one history record
@@ -236,8 +246,7 @@ export class DocHistoryManager {
});
if (!permission) {
// unreachable actually
throw new Error('Workspace owner not found');
throw new WorkspaceNotFound({ workspaceId });
}
const quota = await this.quota.getUserQuota(permission.userId);

View File

@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import {
Args,
Context,
@@ -11,6 +10,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { UserNotFound } from '../../fundamentals';
import { sessionUser } from '../auth/service';
import { Admin } from '../common';
import { UserService } from '../user/service';
@@ -59,7 +59,7 @@ export class FeatureManagementResolver {
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
const user = await this.users.findUserByEmail(email);
if (!user) {
throw new BadRequestException(`User ${email} not found`);
throw new UserNotFound();
}
return this.feature.removeEarlyAccess(user.id);
}
@@ -82,7 +82,7 @@ export class FeatureManagementResolver {
const user = await this.users.findUserByEmail(email);
if (!user) {
throw new BadRequestException(`User ${email} not found`);
throw new UserNotFound();
}
await this.feature.addAdmin(user.id);

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceOwnerNotFound } from '../../fundamentals';
import { FeatureService, FeatureType } from '../features';
import { WorkspaceBlobStorage } from '../storage';
import { PermissionService } from '../workspaces/permission';
@@ -115,7 +116,7 @@ export class QuotaManagementService {
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found');
if (!owner) throw new WorkspaceOwnerNotFound({ workspaceId });
const {
feature: {
name,

View File

@@ -1,81 +0,0 @@
export enum EventErrorCode {
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
ACCESS_DENIED = 'ACCESS_DENIED',
INTERNAL = 'INTERNAL',
VERSION_REJECTED = 'VERSION_REJECTED',
}
// Such errore are generally raised from the gateway handling to user,
// the stack must be full of internal code,
// so there is no need to inherit from `Error` class.
export class EventError {
constructor(
public readonly code: EventErrorCode,
public readonly message: string
) {}
toJSON() {
return {
code: this.code,
message: this.message,
};
}
}
export class WorkspaceNotFoundError extends EventError {
constructor(public readonly workspaceId: string) {
super(
EventErrorCode.WORKSPACE_NOT_FOUND,
`You are trying to access an unknown workspace ${workspaceId}.`
);
}
}
export class DocNotFoundError extends EventError {
constructor(
public readonly workspaceId: string,
public readonly docId: string
) {
super(
EventErrorCode.DOC_NOT_FOUND,
`You are trying to access an unknown doc ${docId} under workspace ${workspaceId}.`
);
}
}
export class NotInWorkspaceError extends EventError {
constructor(public readonly workspaceId: string) {
super(
EventErrorCode.NOT_IN_WORKSPACE,
`You should join in workspace ${workspaceId} before broadcasting messages.`
);
}
}
export class AccessDeniedError extends EventError {
constructor(public readonly workspaceId: string) {
super(
EventErrorCode.ACCESS_DENIED,
`You have no permission to access workspace ${workspaceId}.`
);
}
}
export class InternalError extends EventError {
constructor(public readonly error: Error) {
super(EventErrorCode.INTERNAL, `Internal error happened: ${error.message}`);
}
}
export class VersionRejectedError extends EventError {
constructor(public readonly version: number) {
super(
EventErrorCode.VERSION_REJECTED,
// TODO: Too general error message,
// need to be more specific when versioning system is implemented.
`The version ${version} is rejected by server.`
);
}
}

View File

@@ -11,73 +11,36 @@ import {
import { Server, Socket } from 'socket.io';
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { CallTimer, Config, metrics } from '../../../fundamentals';
import {
CallTimer,
Config,
DocNotFound,
GatewayErrorWrapper,
metrics,
NotInWorkspace,
VersionRejected,
WorkspaceAccessDenied,
} from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { DocManager } from '../../doc';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../../workspaces/permission';
import { Permission } from '../../workspaces/types';
import {
AccessDeniedError,
DocNotFoundError,
EventError,
EventErrorCode,
InternalError,
NotInWorkspaceError,
} from './error';
export const GatewayErrorWrapper = (): MethodDecorator => {
// @ts-expect-error allow
return (
_target,
_key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
}
desc.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args);
} catch (e) {
if (e instanceof EventError) {
return {
error: e,
};
} else {
metrics.socketio.counter('unhandled_errors').add(1);
new Logger('EventsGateway').error(e, (e as Error).stack);
return {
error: new InternalError(e as Error),
};
}
}
};
return desc;
};
};
const SubscribeMessage = (event: string) =>
applyDecorators(
GatewayErrorWrapper(),
GatewayErrorWrapper(event),
CallTimer('socketio', 'event_duration', { event }),
RawSubscribeMessage(event)
);
type EventResponse<Data = any> =
| {
error: EventError;
type EventResponse<Data = any> = Data extends never
? {
data?: never;
}
| (Data extends never
? {
data?: never;
}
: {
data: Data;
});
: {
data: Data;
};
function Sync(workspaceId: string): `${string}:sync` {
return `${workspaceId}:sync`;
@@ -133,10 +96,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
} is outdated, please update to ${AFFiNE.version}`,
});
throw new EventError(
EventErrorCode.VERSION_REJECTED,
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
);
throw new VersionRejected({
version: version || 'unknown',
serverVersion: AFFiNE.version,
});
}
}
@@ -156,7 +119,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
if (!client.rooms.has(room)) {
throw new NotInWorkspaceError(room);
throw new NotInWorkspace({ workspaceId: room.split(':')[0] });
}
}
@@ -172,7 +135,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
permission
))
) {
throw new AccessDeniedError(workspaceId);
throw new WorkspaceAccessDenied({ workspaceId });
}
}
@@ -318,9 +281,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
const res = await this.docManager.get(docId.workspace, docId.guid);
if (!res) {
return {
error: new DocNotFoundError(workspaceId, docId.guid),
};
throw new DocNotFound({ workspaceId, docId: docId.guid });
}
const missing = Buffer.from(

View File

@@ -1,13 +1,7 @@
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
Res,
} from '@nestjs/common';
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { ActionForbidden, UserAvatarNotFound } from '../../fundamentals';
import { AvatarStorage } from '../storage';
@Controller('/api/avatars')
@@ -17,7 +11,7 @@ export class UserAvatarController {
@Get('/:id')
async getAvatar(@Res() res: Response, @Param('id') id: string) {
if (this.storage.provider.type !== 'fs') {
throw new ForbiddenException(
throw new ActionForbidden(
'Only available when avatar storage provider set to fs.'
);
}
@@ -25,7 +19,7 @@ export class UserAvatarController {
const { body, metadata } = await this.storage.get(id);
if (!body) {
throw new NotFoundException(`Avatar ${id} not found.`);
throw new UserAvatarNotFound();
}
// metadata should always exists if body is not null

View File

@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import {
Args,
Field,
@@ -18,6 +17,7 @@ import {
CryptoHelper,
type FileUpload,
Throttle,
UserNotFound,
} from '../../fundamentals';
import { CurrentUser } from '../auth/current-user';
import { Public } from '../auth/guard';
@@ -92,7 +92,7 @@ export class UserResolver {
avatar: FileUpload
) {
if (!user) {
throw new BadRequestException(`User not found`);
throw new UserNotFound();
}
const avatarUrl = await this.storage.put(
@@ -128,7 +128,7 @@ export class UserResolver {
})
async removeAvatar(@CurrentUser() user: CurrentUser) {
if (!user) {
throw new BadRequestException(`User not found`);
throw new UserNotFound();
}
await this.users.updateUser(user.id, { avatarUrl: null });
return { success: true };

View File

@@ -1,8 +1,9 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import {
Config,
EmailAlreadyUsed,
EventEmitter,
type EventPayload,
OnEvent,
@@ -63,7 +64,7 @@ export class UserService {
const user = await this.findUserByEmail(email);
if (user) {
throw new BadRequestException('Email already exists');
throw new EmailAlreadyUsed();
}
return this.createUser({

View File

@@ -1,36 +1,23 @@
import { BadRequestException } from '@nestjs/common';
import z from 'zod';
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
const result = z.safeParse(value);
if (!result.success) {
const firstIssue = result.error.issues.at(0);
if (firstIssue) {
throw new BadRequestException(firstIssue.message);
} else {
throw new BadRequestException('Invalid credential');
}
}
}
import { InvalidEmail, InvalidPasswordLength } from '../../fundamentals';
export function assertValidEmail(email: string) {
assertValid(z.string().email({ message: 'Invalid email address' }), email);
const result = z.string().email().safeParse(email);
if (!result.success) {
throw new InvalidEmail();
}
}
export function assertValidPassword(
password: string,
{ min, max }: { min: number; max: number }
) {
assertValid(
z
.string()
.min(min, { message: `Password must be ${min} or more charactors long` })
.max(max, {
message: `Password must be ${max} or fewer charactors long`,
}),
password
);
const result = z.string().min(min).max(max).safeParse(password);
if (!result.success) {
throw new InvalidPasswordLength({ min, max });
}
}
export const validators = {

View File

@@ -1,16 +1,16 @@
import {
Controller,
ForbiddenException,
Get,
Logger,
NotFoundException,
Param,
Res,
} from '@nestjs/common';
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { Response } from 'express';
import { CallTimer } from '../../fundamentals';
import {
AccessDenied,
ActionForbidden,
BlobNotFound,
CallTimer,
DocHistoryNotFound,
DocNotFound,
InvalidHistoryTimestamp,
} from '../../fundamentals';
import { CurrentUser, Public } from '../auth';
import { DocHistoryManager, DocManager } from '../doc';
import { WorkspaceBlobStorage } from '../storage';
@@ -50,15 +50,16 @@ export class WorkspacesController {
user?.id
))
) {
throw new ForbiddenException('Permission denied');
throw new ActionForbidden();
}
const { body, metadata } = await this.storage.get(workspaceId, name);
if (!body) {
throw new NotFoundException(
`Blob not found in workspace ${workspaceId}: ${name}`
);
throw new BlobNotFound({
workspaceId,
blobId: name,
});
}
// metadata should always exists if body is not null
@@ -93,7 +94,7 @@ export class WorkspacesController {
user?.id
))
) {
throw new ForbiddenException('Permission denied');
throw new AccessDenied();
}
const binResponse = await this.docManager.getBinary(
@@ -102,7 +103,10 @@ export class WorkspacesController {
);
if (!binResponse) {
throw new NotFoundException('Doc not found');
throw new DocNotFound({
workspaceId: docId.workspace,
docId: docId.guid,
});
}
if (!docId.isWorkspace) {
@@ -139,7 +143,7 @@ export class WorkspacesController {
try {
ts = new Date(timestamp);
} catch (e) {
throw new Error('Invalid timestamp');
throw new InvalidHistoryTimestamp({ timestamp });
}
await this.permission.checkPagePermission(
@@ -160,7 +164,11 @@ export class WorkspacesController {
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
res.send(history.blob);
} else {
throw new NotFoundException('Doc history not found');
throw new DocHistoryNotFound({
workspaceId: docId.workspace,
docId: guid,
timestamp: ts.getTime(),
});
}
}
}

View File

@@ -1,4 +1,3 @@
import { ForbiddenException } from '@nestjs/common';
import {
Args,
Int,
@@ -9,6 +8,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { ActionForbidden } from '../../fundamentals';
import { CurrentUser } from '../auth';
import { Admin } from '../common';
import { FeatureManagementService, FeatureType } from '../features';
@@ -56,13 +56,13 @@ export class WorkspaceManagementResolver {
@Args('enable') enable: boolean
): Promise<boolean> {
if (!(await this.feature.canEarlyAccess(user.email))) {
throw new ForbiddenException('You are not allowed to do this');
throw new ActionForbidden();
}
const owner = await this.permission.getWorkspaceOwner(workspaceId);
const availableFeatures = await this.availableFeatures(user);
if (owner.user.id !== user.id || !availableFeatures.includes(feature)) {
throw new ForbiddenException('You are not allowed to do this');
throw new ActionForbidden();
}
if (enable) {

View File

@@ -1,7 +1,8 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { DocAccessDenied, WorkspaceAccessDenied } from '../../fundamentals';
import { Permission } from './types';
export enum PublicPageMode {
@@ -151,7 +152,7 @@ export class PermissionService {
permission: Permission = Permission.Read
) {
if (!(await this.tryCheckWorkspace(ws, user, permission))) {
throw new ForbiddenException('Permission denied');
throw new WorkspaceAccessDenied({ workspaceId: ws });
}
}
@@ -323,7 +324,7 @@ export class PermissionService {
permission = Permission.Read
) {
if (!(await this.tryCheckPage(ws, page, user, permission))) {
throw new ForbiddenException('Permission denied');
throw new DocAccessDenied({ workspaceId: ws, docId: page });
}
}

View File

@@ -1,4 +1,4 @@
import { Logger, PayloadTooLargeException, UseGuards } from '@nestjs/common';
import { Logger, UseGuards } from '@nestjs/common';
import {
Args,
Int,
@@ -13,6 +13,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../fundamentals';
import {
BlobQuotaExceeded,
CloudThrottlerGuard,
MakeCache,
PreventCache,
@@ -126,10 +127,9 @@ export class WorkspaceBlobResolver {
const checkExceeded =
await this.quota.getQuotaCalculatorByWorkspace(workspaceId);
// TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge`
if (checkExceeded(0)) {
throw new PayloadTooLargeException(
'Storage or blob size limit exceeded.'
);
throw new BlobQuotaExceeded();
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -140,9 +140,7 @@ export class WorkspaceBlobResolver {
// 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 PayloadTooLargeException('Storage or blob size limit exceeded.')
);
reject(new BlobQuotaExceeded());
}
});
stream.on('error', reject);
@@ -150,7 +148,7 @@ export class WorkspaceBlobResolver {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new PayloadTooLargeException('Storage limit exceeded.'));
reject(new BlobQuotaExceeded());
} else {
resolve(buffer);
}

View File

@@ -47,10 +47,6 @@ export class DocHistoryResolver {
): Promise<DocHistoryType[]> {
const docId = new DocID(guid, workspace.id);
if (docId.isWorkspace) {
throw new Error('Invalid guid for listing doc histories.');
}
return this.historyManager
.list(workspace.id, docId.guid, timestamp, take)
.then(rows =>
@@ -73,10 +69,6 @@ export class DocHistoryResolver {
): Promise<Date> {
const docId = new DocID(guid, workspaceId);
if (docId.isWorkspace) {
throw new Error('Invalid guid for recovering doc from history.');
}
await this.permission.checkPagePermission(
docId.workspace,
docId.guid,

View File

@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import {
Args,
Field,
@@ -12,6 +11,11 @@ import {
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import {
ExpectToPublishPage,
ExpectToRevokePublicPage,
PageIsNotPublic,
} from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { DocID } from '../../utils/doc';
import { PermissionService, PublicPageMode } from '../permission';
@@ -126,7 +130,7 @@ export class PagePermissionResolver {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new BadRequestException('Expect page not to be workspace');
throw new ExpectToPublishPage();
}
await this.permission.checkWorkspace(
@@ -163,7 +167,7 @@ export class PagePermissionResolver {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new BadRequestException('Expect page not to be workspace');
throw new ExpectToRevokePublicPage('Expect page not to be workspace');
}
await this.permission.checkWorkspace(
@@ -178,7 +182,7 @@ export class PagePermissionResolver {
);
if (!isPublic) {
throw new BadRequestException('Page is not public');
throw new PageIsNotPublic('Page is not public');
}
return this.permission.revokePublicPage(docId.workspace, docId.guid);

View File

@@ -1,10 +1,4 @@
import {
ForbiddenException,
InternalServerErrorException,
Logger,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { Logger } from '@nestjs/common';
import {
Args,
Int,
@@ -21,11 +15,18 @@ import { applyUpdate, Doc } from 'yjs';
import type { FileUpload } from '../../../fundamentals';
import {
CantChangeWorkspaceOwner,
EventEmitter,
InternalServerError,
MailService,
MemberQuotaExceeded,
MutexService,
Throttle,
TooManyRequestsException,
TooManyRequest,
UserNotFound,
WorkspaceAccessDenied,
WorkspaceNotFound,
WorkspaceOwnerNotFound,
} from '../../../fundamentals';
import { CurrentUser, Public } from '../../auth';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
@@ -77,7 +78,7 @@ export class WorkspaceResolver {
const permission = await this.permissions.get(workspace.id, user.id);
if (!permission) {
throw new ForbiddenException();
throw new WorkspaceAccessDenied({ workspaceId: workspace.id });
}
return permission;
@@ -196,7 +197,7 @@ export class WorkspaceResolver {
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) {
throw new NotFoundException("Workspace doesn't exist");
throw new WorkspaceNotFound({ workspaceId: id });
}
return workspace;
@@ -307,7 +308,7 @@ export class WorkspaceResolver {
);
if (permission === Permission.Owner) {
throw new ForbiddenException('Cannot change owner');
throw new CantChangeWorkspaceOwner();
}
try {
@@ -315,7 +316,7 @@ export class WorkspaceResolver {
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.lock(lockFlag);
if (!lock) {
return new TooManyRequestsException('Server is busy');
return new TooManyRequest();
}
// member limit check
@@ -326,7 +327,7 @@ export class WorkspaceResolver {
this.quota.getWorkspaceUsage(workspaceId),
]);
if (memberCount >= quota.memberLimit) {
return new PayloadTooLargeException('Workspace member limit reached.');
return new MemberQuotaExceeded();
}
let target = await this.users.findUserByEmail(email);
@@ -381,7 +382,7 @@ export class WorkspaceResolver {
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new InternalServerErrorException(
throw new InternalServerError(
'Failed to send invite email. Please try again.'
);
}
@@ -389,7 +390,7 @@ export class WorkspaceResolver {
return inviteId;
} catch (e) {
this.logger.error('failed to invite user', e);
return new TooManyRequestsException('Server is busy');
return new TooManyRequest();
}
}
@@ -481,9 +482,7 @@ export class WorkspaceResolver {
} = await this.getInviteInfo(inviteId);
if (!inviter || !invitee) {
throw new ForbiddenException(
`can not find inviter/invitee by inviteId: ${inviteId}`
);
throw new UserNotFound();
}
if (sendAcceptMail) {
@@ -508,9 +507,7 @@ export class WorkspaceResolver {
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner.user) {
throw new ForbiddenException(
`can not find owner by workspaceId: ${workspaceId}`
);
throw new WorkspaceOwnerNotFound({ workspaceId: workspaceId });
}
if (sendLeaveMail) {