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 <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-19 01:04:39 +08:00
parent b5fdb326a4
commit 23d208c091
7 changed files with 102 additions and 38 deletions

View File

@@ -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())

View File

@@ -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
}
}

View File

@@ -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<ErrorCode, ErrorDescriptor> = {
httpStatus: 500,
message: 'Internal server error',
},
[ErrorCode.COMMON_FORBIDDEN]: {
httpStatus: 403,
message: 'Forbidden',
},
[ErrorCode.AUTH_UNAUTHORIZED]: {
httpStatus: 401,
message: 'Unauthorized',

View File

@@ -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
}

View File

@@ -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<FrameworkResponse> {
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)
}
}

View File

@@ -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)
},
};
}

View File

@@ -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