diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index ab3a1cf34c5..cdde91241f2 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -94,6 +94,16 @@ export const generateDefaultConfig: () => IConfig = () => ({ aiSummaryTargetLanguage: 'auto', enableDeepReading: false, }, + imageUploadOptions: { + provider: 'none', + s3Endpoint: '', + s3SecretId: '', + s3SecretKey: '', + s3Bucket: '', + s3Region: '', + s3PathPrefix: 'images', + s3PublicUrlPrefix: '', + }, oauth: { providers: [], secrets: {}, diff --git a/apps/core/src/modules/configs/configs.dto.ts b/apps/core/src/modules/configs/configs.dto.ts index f6f1b4afab1..13bcbb04a19 100644 --- a/apps/core/src/modules/configs/configs.dto.ts +++ b/apps/core/src/modules/configs/configs.dto.ts @@ -469,6 +469,104 @@ export class AIDto { aiSummaryTargetLanguage: string } +@JSONSchema({ title: '编辑器图片上传' }) +export class ImageUploadOptionsDto { + @IsString() + @IsOptional() + @JSONSchemaHalfGirdPlainField('上传方式', { + 'ui:options': { + type: 'select', + values: [ + { + label: '不开启', + value: 'none', + }, + { + label: '自托管 API(站内附件)', + value: 'self', + }, + { + label: 'S3 兼容 API', + value: 's3', + }, + { + label: '云函数自定义上传', + value: 'custom', + }, + ], + }, + }) + provider: 'none' | 'self' | 's3' | 'custom' + + @IsString() + @IsOptional() + @JSONSchemaPlainField('S3 Endpoint', { + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + s3Endpoint?: string + + @IsString() + @IsOptional() + @JSONSchemaHalfGirdPlainField('S3 Access Key ID', { + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + @SecretField + s3SecretId?: string + + @IsString() + @IsOptional() + @JSONSchemaPasswordField('S3 Secret Access Key', { + ...halfFieldOption, + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + @SecretField + s3SecretKey?: string + + @IsString() + @IsOptional() + @JSONSchemaHalfGirdPlainField('S3 Bucket', { + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + s3Bucket?: string + + @IsString() + @IsOptional() + @JSONSchemaHalfGirdPlainField('S3 Region', { + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + s3Region?: string + + @IsString() + @IsOptional() + @JSONSchemaPlainField('S3 路径前缀', { + description: '上传到 S3 的路径前缀,默认为 images', + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + s3PathPrefix?: string + + @IsString() + @IsOptional() + @JSONSchemaPlainField('S3 公共 URL 前缀', { + description: '对外访问的地址,请填写此项以生成正确的图片 URL', + 'ui:options': { + dependsOn: { field: 'provider', value: 's3' }, + }, + }) + s3PublicUrlPrefix?: string +} + export class OAuthDto { @IsObject({ each: true }) @Type(() => OAuthProviderDto) diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index dbd86d30f6e..2f1135946db 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -18,6 +18,7 @@ import { CommentOptionsDto, FeatureListDto, FriendLinkOptionsDto, + ImageUploadOptionsDto, MailOptionsDto, OAuthDto, SeoDto, @@ -84,6 +85,9 @@ export abstract class IConfig { @ConfigField(() => AIDto) ai: AIDto + @ConfigField(() => ImageUploadOptionsDto) + imageUploadOptions: ImageUploadOptionsDto + @ConfigField(() => OAuthDto) oauth: OAuthDto } diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index a0be873945e..f2b6873c4c6 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -103,6 +103,28 @@ export class FileController { } } + @Post('/upload/image') + @Auth() + async uploadImage(@Req() req: FastifyRequest) { + const file = await this.uploadService.getAndValidMultipartField(req) + + // Convert stream to buffer + const chunks: Buffer[] = [] + for await (const chunk of file.file) { + chunks.push(chunk) + } + const buffer = Buffer.concat(chunks) + + const ext = path.extname(file.filename) + const url = await this.service.uploadImageWithConfig( + buffer, + ext, + file.filename, + ) + + return { url } + } + @Delete('/:type/:name') @Auth() async delete(@Param() params: FileQueryDto) { diff --git a/apps/core/src/modules/file/file.module.ts b/apps/core/src/modules/file/file.module.ts index f9cd801fa96..d4a426def40 100644 --- a/apps/core/src/modules/file/file.module.ts +++ b/apps/core/src/modules/file/file.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common' +import { ServerlessModule } from '../serverless/serverless.module' import { FileController } from './file.controller' import { FileService } from './file.service' @Module({ + imports: [ServerlessModule], controllers: [FileController], providers: [FileService], exports: [FileService], diff --git a/apps/core/src/modules/file/file.service.ts b/apps/core/src/modules/file/file.service.ts index e1487f085f8..b122d490450 100644 --- a/apps/core/src/modules/file/file.service.ts +++ b/apps/core/src/modules/file/file.service.ts @@ -4,6 +4,7 @@ import type { Readable } from 'node:stream' import { fs } from '@mx-space/compiled' import { BadRequestException, + HttpException, Injectable, InternalServerErrorException, Logger, @@ -13,13 +14,27 @@ import { STATIC_FILE_DIR, STATIC_FILE_TRASH_DIR, } from '~/constants/path.constant' +import { S3Uploader } from '~/utils/s3.util' +import { lookup } from 'mime-types' import { ConfigsService } from '../configs/configs.service' +import type { + FunctionContextRequest, + FunctionContextResponse, +} from '../serverless/function.types' +import { ServerlessService } from '../serverless/serverless.service' +import { SnippetType } from '../snippet/snippet.model' import type { FileType } from './file.type' +const IMAGE_UPLOAD_FUNCTION_REFERENCE = 'file' +const IMAGE_UPLOAD_FUNCTION_NAME = 'editor-image-upload' + @Injectable() export class FileService { private readonly logger: Logger - constructor(private readonly configService: ConfigsService) { + constructor( + private readonly configService: ConfigsService, + private readonly serverlessService: ServerlessService, + ) { this.logger = new Logger(FileService.name) } @@ -107,4 +122,152 @@ export class FileService { throw new BadRequestException('重命名文件失败') } } + + async uploadImageWithConfig( + buffer: Buffer, + ext: string, + filename: string, + ): Promise { + const config = await this.configService.get('imageUploadOptions') + + if (config.provider === 'none') { + throw new BadRequestException('图片上传功能未开启') + } + + switch (config.provider) { + case 's3': { + if ( + !config.s3Bucket || + !config.s3Region || + !config.s3SecretId || + !config.s3SecretKey + ) { + throw new BadRequestException('S3 配置不完整') + } + + const s3 = new S3Uploader({ + bucket: config.s3Bucket, + region: config.s3Region, + accessKey: config.s3SecretId, + secretKey: config.s3SecretKey, + endpoint: config.s3Endpoint, + }) + if (config.s3PublicUrlPrefix) { + s3.setCustomDomain(config.s3PublicUrlPrefix) + } + + const imagePath = config.s3PathPrefix || 'images/' + return await s3.uploadImage(buffer, imagePath) + } + + case 'custom': { + return await this.uploadViaServerlessFunction(buffer, ext, filename) + } + + default: + throw new BadRequestException('不支持的上传方式') + } + } + + private async uploadViaServerlessFunction( + buffer: Buffer, + ext: string, + filename: string, + ): Promise { + const snippet = await this.serverlessService.model + .findOne({ + reference: IMAGE_UPLOAD_FUNCTION_REFERENCE, + name: IMAGE_UPLOAD_FUNCTION_NAME, + type: SnippetType.Function, + }) + .select('+secret') + .lean({ + getters: true, + }) + + if (!snippet || !snippet.enable) { + throw new BadRequestException('云函数上传未配置或未启用') + } + + const request = this.createServerlessRequest(buffer, ext, filename) + const response = this.createServerlessResponse() + + const result = + await this.serverlessService.injectContextIntoServerlessFunctionAndCall( + snippet, + { + req: request, + res: response, + isAuthenticated: true, + }, + ) + + const resolved = this.extractUrlFromServerlessResult(result) + if (!resolved) { + throw new InternalServerErrorException('云函数未返回有效的 URL') + } + + return resolved + } + + private createServerlessRequest( + buffer: Buffer, + ext: string, + filename: string, + ): FunctionContextRequest { + const mimetype = lookup(ext) || 'application/octet-stream' + + return { + method: 'POST', + url: `/serverless/${IMAGE_UPLOAD_FUNCTION_REFERENCE}/${IMAGE_UPLOAD_FUNCTION_NAME}`, + headers: { + 'content-type': 'application/json', + }, + body: { + filename, + ext, + mimetype, + size: buffer.length, + buffer: buffer.toString('base64'), + }, + params: {}, + query: {}, + } as FunctionContextRequest + } + + private createServerlessResponse(): FunctionContextResponse { + const response: FunctionContextResponse = { + throws(code, message) { + throw new HttpException(message, code) + }, + type() { + return response + }, + status() { + return response + }, + send(data: any) { + return data + }, + } + + return response + } + + private extractUrlFromServerlessResult(result: any): string | null { + if (typeof result === 'string' && result.length > 0) { + return result + } + + if ( + result && + typeof result === 'object' && + typeof result.url === 'string' && + result.url.length > 0 + ) { + return result.url + } + + return null + } } diff --git a/apps/core/src/utils/cos.util.ts b/apps/core/src/utils/cos.util.ts index a5f20a29fe5..8deb7094083 100644 --- a/apps/core/src/utils/cos.util.ts +++ b/apps/core/src/utils/cos.util.ts @@ -22,8 +22,8 @@ export const uploadFileToCOS = async ( const endpoint = `https://${bucket}.cos.${region}.myqcloud.com` const now = Date.now() - const startTime = now / 1000, - expireTime = now / 1000 + 900 + const startTime = now / 1000 + const expireTime = now / 1000 + 900 const keytime = `${startTime};${expireTime}` const tickets = [ { diff --git a/apps/core/src/utils/s3.util.spec.ts b/apps/core/src/utils/s3.util.spec.ts index e112fe031f8..c44465dc49a 100644 --- a/apps/core/src/utils/s3.util.spec.ts +++ b/apps/core/src/utils/s3.util.spec.ts @@ -101,7 +101,7 @@ describe('S3Uploader', () => { headers: expect.objectContaining({ 'Content-Type': 'image/png', }), - body: mockBuffer, + body: expect.any(ArrayBuffer), }), ) }) @@ -130,7 +130,7 @@ describe('S3Uploader', () => { headers: expect.objectContaining({ 'Content-Type': 'application/octet-stream', }), - body: mockBuffer, + body: expect.any(ArrayBuffer), }), ) }) @@ -155,7 +155,7 @@ describe('S3Uploader', () => { expect.stringContaining('/test-bucket/test-object'), expect.objectContaining({ method: 'PUT', - body: mockBuffer, + body: expect.any(ArrayBuffer), }), ) }) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index c60ea52f11f..4be15a664bc 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -45,7 +45,29 @@ export class S3Uploader { } setCustomDomain(domain: string): void { - this.customDomain = domain + this.customDomain = domain?.replace(/\/+$/, '') || '' + } + + private normalizePath(value: string): string { + return value.replace(/\/+$/, '') + } + + private buildPublicUrl(objectKey: string, fallbackPrefix?: string): string { + const normalizedObjectKey = objectKey.replace(/^\/+/, '') + const baseUrl = this.customDomain + if (baseUrl) { + const normalizedBase = baseUrl.replace(/\/+$/, '') + return `${normalizedBase}/${normalizedObjectKey}` + } + + if (fallbackPrefix) { + const normalizedFallback = this.normalizePath(fallbackPrefix) + if (normalizedFallback.length > 0) { + return `${normalizedFallback}/${normalizedObjectKey}` + } + } + + return normalizedObjectKey } // Helper function to calculate HMAC-SHA256 @@ -55,22 +77,24 @@ export class S3Uploader { async uploadImage(imageData: Buffer, path: string): Promise { const md5Filename = crypto.createHash('md5').update(imageData).digest('hex') - const objectKey = `${path}/${md5Filename}.png` + const normalizedPath = this.normalizePath(path) + const objectKey = normalizedPath + ? `${normalizedPath}/${md5Filename}.png` + : `${md5Filename}.png` await this.uploadToS3(objectKey, imageData, 'image/png') - - if (this.customDomain && this.customDomain.length > 0) { - return `${this.customDomain}/${objectKey}` - } - return `${path}/${objectKey}` + return this.buildPublicUrl(objectKey, normalizedPath) } async uploadFile(fileData: Buffer, path: string): Promise { const md5Filename = crypto.createHash('md5').update(fileData).digest('hex') - const ext = extname(path) - const objectKey = `${path}/${md5Filename}${ext}` + const normalizedPath = this.normalizePath(path) + const ext = extname(normalizedPath) + const objectKey = normalizedPath + ? `${normalizedPath}/${md5Filename}${ext}` + : `${md5Filename}${ext}` await this.uploadToS3(objectKey, fileData, 'application/octet-stream') - return `${path}/${objectKey}` + return this.buildPublicUrl(objectKey, normalizedPath) } // Generic S3-compatible storage upload function diff --git a/apps/core/src/utils/tool.util.ts b/apps/core/src/utils/tool.util.ts index 7c4d8ecb8d4..1d9213b3d7f 100644 --- a/apps/core/src/utils/tool.util.ts +++ b/apps/core/src/utils/tool.util.ts @@ -65,8 +65,8 @@ export const deepCloneWithFunction = (object: T): T => { * hash string */ export const hashString = function (str, seed = 0) { - let h1 = 0xdeadbeef ^ seed, - h2 = 0x41c6ce57 ^ seed + let h1 = 0xdeadbeef ^ seed + let h2 = 0x41c6ce57 ^ seed for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i) h1 = Math.imul(h1 ^ ch, 2654435761) diff --git a/apps/core/test/src/modules/link/link.controller.e2e-spec.ts b/apps/core/test/src/modules/link/link.controller.e2e-spec.ts index 64c2e59591b..7c24866a255 100644 --- a/apps/core/test/src/modules/link/link.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/link/link.controller.e2e-spec.ts @@ -12,6 +12,7 @@ import { } from '~/modules/link/link.controller' import { LinkModel, LinkState } from '~/modules/link/link.model' import { LinkService } from '~/modules/link/link.service' +import { ServerlessService } from '~/modules/serverless/serverless.service' import { HttpService } from '~/processors/helper/helper.http.service' import { createE2EApp } from 'test/helper/create-e2e-app' import { gatewayProviders } from 'test/mock/modules/gateway.mock' @@ -20,6 +21,21 @@ import { emailProvider } from 'test/mock/processors/email.mock' import { eventEmitterProvider } from 'test/mock/processors/event.mock' describe('Test LinkController(E2E)', async () => { + const serverlessServiceProvider = { + provide: ServerlessService, + useValue: { + model: { + findOne: () => ({ + select() { + return { + lean: async () => null, + } + }, + }), + }, + injectContextIntoServerlessFunctionAndCall: async () => null, + }, + } const proxy = createE2EApp({ controllers: [LinkController, LinkControllerCrud], models: [LinkModel, OptionModel], @@ -28,6 +44,7 @@ describe('Test LinkController(E2E)', async () => { LinkService, LinkAvatarService, FileService, + serverlessServiceProvider, emailProvider, HttpService,