diff --git a/packages/backend/server/src/app.ts b/packages/backend/server/src/app.ts index 418141c49b..b1aff48598 100644 --- a/packages/backend/server/src/app.ts +++ b/packages/backend/server/src/app.ts @@ -4,9 +4,8 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; -import { SocketIoAdapter } from './fundamentals'; -import { SocketIoAdapterImpl } from './fundamentals/websocket'; -import { ExceptionLogger } from './middleware/exception-logger'; +import { GlobalExceptionFilter } from './fundamentals'; +import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket'; import { serverTimingAndCache } from './middleware/timing'; export async function createApp() { @@ -29,7 +28,7 @@ export async function createApp() { }) ); - app.useGlobalFilters(new ExceptionLogger()); + app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter())); app.use(cookieParser()); if (AFFiNE.flavor.sync) { diff --git a/packages/backend/server/src/core/users/resolver.ts b/packages/backend/server/src/core/users/resolver.ts index eaedd169e0..b047f0c2f1 100644 --- a/packages/backend/server/src/core/users/resolver.ts +++ b/packages/backend/server/src/core/users/resolver.ts @@ -1,4 +1,4 @@ -import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common'; +import { BadRequestException, UseGuards } from '@nestjs/common'; import { Args, Int, @@ -8,13 +8,13 @@ import { Resolver, } from '@nestjs/graphql'; import type { User } from '@prisma/client'; -import { GraphQLError } from 'graphql'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { CloudThrottlerGuard, EventEmitter, type FileUpload, + PaymentRequiredException, PrismaService, Throttle, } from '../../fundamentals'; @@ -97,14 +97,8 @@ export class UserResolver { @Args('email') email?: string ) { if (!email || !(await this.feature.canEarlyAccess(email))) { - return new GraphQLError( - `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`, - { - extensions: { - status: HttpStatus[HttpStatus.PAYMENT_REQUIRED], - code: HttpStatus.PAYMENT_REQUIRED, - }, - } + throw new PaymentRequiredException( + `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information` ); } diff --git a/packages/backend/server/src/core/workspaces/permission.ts b/packages/backend/server/src/core/workspaces/permission.ts index 2f955131ae..3c17355d4a 100644 --- a/packages/backend/server/src/core/workspaces/permission.ts +++ b/packages/backend/server/src/core/workspaces/permission.ts @@ -299,6 +299,18 @@ export class PermissionService { return this.tryCheckWorkspace(ws, user, permission); } + async isPublicPage(ws: string, page: string) { + return this.prisma.workspacePage + .count({ + where: { + workspaceId: ws, + pageId: page, + public: true, + }, + }) + .then(count => count > 0); + } + async publishPage(ws: string, page: string, mode = PublicPageMode.Page) { return this.prisma.workspacePage.upsert({ where: { @@ -321,26 +333,19 @@ export class PermissionService { } async revokePublicPage(ws: string, page: string) { - const workspacePage = await this.prisma.workspacePage.findUnique({ + return this.prisma.workspacePage.upsert({ where: { workspaceId_pageId: { workspaceId: ws, pageId: page, }, }, - }); - if (!workspacePage) { - throw new Error('Page is not public'); - } - - return this.prisma.workspacePage.update({ - where: { - workspaceId_pageId: { - workspaceId: ws, - pageId: page, - }, + update: { + public: false, }, - data: { + create: { + workspaceId: ws, + pageId: page, public: false, }, }); diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 6429956db8..5d859d0c42 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -1,4 +1,9 @@ -import { HttpStatus, Logger, UseGuards } from '@nestjs/common'; +import { + ForbiddenException, + Logger, + PayloadTooLargeException, + UseGuards, +} from '@nestjs/common'; import { Args, Int, @@ -8,7 +13,6 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { GraphQLError } from 'graphql'; import { SafeIntResolver } from 'graphql-scalars'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; @@ -138,12 +142,7 @@ export class WorkspaceBlobResolver { const checkExceeded = (recvSize: number) => { if (!storageQuota) { - throw new GraphQLError('cannot find user quota', { - extensions: { - status: HttpStatus[HttpStatus.FORBIDDEN], - code: HttpStatus.FORBIDDEN, - }, - }); + throw new ForbiddenException('Cannot find user quota.'); } const total = usedSize + recvSize; // only skip total storage check if workspace has unlimited feature @@ -163,12 +162,9 @@ export class WorkspaceBlobResolver { }; if (checkExceeded(0)) { - throw new GraphQLError('storage or blob size limit exceeded', { - extensions: { - status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], - code: HttpStatus.PAYLOAD_TOO_LARGE, - }, - }); + throw new PayloadTooLargeException( + 'Storage or blob size limit exceeded.' + ); } const buffer = await new Promise((resolve, reject) => { const stream = blob.createReadStream(); @@ -180,12 +176,7 @@ export class WorkspaceBlobResolver { const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0); if (checkExceeded(bufferSize)) { reject( - new GraphQLError('storage or blob size limit exceeded', { - extensions: { - status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], - code: HttpStatus.PAYLOAD_TOO_LARGE, - }, - }) + new PayloadTooLargeException('Storage or blob size limit exceeded.') ); } }); @@ -194,14 +185,7 @@ export class WorkspaceBlobResolver { const buffer = Buffer.concat(chunks); if (checkExceeded(buffer.length)) { - reject( - new GraphQLError('storage limit exceeded', { - extensions: { - status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], - code: HttpStatus.PAYLOAD_TOO_LARGE, - }, - }) - ); + reject(new PayloadTooLargeException('Storage limit exceeded.')); } else { resolve(buffer); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index 26454323af..11401895da 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, UseGuards } from '@nestjs/common'; +import { BadRequestException, UseGuards } from '@nestjs/common'; import { Args, Field, @@ -111,7 +111,7 @@ export class PagePermissionResolver { const docId = new DocID(pageId, workspaceId); if (docId.isWorkspace) { - throw new ForbiddenException('Expect page not to be workspace'); + throw new BadRequestException('Expect page not to be workspace'); } await this.permission.checkWorkspace( @@ -148,7 +148,7 @@ export class PagePermissionResolver { const docId = new DocID(pageId, workspaceId); if (docId.isWorkspace) { - throw new ForbiddenException('Expect page not to be workspace'); + throw new BadRequestException('Expect page not to be workspace'); } await this.permission.checkWorkspace( @@ -157,6 +157,15 @@ export class PagePermissionResolver { Permission.Read ); + const isPublic = await this.permission.isPublicPage( + docId.workspace, + docId.guid + ); + + if (!isPublic) { + throw new BadRequestException('Page is not public'); + } + return this.permission.revokePublicPage(docId.workspace, docId.guid); } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 6edcad2ac4..a3351b9776 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -1,8 +1,9 @@ import { ForbiddenException, - HttpStatus, + InternalServerErrorException, Logger, NotFoundException, + PayloadTooLargeException, UseGuards, } from '@nestjs/common'; import { @@ -16,7 +17,6 @@ import { } from '@nestjs/graphql'; import type { User } from '@prisma/client'; import { getStreamAsBuffer } from 'get-stream'; -import { GraphQLError } from 'graphql'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { applyUpdate, Doc } from 'yjs'; @@ -344,12 +344,7 @@ export class WorkspaceResolver { this.quota.getWorkspaceUsage(workspaceId), ]); if (memberCount >= quota.memberLimit) { - throw new GraphQLError('Workspace member limit reached', { - extensions: { - status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], - code: HttpStatus.PAYLOAD_TOO_LARGE, - }, - }); + throw new PayloadTooLargeException('Workspace member limit reached.'); } let target = await this.users.findUserByEmail(email); @@ -401,14 +396,8 @@ export class WorkspaceResolver { `failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}` ); } - return new GraphQLError( - 'failed to send invite email, please try again', - { - extensions: { - status: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR], - code: HttpStatus.INTERNAL_SERVER_ERROR, - }, - } + return new InternalServerErrorException( + 'Failed to send invite email. Please try again.' ); } } diff --git a/packages/backend/server/src/fundamentals/error/index.ts b/packages/backend/server/src/fundamentals/error/index.ts new file mode 100644 index 0000000000..0681702e4c --- /dev/null +++ b/packages/backend/server/src/fundamentals/error/index.ts @@ -0,0 +1 @@ +export * from './payment-required'; diff --git a/packages/backend/server/src/fundamentals/error/payment-required.ts b/packages/backend/server/src/fundamentals/error/payment-required.ts new file mode 100644 index 0000000000..5ebd901908 --- /dev/null +++ b/packages/backend/server/src/fundamentals/error/payment-required.ts @@ -0,0 +1,10 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class PaymentRequiredException extends HttpException { + constructor(desc?: string, code: string = 'Payment Required') { + super( + HttpException.createBody(desc ?? code, code, HttpStatus.PAYMENT_REQUIRED), + HttpStatus.PAYMENT_REQUIRED + ); + } +} diff --git a/packages/backend/server/src/fundamentals/graphql/index.ts b/packages/backend/server/src/fundamentals/graphql/index.ts index 6ca45356bf..3f53ea05dd 100644 --- a/packages/backend/server/src/fundamentals/graphql/index.ts +++ b/packages/backend/server/src/fundamentals/graphql/index.ts @@ -3,9 +3,10 @@ import { fileURLToPath } from 'node:url'; import type { ApolloDriverConfig } from '@nestjs/apollo'; import { ApolloDriver } from '@nestjs/apollo'; -import { Global, Module } from '@nestjs/common'; +import { Global, HttpException, HttpStatus, Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import { Request, Response } from 'express'; +import { GraphQLError } from 'graphql'; import { Config } from '../config'; import { GQLLoggerPlugin } from './logger-plugin'; @@ -34,7 +35,37 @@ import { GQLLoggerPlugin } from './logger-plugin'; res, isAdminQuery: false, }), + includeStacktraceInErrorResponses: !config.node.prod, plugins: [new GQLLoggerPlugin()], + formatError: (formattedError, error) => { + // @ts-expect-error allow assign + formattedError.extensions ??= {}; + + if ( + error instanceof GraphQLError && + error.originalError instanceof HttpException + ) { + 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']; + + 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]; + } + + return formattedError; + }, }; }, inject: [Config], diff --git a/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts b/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts index 628c6ce42a..af52dc54a5 100644 --- a/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts +++ b/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts @@ -4,7 +4,7 @@ import { GraphQLRequestListener, } from '@apollo/server'; import { Plugin } from '@nestjs/apollo'; -import { Logger } from '@nestjs/common'; +import { HttpException, Logger } from '@nestjs/common'; import { Response } from 'express'; import { metrics } from '../metrics/metrics'; @@ -27,28 +27,44 @@ export class GQLLoggerPlugin implements ApolloServerPlugin { metrics.gql.counter('query_counter').add(1, { operation }); const start = Date.now(); + function endTimer() { + return Date.now() - start; + } return Promise.resolve({ willSendResponse: () => { - const costInMilliseconds = Date.now() - start; - res.setHeader( - 'Server-Timing', - `gql;dur=${costInMilliseconds};desc="GraphQL"` - ); - metrics.gql - .histogram('query_duration') - .record(costInMilliseconds, { operation }); + const time = endTimer(); + res.setHeader('Server-Timing', `gql;dur=${time};desc="GraphQL"`); + metrics.gql.histogram('query_duration').record(time, { operation }); return Promise.resolve(); }, - didEncounterErrors: () => { - const costInMilliseconds = Date.now() - start; - res.setHeader( - 'Server-Timing', - `gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"` - ); - metrics.gql - .histogram('query_duration') - .record(costInMilliseconds, { operation }); + didEncounterErrors: ctx => { + metrics.gql.counter('query_error_counter').add(1, { operation }); + + 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); + } + }); + return Promise.resolve(); }, }); diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index 166902359f..35fc1db0c5 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -11,10 +11,15 @@ export { type ConfigPaths, getDefaultAFFiNEStorageConfig, } from './config'; +export * from './error'; export { EventEmitter, type EventPayload, OnEvent } from './event'; export { MailService } from './mailer'; export { CallCounter, CallTimer, metrics } from './metrics'; -export { getOptionalModuleMetadata, OptionalModule } from './nestjs'; +export { + getOptionalModuleMetadata, + GlobalExceptionFilter, + OptionalModule, +} from './nestjs'; export { PrismaService } from './prisma'; export { SessionService } from './session'; export * from './storage'; @@ -25,4 +30,3 @@ export { getRequestResponseFromHost, } from './utils/request'; export type * from './utils/types'; -export { SocketIoAdapter } from './websocket'; diff --git a/packages/backend/server/src/fundamentals/nestjs/exception.ts b/packages/backend/server/src/fundamentals/nestjs/exception.ts new file mode 100644 index 0000000000..231d983361 --- /dev/null +++ b/packages/backend/server/src/fundamentals/nestjs/exception.ts @@ -0,0 +1,25 @@ +import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { GqlContextType } from '@nestjs/graphql'; +import { Response } from 'express'; + +@Catch() +export class GlobalExceptionFilter extends BaseExceptionFilter { + override catch(exception: Error, host: ArgumentsHost) { + // with useGlobalFilters, the context is always HTTP + + if (host.getType() === 'graphql') { + // let Graphql LoggerPlugin handle it + // see '../graphql/logger-plugin.ts' + throw exception; + } else { + if (exception instanceof HttpException) { + const res = host.switchToHttp().getResponse(); + res.status(exception.getStatus()).send(exception.getResponse()); + return; + } else { + super.catch(exception, host); + } + } + } +} diff --git a/packages/backend/server/src/fundamentals/nestjs/index.ts b/packages/backend/server/src/fundamentals/nestjs/index.ts index d1ab9aeef9..7404efcb5c 100644 --- a/packages/backend/server/src/fundamentals/nestjs/index.ts +++ b/packages/backend/server/src/fundamentals/nestjs/index.ts @@ -1 +1,2 @@ +export * from './exception'; export * from './optional-module'; diff --git a/packages/backend/server/src/middleware/exception-logger.ts b/packages/backend/server/src/middleware/exception-logger.ts deleted file mode 100644 index 8630c118d9..0000000000 --- a/packages/backend/server/src/middleware/exception-logger.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - Logger, - NotFoundException, -} from '@nestjs/common'; -import { GqlContextType } from '@nestjs/graphql'; -import { Request, Response } from 'express'; - -const TrivialExceptions = [NotFoundException]; - -export const REQUEST_ID_HEADER = 'x-request-id'; - -@Catch() -export class ExceptionLogger implements ExceptionFilter { - private readonly logger = new Logger('ExceptionLogger'); - - catch(exception: Error, host: ArgumentsHost) { - // with useGlobalFilters, the context is always HTTP - const ctx = host.switchToHttp(); - - const request = ctx.getRequest(); - const requestId = request?.header(REQUEST_ID_HEADER); - - const shouldVerboseLog = !TrivialExceptions.some( - e => exception instanceof e - ); - this.logger.error( - new Error( - `${requestId ? `requestId-${requestId}: ` : ''}${exception.message}${ - shouldVerboseLog ? '\n' + exception.stack : '' - }`, - { cause: exception } - ) - ); - - if (host.getType() === 'graphql') { - return; - } - - const response = ctx.getResponse(); - if (exception instanceof HttpException) { - response.status(exception.getStatus()).json(exception.getResponse()); - } else { - response.status(500).json({ - statusCode: 500, - error: exception.message, - }); - } - } -} diff --git a/packages/backend/server/src/middleware/timing.ts b/packages/backend/server/src/middleware/timing.ts index b0f3eaffd7..d476bcb388 100644 --- a/packages/backend/server/src/middleware/timing.ts +++ b/packages/backend/server/src/middleware/timing.ts @@ -21,7 +21,5 @@ export const serverTimingAndCache = ( res.setHeader('Server-Timing', serverTimingValue); }); - res.setHeader('Cache-Control', 'max-age=0, private, must-revalidate'); - next(); }; diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index c65bde9800..e17895d24f 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -1,4 +1,8 @@ -import { HttpStatus } from '@nestjs/common'; +import { + BadGatewayException, + ForbiddenException, + InternalServerErrorException, +} from '@nestjs/common'; import { Args, Context, @@ -13,7 +17,6 @@ import { Resolver, } from '@nestjs/graphql'; import type { User, UserInvoice, UserSubscription } from '@prisma/client'; -import { GraphQLError } from 'graphql'; import { groupBy } from 'lodash-es'; import { Auth, CurrentUser, Public } from '../../core/auth'; @@ -164,12 +167,9 @@ export class SubscriptionResolver { ); if (!yearly || !monthly) { - throw new GraphQLError('The prices are not configured correctly', { - extensions: { - status: HttpStatus[HttpStatus.BAD_GATEWAY], - code: HttpStatus.BAD_GATEWAY, - }, - }); + throw new InternalServerErrorException( + 'The prices are not configured correctly.' + ); } return { @@ -199,12 +199,7 @@ export class SubscriptionResolver { }); if (!session.url) { - throw new GraphQLError('Failed to create checkout session', { - extensions: { - status: HttpStatus[HttpStatus.BAD_GATEWAY], - code: HttpStatus.BAD_GATEWAY, - }, - }); + throw new BadGatewayException('Failed to create checkout session.'); } return session.url; @@ -263,14 +258,8 @@ export class UserSubscriptionResolver { ) { // allow admin to query other user's subscription if (!ctx.isAdminQuery && me.id !== user.id) { - throw new GraphQLError( - 'You are not allowed to access this subscription', - { - extensions: { - status: HttpStatus[HttpStatus.FORBIDDEN], - code: HttpStatus.FORBIDDEN, - }, - } + throw new ForbiddenException( + 'You are not allowed to access this subscription.' ); } @@ -310,12 +299,9 @@ export class UserSubscriptionResolver { @Args('skip', { type: () => Int, nullable: true }) skip?: number ) { if (me.id !== user.id) { - throw new GraphQLError('You are not allowed to access this invoices', { - extensions: { - status: HttpStatus[HttpStatus.FORBIDDEN], - code: HttpStatus.FORBIDDEN, - }, - }); + throw new ForbiddenException( + 'You are not allowed to access this invoices' + ); } return this.db.userInvoice.findMany({ diff --git a/packages/backend/server/src/plugins/redis/ws-adapter.ts b/packages/backend/server/src/plugins/redis/ws-adapter.ts index 528e0b4473..4633fc235c 100644 --- a/packages/backend/server/src/plugins/redis/ws-adapter.ts +++ b/packages/backend/server/src/plugins/redis/ws-adapter.ts @@ -2,7 +2,7 @@ import { createAdapter } from '@socket.io/redis-adapter'; import { Redis } from 'ioredis'; import { Server, ServerOptions } from 'socket.io'; -import { SocketIoAdapter } from '../../fundamentals'; +import { SocketIoAdapter } from '../../fundamentals/websocket'; export function createSockerIoAdapterImpl( redis: Redis diff --git a/packages/backend/server/tests/graphql.spec.ts b/packages/backend/server/tests/graphql.spec.ts new file mode 100644 index 0000000000..815dc9c5cc --- /dev/null +++ b/packages/backend/server/tests/graphql.spec.ts @@ -0,0 +1,90 @@ +import { + ForbiddenException, + HttpStatus, + INestApplication, +} from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { Test } from '@nestjs/testing'; +import testFn, { TestFn } from 'ava'; +import request from 'supertest'; + +import { ConfigModule } from '../src/fundamentals/config'; +import { GqlModule } from '../src/fundamentals/graphql'; + +@Resolver(() => String) +class TestResolver { + greating = 'hello world'; + + @Query(() => String) + hello() { + return this.greating; + } + + @Mutation(() => String) + update(@Args('greating') greating: string) { + this.greating = greating; + return this.greating; + } + + @Query(() => String) + errorQuery() { + throw new ForbiddenException('forbidden query'); + } + + @Query(() => String) + unknownErrorQuery() { + throw new Error('unknown error'); + } +} + +const test = testFn as TestFn<{ app: INestApplication }>; + +function gql(app: INestApplication, query: string) { + return request(app.getHttpServer()) + .post('/graphql') + .send({ query }) + .expect(200); +} + +test.beforeEach(async ctx => { + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), GqlModule], + providers: [TestResolver], + }).compile(); + + ctx.context.app = await module + .createNestApplication({ + logger: false, + }) + .init(); +}); + +test('should be able to execute query', async t => { + const res = await gql(t.context.app, `query { hello }`); + t.is(res.body.data.hello, 'hello world'); +}); + +test('should be able to execute mutation', async t => { + const res = await gql(t.context.app, `mutation { update(greating: "hi") }`); + + t.is(res.body.data.update, 'hi'); + + const newRes = await gql(t.context.app, `query { hello }`); + t.is(newRes.body.data.hello, 'hi'); +}); + +test('should be able to handle known http exception', async t => { + const res = await gql(t.context.app, `query { errorQuery }`); + const err = res.body.errors[0]; + t.is(err.message, 'forbidden query'); + t.is(err.extensions.code, HttpStatus.FORBIDDEN); + t.is(err.extensions.status, HttpStatus[HttpStatus.FORBIDDEN]); +}); + +test('should be able to handle unknown internal error', async t => { + const res = await gql(t.context.app, `query { unknownErrorQuery }`); + const err = res.body.errors[0]; + t.is(err.message, 'Internal Server Error'); + t.is(err.extensions.code, HttpStatus.INTERNAL_SERVER_ERROR); + t.is(err.extensions.status, HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR]); +}); diff --git a/packages/frontend/core/src/components/affine/auth/sign-in.tsx b/packages/frontend/core/src/components/affine/auth/sign-in.tsx index a43d7b7c06..9635853be1 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in.tsx @@ -5,11 +5,14 @@ import { } from '@affine/component/auth-components'; import { Button } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { type GetUserQuery, getUserQuery } from '@affine/graphql'; +import { + findGraphQLError, + type GetUserQuery, + getUserQuery, +} from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons'; -import { GraphQLError } from 'graphql'; import { type FC, useState } from 'react'; import { useCallback } from 'react'; @@ -64,8 +67,7 @@ export const SignIn: FC = ({ const user: GetUserQuery['user'] | null | 0 = await verifyUser({ email }) .then(({ user }) => user) .catch(err => { - const e = err?.[0]; - if (e instanceof GraphQLError && e.extensions?.code === 402) { + if (findGraphQLError(err, e => e.extensions.code === 402)) { setAuthState('noAccess'); return 0; } else { diff --git a/packages/frontend/graphql/src/error.ts b/packages/frontend/graphql/src/error.ts new file mode 100644 index 0000000000..02aaa28b17 --- /dev/null +++ b/packages/frontend/graphql/src/error.ts @@ -0,0 +1,26 @@ +import { GraphQLError as BaseGraphQLError } from 'graphql'; +import { identity } from 'lodash-es'; + +interface KnownGraphQLErrorExtensions { + code: number; + status: string; + originalError?: unknown; + stacktrace?: string; +} + +export class GraphQLError extends BaseGraphQLError { + // @ts-expect-error better to be a known type without any type casting + override extensions!: KnownGraphQLErrorExtensions; +} +export function findGraphQLError( + errOrArr: any, + filter: (err: GraphQLError) => boolean = identity +): GraphQLError | undefined { + if (errOrArr instanceof GraphQLError) { + return filter(errOrArr) ? errOrArr : undefined; + } else if (Array.isArray(errOrArr)) { + return errOrArr.find(err => err instanceof GraphQLError && filter(err)); + } else { + return undefined; + } +} diff --git a/packages/frontend/graphql/src/index.ts b/packages/frontend/graphql/src/index.ts index f0238e95e3..c0c162802d 100644 --- a/packages/frontend/graphql/src/index.ts +++ b/packages/frontend/graphql/src/index.ts @@ -1,3 +1,4 @@ +export * from './error'; export * from './fetcher'; export * from './graphql'; export * from './schema'; @@ -18,5 +19,3 @@ export function getBaseUrl(): string { } export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql'); - -export { GraphQLError } from 'graphql'; diff --git a/packages/frontend/workspace-impl/src/cloud/blob.ts b/packages/frontend/workspace-impl/src/cloud/blob.ts index f3f127d788..3d0369f2b5 100644 --- a/packages/frontend/workspace-impl/src/cloud/blob.ts +++ b/packages/frontend/workspace-impl/src/cloud/blob.ts @@ -1,14 +1,13 @@ import { deleteBlobMutation, fetchWithTraceReport, + findGraphQLError, getBaseUrl, - GraphQLError, listBlobsQuery, setBlobMutation, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; import { type BlobStorage, BlobStorageOverCapacity } from '@toeverything/infra'; -import { isArray } from 'lodash-es'; import { bufferToBlob } from '../utils/buffer-to-blob'; @@ -43,13 +42,15 @@ export class AffineCloudBlobStorage implements BlobStorage { }) .then(res => res.setBlob) .catch(err => { - if (isArray(err)) { - err.map(e => { - if (e instanceof GraphQLError && e.extensions.code === 413) { - throw new BlobStorageOverCapacity(e); - } else throw e; - }); + const uploadError = findGraphQLError( + err, + e => e.extensions.code === 413 + ); + + if (uploadError) { + throw new BlobStorageOverCapacity(uploadError); } + throw err; }); }