From d471e0e08012c5fef6dd0d2b1769f8b7a2c5109f Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Thu, 1 Jan 2026 14:56:40 +0800 Subject: [PATCH 01/13] 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 02/13] =?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 03/13] =?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 04/13] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20S3Uploader?= =?UTF-8?q?=20=E4=BB=A5=E6=94=AF=E6=8C=81=E8=99=9A=E6=8B=9F=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=92=8C=E8=B7=AF=E5=BE=84=E6=A0=B7=E5=BC=8F=E7=9A=84?= =?UTF-8?q?=E8=A7=84=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 05/13] =?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 06/13] =?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 07/13] =?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 08/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E6=A8=A1=E6=9D=BF=E5=8D=A0=E4=BD=8D=E7=AC=A6?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E5=88=99=E8=A1=A8=E8=BE=BE=E5=BC=8F=E8=BD=AC?= =?UTF-8?q?=E4=B9=89=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 09/13] 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 } /** From d80f1d8c08ed046c848bb3fe52b9049b97c3cc9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:13:11 +0000 Subject: [PATCH 10/13] Initial plan From 96d8a0acd14f890d3870287fa363029418524949 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:23:49 +0000 Subject: [PATCH 11/13] refactor: add extensible S3EndpointStrategy pattern for S3-compatible services Co-authored-by: sysfox <99103591+sysfox@users.noreply.github.com> --- apps/core/src/utils/s3.util.ts | 130 +++++++++++++---- .../core/{ => test}/src/utils/s3.util.spec.ts | 133 +++++++++++++++++- 2 files changed, 235 insertions(+), 28 deletions(-) rename apps/core/{ => test}/src/utils/s3.util.spec.ts (51%) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index e4d9fff0250..e84a68fa37c 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -9,14 +9,114 @@ export interface S3UploaderOptions { endpoint?: string } +export interface S3EndpointContext { + protocol: string + host: string + bucket: string + endpoint: string + encodedObjectKey: string +} + +export interface S3EndpointResolution { + requestHost: string + canonicalUri: string + baseUrl: string +} + +export interface S3EndpointStrategy { + matches: (host: string, bucket: string) => boolean + resolve: (context: S3EndpointContext) => S3EndpointResolution +} + +export class TencentCosEndpointStrategy implements S3EndpointStrategy { + matches(host: string): boolean { + return ( + host === 'myqcloud.com' || + host.endsWith('.myqcloud.com') || + host.includes('.cos.') + ) + } + + resolve({ + protocol, + host, + bucket, + encodedObjectKey, + }: S3EndpointContext): S3EndpointResolution { + let requestHost = host + const cosMatch = host.match(/^cos\.(.+)$/) + if (cosMatch) { + requestHost = `${bucket}.cos.${cosMatch[1]}` + } + return { + requestHost, + canonicalUri: `/${encodedObjectKey}`, + baseUrl: `${protocol}//${requestHost}`, + } + } +} + +export class DefaultS3EndpointStrategy implements S3EndpointStrategy { + matches(_host: string, _bucket: string): boolean { + return true + } + + resolve({ + host, + bucket, + endpoint, + encodedObjectKey, + }: S3EndpointContext): S3EndpointResolution { + const isVirtualHosted = host.startsWith(`${bucket}.`) + return { + requestHost: host, + canonicalUri: isVirtualHosted + ? `/${encodedObjectKey}` + : `/${bucket}/${encodedObjectKey}`, + baseUrl: endpoint, + } + } +} + export class S3Uploader { private options: S3UploaderOptions private customDomain: string = '' + private strategies: S3EndpointStrategy[] = [ + new TencentCosEndpointStrategy(), + new DefaultS3EndpointStrategy(), + ] constructor(options: S3UploaderOptions) { this.options = options } + registerStrategy(strategy: S3EndpointStrategy): this { + // Insert before the default (fallback) strategy, which is always last + this.strategies.splice(this.strategies.length - 1, 0, strategy) + return this + } + + private resolveEndpoint( + url: URL, + encodedObjectKey: string, + ): S3EndpointResolution { + const context: S3EndpointContext = { + protocol: url.protocol, + host: url.host, + bucket: this.bucket, + endpoint: this.endpoint, + encodedObjectKey, + } + for (const strategy of this.strategies) { + if (strategy.matches(url.host, this.bucket)) { + return strategy.resolve(context) + } + } + // Unreachable: DefaultS3EndpointStrategy always matches + /* c8 ignore next */ + return new DefaultS3EndpointStrategy().resolve(context) + } + get endpoint(): string { return ( this.options.endpoint || @@ -110,9 +210,6 @@ export class S3Uploader { // Set request headers const url = new URL(this.endpoint) - const host = url.host - let requestHost = host - let canonicalUri: string // URI encode each path segment for signing const encodedObjectKey = objectKey @@ -120,25 +217,10 @@ export class S3Uploader { .map((seg) => encodeURIComponent(seg)) .join('/') - // Determine canonical URI based on endpoint style - // 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 { requestHost, canonicalUri, baseUrl } = this.resolveEndpoint( + url, + encodedObjectKey, + ) const contentLength = fileData.length.toString() @@ -195,10 +277,6 @@ export class S3Uploader { const authorization = `${algorithm} Credential=${this.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` // Create and send PUT request - // 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 } = { diff --git a/apps/core/src/utils/s3.util.spec.ts b/apps/core/test/src/utils/s3.util.spec.ts similarity index 51% rename from apps/core/src/utils/s3.util.spec.ts rename to apps/core/test/src/utils/s3.util.spec.ts index e112fe031f8..f5439feacf7 100644 --- a/apps/core/src/utils/s3.util.spec.ts +++ b/apps/core/test/src/utils/s3.util.spec.ts @@ -1,7 +1,11 @@ import * as crypto from 'node:crypto' +import type { S3EndpointContext, S3UploaderOptions } from '~/utils/s3.util' +import { + DefaultS3EndpointStrategy, + S3Uploader, + TencentCosEndpointStrategy, +} from '~/utils/s3.util' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { S3UploaderOptions } from './s3.util' -import { S3Uploader } from './s3.util' // Mock fetch global.fetch = vi.fn() @@ -160,4 +164,129 @@ describe('S3Uploader', () => { ) }) }) + + describe('TencentCosEndpointStrategy', () => { + const strategy = new TencentCosEndpointStrategy() + + it('should match myqcloud.com hosts', () => { + expect(strategy.matches('cos.ap-guangzhou.myqcloud.com', 'bucket')).toBe( + true, + ) + }) + + it('should match hosts containing .cos.', () => { + expect( + strategy.matches('bucket.cos.ap-guangzhou.myqcloud.com', 'bucket'), + ).toBe(true) + }) + + it('should not match generic S3 hosts', () => { + expect(strategy.matches('s3.amazonaws.com', 'bucket')).toBe(false) + expect(strategy.matches('test-endpoint.com', 'bucket')).toBe(false) + }) + + it('should convert cos.region.myqcloud.com to virtual-hosted style', () => { + const context: S3EndpointContext = { + protocol: 'https:', + host: 'cos.ap-guangzhou.myqcloud.com', + bucket: 'my-bucket', + endpoint: 'https://cos.ap-guangzhou.myqcloud.com', + encodedObjectKey: 'folder/file.png', + } + const result = strategy.resolve(context) + expect(result.requestHost).toBe('my-bucket.cos.ap-guangzhou.myqcloud.com') + expect(result.canonicalUri).toBe('/folder/file.png') + expect(result.baseUrl).toBe( + 'https://my-bucket.cos.ap-guangzhou.myqcloud.com', + ) + }) + + it('should keep existing requestHost when host does not match cos.* pattern', () => { + const context: S3EndpointContext = { + protocol: 'https:', + host: 'my-bucket.cos.ap-guangzhou.myqcloud.com', + bucket: 'my-bucket', + endpoint: 'https://my-bucket.cos.ap-guangzhou.myqcloud.com', + encodedObjectKey: 'folder/file.png', + } + const result = strategy.resolve(context) + expect(result.requestHost).toBe('my-bucket.cos.ap-guangzhou.myqcloud.com') + expect(result.canonicalUri).toBe('/folder/file.png') + }) + }) + + describe('DefaultS3EndpointStrategy', () => { + const strategy = new DefaultS3EndpointStrategy() + + it('should always match', () => { + expect(strategy.matches('anything.com', 'any-bucket')).toBe(true) + }) + + it('should use virtual-hosted style when host starts with bucket name', () => { + const context: S3EndpointContext = { + protocol: 'https:', + host: 'my-bucket.s3.us-east-1.amazonaws.com', + bucket: 'my-bucket', + endpoint: 'https://my-bucket.s3.us-east-1.amazonaws.com', + encodedObjectKey: 'folder/file.png', + } + const result = strategy.resolve(context) + expect(result.requestHost).toBe('my-bucket.s3.us-east-1.amazonaws.com') + expect(result.canonicalUri).toBe('/folder/file.png') + expect(result.baseUrl).toBe(context.endpoint) + }) + + it('should use path style when host does not start with bucket name', () => { + const context: S3EndpointContext = { + protocol: 'https:', + host: 's3.us-east-1.amazonaws.com', + bucket: 'my-bucket', + endpoint: 'https://s3.us-east-1.amazonaws.com', + encodedObjectKey: 'folder/file.png', + } + const result = strategy.resolve(context) + expect(result.requestHost).toBe('s3.us-east-1.amazonaws.com') + expect(result.canonicalUri).toBe('/my-bucket/folder/file.png') + expect(result.baseUrl).toBe(context.endpoint) + }) + }) + + describe('registerStrategy', () => { + it('should apply a custom strategy before the default one', async () => { + const customStrategy = { + matches: (host: string) => host === 'custom-provider.com', + resolve: (ctx: S3EndpointContext) => ({ + requestHost: ctx.host, + canonicalUri: `/custom/${ctx.encodedObjectKey}`, + baseUrl: ctx.endpoint, + }), + } + + const customUploader = new S3Uploader({ + ...options, + endpoint: 'https://custom-provider.com', + }) + customUploader.registerStrategy(customStrategy) + + await customUploader.uploadToS3('test-object', mockBuffer, 'text/plain') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/custom/test-object'), + expect.anything(), + ) + }) + + it('should fall back to default strategy when no custom strategy matches', async () => { + const nonMatchingStrategy = { + matches: () => false, + resolve: () => ({ requestHost: 'x', canonicalUri: '/x', baseUrl: 'x' }), + } + uploader.registerStrategy(nonMatchingStrategy) + + await uploader.uploadToS3('test-object', mockBuffer, 'text/plain') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/test-bucket/test-object'), + expect.anything(), + ) + }) + }) }) From 211f9fdb4cb3c8cad9aa904877e8babdf9e46d26 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Tue, 3 Mar 2026 12:12:46 +0800 Subject: [PATCH 12/13] Revert "refactor(s3): replace hardcoded Tencent COS detection with extensible endpoint strategy pattern" --- .../core/{test => }/src/utils/s3.util.spec.ts | 133 +----------------- apps/core/src/utils/s3.util.ts | 130 ++++------------- 2 files changed, 28 insertions(+), 235 deletions(-) rename apps/core/{test => }/src/utils/s3.util.spec.ts (51%) diff --git a/apps/core/test/src/utils/s3.util.spec.ts b/apps/core/src/utils/s3.util.spec.ts similarity index 51% rename from apps/core/test/src/utils/s3.util.spec.ts rename to apps/core/src/utils/s3.util.spec.ts index f5439feacf7..e112fe031f8 100644 --- a/apps/core/test/src/utils/s3.util.spec.ts +++ b/apps/core/src/utils/s3.util.spec.ts @@ -1,11 +1,7 @@ import * as crypto from 'node:crypto' -import type { S3EndpointContext, S3UploaderOptions } from '~/utils/s3.util' -import { - DefaultS3EndpointStrategy, - S3Uploader, - TencentCosEndpointStrategy, -} from '~/utils/s3.util' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { S3UploaderOptions } from './s3.util' +import { S3Uploader } from './s3.util' // Mock fetch global.fetch = vi.fn() @@ -164,129 +160,4 @@ describe('S3Uploader', () => { ) }) }) - - describe('TencentCosEndpointStrategy', () => { - const strategy = new TencentCosEndpointStrategy() - - it('should match myqcloud.com hosts', () => { - expect(strategy.matches('cos.ap-guangzhou.myqcloud.com', 'bucket')).toBe( - true, - ) - }) - - it('should match hosts containing .cos.', () => { - expect( - strategy.matches('bucket.cos.ap-guangzhou.myqcloud.com', 'bucket'), - ).toBe(true) - }) - - it('should not match generic S3 hosts', () => { - expect(strategy.matches('s3.amazonaws.com', 'bucket')).toBe(false) - expect(strategy.matches('test-endpoint.com', 'bucket')).toBe(false) - }) - - it('should convert cos.region.myqcloud.com to virtual-hosted style', () => { - const context: S3EndpointContext = { - protocol: 'https:', - host: 'cos.ap-guangzhou.myqcloud.com', - bucket: 'my-bucket', - endpoint: 'https://cos.ap-guangzhou.myqcloud.com', - encodedObjectKey: 'folder/file.png', - } - const result = strategy.resolve(context) - expect(result.requestHost).toBe('my-bucket.cos.ap-guangzhou.myqcloud.com') - expect(result.canonicalUri).toBe('/folder/file.png') - expect(result.baseUrl).toBe( - 'https://my-bucket.cos.ap-guangzhou.myqcloud.com', - ) - }) - - it('should keep existing requestHost when host does not match cos.* pattern', () => { - const context: S3EndpointContext = { - protocol: 'https:', - host: 'my-bucket.cos.ap-guangzhou.myqcloud.com', - bucket: 'my-bucket', - endpoint: 'https://my-bucket.cos.ap-guangzhou.myqcloud.com', - encodedObjectKey: 'folder/file.png', - } - const result = strategy.resolve(context) - expect(result.requestHost).toBe('my-bucket.cos.ap-guangzhou.myqcloud.com') - expect(result.canonicalUri).toBe('/folder/file.png') - }) - }) - - describe('DefaultS3EndpointStrategy', () => { - const strategy = new DefaultS3EndpointStrategy() - - it('should always match', () => { - expect(strategy.matches('anything.com', 'any-bucket')).toBe(true) - }) - - it('should use virtual-hosted style when host starts with bucket name', () => { - const context: S3EndpointContext = { - protocol: 'https:', - host: 'my-bucket.s3.us-east-1.amazonaws.com', - bucket: 'my-bucket', - endpoint: 'https://my-bucket.s3.us-east-1.amazonaws.com', - encodedObjectKey: 'folder/file.png', - } - const result = strategy.resolve(context) - expect(result.requestHost).toBe('my-bucket.s3.us-east-1.amazonaws.com') - expect(result.canonicalUri).toBe('/folder/file.png') - expect(result.baseUrl).toBe(context.endpoint) - }) - - it('should use path style when host does not start with bucket name', () => { - const context: S3EndpointContext = { - protocol: 'https:', - host: 's3.us-east-1.amazonaws.com', - bucket: 'my-bucket', - endpoint: 'https://s3.us-east-1.amazonaws.com', - encodedObjectKey: 'folder/file.png', - } - const result = strategy.resolve(context) - expect(result.requestHost).toBe('s3.us-east-1.amazonaws.com') - expect(result.canonicalUri).toBe('/my-bucket/folder/file.png') - expect(result.baseUrl).toBe(context.endpoint) - }) - }) - - describe('registerStrategy', () => { - it('should apply a custom strategy before the default one', async () => { - const customStrategy = { - matches: (host: string) => host === 'custom-provider.com', - resolve: (ctx: S3EndpointContext) => ({ - requestHost: ctx.host, - canonicalUri: `/custom/${ctx.encodedObjectKey}`, - baseUrl: ctx.endpoint, - }), - } - - const customUploader = new S3Uploader({ - ...options, - endpoint: 'https://custom-provider.com', - }) - customUploader.registerStrategy(customStrategy) - - await customUploader.uploadToS3('test-object', mockBuffer, 'text/plain') - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining('/custom/test-object'), - expect.anything(), - ) - }) - - it('should fall back to default strategy when no custom strategy matches', async () => { - const nonMatchingStrategy = { - matches: () => false, - resolve: () => ({ requestHost: 'x', canonicalUri: '/x', baseUrl: 'x' }), - } - uploader.registerStrategy(nonMatchingStrategy) - - await uploader.uploadToS3('test-object', mockBuffer, 'text/plain') - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-bucket/test-object'), - expect.anything(), - ) - }) - }) }) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index e84a68fa37c..e4d9fff0250 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -9,114 +9,14 @@ export interface S3UploaderOptions { endpoint?: string } -export interface S3EndpointContext { - protocol: string - host: string - bucket: string - endpoint: string - encodedObjectKey: string -} - -export interface S3EndpointResolution { - requestHost: string - canonicalUri: string - baseUrl: string -} - -export interface S3EndpointStrategy { - matches: (host: string, bucket: string) => boolean - resolve: (context: S3EndpointContext) => S3EndpointResolution -} - -export class TencentCosEndpointStrategy implements S3EndpointStrategy { - matches(host: string): boolean { - return ( - host === 'myqcloud.com' || - host.endsWith('.myqcloud.com') || - host.includes('.cos.') - ) - } - - resolve({ - protocol, - host, - bucket, - encodedObjectKey, - }: S3EndpointContext): S3EndpointResolution { - let requestHost = host - const cosMatch = host.match(/^cos\.(.+)$/) - if (cosMatch) { - requestHost = `${bucket}.cos.${cosMatch[1]}` - } - return { - requestHost, - canonicalUri: `/${encodedObjectKey}`, - baseUrl: `${protocol}//${requestHost}`, - } - } -} - -export class DefaultS3EndpointStrategy implements S3EndpointStrategy { - matches(_host: string, _bucket: string): boolean { - return true - } - - resolve({ - host, - bucket, - endpoint, - encodedObjectKey, - }: S3EndpointContext): S3EndpointResolution { - const isVirtualHosted = host.startsWith(`${bucket}.`) - return { - requestHost: host, - canonicalUri: isVirtualHosted - ? `/${encodedObjectKey}` - : `/${bucket}/${encodedObjectKey}`, - baseUrl: endpoint, - } - } -} - export class S3Uploader { private options: S3UploaderOptions private customDomain: string = '' - private strategies: S3EndpointStrategy[] = [ - new TencentCosEndpointStrategy(), - new DefaultS3EndpointStrategy(), - ] constructor(options: S3UploaderOptions) { this.options = options } - registerStrategy(strategy: S3EndpointStrategy): this { - // Insert before the default (fallback) strategy, which is always last - this.strategies.splice(this.strategies.length - 1, 0, strategy) - return this - } - - private resolveEndpoint( - url: URL, - encodedObjectKey: string, - ): S3EndpointResolution { - const context: S3EndpointContext = { - protocol: url.protocol, - host: url.host, - bucket: this.bucket, - endpoint: this.endpoint, - encodedObjectKey, - } - for (const strategy of this.strategies) { - if (strategy.matches(url.host, this.bucket)) { - return strategy.resolve(context) - } - } - // Unreachable: DefaultS3EndpointStrategy always matches - /* c8 ignore next */ - return new DefaultS3EndpointStrategy().resolve(context) - } - get endpoint(): string { return ( this.options.endpoint || @@ -210,6 +110,9 @@ export class S3Uploader { // Set request headers const url = new URL(this.endpoint) + const host = url.host + let requestHost = host + let canonicalUri: string // URI encode each path segment for signing const encodedObjectKey = objectKey @@ -217,10 +120,25 @@ export class S3Uploader { .map((seg) => encodeURIComponent(seg)) .join('/') - const { requestHost, canonicalUri, baseUrl } = this.resolveEndpoint( - url, - encodedObjectKey, - ) + // Determine canonical URI based on endpoint style + // 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() @@ -277,6 +195,10 @@ export class S3Uploader { const authorization = `${algorithm} Credential=${this.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` // Create and send PUT request + // 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 } = { From eb7ad403311a954036763301b3146d0e7284ec36 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Thu, 5 Mar 2026 22:49:14 +0800 Subject: [PATCH 13/13] feat: implement extensible endpoint strategies for S3Uploader --- apps/core/src/utils/s3.util.spec.ts | 125 +++++++++++++++++- apps/core/src/utils/s3.util.ts | 188 ++++++++++++++++++++++++---- 2 files changed, 284 insertions(+), 29 deletions(-) diff --git a/apps/core/src/utils/s3.util.spec.ts b/apps/core/src/utils/s3.util.spec.ts index e112fe031f8..71d32e55c64 100644 --- a/apps/core/src/utils/s3.util.spec.ts +++ b/apps/core/src/utils/s3.util.spec.ts @@ -1,7 +1,7 @@ import * as crypto from 'node:crypto' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { S3UploaderOptions } from './s3.util' -import { S3Uploader } from './s3.util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { S3EndpointStrategy, S3UploaderOptions } from './s3.util' +import { DefaultS3Strategy, S3Uploader, TencentCosStrategy } from './s3.util' // Mock fetch global.fetch = vi.fn() @@ -160,4 +160,123 @@ describe('S3Uploader', () => { ) }) }) + + describe('endpoint strategies', () => { + afterEach(() => { + S3Uploader.resetStrategies() + }) + + describe('TencentCosStrategy', () => { + const strategy = new TencentCosStrategy() + + it('should match myqcloud.com hosts', () => { + expect(strategy.matches('cos.ap-guangzhou.myqcloud.com')).toBe(true) + }) + + it('should match hosts containing .cos.', () => { + expect(strategy.matches('bucket.cos.ap-guangzhou.myqcloud.com')).toBe( + true, + ) + }) + + it('should not match unrelated hosts', () => { + expect(strategy.matches('s3.amazonaws.com')).toBe(false) + }) + + it('should resolve virtual-hosted style for cos.* hosts', () => { + const result = strategy.resolve({ + host: 'cos.ap-guangzhou.myqcloud.com', + bucket: 'my-bucket', + encodedObjectKey: 'path/to/file.png', + protocol: 'https:', + }) + expect(result.requestHost).toBe( + 'my-bucket.cos.ap-guangzhou.myqcloud.com', + ) + expect(result.canonicalUri).toBe('/path/to/file.png') + expect(result.baseUrl).toBe( + 'https://my-bucket.cos.ap-guangzhou.myqcloud.com', + ) + }) + }) + + describe('DefaultS3Strategy', () => { + const strategy = new DefaultS3Strategy() + + it('should match any host', () => { + expect(strategy.matches('anything.example.com')).toBe(true) + }) + + it('should use path style when host does not start with bucket', () => { + const result = strategy.resolve({ + host: 's3.us-east-1.amazonaws.com', + bucket: 'my-bucket', + encodedObjectKey: 'file.txt', + protocol: 'https:', + }) + expect(result.canonicalUri).toBe('/my-bucket/file.txt') + expect(result.requestHost).toBe('s3.us-east-1.amazonaws.com') + }) + + it('should use virtual-hosted style when host starts with bucket', () => { + const result = strategy.resolve({ + host: 'my-bucket.s3.us-east-1.amazonaws.com', + bucket: 'my-bucket', + encodedObjectKey: 'file.txt', + protocol: 'https:', + }) + expect(result.canonicalUri).toBe('/file.txt') + }) + }) + + describe('custom strategy registration', () => { + it('should use a registered custom strategy when it matches', async () => { + const customStrategy: S3EndpointStrategy = { + name: 'CustomProvider', + matches: (host) => host.includes('custom-storage.example.com'), + resolve: (ctx) => ({ + requestHost: `${ctx.bucket}.custom-storage.example.com`, + canonicalUri: `/${ctx.encodedObjectKey}`, + baseUrl: `${ctx.protocol}//${ctx.bucket}.custom-storage.example.com`, + }), + } + + S3Uploader.registerStrategy(customStrategy) + + const customUploader = new S3Uploader({ + bucket: 'test-bucket', + region: 'us-east-1', + accessKey: 'key', + secretKey: 'secret', + endpoint: 'https://custom-storage.example.com', + }) + + await customUploader.uploadToS3('obj', mockBuffer, 'text/plain') + + expect(fetch).toHaveBeenCalledWith( + 'https://test-bucket.custom-storage.example.com/obj', + expect.objectContaining({ method: 'PUT' }), + ) + }) + + it('should fall back to default strategy when no custom matches', async () => { + const neverMatchStrategy: S3EndpointStrategy = { + name: 'NeverMatch', + matches: () => false, + resolve: () => { + throw new Error('Should not be called') + }, + } + + S3Uploader.registerStrategy(neverMatchStrategy) + + await uploader.uploadToS3('obj', mockBuffer, 'text/plain') + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/test-bucket/obj'), + expect.objectContaining({ method: 'PUT' }), + ) + }) + }) + }) }) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index e4d9fff0250..66d2d022ae9 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -9,10 +9,148 @@ export interface S3UploaderOptions { endpoint?: string } +/** + * Resolved endpoint information used for signing and sending S3 requests. + */ +export interface S3ResolvedEndpoint { + /** The Host header value */ + requestHost: string + /** The canonical URI used for AWS Signature V4 signing */ + canonicalUri: string + /** The base URL (scheme + host) for the final HTTP request */ + baseUrl: string +} + +/** + * Extensible strategy interface for resolving S3-compatible endpoint styles. + * + * Implement this interface to add support for additional S3-compatible storage + * providers that require custom host / URI resolution (e.g. virtual-hosted + * style, path style, or provider-specific conventions). + */ +export interface S3EndpointStrategy { + /** Human-readable name for debugging / logging */ + readonly name: string + + /** + * Return `true` when this strategy should handle the given host. + * Strategies are evaluated in registration order; the first match wins. + */ + matches: (host: string) => boolean + + /** + * Resolve the request host, canonical URI, and base URL for the given + * endpoint information. + */ + resolve: (ctx: { + host: string + bucket: string + encodedObjectKey: string + protocol: string + }) => S3ResolvedEndpoint +} + +// --------------------------------------------------------------------------- +// Built-in strategies +// --------------------------------------------------------------------------- + +/** + * Strategy for Tencent Cloud COS. + * + * Converts `cos..myqcloud.com` → `.cos..myqcloud.com` + * (virtual-hosted style) and uses `/` as the canonical URI. + */ +export class TencentCosStrategy implements S3EndpointStrategy { + readonly name = 'TencentCOS' + + matches(host: string): boolean { + return host.includes('myqcloud.com') || host.includes('.cos.') + } + + resolve(ctx: { + host: string + bucket: string + encodedObjectKey: string + protocol: string + }): S3ResolvedEndpoint { + let requestHost = ctx.host + const cosMatch = ctx.host.match(/^cos\.(.+)$/) + if (cosMatch) { + requestHost = `${ctx.bucket}.cos.${cosMatch[1]}` + } + return { + requestHost, + canonicalUri: `/${ctx.encodedObjectKey}`, + baseUrl: `${ctx.protocol}//${requestHost}`, + } + } +} + +/** + * Default strategy for AWS S3 and most S3-compatible services. + * + * - If the host already starts with `.`, it assumes virtual-hosted + * style and uses `/` as the canonical URI. + * - Otherwise it falls back to path style: `//`. + */ +export class DefaultS3Strategy implements S3EndpointStrategy { + readonly name = 'DefaultS3' + + /** Always matches – used as the fallback strategy. */ + matches(_host: string): boolean { + return true + } + + resolve(ctx: { + host: string + bucket: string + encodedObjectKey: string + protocol: string + }): S3ResolvedEndpoint { + const isVirtualHosted = ctx.host.startsWith(`${ctx.bucket}.`) + const canonicalUri = isVirtualHosted + ? `/${ctx.encodedObjectKey}` + : `/${ctx.bucket}/${ctx.encodedObjectKey}` + return { + requestHost: ctx.host, + canonicalUri, + baseUrl: `${ctx.protocol}//${ctx.host}`, + } + } +} + export class S3Uploader { private options: S3UploaderOptions private customDomain: string = '' + /** + * Ordered list of endpoint strategies. The first strategy whose `matches()` + * returns `true` is used. The {@link DefaultS3Strategy} is always appended + * as a fallback. + */ + private static globalStrategies: S3EndpointStrategy[] = [ + new TencentCosStrategy(), + ] + + /** + * Register a custom endpoint strategy. Strategies registered earlier take + * precedence. The built-in {@link DefaultS3Strategy} is always evaluated + * last, so you do not need to worry about ordering relative to it. + */ + static registerStrategy(strategy: S3EndpointStrategy): void { + S3Uploader.globalStrategies.push(strategy) + } + + /** + * Remove all custom strategies and reset to defaults. + * Useful in tests. + */ + static resetStrategies(): void { + S3Uploader.globalStrategies = [new TencentCosStrategy()] + } + + private static readonly defaultStrategy = new DefaultS3Strategy() + constructor(options: S3UploaderOptions) { this.options = options } @@ -53,6 +191,26 @@ export class S3Uploader { return crypto.createHmac('sha256', key).update(message).digest() } + /** + * Walk the strategy chain and return the first matching result. + */ + private resolveEndpoint( + host: string, + encodedObjectKey: string, + protocol: string, + ): S3ResolvedEndpoint { + const ctx = { host, bucket: this.bucket, encodedObjectKey, protocol } + + for (const strategy of S3Uploader.globalStrategies) { + if (strategy.matches(host)) { + return strategy.resolve(ctx) + } + } + + // Fallback – always matches + return S3Uploader.defaultStrategy.resolve(ctx) + } + async uploadImage(imageData: Buffer, path: string): Promise { const md5Filename = crypto.createHash('md5').update(imageData).digest('hex') const objectKey = `${path}/${md5Filename}.png` @@ -111,8 +269,6 @@ export class S3Uploader { // Set request headers const url = new URL(this.endpoint) const host = url.host - let requestHost = host - let canonicalUri: string // URI encode each path segment for signing const encodedObjectKey = objectKey @@ -120,25 +276,9 @@ export class S3Uploader { .map((seg) => encodeURIComponent(seg)) .join('/') - // Determine canonical URI based on endpoint style - // 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}` - } + // Resolve endpoint using the extensible strategy chain + const resolved = this.resolveEndpoint(host, encodedObjectKey, url.protocol) + const { requestHost, canonicalUri } = resolved const contentLength = fileData.length.toString() @@ -195,11 +335,7 @@ export class S3Uploader { const authorization = `${algorithm} Credential=${this.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` // Create and send PUT request - // For Tencent COS, use virtual-hosted style URL - const baseUrl = isTencentCos - ? `${url.protocol}//${requestHost}` - : this.endpoint - const requestUrl = `${baseUrl}${canonicalUri}` + const requestUrl = `${resolved.baseUrl}${canonicalUri}` const fetchOptions: RequestInit & { dispatcher?: unknown } = { method: 'PUT',