From d471e0e08012c5fef6dd0d2b1769f8b7a2c5109f Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Thu, 1 Jan 2026 14:56:40 +0800 Subject: [PATCH 1/9] Add Strix penetration test workflow Signed-off-by: Teror Fox --- .github/workflows/strix.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/strix.yml diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml new file mode 100644 index 00000000000..f05a7253ca7 --- /dev/null +++ b/.github/workflows/strix.yml @@ -0,0 +1,21 @@ +name: strix-penetration-test + +on: + pull_request: + workflow_dispatch: + +jobs: + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Strix + run: curl -sSL https://strix.ai/install | bash + + - name: Run Strix + env: + STRIX_LLM: ${{ secrets.STRIX_LLM }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + + run: strix -n -t ./ --scan-mode quick From 5435fb2208af118bf5ebd04e3be03249d649437d Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 21:15:37 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E9=80=89=E9=A1=B9=E5=92=8C=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E6=A8=A1=E6=9D=BF=E7=94=9F=E6=88=90=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/configs/configs.default.ts | 5 + .../src/modules/configs/configs.interface.ts | 2 + .../src/modules/configs/configs.schema.ts | 25 +++ apps/core/src/modules/file/file.controller.ts | 44 ++++- apps/core/src/utils/filename-template.util.ts | 182 ++++++++++++++++++ .../src/utils/filename-template.util.spec.ts | 167 ++++++++++++++++ 6 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 apps/core/src/utils/filename-template.util.ts create mode 100644 apps/core/test/src/utils/filename-template.util.spec.ts diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index d7db2cb591d..4813b9a4e80 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -74,6 +74,11 @@ export const generateDefaultConfig: () => IConfig = () => ({ customDomain: '', prefix: '', }, + fileUploadOptions: { + enableCustomNaming: false, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + pathTemplate: '{type}', + }, baiduSearchOptions: { enable: false, token: null! }, bingSearchOptions: { enable: false, token: null! }, algoliaSearchOptions: { diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index 6319105ecba..722c1bd0311 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -11,6 +11,7 @@ import { type BingSearchOptionsSchema, type CommentOptionsSchema, type FeatureListSchema, + type FileUploadOptionsSchema, type FriendLinkOptionsSchema, type ImageStorageOptionsSchema, type MailOptionsSchema, @@ -39,6 +40,7 @@ export abstract class IConfig { friendLinkOptions: Required> backupOptions: Required> imageStorageOptions: Required> + fileUploadOptions: Required> baiduSearchOptions: Required> bingSearchOptions: Required> algoliaSearchOptions: Required> diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 768a951c6e6..1b4dcc58113 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -165,6 +165,29 @@ export type ImageStorageOptionsConfig = z.infer< typeof ImageStorageOptionsSchema > +// ==================== File Upload Options ==================== +export const FileUploadOptionsSchema = section('文件上传设定', { + enableCustomNaming: field.toggle( + z.boolean().optional(), + '启用自定义文件命名', + { + description: '开启后将使用下方的命名模板规则', + }, + ), + filenameTemplate: field.plain(z.string().optional(), '文件名模板', { + description: + '支持占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {ms}毫秒, {timestamp}时间戳, {md5}随机MD5, {md5-16}随机MD5(16位), {uuid}UUID, {str-数字}随机字符串, {filename}原文件名(含扩展名), {name}原文件名(不含扩展名), {ext}扩展名', + }), + pathTemplate: field.plain(z.string().optional(), '文件路径模板', { + description: + '支持占位符同文件名模板,另外支持 {type} 文件类型, {localFolder:数字} 原文件所在文件夹层级', + }), +}) +export class FileUploadOptionsDto extends createZodDto( + FileUploadOptionsSchema, +) {} +export type FileUploadOptionsConfig = z.infer + // ==================== Baidu Search Options ==================== export const BaiduSearchOptionsSchema = section('百度推送设定', { enable: field.toggle(z.boolean().optional(), '开启推送'), @@ -421,6 +444,7 @@ export const configSchemaMapping = { friendLinkOptions: FriendLinkOptionsSchema, backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, + fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, algoliaSearchOptions: AlgoliaSearchOptionsSchema, @@ -446,6 +470,7 @@ export const FullConfigSchema = withMeta( friendLinkOptions: FriendLinkOptionsSchema, backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, + fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, algoliaSearchOptions: AlgoliaSearchOptionsSchema, diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index 166f35185e3..28234c68d56 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -17,15 +17,17 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { alphabet } from '~/constants/other.constant' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { UploadService } from '~/processors/helper/helper.upload.service' import { PagerDto } from '~/shared/dto/pager.dto' +import { + generateFilename, + generateFilePath, +} from '~/utils/filename-template.util' import { S3Uploader } from '~/utils/s3.util' import type { FastifyReply, FastifyRequest } from 'fastify' import { lookup } from 'mime-types' -import { customAlphabet } from 'nanoid' import { FileReferenceService } from './file-reference.service' import { BatchOrphanDeleteDto, @@ -248,20 +250,46 @@ export class FileController { const file = await this.uploadService.getAndValidMultipartField(req) const { type = 'file' } = query - const ext = path.extname(file.filename) - const filename = customAlphabet(alphabet)(18) + ext.toLowerCase() + // 获取文件上传配置 + const uploadConfig = await this.configsService.get('fileUploadOptions') + + // 生成文件名(可能包含子路径) + const rawFilename = generateFilename(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 生成基础路径 + const basePath = generateFilePath(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 合并路径和文件名 + // basePath 通常是 type 或自定义路径 + // rawFilename 可能包含子目录(比如 "2026/01/15/file.jpg") + const fullPath = basePath + ? path.join(basePath, rawFilename) + : path.join(type, rawFilename) + + // 分离出目录和最终文件名 + const directory = path.dirname(fullPath) + const finalFilename = path.basename(fullPath) - await this.service.writeFile(type, filename, file.file) + await this.service.writeFile(directory, finalFilename, file.file) - const fileUrl = await this.service.resolveFileUrl(type, filename) + const fileUrl = await this.service.resolveFileUrl(directory, finalFilename) if (type === 'image') { - await this.fileReferenceService.createPendingReference(fileUrl, filename) + await this.fileReferenceService.createPendingReference( + fileUrl, + finalFilename, + ) } return { url: fileUrl, - name: filename, + name: finalFilename, } } diff --git a/apps/core/src/utils/filename-template.util.ts b/apps/core/src/utils/filename-template.util.ts new file mode 100644 index 00000000000..4c49d954bda --- /dev/null +++ b/apps/core/src/utils/filename-template.util.ts @@ -0,0 +1,182 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import { alphabet } from '~/constants/other.constant' +import { customAlphabet } from 'nanoid' + +/** + * 文件名模板占位符替换工具 + * 支持的占位符: + * - {Y} 年份 (4位) + * - {y} 年份 (2位) + * - {m} 月份 (2位) + * - {d} 日期 (2位) + * - {h} 小时 (2位) + * - {i} 分钟 (2位) + * - {s} 秒钟 (2位) + * - {ms} 毫秒 (3位) + * - {timestamp} 时间戳 (毫秒) + * - {md5} 随机MD5字符串 (32位) + * - {md5-16} 随机MD5字符串 (16位) + * - {uuid} UUID字符串 + * - {str-数字} 随机字符串,数字表示长度 + * - {filename} 原文件名 (包含扩展名) + * - {name} 原文件名 (不含扩展名) + * - {ext} 扩展名 (包含点号) + * - {type} 文件类型 + * - {localFolder:数字} 原文件所在文件夹 (数字表示层级) + */ +export interface FilenameTemplateContext { + /** + * 原始文件名 (包含扩展名) + */ + originalFilename: string + + /** + * 文件类型 (如: image, file, avatar, icon) + */ + fileType?: string + + /** + * 本地文件夹路径 (用于 localFolder 占位符) + */ + localFolderPath?: string +} + +/** + * 生成一个随机的 MD5 字符串 + */ +function generateRandomMd5(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * 生成一个 UUID v4 字符串 + */ +function generateUuid(): string { + return crypto.randomUUID() +} + +/** + * 格式化数字,补零到指定位数 + */ +function padZero(num: number, length: number): string { + return num.toString().padStart(length, '0') +} + +/** + * 提取文件名中的文件夹层级 + * @param folderPath 文件夹路径 + * @param level 提取的层级数 + */ +function extractFolderLevel( + folderPath: string | undefined, + level: number, +): string { + if (!folderPath) return '' + + const parts = folderPath.split(/[/\\]/).filter(Boolean) + if (level <= 0 || level > parts.length) return '' + + return parts.slice(-level).join('/') +} + +/** + * 替换模板中的占位符 + * @param template 模板字符串 + * @param context 上下文信息 + * @returns 替换后的字符串 + */ +export function replaceFilenameTemplate( + template: string, + context: FilenameTemplateContext, +): string { + const now = new Date() + const { originalFilename, fileType = '', localFolderPath } = context + + // 提取文件名和扩展名 + const ext = path.extname(originalFilename).toLowerCase() + const nameWithoutExt = path.basename(originalFilename, ext) + + let result = template + + // 时间相关占位符 + result = result.replaceAll('{Y}', now.getFullYear().toString()) + result = result.replaceAll('{y}', padZero(now.getFullYear() % 100, 2)) + result = result.replaceAll('{m}', padZero(now.getMonth() + 1, 2)) + result = result.replaceAll('{d}', padZero(now.getDate(), 2)) + result = result.replaceAll('{h}', padZero(now.getHours(), 2)) + result = result.replaceAll('{i}', padZero(now.getMinutes(), 2)) + result = result.replaceAll('{s}', padZero(now.getSeconds(), 2)) + result = result.replaceAll('{ms}', padZero(now.getMilliseconds(), 3)) + result = result.replaceAll('{timestamp}', now.getTime().toString()) + + // 随机字符串占位符 + result = result.replaceAll('{md5}', () => generateRandomMd5()) + result = result.replaceAll('{md5-16}', () => generateRandomMd5().slice(0, 16)) + result = result.replaceAll('{uuid}', () => generateUuid()) + + // 自定义长度的随机字符串 {str-数字} + result = result.replaceAll(/\{str-(\d+)\}/g, (_match, length) => { + const len = Number.parseInt(length, 10) + return customAlphabet(alphabet)(len) + }) + + // 文件名相关占位符 + result = result.replaceAll('{filename}', originalFilename) + result = result.replaceAll('{name}', nameWithoutExt) + result = result.replaceAll('{ext}', ext) + + // 文件类型占位符 + result = result.replaceAll('{type}', fileType) + + // 本地文件夹占位符 {localFolder:数字} + result = result.replaceAll(/\{localFolder:(\d+)\}/g, (_match, level) => { + const lvl = Number.parseInt(level, 10) + return extractFolderLevel(localFolderPath, lvl) + }) + + return result +} + +/** + * 生成文件名(应用模板或使用默认规则) + * @param config 配置对象 + * @param context 上下文信息 + * @returns 生成的文件名 + */ +export function generateFilename( + config: { + enableCustomNaming?: boolean + filenameTemplate?: string + }, + context: FilenameTemplateContext, +): string { + // 如果未启用自定义命名或没有模板,使用默认规则 + if (!config.enableCustomNaming || !config.filenameTemplate) { + const ext = path.extname(context.originalFilename).toLowerCase() + return customAlphabet(alphabet)(18) + ext + } + + return replaceFilenameTemplate(config.filenameTemplate, context) +} + +/** + * 生成文件路径(应用模板或使用默认规则) + * @param config 配置对象 + * @param context 上下文信息 + * @returns 生成的路径 + */ +export function generateFilePath( + config: { + enableCustomNaming?: boolean + pathTemplate?: string + }, + context: FilenameTemplateContext, +): string { + // 如果未启用自定义命名或没有路径模板,使用默认规则(文件类型) + if (!config.enableCustomNaming || !config.pathTemplate) { + return context.fileType || '' + } + + return replaceFilenameTemplate(config.pathTemplate, context) +} diff --git a/apps/core/test/src/utils/filename-template.util.spec.ts b/apps/core/test/src/utils/filename-template.util.spec.ts new file mode 100644 index 00000000000..a9089e529e0 --- /dev/null +++ b/apps/core/test/src/utils/filename-template.util.spec.ts @@ -0,0 +1,167 @@ +import { + generateFilename, + generateFilePath, + replaceFilenameTemplate, +} from '~/utils/filename-template.util' +import { describe, expect, it } from 'vitest' + +describe('Filename Template Util', () => { + const mockContext = { + originalFilename: 'test-photo.jpg', + fileType: 'image', + localFolderPath: 'photos/2026/vacation', + } + + describe('replaceFilenameTemplate', () => { + it('应该替换文件名占位符', () => { + const result = replaceFilenameTemplate('{filename}', mockContext) + expect(result).toBe('test-photo.jpg') + }) + + it('应该替换文件名(不含扩展名)和扩展名', () => { + const result = replaceFilenameTemplate('{name}{ext}', mockContext) + expect(result).toBe('test-photo.jpg') + }) + + it('应该替换文件类型', () => { + const result = replaceFilenameTemplate('{type}/{filename}', mockContext) + expect(result).toBe('image/test-photo.jpg') + }) + + it('应该替换年份占位符', () => { + const template = '{Y}/{y}/{filename}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^\d{4}\/\d{2}\/test-photo\.jpg$/) + }) + + it('应该替换日期时间占位符', () => { + const template = '{Y}{m}{d}_{h}{i}{s}{ext}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^\d{8}_\d{6}\.jpg$/) + }) + + it('应该生成MD5随机字符串', () => { + const result = replaceFilenameTemplate('{md5}{ext}', mockContext) + expect(result).toMatch(/^[a-f0-9]{32}\.jpg$/) + }) + + it('应该生成16位MD5随机字符串', () => { + const result = replaceFilenameTemplate('{md5-16}{ext}', mockContext) + expect(result).toMatch(/^[a-f0-9]{16}\.jpg$/) + }) + + it('应该生成UUID', () => { + const result = replaceFilenameTemplate('{uuid}{ext}', mockContext) + expect(result).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.jpg$/, + ) + }) + + it('应该生成自定义长度的随机字符串', () => { + const result = replaceFilenameTemplate('{str-8}{ext}', mockContext) + expect(result).toMatch(/^.{8}\.jpg$/) + }) + + it('应该生成时间戳', () => { + const result = replaceFilenameTemplate('{timestamp}{ext}', mockContext) + expect(result).toMatch(/^\d+\.jpg$/) + }) + + it('应该提取文件夹层级', () => { + const result1 = replaceFilenameTemplate( + '{localFolder:1}/{filename}', + mockContext, + ) + expect(result1).toBe('vacation/test-photo.jpg') + + const result2 = replaceFilenameTemplate( + '{localFolder:2}/{filename}', + mockContext, + ) + expect(result2).toBe('2026/vacation/test-photo.jpg') + + const result3 = replaceFilenameTemplate( + '{localFolder:3}/{filename}', + mockContext, + ) + expect(result3).toBe('photos/2026/vacation/test-photo.jpg') + }) + + it('应该处理没有本地文件夹路径的情况', () => { + const contextWithoutFolder = { + ...mockContext, + localFolderPath: undefined, + } + const result = replaceFilenameTemplate( + '{localFolder:1}/{filename}', + contextWithoutFolder, + ) + expect(result).toBe('/test-photo.jpg') + }) + + it('应该正确处理复杂模板', () => { + const template = '{type}/{Y}/{m}/{d}/{md5-16}{ext}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^image\/\d{4}\/\d{2}\/\d{2}\/[a-f0-9]{16}\.jpg$/) + }) + }) + + describe('generateFilename', () => { + it('未启用自定义命名时应该生成默认文件名', () => { + const config = { + enableCustomNaming: false, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + } + const result = generateFilename(config, mockContext) + // 默认文件名是18位随机字符 + 扩展名 + expect(result).toMatch(/^.{18}\.jpg$/) + }) + + it('启用自定义命名时应该使用模板', () => { + const config = { + enableCustomNaming: true, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + } + const result = generateFilename(config, mockContext) + expect(result).toMatch(/^\d{8}\/[a-f0-9]{16}\.jpg$/) + }) + + it('没有模板时应该使用默认规则', () => { + const config = { + enableCustomNaming: true, + filenameTemplate: undefined, + } + const result = generateFilename(config, mockContext) + expect(result).toMatch(/^.{18}\.jpg$/) + }) + }) + + describe('generateFilePath', () => { + it('未启用自定义命名时应该返回文件类型', () => { + const config = { + enableCustomNaming: false, + pathTemplate: '{type}/{Y}/{m}', + } + const result = generateFilePath(config, mockContext) + expect(result).toBe('image') + }) + + it('启用自定义命名时应该使用路径模板', () => { + const config = { + enableCustomNaming: true, + pathTemplate: '{type}/{Y}/{m}', + } + const result = generateFilePath(config, mockContext) + expect(result).toMatch(/^image\/\d{4}\/\d{2}$/) + }) + + it('没有路径模板时应该返回文件类型', () => { + const config = { + enableCustomNaming: true, + pathTemplate: undefined, + } + const result = generateFilePath(config, mockContext) + expect(result).toBe('image') + }) + }) +}) From 55e36db2c0fc9575264ff9a935e72eb129663afc Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 21:19:12 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=B7=AF=E5=BE=84=E6=9E=84=E5=BB=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/src/modules/file/file.controller.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index 28234c68d56..7c5bb9d8368 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -143,7 +143,7 @@ export class FileController { } private extractLocalImageFilename(url: string): string | null { - const match = url.match(/\/objects\/image\/([^/?#]+)/) + const match = url.match(/\/objects\/image\/([^#/?]+)/) return match ? match[1] : null } @@ -265,31 +265,35 @@ export class FileController { fileType: type, }) - // 合并路径和文件名 - // basePath 通常是 type 或自定义路径 - // rawFilename 可能包含子目录(比如 "2026/01/15/file.jpg") - const fullPath = basePath - ? path.join(basePath, rawFilename) - : path.join(type, rawFilename) - - // 分离出目录和最终文件名 - const directory = path.dirname(fullPath) - const finalFilename = path.basename(fullPath) + // 构建相对路径(相对于文件类型目录) + // 如果 basePath 就是 type,则直接使用 rawFilename + // 否则,将 basePath 中除去 type 部分后与 rawFilename 合并 + let relativePath: string + if (basePath === type || !basePath) { + // basePath 就是 type 或为空,直接使用 rawFilename + relativePath = rawFilename + } else { + // basePath 包含自定义路径,需要去除开头的 type 部分 + const pathWithoutType = basePath.startsWith(`${type}/`) + ? basePath.slice(Math.max(0, type.length + 1)) + : basePath + relativePath = path.join(pathWithoutType, rawFilename) + } - await this.service.writeFile(directory, finalFilename, file.file) + await this.service.writeFile(type, relativePath, file.file) - const fileUrl = await this.service.resolveFileUrl(directory, finalFilename) + const fileUrl = await this.service.resolveFileUrl(type, relativePath) if (type === 'image') { await this.fileReferenceService.createPendingReference( fileUrl, - finalFilename, + relativePath, ) } return { url: fileUrl, - name: finalFilename, + name: path.basename(relativePath), } } From 58e2495d7bb07275674614dbd3ca376d4c07ff57 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 21:45:22 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20S3Uploader=20?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E8=99=9A=E6=8B=9F=E4=B8=BB=E6=9C=BA?= =?UTF-8?q?=E5=92=8C=E8=B7=AF=E5=BE=84=E6=A0=B7=E5=BC=8F=E7=9A=84=E8=A7=84?= =?UTF-8?q?=E8=8C=83=20URI=20=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/src/utils/s3.util.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index 89ddb0bf848..d66dfa97acb 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -118,7 +118,14 @@ export class S3Uploader { .split('/') .map((seg) => encodeURIComponent(seg)) .join('/') - const canonicalUri = `/${this.bucket}/${encodedObjectKey}` + + // Determine canonical URI based on endpoint style + // Virtual-hosted style: bucket in host, just use /key + // Path style: bucket in path + const isVirtualHosted = host.startsWith(`${this.bucket}.`) + const canonicalUri = isVirtualHosted + ? `/${encodedObjectKey}` + : `/${this.bucket}/${encodedObjectKey}` const headers: Record = { Host: host, From a10c45cb83ab9ad1f6ef138633901e4c6e2a88eb Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 21:58:02 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=85=BE?= =?UTF-8?q?=E8=AE=AF=E4=BA=91COS=E7=9A=84=E8=99=9A=E6=8B=9F=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E6=A0=B7=E5=BC=8F=E4=B8=8A=E4=BC=A0=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/src/utils/s3.util.ts | 42 ++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index d66dfa97acb..e4d9fff0250 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -111,7 +111,8 @@ export class S3Uploader { // Set request headers const url = new URL(this.endpoint) const host = url.host - const contentLength = fileData.length.toString() + let requestHost = host + let canonicalUri: string // URI encode each path segment for signing const encodedObjectKey = objectKey @@ -120,15 +121,29 @@ export class S3Uploader { .join('/') // Determine canonical URI based on endpoint style - // Virtual-hosted style: bucket in host, just use /key - // Path style: bucket in path - const isVirtualHosted = host.startsWith(`${this.bucket}.`) - const canonicalUri = isVirtualHosted - ? `/${encodedObjectKey}` - : `/${this.bucket}/${encodedObjectKey}` + // For Tencent COS and other services that require virtual-hosted style + const isTencentCos = host.includes('myqcloud.com') || host.includes('.cos.') + + if (isTencentCos) { + // Tencent COS requires virtual-hosted style + // Convert cos.ap-guangzhou.myqcloud.com to bucket.cos.ap-guangzhou.myqcloud.com + const cosMatch = host.match(/^cos\.(.+)$/) + if (cosMatch) { + requestHost = `${this.bucket}.cos.${cosMatch[1]}` + } + canonicalUri = `/${encodedObjectKey}` + } else { + // Check if already in virtual-hosted style + const isVirtualHosted = host.startsWith(`${this.bucket}.`) + canonicalUri = isVirtualHosted + ? `/${encodedObjectKey}` + : `/${this.bucket}/${encodedObjectKey}` + } + + const contentLength = fileData.length.toString() const headers: Record = { - Host: host, + Host: requestHost, 'Content-Type': contentType, 'Content-Length': contentLength, 'x-amz-date': xAmzDate, @@ -180,7 +195,11 @@ export class S3Uploader { const authorization = `${algorithm} Credential=${this.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` // Create and send PUT request - const requestUrl = `${this.endpoint}${canonicalUri}` + // For Tencent COS, use virtual-hosted style URL + const baseUrl = isTencentCos + ? `${url.protocol}//${requestHost}` + : this.endpoint + const requestUrl = `${baseUrl}${canonicalUri}` const fetchOptions: RequestInit & { dispatcher?: unknown } = { method: 'PUT', @@ -201,7 +220,10 @@ export class S3Uploader { const response = await fetch(requestUrl, fetchOptions as RequestInit) if (!response.ok) { - throw new Error(`Upload failed with status code: ${response.status}`) + const responseText = await response.text() + throw new Error( + `Upload failed with status code: ${response.status} - ${responseText}`, + ) } } finally { if (isDev) { From 09b7046ccf1600c7bb9f0aa24b952f1d92dbe733 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 22:32:38 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=89=8D=E7=BC=80=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=8D=A0=E4=BD=8D=E7=AC=A6=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=81=B5=E6=B4=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/src/modules/configs/configs.schema.ts | 3 ++- apps/core/src/modules/file/file.controller.ts | 15 ++++++++++++--- .../helper/helper.image-migration.service.ts | 17 +++++++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 1b4dcc58113..b9e24331ee8 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -155,7 +155,8 @@ export const ImageStorageOptionsSchema = section('图床设置', { }, ), prefix: field.plain(z.string().optional(), '文件路径前缀', { - description: '上传到 S3 的文件路径前缀,例如 images/', + description: + '上传到 S3 的文件路径前缀,支持模板占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {md5}随机MD5, {type}文件类型等。例如: blog/{Y}/{m}/{d} 或 images/', }), }) export class ImageStorageOptionsDto extends createZodDto( diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index 7c5bb9d8368..40a7f1c545b 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -24,6 +24,7 @@ import { PagerDto } from '~/shared/dto/pager.dto' import { generateFilename, generateFilePath, + replaceFilenameTemplate, } from '~/utils/filename-template.util' import { S3Uploader } from '~/utils/s3.util' import type { FastifyReply, FastifyRequest } from 'fastify' @@ -115,9 +116,17 @@ export class FileController { const buffer = await fs.readFile(localPath) const contentType = lookup(filename) || 'application/octet-stream' - const objectKey = config.prefix - ? `${config.prefix.replace(/\/+$/, '')}/${filename}` - : filename + // 处理 prefix 中的模板变量 + let prefixPath = '' + if (config.prefix) { + prefixPath = replaceFilenameTemplate(config.prefix, { + originalFilename: filename, + fileType: 'image', + }) + prefixPath = prefixPath.replace(/\/+$/, '') + } + + const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename const s3Url = await s3Uploader.uploadBuffer( buffer, diff --git a/apps/core/src/processors/helper/helper.image-migration.service.ts b/apps/core/src/processors/helper/helper.image-migration.service.ts index 9f3cb142bd0..8b26705f870 100644 --- a/apps/core/src/processors/helper/helper.image-migration.service.ts +++ b/apps/core/src/processors/helper/helper.image-migration.service.ts @@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import type { ImageModel } from '~/shared/model/image.model' +import { replaceFilenameTemplate } from '~/utils/filename-template.util' import { S3Uploader } from '~/utils/s3.util' import { lookup } from 'mime-types' @@ -25,7 +26,7 @@ export class ImageMigrationService { } extractLocalImageFilename(url: string): string | null { - const match = url.match(/\/objects\/image\/([^/?#]+)/) + const match = url.match(/\/objects\/image\/([^#/?]+)/) return match ? match[1] : null } @@ -95,9 +96,17 @@ export class ImageMigrationService { const buffer = await readFile(localPath) const contentType = lookup(filename) || 'application/octet-stream' - const objectKey = config.prefix - ? `${config.prefix.replace(/\/+$/, '')}/${filename}` - : filename + // 处理 prefix 中的模板变量 + let prefixPath = '' + if (config.prefix) { + prefixPath = replaceFilenameTemplate(config.prefix, { + originalFilename: filename, + fileType: 'image', + }) + prefixPath = prefixPath.replace(/\/+$/, '') + } + + const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename const s3Url = await s3Uploader.uploadBuffer( buffer, From 2d504a4090cff819af43c319263b4a831a51df35 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 22:44:44 +0800 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20Strix=20?= =?UTF-8?q?=E6=B8=97=E9=80=8F=E6=B5=8B=E8=AF=95=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/strix.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/strix.yml diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml deleted file mode 100644 index f05a7253ca7..00000000000 --- a/.github/workflows/strix.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: strix-penetration-test - -on: - pull_request: - workflow_dispatch: - -jobs: - security-scan: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Strix - run: curl -sSL https://strix.ai/install | bash - - - name: Run Strix - env: - STRIX_LLM: ${{ secrets.STRIX_LLM }} - LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - - run: strix -n -t ./ --scan-mode quick From f6c30d1e5e48ea7aa42054f3e9628fd822245e63 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 22:52:53 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E6=A8=A1=E6=9D=BF=E5=8D=A0=E4=BD=8D=E7=AC=A6=E7=9A=84?= =?UTF-8?q?=E6=AD=A3=E5=88=99=E8=A1=A8=E8=BE=BE=E5=BC=8F=E8=BD=AC=E4=B9=89?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/src/utils/filename-template.util.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/core/src/utils/filename-template.util.ts b/apps/core/src/utils/filename-template.util.ts index 4c49d954bda..76b65c28516 100644 --- a/apps/core/src/utils/filename-template.util.ts +++ b/apps/core/src/utils/filename-template.util.ts @@ -116,6 +116,7 @@ export function replaceFilenameTemplate( result = result.replaceAll('{uuid}', () => generateUuid()) // 自定义长度的随机字符串 {str-数字} + // eslint-disable-next-line unicorn/better-regex result = result.replaceAll(/\{str-(\d+)\}/g, (_match, length) => { const len = Number.parseInt(length, 10) return customAlphabet(alphabet)(len) @@ -130,6 +131,7 @@ export function replaceFilenameTemplate( result = result.replaceAll('{type}', fileType) // 本地文件夹占位符 {localFolder:数字} + // eslint-disable-next-line unicorn/better-regex result = result.replaceAll(/\{localFolder:(\d+)\}/g, (_match, level) => { const lvl = Number.parseInt(level, 10) return extractFolderLevel(localFolderPath, lvl) From 6b7f838b78eb520c8282bd5c5c1b32e7a7b6e880 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 15 Feb 2026 23:01:08 +0800 Subject: [PATCH 9/9] Update apps/core/src/utils/filename-template.util.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Teror Fox --- apps/core/src/utils/filename-template.util.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/core/src/utils/filename-template.util.ts b/apps/core/src/utils/filename-template.util.ts index 76b65c28516..2c4531776c9 100644 --- a/apps/core/src/utils/filename-template.util.ts +++ b/apps/core/src/utils/filename-template.util.ts @@ -137,7 +137,12 @@ export function replaceFilenameTemplate( return extractFolderLevel(localFolderPath, lvl) }) - return result + // 防止路径遍历:移除父目录引用(..) + const segments = result.split(/[/\\]+/) + const safeSegments = segments.filter(segment => segment !== '..') + const safeResult = safeSegments.join('/') + + return safeResult } /**