From 23d208c091d434f6eae620d95ac558ecaeaad439 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 19 Nov 2025 01:04:39 +0800 Subject: [PATCH] feat: implement simple CORS support and enhance error handling - Added SimpleCorsInterceptor to manage CORS headers for requests. - Introduced AllowSimpleCors decorator for easy application of CORS settings on controllers and methods. - Created helper function to apply CORS headers consistently across responses. - Updated TenantController to utilize the new CORS functionality. - Added COMMON_FORBIDDEN error code to improve error handling. Signed-off-by: Innei --- be/apps/core/src/app.factory.ts | 2 + .../src/decorators/simple-cors.decorator.ts | 31 +++++++++ be/apps/core/src/errors/error-codes.ts | 5 ++ be/apps/core/src/helpers/cors.helper.ts | 6 ++ .../interceptors/simple-cors.interceptor.ts | 19 +++++ .../static-web/static-controller.utils.ts | 8 +-- .../platform/tenant/tenant.controller.ts | 69 ++++++++++--------- 7 files changed, 102 insertions(+), 38 deletions(-) create mode 100644 be/apps/core/src/decorators/simple-cors.decorator.ts create mode 100644 be/apps/core/src/helpers/cors.helper.ts create mode 100644 be/apps/core/src/interceptors/simple-cors.interceptor.ts diff --git a/be/apps/core/src/app.factory.ts b/be/apps/core/src/app.factory.ts index 76414580..706ad13f 100644 --- a/be/apps/core/src/app.factory.ts +++ b/be/apps/core/src/app.factory.ts @@ -10,6 +10,7 @@ import { PgPoolProvider } from './database/database.provider' import { AllExceptionsFilter } from './filters/all-exceptions.filter' import { LoggingInterceptor } from './interceptors/logging.interceptor' import { ResponseTransformInterceptor } from './interceptors/response-transform.interceptor' +import { SimpleCorsInterceptor } from './interceptors/simple-cors.interceptor' import { AppModules } from './modules/index.module' import { registerOpenApiRoutes } from './openapi' import { RedisProvider } from './redis/redis.provider' @@ -45,6 +46,7 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi app.useGlobalFilters(new AllExceptionsFilter()) app.useGlobalInterceptors(new LoggingInterceptor()) + app.useGlobalInterceptors(new SimpleCorsInterceptor()) app.useGlobalInterceptors(new ResponseTransformInterceptor()) app.useGlobalPipes(new GlobalValidationPipe()) diff --git a/be/apps/core/src/decorators/simple-cors.decorator.ts b/be/apps/core/src/decorators/simple-cors.decorator.ts new file mode 100644 index 00000000..223a5d21 --- /dev/null +++ b/be/apps/core/src/decorators/simple-cors.decorator.ts @@ -0,0 +1,31 @@ +const SIMPLE_CORS_METADATA = Symbol.for('core.cors.simple') + +type DecoratorTarget = object | Function + +function setSimpleCorsMetadata(target: DecoratorTarget): void { + Reflect.defineMetadata(SIMPLE_CORS_METADATA, true, target) +} + +export function AllowSimpleCors(): ClassDecorator & MethodDecorator { + return ((target: DecoratorTarget, _propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => { + if (descriptor?.value && typeof descriptor.value === 'function') { + setSimpleCorsMetadata(descriptor.value) + return descriptor + } + + setSimpleCorsMetadata(target) + return descriptor + }) as unknown as ClassDecorator & MethodDecorator +} + +export function shouldAllowSimpleCors(target: DecoratorTarget | undefined): boolean { + if (!target) { + return false + } + + try { + return (Reflect.getMetadata(SIMPLE_CORS_METADATA, target) ?? false) === true + } catch { + return false + } +} diff --git a/be/apps/core/src/errors/error-codes.ts b/be/apps/core/src/errors/error-codes.ts index 658312d1..6684f48f 100644 --- a/be/apps/core/src/errors/error-codes.ts +++ b/be/apps/core/src/errors/error-codes.ts @@ -6,6 +6,7 @@ export enum ErrorCode { COMMON_CONFLICT = 4, COMMON_RATE_LIMITED = 5, COMMON_INTERNAL_SERVER_ERROR = 6, + COMMON_FORBIDDEN = 7, // Auth AUTH_UNAUTHORIZED = 10, @@ -58,6 +59,10 @@ export const ERROR_CODE_DESCRIPTORS: Record = { httpStatus: 500, message: 'Internal server error', }, + [ErrorCode.COMMON_FORBIDDEN]: { + httpStatus: 403, + message: 'Forbidden', + }, [ErrorCode.AUTH_UNAUTHORIZED]: { httpStatus: 401, message: 'Unauthorized', diff --git a/be/apps/core/src/helpers/cors.helper.ts b/be/apps/core/src/helpers/cors.helper.ts new file mode 100644 index 00000000..5bc5d3dd --- /dev/null +++ b/be/apps/core/src/helpers/cors.helper.ts @@ -0,0 +1,6 @@ +export function applySimpleCorsHeaders(response: Response): Response { + response.headers.set('Access-Control-Allow-Origin', '*') + response.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') + response.headers.set('Access-Control-Allow-Headers', 'Content-Type') + return response +} diff --git a/be/apps/core/src/interceptors/simple-cors.interceptor.ts b/be/apps/core/src/interceptors/simple-cors.interceptor.ts new file mode 100644 index 00000000..cb51a1a6 --- /dev/null +++ b/be/apps/core/src/interceptors/simple-cors.interceptor.ts @@ -0,0 +1,19 @@ +import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework' +import { shouldAllowSimpleCors } from 'core/decorators/simple-cors.decorator' +import { applySimpleCorsHeaders } from 'core/helpers/cors.helper' +import { injectable } from 'tsyringe' + +@injectable() +export class SimpleCorsInterceptor implements Interceptor { + async intercept(context: ExecutionContext, next: CallHandler): Promise { + const handler = context.getHandler() + const clazz = context.getClass() + + if (!shouldAllowSimpleCors(handler) && !shouldAllowSimpleCors(clazz)) { + return await next.handle() + } + + const response = await next.handle() + return applySimpleCorsHeaders(response) + } +} diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-controller.utils.ts b/be/apps/core/src/modules/infrastructure/static-web/static-controller.utils.ts index ca8adb8b..9fac4b42 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-controller.utils.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-controller.utils.ts @@ -1,5 +1,6 @@ import { isTenantSlugReserved } from '@afilmory/utils' import { BizException, ErrorCode } from 'core/errors' +import { applySimpleCorsHeaders } from 'core/helpers/cors.helper' import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants' import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context' import type { Context } from 'hono' @@ -94,9 +95,6 @@ export const StaticControllerUtils = { }, applyStaticAssetCors(response: Response): Response { - response.headers.set('Access-Control-Allow-Origin', '*') - response.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') - response.headers.set('Access-Control-Allow-Headers', 'Content-Type') - return response + return applySimpleCorsHeaders(response) }, -}; +} diff --git a/be/apps/core/src/modules/platform/tenant/tenant.controller.ts b/be/apps/core/src/modules/platform/tenant/tenant.controller.ts index 45a3744a..0c69907a 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.controller.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.controller.ts @@ -1,17 +1,46 @@ -import { Body, Controller, Post } from '@afilmory/framework' +import { Body, Controller, createZodSchemaDto, Post } from '@afilmory/framework' import { isTenantSlugReserved } from '@afilmory/utils' import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator' +import { AllowSimpleCors } from 'core/decorators/simple-cors.decorator' import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator' import { BizException, ErrorCode } from 'core/errors' import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import { z } from 'zod' import { TenantService } from './tenant.service' const TENANT_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i -type CheckTenantSlugRequest = { - slug?: string | null -} +const checkTenantSlugSchema = z.object({ + slug: z + .union([z.string(), z.null()]) + .optional() + .transform((val) => { + if (!val) { + return null + } + const normalized = val.trim().toLowerCase() + return normalized.length > 0 ? normalized : null + }) + .pipe( + z + .string() + .nullable() + .refine((val) => val !== null, { message: '空间名称不能为空' }) + .transform((val) => val as string) + .pipe( + z + .string() + .min(3, { message: '空间名称至少需要 3 个字符' }) + .max(63, { message: '空间名称长度不能超过 63 个字符' }) + .regex(TENANT_SLUG_PATTERN, { + message: '空间名称只能包含字母、数字或连字符 (-),且不能以连字符开头或结尾。', + }), + ), + ), +}) + +class CheckTenantSlugDto extends createZodSchemaDto(checkTenantSlugSchema) {} @Controller('tenant') export class TenantController { @@ -20,18 +49,14 @@ export class TenantController { private readonly systemSettings: SystemSettingService, ) {} + @AllowSimpleCors() @AllowPlaceholderTenant() @SkipTenantGuard() @Post('/check-slug') - async checkTenantSlug(@Body() body: CheckTenantSlugRequest) { + async checkTenantSlug(@Body() body: CheckTenantSlugDto) { await this.systemSettings.ensureRegistrationAllowed() - const slug = this.normalizeTenantSlug(body?.slug) - if (!slug) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '空间名称不能为空' }) - } - - this.validateTenantSlug(slug) + const { slug } = body if (isTenantSlugReserved(slug)) { throw new BizException(ErrorCode.TENANT_SLUG_RESERVED, { message: '该空间名称已被系统保留,请尝试其他名称。' }) @@ -54,28 +79,6 @@ export class TenantController { } } - private normalizeTenantSlug(slug?: string | null): string | null { - if (!slug) { - return null - } - const normalized = slug.trim().toLowerCase() - return normalized.length > 0 ? normalized : null - } - - private validateTenantSlug(slug: string): void { - if (slug.length < 3) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '空间名称至少需要 3 个字符' }) - } - if (slug.length > 63) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '空间名称长度不能超过 63 个字符' }) - } - if (!TENANT_SLUG_PATTERN.test(slug)) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: '空间名称只能包含字母、数字或连字符 (-),且不能以连字符开头或结尾。', - }) - } - } - private buildTenantWelcomeUrl(slug: string, baseDomain: string): string { const normalizedBase = baseDomain.trim() const host = normalizedBase ? `${slug}.${normalizedBase}` : slug