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

@@ -27,6 +27,7 @@ import {
ConfigModule,
mergeConfigOverride,
} from './fundamentals/config';
import { ErrorModule } from './fundamentals/error';
import { EventModule } from './fundamentals/event';
import { GqlModule } from './fundamentals/graphql';
import { HelpersModule } from './fundamentals/helpers';
@@ -52,6 +53,7 @@ export const FunctionalityModules = [
MailModule,
StorageProviderModule,
HelpersModule,
ErrorModule,
];
function filterOptionalModule(

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) {

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
@@ -10,6 +9,7 @@ import { PrismaClient } from '@prisma/client';
import { difference, keyBy } from 'lodash-es';
import { Cache } from '../../cache';
import { InvalidRuntimeConfigType, RuntimeConfigNotFound } from '../../error';
import { defer } from '../../utils/promise';
import { defaultRuntimeConfig, runtimeConfigType } from '../register';
import { AppRuntimeConfigModules, FlattenedAppRuntimeConfig } from '../types';
@@ -21,15 +21,17 @@ function validateConfigType<K extends keyof FlattenedAppRuntimeConfig>(
const config = defaultRuntimeConfig[key];
if (!config) {
throw new BadRequestException(`Unknown runtime config key '${key}'`);
throw new RuntimeConfigNotFound({ key });
}
const want = config.type;
const get = runtimeConfigType(value);
if (get !== want) {
throw new BadRequestException(
`Invalid runtime config type for '${key}', want '${want}', but get '${get}'`
);
throw new InvalidRuntimeConfigType({
key,
want,
get,
});
}
}
@@ -68,7 +70,7 @@ export class Runtime implements OnApplicationBootstrap {
const dbValue = await this.loadDb<K>(k);
if (dbValue === undefined) {
throw new Error(`Runtime config ${k} not found`);
throw new RuntimeConfigNotFound({ key: k });
}
await this.setCache(k, dbValue);

View File

@@ -0,0 +1,481 @@
import { STATUS_CODES } from 'node:http';
import { HttpStatus, Logger } from '@nestjs/common';
import { capitalize } from 'lodash-es';
export type UserFriendlyErrorBaseType =
| 'bad_request'
| 'too_many_requests'
| 'resource_not_found'
| 'resource_already_exists'
| 'invalid_input'
| 'action_forbidden'
| 'no_permission'
| 'quota_exceeded'
| 'authentication_required'
| 'internal_server_error';
type ErrorArgType = 'string' | 'number' | 'boolean';
type ErrorArgs = Record<string, ErrorArgType | Record<string, ErrorArgType>>;
export type UserFriendlyErrorOptions = {
type: UserFriendlyErrorBaseType;
args?: ErrorArgs;
message: string | ((args: any) => string);
};
const BaseTypeToHttpStatusMap: Record<UserFriendlyErrorBaseType, HttpStatus> = {
too_many_requests: HttpStatus.TOO_MANY_REQUESTS,
bad_request: HttpStatus.BAD_REQUEST,
resource_not_found: HttpStatus.NOT_FOUND,
resource_already_exists: HttpStatus.BAD_REQUEST,
invalid_input: HttpStatus.BAD_REQUEST,
action_forbidden: HttpStatus.FORBIDDEN,
no_permission: HttpStatus.FORBIDDEN,
quota_exceeded: HttpStatus.PAYMENT_REQUIRED,
authentication_required: HttpStatus.UNAUTHORIZED,
internal_server_error: HttpStatus.INTERNAL_SERVER_ERROR,
};
export class UserFriendlyError extends Error {
/**
* Standard HTTP status code
*/
status: number;
/**
* Business error category, for example 'resource_already_exists' or 'quota_exceeded'
*/
type: string;
/**
* Additional data that could be used for error handling or formatting
*/
data: any;
constructor(
type: UserFriendlyErrorBaseType,
name: keyof typeof USER_FRIENDLY_ERRORS,
message?: string | ((args?: any) => string),
args?: any
) {
const defaultMsg = USER_FRIENDLY_ERRORS[name].message;
// disallow message override for `internal_server_error`
// to avoid leak internal information to user
let msg =
name === 'internal_server_error' ? defaultMsg : message ?? defaultMsg;
if (typeof msg === 'function') {
msg = msg(args);
}
super(msg);
this.status = BaseTypeToHttpStatusMap[type];
this.type = type;
this.name = name;
this.data = args;
}
json() {
return {
status: this.status,
code: STATUS_CODES[this.status] ?? 'BAD REQUEST',
type: this.type.toUpperCase(),
name: this.name.toUpperCase(),
message: this.message,
data: this.data,
};
}
log(context: string) {
// ignore all user behavior error log
if (this.type !== 'internal_server_error') {
return;
}
new Logger(context).error(
'Internal server error',
this.cause ? (this.cause as any).stack ?? this.cause : this.stack
);
}
}
/**
*
* @ObjectType()
* export class XXXDataType {
* @Field()
*
* }
*/
function generateErrorArgs(name: string, args: ErrorArgs) {
const typeName = `${name}DataType`;
const lines = [`@ObjectType()`, `class ${typeName} {`];
Object.entries(args).forEach(([arg, fieldArgs]) => {
if (typeof fieldArgs === 'object') {
const subResult = generateErrorArgs(
name + 'Field' + capitalize(arg),
fieldArgs
);
lines.unshift(subResult.def);
lines.push(
` @Field(() => ${subResult.name}) ${arg}!: ${subResult.name};`
);
} else {
lines.push(` @Field() ${arg}!: ${fieldArgs}`);
}
});
lines.push('}');
return { name: typeName, def: lines.join('\n') };
}
export function generateUserFriendlyErrors() {
const output = [
'// AUTO GENERATED FILE',
`import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';`,
'',
`import { UserFriendlyError } from './def';`,
];
const errorNames: string[] = [];
const argTypes: string[] = [];
for (const code in USER_FRIENDLY_ERRORS) {
errorNames.push(code.toUpperCase());
// @ts-expect-error allow
const options: UserFriendlyErrorOptions = USER_FRIENDLY_ERRORS[code];
const className = code
.split('_')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const args = options.args
? generateErrorArgs(className, options.args)
: null;
const classDef = `
export class ${className} extends UserFriendlyError {
constructor(${args ? `args: ${args.name}, ` : ''}message?: string${args ? ` | ((args: ${args.name}) => string)` : ''}) {
super('${options.type}', '${code}', message${args ? ', args' : ''});
}
}`;
if (args) {
output.push(args.def);
argTypes.push(args.name);
}
output.push(classDef);
}
output.push(`export enum ErrorNames {
${errorNames.join(',\n ')}
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
})
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[${argTypes.join(', ')}] as const,
});
`);
return output.join('\n');
}
// DEFINE ALL USER FRIENDLY ERRORS HERE
export const USER_FRIENDLY_ERRORS = {
// Internal uncaught errors
internal_server_error: {
type: 'internal_server_error',
message: 'An internal error occurred.',
},
too_many_request: {
type: 'too_many_requests',
message: 'Too many requests.',
},
// User Errors
user_not_found: {
type: 'resource_not_found',
message: 'User not found.',
},
user_avatar_not_found: {
type: 'resource_not_found',
message: 'User avatar not found.',
},
email_already_used: {
type: 'resource_already_exists',
message: 'This email has already been registered.',
},
same_email_provided: {
type: 'invalid_input',
message:
'You are trying to update your account email to the same as the old one.',
},
wrong_sign_in_credentials: {
type: 'invalid_input',
message: 'Wrong user email or password.',
},
unknown_oauth_provider: {
type: 'invalid_input',
args: { name: 'string' },
message: ({ name }) => `Unknown authentication provider ${name}.`,
},
oauth_state_expired: {
type: 'bad_request',
message: 'OAuth state expired, please try again.',
},
invalid_oauth_callback_state: {
type: 'bad_request',
message: 'Invalid callback state parameter.',
},
missing_oauth_query_parameter: {
type: 'bad_request',
args: { name: 'string' },
message: ({ name }) => `Missing query parameter \`${name}\`.`,
},
oauth_account_already_connected: {
type: 'bad_request',
message:
'The third-party account has already been connected to another user.',
},
invalid_email: {
type: 'invalid_input',
message: 'An invalid email provided.',
},
invalid_password_length: {
type: 'invalid_input',
args: { min: 'number', max: 'number' },
message: ({ min, max }) =>
`Password must be between ${min} and ${max} characters`,
},
wrong_sign_in_method: {
type: 'invalid_input',
message:
'You are trying to sign in by a different method than you signed up with.',
},
early_access_required: {
type: 'action_forbidden',
message: `You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.`,
},
sign_up_forbidden: {
type: 'action_forbidden',
message: `You are not allowed to sign up.`,
},
email_token_not_found: {
type: 'invalid_input',
message: 'The email token provided is not found.',
},
invalid_email_token: {
type: 'invalid_input',
message: 'An invalid email token provided.',
},
// Authentication & Permission Errors
authentication_required: {
type: 'authentication_required',
message: 'You must sign in first to access this resource.',
},
action_forbidden: {
type: 'action_forbidden',
message: 'You are not allowed to perform this action.',
},
access_denied: {
type: 'no_permission',
message: 'You do not have permission to access this resource.',
},
email_verification_required: {
type: 'action_forbidden',
message: 'You must verify your email before accessing this resource.',
},
// Workspace & Doc & Sync errors
workspace_not_found: {
type: 'resource_not_found',
args: { workspaceId: 'string' },
message: ({ workspaceId }) => `Workspace ${workspaceId} not found.`,
},
not_in_workspace: {
type: 'action_forbidden',
args: { workspaceId: 'string' },
message: ({ workspaceId }) =>
`You should join in workspace ${workspaceId} before broadcasting messages.`,
},
workspace_access_denied: {
type: 'no_permission',
args: { workspaceId: 'string' },
message: ({ workspaceId }) =>
`You do not have permission to access workspace ${workspaceId}.`,
},
workspace_owner_not_found: {
type: 'internal_server_error',
args: { workspaceId: 'string' },
message: ({ workspaceId }) =>
`Owner of workspace ${workspaceId} not found.`,
},
cant_change_workspace_owner: {
type: 'action_forbidden',
message: 'You are not allowed to change the owner of a workspace.',
},
doc_not_found: {
type: 'resource_not_found',
args: { workspaceId: 'string', docId: 'string' },
message: ({ workspaceId, docId }) =>
`Doc ${docId} under workspace ${workspaceId} not found.`,
},
doc_access_denied: {
type: 'no_permission',
args: { workspaceId: 'string', docId: 'string' },
message: ({ workspaceId, docId }) =>
`You do not have permission to access doc ${docId} under workspace ${workspaceId}.`,
},
version_rejected: {
type: 'action_forbidden',
args: { version: 'string', serverVersion: 'string' },
message: ({ version, serverVersion }) =>
`Your client with version ${version} is rejected by remote sync server. Please upgrade to ${serverVersion}.`,
},
invalid_history_timestamp: {
type: 'invalid_input',
args: { timestamp: 'string' },
message: 'Invalid doc history timestamp provided.',
},
doc_history_not_found: {
type: 'resource_not_found',
args: { workspaceId: 'string', docId: 'string', timestamp: 'number' },
message: ({ workspaceId, docId, timestamp }) =>
`History of ${docId} at ${timestamp} under workspace ${workspaceId}.`,
},
blob_not_found: {
type: 'resource_not_found',
args: { workspaceId: 'string', blobId: 'string' },
message: ({ workspaceId, blobId }) =>
`Blob ${blobId} not found in workspace ${workspaceId}.`,
},
expect_to_publish_page: {
type: 'invalid_input',
message: 'Expected to publish a page, not a workspace.',
},
expect_to_revoke_public_page: {
type: 'invalid_input',
message: 'Expected to revoke a public page, not a workspace.',
},
page_is_not_public: {
type: 'bad_request',
message: 'Page is not public.',
},
// Subscription Errors
failed_to_checkout: {
type: 'internal_server_error',
message: 'Failed to create checkout session.',
},
subscription_already_exists: {
type: 'resource_already_exists',
args: { plan: 'string' },
message: ({ plan }) => `You have already subscribed to the ${plan} plan.`,
},
subscription_not_exists: {
type: 'resource_not_found',
args: { plan: 'string' },
message: ({ plan }) => `You didn't subscribe to the ${plan} plan.`,
},
subscription_has_been_canceled: {
type: 'action_forbidden',
message: 'Your subscription has already been canceled.',
},
subscription_expired: {
type: 'action_forbidden',
message: 'Your subscription has expired.',
},
same_subscription_recurring: {
type: 'bad_request',
args: { recurring: 'string' },
message: ({ recurring }) =>
`Your subscription has already been in ${recurring} recurring state.`,
},
customer_portal_create_failed: {
type: 'internal_server_error',
message: 'Failed to create customer portal session.',
},
subscription_plan_not_found: {
type: 'resource_not_found',
args: { plan: 'string', recurring: 'string' },
message: 'You are trying to access a unknown subscription plan.',
},
// Copilot errors
copilot_session_not_found: {
type: 'resource_not_found',
message: `Copilot session not found.`,
},
copilot_session_deleted: {
type: 'action_forbidden',
message: `Copilot session has been deleted.`,
},
no_copilot_provider_available: {
type: 'internal_server_error',
message: `No copilot provider available.`,
},
copilot_failed_to_generate_text: {
type: 'internal_server_error',
message: `Failed to generate text.`,
},
copilot_failed_to_create_message: {
type: 'internal_server_error',
message: `Failed to create chat message.`,
},
unsplash_is_not_configured: {
type: 'internal_server_error',
message: `Unsplash is not configured.`,
},
copilot_action_taken: {
type: 'action_forbidden',
message: `Action has been taken, no more messages allowed.`,
},
copilot_message_not_found: {
type: 'resource_not_found',
message: `Copilot message not found.`,
},
copilot_prompt_not_found: {
type: 'resource_not_found',
args: { name: 'string' },
message: ({ name }) => `Copilot prompt ${name} not found.`,
},
// Quota & Limit errors
blob_quota_exceeded: {
type: 'quota_exceeded',
message: 'You have exceeded your blob storage quota.',
},
member_quota_exceeded: {
type: 'quota_exceeded',
message: 'You have exceeded your workspace member quota.',
},
copilot_quota_exceeded: {
type: 'quota_exceeded',
message:
'You have reached the limit of actions in this workspace, please upgrade your plan.',
},
// Config errors
runtime_config_not_found: {
type: 'resource_not_found',
args: { key: 'string' },
message: ({ key }) => `Runtime config ${key} not found.`,
},
invalid_runtime_config_type: {
type: 'invalid_input',
args: { key: 'string', want: 'string', get: 'string' },
message: ({ key, want, get }) =>
`Invalid runtime config type for '${key}', want '${want}', but get ${get}.`,
},
mailer_service_is_not_configured: {
type: 'internal_server_error',
message: 'Mailer service is not configured.',
},
} satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -0,0 +1,616 @@
// AUTO GENERATED FILE
import {
createUnionType,
Field,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { UserFriendlyError } from './def';
export class InternalServerError extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'internal_server_error', message);
}
}
export class TooManyRequest extends UserFriendlyError {
constructor(message?: string) {
super('too_many_requests', 'too_many_request', message);
}
}
export class UserNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'user_not_found', message);
}
}
export class UserAvatarNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'user_avatar_not_found', message);
}
}
export class EmailAlreadyUsed extends UserFriendlyError {
constructor(message?: string) {
super('resource_already_exists', 'email_already_used', message);
}
}
export class SameEmailProvided extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'same_email_provided', message);
}
}
export class WrongSignInCredentials extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'wrong_sign_in_credentials', message);
}
}
@ObjectType()
class UnknownOauthProviderDataType {
@Field() name!: string;
}
export class UnknownOauthProvider extends UserFriendlyError {
constructor(
args: UnknownOauthProviderDataType,
message?: string | ((args: UnknownOauthProviderDataType) => string)
) {
super('invalid_input', 'unknown_oauth_provider', message, args);
}
}
export class OauthStateExpired extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'oauth_state_expired', message);
}
}
export class InvalidOauthCallbackState extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'invalid_oauth_callback_state', message);
}
}
@ObjectType()
class MissingOauthQueryParameterDataType {
@Field() name!: string;
}
export class MissingOauthQueryParameter extends UserFriendlyError {
constructor(
args: MissingOauthQueryParameterDataType,
message?: string | ((args: MissingOauthQueryParameterDataType) => string)
) {
super('bad_request', 'missing_oauth_query_parameter', message, args);
}
}
export class OauthAccountAlreadyConnected extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'oauth_account_already_connected', message);
}
}
export class InvalidEmail extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_email', message);
}
}
@ObjectType()
class InvalidPasswordLengthDataType {
@Field() min!: number;
@Field() max!: number;
}
export class InvalidPasswordLength extends UserFriendlyError {
constructor(
args: InvalidPasswordLengthDataType,
message?: string | ((args: InvalidPasswordLengthDataType) => string)
) {
super('invalid_input', 'invalid_password_length', message, args);
}
}
export class WrongSignInMethod extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'wrong_sign_in_method', message);
}
}
export class EarlyAccessRequired extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'early_access_required', message);
}
}
export class SignUpForbidden extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'sign_up_forbidden', message);
}
}
export class EmailTokenNotFound extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'email_token_not_found', message);
}
}
export class InvalidEmailToken extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_email_token', message);
}
}
export class AuthenticationRequired extends UserFriendlyError {
constructor(message?: string) {
super('authentication_required', 'authentication_required', message);
}
}
export class ActionForbidden extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'action_forbidden', message);
}
}
export class AccessDenied extends UserFriendlyError {
constructor(message?: string) {
super('no_permission', 'access_denied', message);
}
}
export class EmailVerificationRequired extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'email_verification_required', message);
}
}
@ObjectType()
class WorkspaceNotFoundDataType {
@Field() workspaceId!: string;
}
export class WorkspaceNotFound extends UserFriendlyError {
constructor(
args: WorkspaceNotFoundDataType,
message?: string | ((args: WorkspaceNotFoundDataType) => string)
) {
super('resource_not_found', 'workspace_not_found', message, args);
}
}
@ObjectType()
class NotInWorkspaceDataType {
@Field() workspaceId!: string;
}
export class NotInWorkspace extends UserFriendlyError {
constructor(
args: NotInWorkspaceDataType,
message?: string | ((args: NotInWorkspaceDataType) => string)
) {
super('action_forbidden', 'not_in_workspace', message, args);
}
}
@ObjectType()
class WorkspaceAccessDeniedDataType {
@Field() workspaceId!: string;
}
export class WorkspaceAccessDenied extends UserFriendlyError {
constructor(
args: WorkspaceAccessDeniedDataType,
message?: string | ((args: WorkspaceAccessDeniedDataType) => string)
) {
super('no_permission', 'workspace_access_denied', message, args);
}
}
@ObjectType()
class WorkspaceOwnerNotFoundDataType {
@Field() workspaceId!: string;
}
export class WorkspaceOwnerNotFound extends UserFriendlyError {
constructor(
args: WorkspaceOwnerNotFoundDataType,
message?: string | ((args: WorkspaceOwnerNotFoundDataType) => string)
) {
super('internal_server_error', 'workspace_owner_not_found', message, args);
}
}
export class CantChangeWorkspaceOwner extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'cant_change_workspace_owner', message);
}
}
@ObjectType()
class DocNotFoundDataType {
@Field() workspaceId!: string;
@Field() docId!: string;
}
export class DocNotFound extends UserFriendlyError {
constructor(
args: DocNotFoundDataType,
message?: string | ((args: DocNotFoundDataType) => string)
) {
super('resource_not_found', 'doc_not_found', message, args);
}
}
@ObjectType()
class DocAccessDeniedDataType {
@Field() workspaceId!: string;
@Field() docId!: string;
}
export class DocAccessDenied extends UserFriendlyError {
constructor(
args: DocAccessDeniedDataType,
message?: string | ((args: DocAccessDeniedDataType) => string)
) {
super('no_permission', 'doc_access_denied', message, args);
}
}
@ObjectType()
class VersionRejectedDataType {
@Field() version!: string;
@Field() serverVersion!: string;
}
export class VersionRejected extends UserFriendlyError {
constructor(
args: VersionRejectedDataType,
message?: string | ((args: VersionRejectedDataType) => string)
) {
super('action_forbidden', 'version_rejected', message, args);
}
}
@ObjectType()
class InvalidHistoryTimestampDataType {
@Field() timestamp!: string;
}
export class InvalidHistoryTimestamp extends UserFriendlyError {
constructor(
args: InvalidHistoryTimestampDataType,
message?: string | ((args: InvalidHistoryTimestampDataType) => string)
) {
super('invalid_input', 'invalid_history_timestamp', message, args);
}
}
@ObjectType()
class DocHistoryNotFoundDataType {
@Field() workspaceId!: string;
@Field() docId!: string;
@Field() timestamp!: number;
}
export class DocHistoryNotFound extends UserFriendlyError {
constructor(
args: DocHistoryNotFoundDataType,
message?: string | ((args: DocHistoryNotFoundDataType) => string)
) {
super('resource_not_found', 'doc_history_not_found', message, args);
}
}
@ObjectType()
class BlobNotFoundDataType {
@Field() workspaceId!: string;
@Field() blobId!: string;
}
export class BlobNotFound extends UserFriendlyError {
constructor(
args: BlobNotFoundDataType,
message?: string | ((args: BlobNotFoundDataType) => string)
) {
super('resource_not_found', 'blob_not_found', message, args);
}
}
export class ExpectToPublishPage extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'expect_to_publish_page', message);
}
}
export class ExpectToRevokePublicPage extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'expect_to_revoke_public_page', message);
}
}
export class PageIsNotPublic extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'page_is_not_public', message);
}
}
export class FailedToCheckout extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'failed_to_checkout', message);
}
}
@ObjectType()
class SubscriptionAlreadyExistsDataType {
@Field() plan!: string;
}
export class SubscriptionAlreadyExists extends UserFriendlyError {
constructor(
args: SubscriptionAlreadyExistsDataType,
message?: string | ((args: SubscriptionAlreadyExistsDataType) => string)
) {
super(
'resource_already_exists',
'subscription_already_exists',
message,
args
);
}
}
@ObjectType()
class SubscriptionNotExistsDataType {
@Field() plan!: string;
}
export class SubscriptionNotExists extends UserFriendlyError {
constructor(
args: SubscriptionNotExistsDataType,
message?: string | ((args: SubscriptionNotExistsDataType) => string)
) {
super('resource_not_found', 'subscription_not_exists', message, args);
}
}
export class SubscriptionHasBeenCanceled extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'subscription_has_been_canceled', message);
}
}
export class SubscriptionExpired extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'subscription_expired', message);
}
}
@ObjectType()
class SameSubscriptionRecurringDataType {
@Field() recurring!: string;
}
export class SameSubscriptionRecurring extends UserFriendlyError {
constructor(
args: SameSubscriptionRecurringDataType,
message?: string | ((args: SameSubscriptionRecurringDataType) => string)
) {
super('bad_request', 'same_subscription_recurring', message, args);
}
}
export class CustomerPortalCreateFailed extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'customer_portal_create_failed', message);
}
}
@ObjectType()
class SubscriptionPlanNotFoundDataType {
@Field() plan!: string;
@Field() recurring!: string;
}
export class SubscriptionPlanNotFound extends UserFriendlyError {
constructor(
args: SubscriptionPlanNotFoundDataType,
message?: string | ((args: SubscriptionPlanNotFoundDataType) => string)
) {
super('resource_not_found', 'subscription_plan_not_found', message, args);
}
}
export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'copilot_session_not_found', message);
}
}
export class CopilotSessionDeleted extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'copilot_session_deleted', message);
}
}
export class NoCopilotProviderAvailable extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'no_copilot_provider_available', message);
}
}
export class CopilotFailedToGenerateText extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'copilot_failed_to_generate_text', message);
}
}
export class CopilotFailedToCreateMessage extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'copilot_failed_to_create_message', message);
}
}
export class UnsplashIsNotConfigured extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'unsplash_is_not_configured', message);
}
}
export class CopilotActionTaken extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'copilot_action_taken', message);
}
}
export class CopilotMessageNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'copilot_message_not_found', message);
}
}
@ObjectType()
class CopilotPromptNotFoundDataType {
@Field() name!: string;
}
export class CopilotPromptNotFound extends UserFriendlyError {
constructor(
args: CopilotPromptNotFoundDataType,
message?: string | ((args: CopilotPromptNotFoundDataType) => string)
) {
super('resource_not_found', 'copilot_prompt_not_found', message, args);
}
}
export class BlobQuotaExceeded extends UserFriendlyError {
constructor(message?: string) {
super('quota_exceeded', 'blob_quota_exceeded', message);
}
}
export class MemberQuotaExceeded extends UserFriendlyError {
constructor(message?: string) {
super('quota_exceeded', 'member_quota_exceeded', message);
}
}
export class CopilotQuotaExceeded extends UserFriendlyError {
constructor(message?: string) {
super('quota_exceeded', 'copilot_quota_exceeded', message);
}
}
@ObjectType()
class RuntimeConfigNotFoundDataType {
@Field() key!: string;
}
export class RuntimeConfigNotFound extends UserFriendlyError {
constructor(
args: RuntimeConfigNotFoundDataType,
message?: string | ((args: RuntimeConfigNotFoundDataType) => string)
) {
super('resource_not_found', 'runtime_config_not_found', message, args);
}
}
@ObjectType()
class InvalidRuntimeConfigTypeDataType {
@Field() key!: string;
@Field() want!: string;
@Field() get!: string;
}
export class InvalidRuntimeConfigType extends UserFriendlyError {
constructor(
args: InvalidRuntimeConfigTypeDataType,
message?: string | ((args: InvalidRuntimeConfigTypeDataType) => string)
) {
super('invalid_input', 'invalid_runtime_config_type', message, args);
}
}
export class MailerServiceIsNotConfigured extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'mailer_service_is_not_configured', message);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
USER_NOT_FOUND,
USER_AVATAR_NOT_FOUND,
EMAIL_ALREADY_USED,
SAME_EMAIL_PROVIDED,
WRONG_SIGN_IN_CREDENTIALS,
UNKNOWN_OAUTH_PROVIDER,
OAUTH_STATE_EXPIRED,
INVALID_OAUTH_CALLBACK_STATE,
MISSING_OAUTH_QUERY_PARAMETER,
OAUTH_ACCOUNT_ALREADY_CONNECTED,
INVALID_EMAIL,
INVALID_PASSWORD_LENGTH,
WRONG_SIGN_IN_METHOD,
EARLY_ACCESS_REQUIRED,
SIGN_UP_FORBIDDEN,
EMAIL_TOKEN_NOT_FOUND,
INVALID_EMAIL_TOKEN,
AUTHENTICATION_REQUIRED,
ACTION_FORBIDDEN,
ACCESS_DENIED,
EMAIL_VERIFICATION_REQUIRED,
WORKSPACE_NOT_FOUND,
NOT_IN_WORKSPACE,
WORKSPACE_ACCESS_DENIED,
WORKSPACE_OWNER_NOT_FOUND,
CANT_CHANGE_WORKSPACE_OWNER,
DOC_NOT_FOUND,
DOC_ACCESS_DENIED,
VERSION_REJECTED,
INVALID_HISTORY_TIMESTAMP,
DOC_HISTORY_NOT_FOUND,
BLOB_NOT_FOUND,
EXPECT_TO_PUBLISH_PAGE,
EXPECT_TO_REVOKE_PUBLIC_PAGE,
PAGE_IS_NOT_PUBLIC,
FAILED_TO_CHECKOUT,
SUBSCRIPTION_ALREADY_EXISTS,
SUBSCRIPTION_NOT_EXISTS,
SUBSCRIPTION_HAS_BEEN_CANCELED,
SUBSCRIPTION_EXPIRED,
SAME_SUBSCRIPTION_RECURRING,
CUSTOMER_PORTAL_CREATE_FAILED,
SUBSCRIPTION_PLAN_NOT_FOUND,
COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_DELETED,
NO_COPILOT_PROVIDER_AVAILABLE,
COPILOT_FAILED_TO_GENERATE_TEXT,
COPILOT_FAILED_TO_CREATE_MESSAGE,
UNSPLASH_IS_NOT_CONFIGURED,
COPILOT_ACTION_TAKEN,
COPILOT_MESSAGE_NOT_FOUND,
COPILOT_PROMPT_NOT_FOUND,
BLOB_QUOTA_EXCEEDED,
MEMBER_QUOTA_EXCEEDED,
COPILOT_QUOTA_EXCEEDED,
RUNTIME_CONFIG_NOT_FOUND,
INVALID_RUNTIME_CONFIG_TYPE,
MAILER_SERVICE_IS_NOT_CONFIGURED,
}
registerEnumType(ErrorNames, {
name: 'ErrorNames',
});
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[
UnknownOauthProviderDataType,
MissingOauthQueryParameterDataType,
InvalidPasswordLengthDataType,
WorkspaceNotFoundDataType,
NotInWorkspaceDataType,
WorkspaceAccessDeniedDataType,
WorkspaceOwnerNotFoundDataType,
DocNotFoundDataType,
DocAccessDeniedDataType,
VersionRejectedDataType,
InvalidHistoryTimestampDataType,
DocHistoryNotFoundDataType,
BlobNotFoundDataType,
SubscriptionAlreadyExistsDataType,
SubscriptionNotExistsDataType,
SameSubscriptionRecurringDataType,
SubscriptionPlanNotFoundDataType,
CopilotPromptNotFoundDataType,
RuntimeConfigNotFoundDataType,
InvalidRuntimeConfigTypeDataType,
] as const,
});

