Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/core/src/modules/configs/configs.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
98 changes: 98 additions & 0 deletions apps/core/src/modules/configs/configs.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions apps/core/src/modules/configs/configs.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
CommentOptionsDto,
FeatureListDto,
FriendLinkOptionsDto,
ImageUploadOptionsDto,
MailOptionsDto,
OAuthDto,
SeoDto,
Expand Down Expand Up @@ -84,6 +85,9 @@ export abstract class IConfig {
@ConfigField(() => AIDto)
ai: AIDto

@ConfigField(() => ImageUploadOptionsDto)
imageUploadOptions: ImageUploadOptionsDto

@ConfigField(() => OAuthDto)
oauth: OAuthDto
}
Expand Down
22 changes: 22 additions & 0 deletions apps/core/src/modules/file/file.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/core/src/modules/file/file.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
165 changes: 164 additions & 1 deletion apps/core/src/modules/file/file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Readable } from 'node:stream'
import { fs } from '@mx-space/compiled'
import {
BadRequestException,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
Expand All @@ -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)
}

Expand Down Expand Up @@ -107,4 +122,152 @@ export class FileService {
throw new BadRequestException('重命名文件失败')
}
}

async uploadImageWithConfig(
buffer: Buffer,
ext: string,
filename: string,
): Promise<string> {
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<string> {
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
}
}
4 changes: 2 additions & 2 deletions apps/core/src/utils/cos.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
Loading