View File

@@ -1,2 +1,44 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { Config } from '../config';
import { generateUserFriendlyErrors } from './def';
import { ActionForbidden, ErrorDataUnionType, ErrorNames } from './errors.gen';
@Resolver(() => ErrorDataUnionType)
class ErrorResolver {
// only exists for type registering
@Query(() => ErrorDataUnionType)
error(@Args({ name: 'name', type: () => ErrorNames }) _name: ErrorNames) {
throw new ActionForbidden();
}
}
@Module({
providers: [ErrorResolver],
})
export class ErrorModule implements OnModuleInit {
logger = new Logger('ErrorModule');
constructor(private readonly config: Config) {}
onModuleInit() {
if (!this.config.node.dev) {
return;
}
this.logger.log('Generating UserFriendlyError classes');
const def = generateUserFriendlyErrors();
writeFileSync(
join(fileURLToPath(import.meta.url), '../errors.gen.ts'),
def
);
}
}
export { UserFriendlyError } from './def';
export * from './errors.gen';
export * from './payment-required';
export * from './too-many-requests';

View File

@@ -1,16 +1,18 @@
import './config';
import { STATUS_CODES } from 'node:http';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Global, HttpException, HttpStatus, Module } from '@nestjs/common';
import { Global, HttpStatus, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { Request, Response } from 'express';
import { GraphQLError } from 'graphql';
import { Config } from '../config';
import { UserFriendlyError } from '../error';
import { GQLLoggerPlugin } from './logger-plugin';
export type GraphqlContext = {
@@ -57,25 +59,20 @@ export type GraphqlContext = {
if (
error instanceof GraphQLError &&
error.originalError instanceof HttpException
error.originalError instanceof UserFriendlyError
) {
const statusCode = error.originalError.getStatus();
const statusName = HttpStatus[statusCode];
// originally be 'INTERNAL_SERVER_ERROR'
formattedError.extensions['code'] = statusCode;
formattedError.extensions['status'] = statusName;
delete formattedError.extensions['originalError'];
// @ts-expect-error allow assign
formattedError.extensions = error.originalError.json();
formattedError.extensions.stacktrace = error.originalError.stack;
return formattedError;
} else {
// @ts-expect-error allow assign
formattedError.message = 'Internal Server Error';
formattedError.extensions['code'] =
HttpStatus.INTERNAL_SERVER_ERROR;
formattedError.extensions['status'] =
HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR];
HttpStatus.INTERNAL_SERVER_ERROR;
formattedError.extensions['code'] =
STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR];
}
return formattedError;

View File

@@ -4,10 +4,10 @@ import {
GraphQLRequestListener,
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { HttpException, Logger } from '@nestjs/common';
import { Response } from 'express';
import { metrics } from '../metrics/metrics';
import { mapAnyError } from '../nestjs';
export interface RequestContext {
req: Express.Request & {
@@ -17,8 +17,6 @@ export interface RequestContext {
@Plugin()
export class GQLLoggerPlugin implements ApolloServerPlugin {
protected logger = new Logger(GQLLoggerPlugin.name);
requestDidStart(
ctx: GraphQLRequestContext<RequestContext>
): Promise<GraphQLRequestListener<GraphQLRequestContext<RequestContext>>> {
@@ -39,30 +37,15 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
return Promise.resolve();
},
didEncounterErrors: ctx => {
metrics.gql.counter('query_error_counter').add(1, { operation });
ctx.errors.forEach(gqlErr => {
const error = mapAnyError(
gqlErr.originalError ? gqlErr.originalError : gqlErr
);
error.log('GraphQL');
ctx.errors.forEach(err => {
// only log non-user errors
let msg: string | undefined;
if (!err.originalError) {
msg = err.toString();
} else {
const originalError = err.originalError;
// do not log client errors, and put more information in the error extensions.
if (!(originalError instanceof HttpException)) {
if (originalError.cause && originalError.cause instanceof Error) {
msg = originalError.cause.stack ?? originalError.cause.message;
} else {
msg = originalError.stack ?? originalError.message;
}
}
}
if (msg) {
this.logger.error('GraphQL Unhandled Error', msg);
}
metrics.gql
.counter('query_error_counter')
.add(1, { operation, code: error.status });
});
return Promise.resolve();

View File

@@ -21,8 +21,11 @@ export { MailService } from './mailer';
export { CallCounter, CallTimer, metrics } from './metrics';
export { type ILocker, Lock, Locker, MutexService } from './mutex';
export {
GatewayErrorWrapper,
getOptionalModuleMetadata,
GlobalExceptionFilter,
mapAnyError,
mapSseError,
OptionalModule,
} from './nestjs';
export type { PrismaTransaction } from './prisma';

View File

@@ -1,6 +1,7 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Config } from '../config';
import { MailerServiceIsNotConfigured } from '../error';
import { URLHelper } from '../helpers';
import type { MailerService, Options } from './mailer';
import { MAILER_SERVICE } from './mailer';
@@ -15,7 +16,7 @@ export class MailService {
async sendMail(options: Options) {
if (!this.mailer) {
throw new Error('Mailer service is not configured.');
throw new MailerServiceIsNotConfigured();
}
return this.mailer.sendMail({

View File

@@ -34,7 +34,8 @@ export type KnownMetricScopes =
| 'jwst'
| 'auth'
| 'controllers'
| 'doc';
| 'doc'
| 'sse';
const metricCreators: MetricCreators = {
counter(meter: Meter, name: string, opts?: MetricOptions) {

View File

@@ -1,25 +1,87 @@
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { GqlContextType } from '@nestjs/graphql';
import { ThrottlerException } from '@nestjs/throttler';
import { Response } from 'express';
import { of } from 'rxjs';
import {
InternalServerError,
TooManyRequest,
UserFriendlyError,
} from '../error';
import { metrics } from '../metrics';
export function mapAnyError(error: any): UserFriendlyError {
if (error instanceof UserFriendlyError) {
return error;
} else if (error instanceof ThrottlerException) {
return new TooManyRequest();
} else {
const e = new InternalServerError();
e.cause = error;
return e;
}
}
@Catch()
export class GlobalExceptionFilter extends BaseExceptionFilter {
logger = new Logger('GlobalExceptionFilter');
override catch(exception: Error, host: ArgumentsHost) {
const error = mapAnyError(exception);
// with useGlobalFilters, the context is always HTTP
if (host.getType<GqlContextType>() === 'graphql') {
// let Graphql LoggerPlugin handle it
// see '../graphql/logger-plugin.ts'
throw exception;
throw error;
} else {
if (exception instanceof HttpException) {
const res = host.switchToHttp().getResponse<Response>();
res.status(exception.getStatus()).send(exception.getResponse());
return;
} else {
super.catch(exception, host);
}
error.log('HTTP');
metrics.controllers.counter('error').add(1, { status: error.status });
const res = host.switchToHttp().getResponse<Response>();
res.status(error.status).send(error.json());
return;
}
}
}
export const GatewayErrorWrapper = (event: string): 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 (error) {
const mappedError = mapAnyError(error);
mappedError.log('Websocket');
metrics.socketio
.counter('error')
.add(1, { event, status: mappedError.status });
return {
error: mappedError.json(),
};
}
};
return desc;
};
};
export function mapSseError(originalError: any) {
const error = mapAnyError(originalError);
error.log('Sse');
metrics.sse.counter('error').add(1, { status: error.status });
return of({
type: 'error' as const,
data: error.json(),
});
}

View File

@@ -1,11 +1,7 @@
import {
BadRequestException,
Controller,
Get,
HttpException,
InternalServerErrorException,
Logger,
NotFoundException,
Param,
Query,
Req,
@@ -23,14 +19,21 @@ import {
merge,
mergeMap,
Observable,
of,
switchMap,
toArray,
} from 'rxjs';
import { Public } from '../../core/auth';
import { CurrentUser } from '../../core/auth/current-user';
import { Config } from '../../fundamentals';
import {
BlobNotFound,
Config,
CopilotFailedToGenerateText,
CopilotSessionNotFound,
mapSseError,
NoCopilotProviderAvailable,
UnsplashIsNotConfigured,
} from '../../fundamentals';
import { CopilotProviderService } from './providers';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
@@ -40,7 +43,7 @@ import { CopilotWorkflowService } from './workflow';
export interface ChatEvent {
type: 'attachment' | 'message' | 'error';
id?: string;
data: string;
data: string | object;
}
type CheckResult = {
@@ -68,7 +71,7 @@ export class CopilotController {
await this.chatSession.checkQuota(userId);
const session = await this.chatSession.get(sessionId);
if (!session || session.config.userId !== userId) {
throw new BadRequestException('Session not found');
throw new CopilotSessionNotFound();
}
const ret: CheckResult = { model: session.model };
@@ -104,7 +107,7 @@ export class CopilotController {
);
}
if (!provider) {
throw new InternalServerErrorException('No provider available');
throw new NoCopilotProviderAvailable();
}
return provider;
@@ -116,7 +119,7 @@ export class CopilotController {
): Promise<ChatSession> {
const session = await this.chatSession.get(sessionId);
if (!session) {
throw new BadRequestException('Session not found');
throw new CopilotSessionNotFound();
}
if (messageId) {
@@ -148,20 +151,6 @@ export class CopilotController {
return num;
}
private handleError(err: any) {
if (err instanceof Error) {
const ret = {
message: err.message,
status: (err as any).status,
};
if (err instanceof HttpException) {
ret.status = err.getStatus();
}
return ret;
}
return err;
}
@Get('/chat/:sessionId')
async chat(
@CurrentUser() user: CurrentUser,
@@ -200,9 +189,7 @@ export class CopilotController {
return content;
} catch (e: any) {
throw new InternalServerErrorException(
e.message || "Couldn't generate text"
);
throw new CopilotFailedToGenerateText(e.message);
}
}
@@ -253,18 +240,10 @@ export class CopilotController {
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
catchError(mapSseError)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
return mapSseError(err);
}
}
@@ -318,18 +297,10 @@ export class CopilotController {
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
catchError(mapSseError)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
return mapSseError(err);
}
}
@@ -356,7 +327,7 @@ export class CopilotController {
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
throw new NoCopilotProviderAvailable();
}
const session = await this.appendSessionMessage(sessionId, messageId);
@@ -402,18 +373,10 @@ export class CopilotController {
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
catchError(mapSseError)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
return mapSseError(err);
}
}
@@ -425,7 +388,7 @@ export class CopilotController {
) {
const { unsplashKey } = this.config.plugins.copilot || {};
if (!unsplashKey) {
throw new InternalServerErrorException('Unsplash key is not configured');
throw new UnsplashIsNotConfigured();
}
const query = new URLSearchParams(params);
@@ -458,9 +421,10 @@ export class CopilotController {
const { body, metadata } = await this.storage.get(userId, workspaceId, key);
if (!body) {
throw new NotFoundException(
`Blob not found in ${userId}'s workspace ${workspaceId}: ${key}`
);
throw new BlobNotFound({
workspaceId,
blobId: key,
});
}
// metadata should always exists if body is not null

View File

@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
@@ -23,6 +23,7 @@ import { Admin } from '../../core/common';
import { UserType } from '../../core/user';
import { PermissionService } from '../../core/workspaces/permission';
import {
CopilotFailedToCreateMessage,
FileUpload,
MutexService,
Throttle,
@@ -201,8 +202,6 @@ export class CopilotType {
@Throttle()
@Resolver(() => CopilotType)
export class CopilotResolver {
private readonly logger = new Logger(CopilotResolver.name);
constructor(
private readonly permissions: PermissionService,
private readonly mutex: MutexService,
@@ -385,8 +384,7 @@ export class CopilotResolver {
try {
return await this.chatSession.createMessage(options);
} catch (e: any) {
this.logger.error(`Failed to create chat message: ${e.message}`);
throw new Error('Failed to create chat message');
throw new CopilotFailedToCreateMessage(e.message);
}
}
}

View File

@@ -5,7 +5,14 @@ import { AiPromptRole, PrismaClient } from '@prisma/client';
import { FeatureManagementService } from '../../core/features';
import { QuotaService } from '../../core/quota';
import { PaymentRequiredException } from '../../fundamentals';
import {
CopilotActionTaken,
CopilotMessageNotFound,
CopilotPromptNotFound,
CopilotQuotaExceeded,
CopilotSessionDeleted,
CopilotSessionNotFound,
} from '../../fundamentals';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import {
@@ -58,7 +65,7 @@ export class ChatSession implements AsyncDisposable {
this.state.messages.length > 0 &&
message.role === 'user'
) {
throw new Error('Action has been taken, no more messages allowed');
throw new CopilotActionTaken();
}
this.state.messages.push(message);
this.stashMessageCount += 1;
@@ -74,7 +81,7 @@ export class ChatSession implements AsyncDisposable {
async getMessageById(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new Error(`Message not found: ${messageId}`);
throw new CopilotMessageNotFound();
}
return message;
}
@@ -82,7 +89,7 @@ export class ChatSession implements AsyncDisposable {
async pushByMessageId(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new Error(`Message not found: ${messageId}`);
throw new CopilotMessageNotFound();
}
this.push({
@@ -196,7 +203,7 @@ export class ChatSessionService {
},
select: { id: true, deletedAt: true },
})) || {};
if (deletedAt) throw new Error(`Session is deleted: ${id}`);
if (deletedAt) throw new CopilotSessionDeleted();
if (id) sessionId = id;
}
@@ -274,7 +281,8 @@ export class ChatSessionService {
.then(async session => {
if (!session) return;
const prompt = await this.prompt.get(session.promptName);
if (!prompt) throw new Error(`Prompt not found: ${session.promptName}`);
if (!prompt)
throw new CopilotPromptNotFound({ name: session.promptName });
const messages = ChatMessageSchema.array().safeParse(session.messages);
@@ -300,7 +308,7 @@ export class ChatSessionService {
})
.then(session => session?.id);
if (!id) {
throw new Error(`Session not found: ${sessionId}`);
throw new CopilotSessionNotFound();
}
const ids = await tx.aiSessionMessage
.findMany({
@@ -412,7 +420,7 @@ export class ChatSessionService {
if (ret.success) {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new Error(`Prompt not found: ${promptName}`);
throw new CopilotPromptNotFound({ name: promptName });
}
// render system prompt
@@ -471,9 +479,7 @@ export class ChatSessionService {
async checkQuota(userId: string) {
const { limit, used } = await this.getQuota(userId);
if (limit && Number.isFinite(limit) && used >= limit) {
throw new PaymentRequiredException(
`You have reached the limit of actions in this workspace, please upgrade your plan.`
);
throw new CopilotQuotaExceeded();
}
}
@@ -482,7 +488,7 @@ export class ChatSessionService {
const prompt = await this.prompt.get(options.promptName);
if (!prompt) {
this.logger.error(`Prompt not found: ${options.promptName}`);
throw new Error('Prompt not found');
throw new CopilotPromptNotFound({ name: options.promptName });
}
return await this.setSession({
...options,

View File

@@ -1,10 +1,11 @@
import { createHash } from 'node:crypto';
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { QuotaManagementService } from '../../core/quota';
import {
type BlobInputType,
BlobQuotaExceeded,
Config,
type FileUpload,
type StorageProvider,
@@ -54,9 +55,7 @@ export class CopilotStorage {
const checkExceeded = await this.quota.getQuotaCalculator(userId);
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();
@@ -67,9 +66,7 @@ export class CopilotStorage {
// 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);
@@ -77,7 +74,7 @@ export class CopilotStorage {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new PayloadTooLargeException('Storage limit exceeded.'));
reject(new BlobQuotaExceeded());
} else {
resolve(buffer);
}

View File

@@ -1,17 +1,18 @@
import {
BadRequestException,
Controller,
Get,
Query,
Req,
Res,
} from '@nestjs/common';
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
import { ConnectedAccount, PrismaClient } from '@prisma/client';
import type { Request, Response } from 'express';
import { AuthService, Public } from '../../core/auth';
import { UserService } from '../../core/user';
import { URLHelper } from '../../fundamentals';
import {
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthAccountAlreadyConnected,
OauthStateExpired,
UnknownOauthProvider,
URLHelper,
WrongSignInMethod,
} from '../../fundamentals';
import { OAuthProviderName } from './config';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthProviderFactory } from './register';
@@ -35,12 +36,15 @@ export class OAuthController {
@Query('provider') unknownProviderName: string,
@Query('redirect_uri') redirectUri?: string
) {
if (!unknownProviderName) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
// @ts-expect-error safe
const providerName = OAuthProviderName[unknownProviderName];
const provider = this.providerFactory.get(providerName);
if (!provider) {
throw new BadRequestException('Invalid OAuth provider');
throw new UnknownOauthProvider({ name: unknownProviderName });
}
const state = await this.oauth.saveOAuthState({
@@ -60,29 +64,31 @@ export class OAuthController {
@Query('state') stateStr?: string
) {
if (!code) {
throw new BadRequestException('Missing query parameter `code`');
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (!stateStr) {
throw new BadRequestException('Invalid callback state parameter');
throw new MissingOauthQueryParameter({ name: 'state' });
}
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const state = await this.oauth.getOAuthState(stateStr);
if (!state) {
throw new BadRequestException('OAuth state expired, please try again.');
throw new OauthStateExpired();
}
if (!state.provider) {
throw new BadRequestException(
'Missing callback state parameter `provider`'
);
throw new MissingOauthQueryParameter({ name: 'provider' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new BadRequestException('Invalid provider');
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
const tokens = await provider.getToken(code);
@@ -154,15 +160,9 @@ export class OAuthController {
// we can't directly connect the external account with given email in sign in scenario for safety concern.
// let user manually connect in account sessions instead.
if (user.registered) {
throw new BadRequestException(
'The account with provided email is not register in the same way.'
);
throw new WrongSignInMethod();
}
await this.user.fulfillUser(externalAccount.email, {
emailVerifiedAt: new Date(),
registered: true,
});
await this.db.connectedAccount.create({
data: {
userId: user.id,
@@ -228,9 +228,7 @@ export class OAuthController {
if (connectedUser) {
if (connectedUser.id !== user.id) {
throw new BadRequestException(
'The third-party account has already been connected to another user.'
);
throw new OauthAccountAlreadyConnected();
}
} else {
await this.db.connectedAccount.create({

View File

@@ -1,4 +1,4 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Config, URLHelper } from '../../../fundamentals';
import { OAuthProviderName } from '../config';
@@ -39,74 +39,60 @@ export class GithubOAuthProvider extends AutoRegisteredOAuthProvider {
}
async getToken(code: string) {
try {
const response = await fetch(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
if (response.ok) {
const ghToken = (await response.json()) as AuthTokenResponse;
return {
accessToken: ghToken.access_token,
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
const response = await fetch(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
}
} catch (e) {
throw new HttpException(
`Failed to get access_token, err: ${(e as Error).message}`,
HttpStatus.BAD_REQUEST
);
if (response.ok) {
const ghToken = (await response.json()) as AuthTokenResponse;
return {
accessToken: ghToken.access_token,
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
}
}
async getUser(token: string) {
try {
const response = await fetch('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
const response = await fetch('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const user = (await response.json()) as UserInfo;
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.login,
avatarUrl: user.avatar_url,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
} catch (e) {
throw new HttpException(
`Failed to get user information, err: ${(e as Error).stack}`,
HttpStatus.BAD_REQUEST
return {
id: user.login,
avatarUrl: user.avatar_url,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
}

View File

@@ -1,4 +1,4 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Config, URLHelper } from '../../../fundamentals';
import { OAuthProviderName } from '../config';
@@ -44,77 +44,63 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
}
async getToken(code: string) {
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
grant_type: 'authorization_code',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
grant_type: 'authorization_code',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (response.ok) {
const ghToken = (await response.json()) as GoogleOAuthTokenResponse;
if (response.ok) {
const ghToken = (await response.json()) as GoogleOAuthTokenResponse;
return {
accessToken: ghToken.access_token,
refreshToken: ghToken.refresh_token,
expiresAt: new Date(Date.now() + ghToken.expires_in * 1000),
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
}
} catch (e) {
throw new HttpException(
`Failed to get access_token, err: ${(e as Error).message}`,
HttpStatus.BAD_REQUEST
return {
accessToken: ghToken.access_token,
refreshToken: ghToken.refresh_token,
expiresAt: new Date(Date.now() + ghToken.expires_in * 1000),
scope: ghToken.scope,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
}, ${JSON.stringify(await response.json())}`
);
}
}
async getUser(token: string) {
try {
const response = await fetch(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.id,
avatarUrl: user.picture,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
const response = await fetch(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
}
} catch (e) {
throw new HttpException(
`Failed to get user information, err: ${(e as Error).stack}`,
HttpStatus.BAD_REQUEST
);
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.id,
avatarUrl: user.picture,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
}

View File

@@ -1,9 +1,4 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { z } from 'zod';
import { Config, URLHelper } from '../../../fundamentals';
@@ -44,6 +39,8 @@ const OIDCConfigurationSchema = z.object({
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
const logger = new Logger('OIDCClient');
class OIDCClient {
private static async fetch<T = any>(
url: string,
@@ -53,17 +50,8 @@ class OIDCClient {
const response = await fetch(url, options);
if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(`Invalid OIDC configuration`, {
cause: await response.json(),
description: response.statusText,
});
} else {
throw new InternalServerErrorException(`Failed to configure client`, {
cause: await response.json(),
description: response.statusText,
});
}
logger.error('Failed to fetch OIDC configuration', await response.json());
throw new Error(`Failed to configure client`);
}
const data = await response.json();
return verifier.parse(data);

View File

@@ -20,6 +20,10 @@ export class OAuthService {
private readonly cache: SessionCache
) {}
isValidState(stateStr: string) {
return stateStr.length === 36;
}
async saveOAuthState(state: OAuthState) {
const token = randomUUID();
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {

View File

@@ -1,4 +1,3 @@
import { BadGatewayException, ForbiddenException } from '@nestjs/common';
import {
Args,
Context,
@@ -19,7 +18,12 @@ import { groupBy } from 'lodash-es';
import { CurrentUser, Public } from '../../core/auth';
import { UserType } from '../../core/user';
import { Config, URLHelper } from '../../fundamentals';
import {
AccessDenied,
Config,
FailedToCheckout,
URLHelper,
} from '../../fundamentals';
import { decodeLookupKey, SubscriptionService } from './service';
import {
InvoiceStatus,
@@ -227,7 +231,7 @@ export class SubscriptionResolver {
});
if (!session.url) {
throw new BadGatewayException('Failed to create checkout session.');
throw new FailedToCheckout();
}
return session.url;
@@ -322,9 +326,7 @@ export class UserSubscriptionResolver {
) {
// allow admin to query other user's subscription
if (!ctx.isAdminQuery && me.id !== user.id) {
throw new ForbiddenException(
'You are not allowed to access this subscription.'
);
throw new AccessDenied();
}
// @FIXME(@forehalo): should not mock any api for selfhosted server
@@ -363,9 +365,7 @@ export class UserSubscriptionResolver {
@Parent() user: User
): Promise<UserSubscription[]> {
if (me.id !== user.id) {
throw new ForbiddenException(
'You are not allowed to access this subscription.'
);
throw new AccessDenied();
}
return this.db.userSubscription.findMany({
@@ -385,9 +385,7 @@ export class UserSubscriptionResolver {
@Args('skip', { type: () => Int, nullable: true }) skip?: number
) {
if (me.id !== user.id) {
throw new ForbiddenException(
'You are not allowed to access this invoices'
);
throw new AccessDenied();
}
return this.db.userInvoice.findMany({

View File

@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
import type {
Prisma,
@@ -14,7 +14,20 @@ import Stripe from 'stripe';
import { CurrentUser } from '../../core/auth';
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
import { Config, EventEmitter, OnEvent } from '../../fundamentals';
import {
ActionForbidden,
Config,
CustomerPortalCreateFailed,
EventEmitter,
OnEvent,
SameSubscriptionRecurring,
SubscriptionAlreadyExists,
SubscriptionExpired,
SubscriptionHasBeenCanceled,
SubscriptionNotExists,
SubscriptionPlanNotFound,
UserNotFound,
} from '../../fundamentals';
import { ScheduleManager } from './schedule';
import {
InvoiceStatus,
@@ -160,7 +173,7 @@ export class SubscriptionService {
this.config.affine.canary &&
!this.features.isStaff(user.email)
) {
throw new BadRequestException('You are not allowed to do this.');
throw new ActionForbidden();
}
const currentSubscription = await this.db.userSubscription.findFirst({
@@ -172,9 +185,7 @@ export class SubscriptionService {
});
if (currentSubscription) {
throw new BadRequestException(
`You've already subscribed to the ${plan} plan`
);
throw new SubscriptionAlreadyExists({ plan });
}
const customer = await this.getOrCreateCustomer(
@@ -245,18 +256,16 @@ export class SubscriptionService {
});
if (!user) {
throw new BadRequestException('Unknown user');
throw new UserNotFound();
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
throw new SubscriptionNotExists({ plan });
}
if (subscriptionInDB.canceledAt) {
throw new BadRequestException(
'Your subscription has already been canceled'
);
throw new SubscriptionHasBeenCanceled();
}
// should release the schedule first
@@ -298,22 +307,20 @@ export class SubscriptionService {
});
if (!user) {
throw new BadRequestException('Unknown user');
throw new UserNotFound();
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
throw new SubscriptionNotExists({ plan });
}
if (!subscriptionInDB.canceledAt) {
throw new BadRequestException('Your subscription has not been canceled');
throw new SubscriptionHasBeenCanceled();
}
if (subscriptionInDB.end < new Date()) {
throw new BadRequestException(
'Your subscription is expired, please checkout again.'
);
throw new SubscriptionExpired();
}
if (subscriptionInDB.stripeScheduleId) {
@@ -354,23 +361,19 @@ export class SubscriptionService {
});
if (!user) {
throw new BadRequestException('Unknown user');
throw new UserNotFound();
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
throw new SubscriptionNotExists({ plan });
}
if (subscriptionInDB.canceledAt) {
throw new BadRequestException(
'Your subscription has already been canceled'
);
throw new SubscriptionHasBeenCanceled();
}
if (subscriptionInDB.recurring === recurring) {
throw new BadRequestException(
`You are already in ${recurring} recurring`
);
throw new SameSubscriptionRecurring({ recurring });
}
const price = await this.getPrice(
@@ -404,7 +407,7 @@ export class SubscriptionService {
});
if (!user) {
throw new BadRequestException('Unknown user');
throw new UserNotFound();
}
try {
@@ -415,7 +418,7 @@ export class SubscriptionService {
return portal.url;
} catch (e) {
this.logger.error('Failed to create customer portal.', e);
throw new BadRequestException('Failed to create customer portal');
throw new CustomerPortalCreateFailed();
}
}
@@ -751,9 +754,10 @@ export class SubscriptionService {
});
if (!prices.data.length) {
throw new BadRequestException(
`Unknown subscription plan ${plan} with ${recurring} recurring`
);
throw new SubscriptionPlanNotFound({
plan,
recurring,
});
}
return prices.data[0].id;

View File

@@ -1,19 +1,13 @@
import assert from 'node:assert';
import type { RawBodyRequest } from '@nestjs/common';
import {
Controller,
Logger,
NotAcceptableException,
Post,
Req,
} from '@nestjs/common';
import { Controller, Logger, Post, Req } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Request } from 'express';
import Stripe from 'stripe';
import { Public } from '../../core/auth';
import { Config } from '../../fundamentals';
import { Config, InternalServerError } from '../../fundamentals';
@Controller('/api/stripe')
export class StripeWebhook {
@@ -55,9 +49,8 @@ export class StripeWebhook {
this.logger.error('Failed to handle Stripe Webhook event.', e);
});
});
} catch (err) {
this.logger.error('Stripe Webhook error', err);
throw new NotAcceptableException();
} catch (err: any) {
throw new InternalServerError(err.message);
}
}
}

View File

@@ -2,6 +2,11 @@
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
type BlobNotFoundDataType {
blobId: String!
workspaceId: String!
}
type ChatMessage {
attachments: [String!]
content: String!
@@ -65,6 +70,10 @@ type CopilotPromptMessageType {
role: CopilotPromptMessageRole!
}
type CopilotPromptNotFoundDataType {
name: String!
}
type CopilotPromptType {
action: String
messages: [CopilotPromptMessageType!]!
@@ -133,17 +142,98 @@ input DeleteSessionInput {
workspaceId: String!
}
type DocAccessDeniedDataType {
docId: String!
workspaceId: String!
}
type DocHistoryNotFoundDataType {
docId: String!
timestamp: Int!
workspaceId: String!
}
type DocHistoryType {
id: String!
timestamp: DateTime!
workspaceId: String!
}
type DocNotFoundDataType {
docId: String!
workspaceId: String!
}
enum EarlyAccessType {
AI
App
}
union ErrorDataUnion = BlobNotFoundDataType | CopilotPromptNotFoundDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType
enum ErrorNames {
ACCESS_DENIED
ACTION_FORBIDDEN
AUTHENTICATION_REQUIRED
BLOB_NOT_FOUND
BLOB_QUOTA_EXCEEDED
CANT_CHANGE_WORKSPACE_OWNER
COPILOT_ACTION_TAKEN
COPILOT_FAILED_TO_CREATE_MESSAGE
COPILOT_FAILED_TO_GENERATE_TEXT
COPILOT_MESSAGE_NOT_FOUND
COPILOT_PROMPT_NOT_FOUND
COPILOT_QUOTA_EXCEEDED
COPILOT_SESSION_DELETED
COPILOT_SESSION_NOT_FOUND
CUSTOMER_PORTAL_CREATE_FAILED
DOC_ACCESS_DENIED
DOC_HISTORY_NOT_FOUND
DOC_NOT_FOUND
EARLY_ACCESS_REQUIRED
EMAIL_ALREADY_USED
EMAIL_TOKEN_NOT_FOUND
EMAIL_VERIFICATION_REQUIRED
EXPECT_TO_PUBLISH_PAGE
EXPECT_TO_REVOKE_PUBLIC_PAGE
FAILED_TO_CHECKOUT
INTERNAL_SERVER_ERROR
INVALID_EMAIL
INVALID_EMAIL_TOKEN
INVALID_HISTORY_TIMESTAMP
INVALID_OAUTH_CALLBACK_STATE
INVALID_PASSWORD_LENGTH
INVALID_RUNTIME_CONFIG_TYPE
MAILER_SERVICE_IS_NOT_CONFIGURED
MEMBER_QUOTA_EXCEEDED
MISSING_OAUTH_QUERY_PARAMETER
NOT_IN_WORKSPACE
NO_COPILOT_PROVIDER_AVAILABLE
OAUTH_ACCOUNT_ALREADY_CONNECTED
OAUTH_STATE_EXPIRED
PAGE_IS_NOT_PUBLIC
RUNTIME_CONFIG_NOT_FOUND
SAME_EMAIL_PROVIDED
SAME_SUBSCRIPTION_RECURRING
SIGN_UP_FORBIDDEN
SUBSCRIPTION_ALREADY_EXISTS
SUBSCRIPTION_EXPIRED
SUBSCRIPTION_HAS_BEEN_CANCELED
SUBSCRIPTION_NOT_EXISTS
SUBSCRIPTION_PLAN_NOT_FOUND
TOO_MANY_REQUEST
UNKNOWN_OAUTH_PROVIDER
UNSPLASH_IS_NOT_CONFIGURED
USER_AVATAR_NOT_FOUND
USER_NOT_FOUND
VERSION_REJECTED
WORKSPACE_ACCESS_DENIED
WORKSPACE_NOT_FOUND
WORKSPACE_OWNER_NOT_FOUND
WRONG_SIGN_IN_CREDENTIALS
WRONG_SIGN_IN_METHOD
}
"""The type of workspace feature"""
enum FeatureType {
AIEarlyAccess
@@ -163,6 +253,21 @@ type HumanReadableQuotaType {
storageQuota: String!
}
type InvalidHistoryTimestampDataType {
timestamp: String!
}
type InvalidPasswordLengthDataType {
max: Int!
min: Int!
}
type InvalidRuntimeConfigTypeDataType {
get: String!
key: String!
want: String!
}
type InvitationType {
"""Invitee information"""
invitee: UserType!
@@ -244,6 +349,10 @@ input ListUserInput {
skip: Int = 0
}
type MissingOauthQueryParameterDataType {
name: String!
}
type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addAdminister(email: String!): Boolean!
@@ -323,6 +432,10 @@ type Mutation {
verifyEmail(token: String!): Boolean!
}
type NotInWorkspaceDataType {
workspaceId: String!
}
enum OAuthProviderType {
GitHub
Google
@@ -355,6 +468,7 @@ type Query {
"""Get current user"""
currentUser: UserType
earlyAccessUsers: [UserType!]!
error(name: ErrorNames!): ErrorDataUnion!
"""send workspace invitation"""
getInviteInfo(inviteId: String!): InvitationType!
@@ -414,6 +528,10 @@ type RemoveAvatar {
success: Boolean!
}
type RuntimeConfigNotFoundDataType {
key: String!
}
enum RuntimeConfigType {
Array
Boolean
@@ -427,6 +545,10 @@ The `SafeInt` scalar type represents non-fractional signed whole numeric values
"""
scalar SafeInt @specifiedBy(url: "https://www.ecma-international.org/ecma-262/#sec-number.issafeinteger")
type SameSubscriptionRecurringDataType {
recurring: String!
}
type ServerConfigType {
"""server base url"""
baseUrl: String!
@@ -483,6 +605,14 @@ type ServerRuntimeConfigType {
value: JSON!
}
type SubscriptionAlreadyExistsDataType {
plan: String!
}
type SubscriptionNotExistsDataType {
plan: String!
}
enum SubscriptionPlan {
AI
Enterprise
@@ -492,6 +622,11 @@ enum SubscriptionPlan {
Team
}
type SubscriptionPlanNotFoundDataType {
plan: String!
recurring: String!
}
type SubscriptionPrice {
amount: Int
currency: String!
@@ -516,6 +651,10 @@ enum SubscriptionStatus {
Unpaid
}
type UnknownOauthProviderDataType {
name: String!
}
input UpdateUserInput {
"""User name"""
name: String
@@ -617,10 +756,27 @@ type UserType {
token: tokenType! @deprecated(reason: "use [/api/auth/authorize]")
}
type VersionRejectedDataType {
serverVersion: String!
version: String!
}
type WorkspaceAccessDeniedDataType {
workspaceId: String!
}
type WorkspaceBlobSizes {
size: SafeInt!
}
type WorkspaceNotFoundDataType {
workspaceId: String!
}
type WorkspaceOwnerNotFoundDataType {
workspaceId: String!
}
type WorkspacePage {
id: String!
mode: PublicPageMode!