From d9a5be56e684b8814a64401475151a1c1525d11f Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 24 Nov 2025 22:26:47 +0800 Subject: [PATCH] feat: add support for Aliyun OSS and Tencent COS storage providers - Updated documentation to include Aliyun OSS and Tencent COS as storage options. - Introduced configuration examples for both providers in the storage providers documentation. - Enhanced the storage provider registration to accommodate new providers. - Updated the storage configuration interfaces to support OSS and COS. - Modified the S3 client and provider implementations to handle requests for OSS and COS. - Added environment variable configurations for OSS and COS. - Implemented necessary changes in the UI schema and routes to reflect the new providers. - Updated localization files for new storage provider types. Signed-off-by: Innei --- apps/docs/contents/storage/providers/cos.mdx | 64 +++++++++ .../docs/contents/storage/providers/index.mdx | 24 +++- apps/docs/contents/storage/providers/oss.mdx | 63 ++++++++ apps/docs/src/routes.json | 24 +++- apps/docs/src/routes.ts | 28 +++- .../setting/storage-provider.constants.ts | 2 + .../storage-provider.ui-schema.ts | 134 +++++++++--------- .../photo/assets/photo-asset.service.ts | 12 +- .../content/photo/assets/photo-asset.types.ts | 3 +- .../builder/photo-builder-logger.adapter.ts | 1 + .../photo/storage/photo-storage.service.ts | 20 ++- .../infrastructure/data-sync/data-sync.dto.ts | 24 +++- .../managed-storage.provider.ts | 18 ++- .../super-admin-settings.controller.ts | 21 ++- .../components/ProviderCard.tsx | 14 +- .../modules/storage-providers/constants.ts | 4 + locales/dashboard/en.json | 4 +- locales/dashboard/zh-CN.json | 4 +- packages/builder/src/builder/builder.ts | 4 +- packages/builder/src/cli.ts | 4 +- .../builder/src/photo/execution-context.ts | 8 +- packages/builder/src/plugins/storage/s3.ts | 4 +- .../src/plugins/thumbnail-storage/index.ts | 8 +- packages/builder/src/s3/client.ts | 77 ++++++++-- packages/builder/src/storage/interfaces.ts | 30 +++- .../builder/src/storage/providers/README.md | 52 ++++++- .../builder/src/storage/providers/register.ts | 12 ++ .../src/storage/providers/s3-client.ts | 4 +- .../src/storage/providers/s3-provider.ts | 43 ++---- 29 files changed, 554 insertions(+), 156 deletions(-) create mode 100644 apps/docs/contents/storage/providers/cos.mdx create mode 100644 apps/docs/contents/storage/providers/oss.mdx diff --git a/apps/docs/contents/storage/providers/cos.mdx b/apps/docs/contents/storage/providers/cos.mdx new file mode 100644 index 00000000..58f9350f --- /dev/null +++ b/apps/docs/contents/storage/providers/cos.mdx @@ -0,0 +1,64 @@ +--- +title: Tencent COS +description: Configure Tencent Cloud Object Storage (COS) for deployments within the Tencent ecosystem. +createdAt: 2025-11-24T10:06:00+08:00 +lastModified: 2025-11-24T22:26:48+08:00 +order: 38 +--- + +# Tencent COS Storage + +Tencent Cloud COS is fully compatible with the S3 API and now has a dedicated provider inside Afilmory. Using `provider: 'cos'` takes care of bucket/APPID handling and builds the default `cos..myqcloud.com` endpoint for you. + +## Configuration + +```typescript +import { defineBuilderConfig } from '@afilmory/builder' + +export default defineBuilderConfig(() => ({ + storage: { + provider: 'cos', + bucket: process.env.COS_BUCKET!, // include the -APPID suffix, e.g. gallery-1250000000 + region: process.env.COS_REGION || 'ap-shanghai', + accessKeyId: process.env.COS_SECRET_ID!, + secretAccessKey: process.env.COS_SECRET_KEY!, + endpoint: process.env.COS_ENDPOINT, // optional, defaults to https://.cos..myqcloud.com + prefix: process.env.COS_PREFIX || 'photos/', + customDomain: process.env.COS_CUSTOM_DOMAIN, + }, +})) +``` + +## Environment Variables + +```bash +# Required +COS_BUCKET=gallery-1250000000 +COS_REGION=ap-shanghai +COS_SECRET_ID=AKIDxxxxxxxx +COS_SECRET_KEY=yyyyyyyyyyyy + +# Optional +COS_ENDPOINT=https://cos.ap-shanghai.myqcloud.com +COS_PREFIX=photos/ +COS_CUSTOM_DOMAIN=https://assets.example.com +``` + +## COS-specific Considerations + +- Buckets MUST include the APPID suffix (`bucketname-125xxxxxxx`). +- Regional endpoints follow the pattern `https://.cos..myqcloud.com`. +- SigV4 service defaults to `s3`, matching Tencent's AWS compatibility layer. Override `sigV4Service` only for private gateways. +- COS supports acceleration and CDN domains; set `customDomain` so generated URLs point to your preferred edge domain. + +## Best Practices + +- **Use permanent keys** for builder workloads and scope permissions with CAM policies (`name/cos:GetObject`, `cos:PutObject`, etc.). +- **Leverage CLS logs** and bucket inventory to monitor sync health. +- **Combine with SCF or CI** pipelines if you need to trigger builder runs after uploads. + +## Troubleshooting + +- _403 or 404 errors_: confirm the bucket region matches `COS_REGION` and that the IAM policy allows the requested action. +- _Slow scans_: reduce `downloadConcurrency` or run builder from a Tencent CVM within the same region to minimize latency. +- _Invalid bucket name_: double-check the `-APPID` suffix and that the bucket really exists in the selected region. diff --git a/apps/docs/contents/storage/providers/index.mdx b/apps/docs/contents/storage/providers/index.mdx index 59f967ae..8ccb4a7d 100644 --- a/apps/docs/contents/storage/providers/index.mdx +++ b/apps/docs/contents/storage/providers/index.mdx @@ -2,7 +2,7 @@ title: Storage Providers description: Choose a storage provider for your photo collection. createdAt: 2025-11-14T22:40:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-24T22:26:48+08:00 order: 30 --- @@ -21,6 +21,24 @@ Afilmory supports multiple storage providers for your photo collection. Choose t - Works with AWS S3, MinIO, Cloudflare R2, and other S3-compatible services - Recommended for large collections +### [Aliyun OSS](/storage/providers/oss) + +**China-mainland optimized** + +- Native endpoint defaults for `oss-` regions +- Works with classic or internal network OSS domains +- Ideal when data residency or ICP compliance is required +- Supports CDN fronting via `customDomain` + +### [Tencent COS](/storage/providers/cos) + +**Tencent Cloud ecosystems** + +- First-class support for COS buckets (`bucket-appid`) +- Automatic `cos..myqcloud.com` endpoint handling +- Compatible with COS acceleration domains and CLS logging +- Preferred for deployments already on Tencent Cloud + ### [B2 (Backblaze B2)](/storage/providers/b2) **Cost-effective cloud storage** @@ -60,13 +78,16 @@ Afilmory supports multiple storage providers for your photo collection. Choose t ## Choosing a Provider **For production:** + - Use **S3** or **B2** for scalability and reliability **For development:** + - Use **Local** for fastest iteration - Use **GitHub** for simple demos **For existing workflows:** + - Use **Eagle** if you already manage photos there - Use **GitHub** if your photos are already in a repo @@ -88,4 +109,3 @@ export default defineBuilderConfig(() => ({ Credentials and sensitive information should be stored in `.env` and referenced via `process.env`. See each provider's documentation for specific configuration options. - diff --git a/apps/docs/contents/storage/providers/oss.mdx b/apps/docs/contents/storage/providers/oss.mdx new file mode 100644 index 00000000..101b0fbd --- /dev/null +++ b/apps/docs/contents/storage/providers/oss.mdx @@ -0,0 +1,63 @@ +--- +title: Aliyun OSS +description: Configure Aliyun Object Storage Service (OSS) for China-mainland friendly deployments. +createdAt: 2025-11-24T10:05:00+08:00 +lastModified: 2025-11-24T22:26:48+08:00 +order: 37 +--- + +# Aliyun OSS Storage + +Aliyun OSS is ideal when you need data residency in mainland China or want to leverage Alibaba Cloud's backbone network. The new `provider: 'oss'` option in Afilmory automatically sets sane defaults for endpoints and SigV4 signing so you only fill in the essentials. + +## Configuration + +```typescript +import { defineBuilderConfig } from '@afilmory/builder' + +export default defineBuilderConfig(() => ({ + storage: { + provider: 'oss', + bucket: process.env.OSS_BUCKET!, + region: process.env.OSS_REGION || 'oss-cn-hangzhou', + accessKeyId: process.env.OSS_ACCESS_KEY_ID!, + secretAccessKey: process.env.OSS_ACCESS_KEY_SECRET!, + endpoint: process.env.OSS_ENDPOINT, // optional, defaults to bucket.region.aliyuncs.com + prefix: process.env.OSS_PREFIX || 'photos/', + customDomain: process.env.OSS_CUSTOM_DOMAIN, // optional CDN or acceleration domain + }, +})) +``` + +## Environment Variables + +```bash +# Required +OSS_BUCKET=my-gallery-assets +OSS_REGION=oss-cn-hangzhou +OSS_ACCESS_KEY_ID=xxxxxxxx +OSS_ACCESS_KEY_SECRET=yyyyyyyy + +# Optional +OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +OSS_PREFIX=photos/ +OSS_CUSTOM_DOMAIN=https://img.example.cn +``` + +## Notes on Endpoints + +- If you omit `endpoint`, the provider emits `https://..aliyuncs.com`. +- You can point `endpoint` to an internal VPC domain or acceleration endpoint; public URLs still honor `customDomain` when provided. +- The SigV4 service defaults to `oss`. Override `sigV4Service` only when using custom gateways that expect a different service name. + +## Best Practices + +- **Use ICP-ready domains**: set `customDomain` to an OSS-bound domain that has completed ICP filing for production in mainland China. +- **Prefix per tenant**: combine `prefix` with tenant IDs or years to simplify lifecycle policies. +- **Network paths**: pair `endpoint` with the closest region (e.g., `oss-cn-shanghai-internal.aliyuncs.com`) when running builder inside Alibaba Cloud to avoid public egress. + +## Troubleshooting + +- _Signature mismatch_: verify your AccessKey pair and ensure system clock drift is under 5 minutes. +- _403 Forbidden after upload_: confirm the RAM policy allows `oss:PutObject` / `oss:GetObject` for the bucket prefix. +- _Slow downloads outside China_: front OSS with a CDN and set `customDomain` so generated URLs route through the CDN. diff --git a/apps/docs/src/routes.json b/apps/docs/src/routes.json index 89ca1306..63ecc22d 100644 --- a/apps/docs/src/routes.json +++ b/apps/docs/src/routes.json @@ -94,7 +94,7 @@ "title": "Storage Providers", "description": "Choose a storage provider for your photo collection.", "createdAt": "2025-11-14T22:40:00+08:00", - "lastModified": "2025-11-23T19:40:52+08:00", + "lastModified": "2025-11-24T10:15:00+08:00", "order": "30" } }, @@ -109,6 +109,28 @@ "order": "32" } }, + { + "path": "/storage/providers/oss", + "title": "Aliyun OSS", + "meta": { + "title": "Aliyun OSS", + "description": "Configure Aliyun Object Storage Service (OSS) for China-mainland friendly deployments.", + "createdAt": "2025-11-24T10:05:00+08:00", + "lastModified": "2025-11-24T10:05:00+08:00", + "order": "37" + } + }, + { + "path": "/storage/providers/cos", + "title": "Tencent COS", + "meta": { + "title": "Tencent COS", + "description": "Configure Tencent Cloud Object Storage (COS) for deployments within the Tencent ecosystem.", + "createdAt": "2025-11-24T10:06:00+08:00", + "lastModified": "2025-11-24T10:06:00+08:00", + "order": "38" + } + }, { "path": "/storage/providers/b2", "title": "B2 (Backblaze B2)", diff --git a/apps/docs/src/routes.ts b/apps/docs/src/routes.ts index 1185a347..077885fc 100644 --- a/apps/docs/src/routes.ts +++ b/apps/docs/src/routes.ts @@ -22,10 +22,12 @@ import Route23 from '../contents/saas/cms.mdx' import Route15 from '../contents/saas/deployment.mdx' import Route22 from '../contents/saas/index.mdx' import Route10 from '../contents/storage/providers/b2.mdx' +import Route26 from '../contents/storage/providers/cos.mdx' import Route14 from '../contents/storage/providers/eagle.mdx' import Route11 from '../contents/storage/providers/github.mdx' import Route8 from '../contents/storage/providers/index.mdx' import Route12 from '../contents/storage/providers/local.mdx' +import Route25 from '../contents/storage/providers/oss.mdx' import Route9 from '../contents/storage/providers/s3.mdx' export interface RouteConfig { @@ -140,7 +142,7 @@ export const routes: RouteConfig[] = [ title: 'Storage Providers', description: 'Choose a storage provider for your photo collection.', createdAt: '2025-11-14T22:40:00+08:00', - lastModified: '2025-11-23T19:40:52+08:00', + lastModified: '2025-11-24T10:15:00+08:00', order: '30', }, }, @@ -156,6 +158,30 @@ export const routes: RouteConfig[] = [ order: '32', }, }, + { + path: '/storage/providers/oss', + component: Route25, + title: 'Aliyun OSS', + meta: { + title: 'Aliyun OSS', + description: 'Configure Aliyun Object Storage Service (OSS) for China-mainland friendly deployments.', + createdAt: '2025-11-24T10:05:00+08:00', + lastModified: '2025-11-24T10:05:00+08:00', + order: '37', + }, + }, + { + path: '/storage/providers/cos', + component: Route26, + title: 'Tencent COS', + meta: { + title: 'Tencent COS', + description: 'Configure Tencent Cloud Object Storage (COS) for deployments within the Tencent ecosystem.', + createdAt: '2025-11-24T10:06:00+08:00', + lastModified: '2025-11-24T10:06:00+08:00', + order: '38', + }, + }, { path: '/storage/providers/b2', component: Route10, diff --git a/be/apps/core/src/modules/configuration/setting/storage-provider.constants.ts b/be/apps/core/src/modules/configuration/setting/storage-provider.constants.ts index fd266d19..622be37d 100644 --- a/be/apps/core/src/modules/configuration/setting/storage-provider.constants.ts +++ b/be/apps/core/src/modules/configuration/setting/storage-provider.constants.ts @@ -2,6 +2,8 @@ export const STORAGE_PROVIDERS_SETTING_KEY = 'builder.storage.providers' export const STORAGE_PROVIDER_SENSITIVE_FIELDS: Record = { s3: ['secretAccessKey'], + oss: ['secretAccessKey'], + cos: ['secretAccessKey'], github: ['token'], b2: ['applicationKey'], } diff --git a/be/apps/core/src/modules/configuration/storage-setting/storage-provider.ui-schema.ts b/be/apps/core/src/modules/configuration/storage-setting/storage-provider.ui-schema.ts index 098cf5da..7fc4f3cd 100644 --- a/be/apps/core/src/modules/configuration/storage-setting/storage-provider.ui-schema.ts +++ b/be/apps/core/src/modules/configuration/storage-setting/storage-provider.ui-schema.ts @@ -1,6 +1,6 @@ import type { UiSchemaTFunction } from '../../ui/ui-schema/ui-schema.i18n' -export type StorageProviderType = 's3' | 'github' | 'b2' +export type StorageProviderType = 's3' | 'oss' | 'cos' | 'github' | 'b2' export interface StorageProviderFieldSchema { key: string @@ -18,7 +18,7 @@ export interface StorageProviderFormSchema { fields: Record } -const STORAGE_PROVIDER_TYPES: readonly StorageProviderType[] = ['s3', 'github', 'b2'] +const STORAGE_PROVIDER_TYPES: readonly StorageProviderType[] = ['s3', 'oss', 'cos', 'github', 'b2'] type StorageProviderFieldConfig = { key: string @@ -31,70 +31,74 @@ type StorageProviderFieldConfig = { required?: boolean } +const S3_FIELD_CONFIG: readonly StorageProviderFieldConfig[] = [ + { + key: 'bucket', + labelKey: 'storage.providers.fields.s3.bucket.label', + placeholderKey: 'storage.providers.fields.s3.bucket.placeholder', + descriptionKey: 'storage.providers.fields.s3.bucket.description', + required: true, + }, + { + key: 'region', + labelKey: 'storage.providers.fields.s3.region.label', + placeholderKey: 'storage.providers.fields.s3.region.placeholder', + descriptionKey: 'storage.providers.fields.s3.region.description', + required: true, + }, + { + key: 'endpoint', + labelKey: 'storage.providers.fields.s3.endpoint.label', + placeholderKey: 'storage.providers.fields.s3.endpoint.placeholder', + descriptionKey: 'storage.providers.fields.s3.endpoint.description', + helperKey: 'storage.providers.fields.s3.endpoint.helper', + required: true, + }, + { + key: 'accessKeyId', + labelKey: 'storage.providers.fields.s3.access-key.label', + placeholderKey: 'storage.providers.fields.s3.access-key.placeholder', + required: true, + }, + { + key: 'secretAccessKey', + labelKey: 'storage.providers.fields.s3.secret-key.label', + placeholderKey: 'storage.providers.fields.s3.secret-key.placeholder', + sensitive: true, + required: true, + }, + { + key: 'prefix', + labelKey: 'storage.providers.fields.s3.prefix.label', + placeholderKey: 'storage.providers.fields.s3.prefix.placeholder', + descriptionKey: 'storage.providers.fields.s3.prefix.description', + }, + { + key: 'customDomain', + labelKey: 'storage.providers.fields.s3.custom-domain.label', + placeholderKey: 'storage.providers.fields.s3.custom-domain.placeholder', + descriptionKey: 'storage.providers.fields.s3.custom-domain.description', + }, + { + key: 'excludeRegex', + labelKey: 'storage.providers.fields.s3.exclude-regex.label', + placeholderKey: 'storage.providers.fields.s3.exclude-regex.placeholder', + descriptionKey: 'storage.providers.fields.s3.exclude-regex.description', + helperKey: 'storage.providers.fields.s3.exclude-regex.helper', + multiline: true, + }, + // { + // key: 'maxFileLimit', + // labelKey: 'storage.providers.fields.s3.max-files.label', + // placeholderKey: 'storage.providers.fields.s3.max-files.placeholder', + // descriptionKey: 'storage.providers.fields.s3.max-files.description', + // }, +] + const STORAGE_PROVIDER_FIELD_CONFIG: Record = { - s3: [ - { - key: 'bucket', - labelKey: 'storage.providers.fields.s3.bucket.label', - placeholderKey: 'storage.providers.fields.s3.bucket.placeholder', - descriptionKey: 'storage.providers.fields.s3.bucket.description', - required: true, - }, - { - key: 'region', - labelKey: 'storage.providers.fields.s3.region.label', - placeholderKey: 'storage.providers.fields.s3.region.placeholder', - descriptionKey: 'storage.providers.fields.s3.region.description', - required: true, - }, - { - key: 'endpoint', - labelKey: 'storage.providers.fields.s3.endpoint.label', - placeholderKey: 'storage.providers.fields.s3.endpoint.placeholder', - descriptionKey: 'storage.providers.fields.s3.endpoint.description', - helperKey: 'storage.providers.fields.s3.endpoint.helper', - required: true, - }, - { - key: 'accessKeyId', - labelKey: 'storage.providers.fields.s3.access-key.label', - placeholderKey: 'storage.providers.fields.s3.access-key.placeholder', - required: true, - }, - { - key: 'secretAccessKey', - labelKey: 'storage.providers.fields.s3.secret-key.label', - placeholderKey: 'storage.providers.fields.s3.secret-key.placeholder', - sensitive: true, - required: true, - }, - { - key: 'prefix', - labelKey: 'storage.providers.fields.s3.prefix.label', - placeholderKey: 'storage.providers.fields.s3.prefix.placeholder', - descriptionKey: 'storage.providers.fields.s3.prefix.description', - }, - { - key: 'customDomain', - labelKey: 'storage.providers.fields.s3.custom-domain.label', - placeholderKey: 'storage.providers.fields.s3.custom-domain.placeholder', - descriptionKey: 'storage.providers.fields.s3.custom-domain.description', - }, - { - key: 'excludeRegex', - labelKey: 'storage.providers.fields.s3.exclude-regex.label', - placeholderKey: 'storage.providers.fields.s3.exclude-regex.placeholder', - descriptionKey: 'storage.providers.fields.s3.exclude-regex.description', - helperKey: 'storage.providers.fields.s3.exclude-regex.helper', - multiline: true, - }, - // { - // key: 'maxFileLimit', - // labelKey: 'storage.providers.fields.s3.max-files.label', - // placeholderKey: 'storage.providers.fields.s3.max-files.placeholder', - // descriptionKey: 'storage.providers.fields.s3.max-files.description', - // }, - ], + s3: S3_FIELD_CONFIG, + oss: S3_FIELD_CONFIG, + cos: S3_FIELD_CONFIG, github: [ { key: 'owner', diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts index 244342c1..b58dfb8e 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts @@ -7,7 +7,7 @@ import { DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY, } from '@afilmory/builder/plugins/thumbnail-storage/shared.js' import { StorageManager } from '@afilmory/builder/storage/index.js' -import type { GitHubConfig, ManagedStorageConfig, S3Config } from '@afilmory/builder/storage/interfaces.js' +import type { GitHubConfig, ManagedStorageConfig, S3CompatibleConfig } from '@afilmory/builder/storage/interfaces.js' import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db' import { EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' @@ -1477,8 +1477,10 @@ export class PhotoAssetService { private resolveStorageDirectory(storageConfig: StorageConfig): string | null { switch (storageConfig.provider) { - case 's3': { - return this.normalizeDirectory((storageConfig as unknown as S3Config).prefix) + case 's3': + case 'oss': + case 'cos': { + return this.normalizeDirectory((storageConfig as S3CompatibleConfig).prefix) } case 'github': { return this.normalizeDirectory((storageConfig as GitHubConfig).path) @@ -1545,8 +1547,8 @@ export class PhotoAssetService { return null } - if (storageConfig.provider === 's3') { - const base = this.normalizeStorageSegment((storageConfig as S3Config).prefix) + if (storageConfig.provider === 's3' || storageConfig.provider === 'oss' || storageConfig.provider === 'cos') { + const base = this.normalizeStorageSegment((storageConfig as S3CompatibleConfig).prefix) return this.joinStorageSegments(base, directory) } diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.types.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.types.ts index d20c710b..76a7e61d 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.types.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.types.ts @@ -1,6 +1,5 @@ -import type { photoAssets } from '@afilmory/db' +import type { PhotoAssetManifest, photoAssets } from '@afilmory/db' -export type { PhotoAssetManifest } from '@afilmory/db' export type PhotoAssetRecord = typeof photoAssets.$inferSelect export interface PhotoAssetListItem { diff --git a/be/apps/core/src/modules/content/photo/builder/photo-builder-logger.adapter.ts b/be/apps/core/src/modules/content/photo/builder/photo-builder-logger.adapter.ts index 9900d30f..778b7fd8 100644 --- a/be/apps/core/src/modules/content/photo/builder/photo-builder-logger.adapter.ts +++ b/be/apps/core/src/modules/content/photo/builder/photo-builder-logger.adapter.ts @@ -57,6 +57,7 @@ export function createBuilderLoggerAdapter(baseLogger: PrettyLogger): BuilderLog return { main: createTaggedLogger('PhotoBuilder:Main') as unknown as ConsolaInstance, s3: createTaggedLogger('PhotoBuilder:S3') as unknown as ConsolaInstance, + b2: createTaggedLogger('PhotoBuilder:B2') as unknown as ConsolaInstance, image: createTaggedLogger('PhotoBuilder:Image') as unknown as ConsolaInstance, thumbnail: createTaggedLogger('PhotoBuilder:Thumbnail') as unknown as ConsolaInstance, blurhash: createTaggedLogger('PhotoBuilder:Blurhash') as unknown as ConsolaInstance, diff --git a/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts b/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts index 4e1099a6..8ac1a046 100644 --- a/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts +++ b/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts @@ -6,7 +6,7 @@ import type { LocalStorageProviderName, ManagedStorageConfig, RemoteStorageConfig, - S3Config, + S3CompatibleConfig, } from '@afilmory/builder/storage/interfaces.js' import { BizException, ErrorCode } from 'core/errors' import { normalizeStringToUndefined, requireStringWithMessage } from 'core/helpers/normalize.helper' @@ -110,10 +110,16 @@ export class PhotoStorageService { const config = provider.config ?? {} switch (provider.type) { - case 's3': { - const bucket = requireStringWithMessage(config.bucket, 'Active S3 storage provider is missing `bucket`.') - const result: S3Config = { - provider: 's3', + case 's3': + case 'oss': + case 'cos': { + const providerLabel = provider.type.toUpperCase() + const bucket = requireStringWithMessage( + config.bucket, + `Active ${providerLabel} storage provider is missing \`bucket\`.`, + ) + const result: S3CompatibleConfig = { + provider: provider.type as S3CompatibleConfig['provider'], bucket, } @@ -154,6 +160,8 @@ export class PhotoStorageService { if (typeof maxAttempts === 'number') result.maxAttempts = maxAttempts const downloadConcurrency = this.parseNumber(config.downloadConcurrency) if (typeof downloadConcurrency === 'number') result.downloadConcurrency = downloadConcurrency + const sigV4Service = normalizeStringToUndefined(config.sigV4Service) + if (sigV4Service) result.sigV4Service = sigV4Service return result } @@ -260,7 +268,7 @@ export class PhotoStorageService { return undefined } - private parseRetryMode(value?: string | null): S3Config['retryMode'] | undefined { + private parseRetryMode(value?: string | null): S3CompatibleConfig['retryMode'] | undefined { const normalized = normalizeStringToUndefined(value) if (!normalized) { return undefined diff --git a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.dto.ts b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.dto.ts index 9d44d7fd..c6b71b8f 100644 --- a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.dto.ts +++ b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.dto.ts @@ -3,13 +3,13 @@ import { z } from 'zod' import { ConflictResolutionStrategy } from './data-sync.types' -const s3ConfigSchema = z.object({ - provider: z.literal('s3'), +const s3CompatibleBaseSchema = z.object({ bucket: z.string().min(1), region: z.string().optional(), endpoint: z.string().optional(), accessKeyId: z.string().optional(), secretAccessKey: z.string().optional(), + sessionToken: z.string().optional(), prefix: z.string().optional(), customDomain: z.string().optional(), excludeRegex: z.string().optional(), @@ -24,6 +24,19 @@ const s3ConfigSchema = z.object({ retryMode: z.enum(['standard', 'adaptive', 'legacy']).optional(), maxAttempts: z.number().int().positive().optional(), downloadConcurrency: z.number().int().positive().optional(), + sigV4Service: z.string().optional(), +}) + +const s3ConfigSchema = s3CompatibleBaseSchema.extend({ + provider: z.literal('s3'), +}) + +const ossConfigSchema = s3CompatibleBaseSchema.extend({ + provider: z.literal('oss'), +}) + +const cosConfigSchema = s3CompatibleBaseSchema.extend({ + provider: z.literal('cos'), }) const githubConfigSchema = z.object({ @@ -36,7 +49,12 @@ const githubConfigSchema = z.object({ useRawUrl: z.boolean().optional(), }) -const storageConfigSchema = z.discriminatedUnion('provider', [s3ConfigSchema, githubConfigSchema]) +const storageConfigSchema = z.discriminatedUnion('provider', [ + s3ConfigSchema, + ossConfigSchema, + cosConfigSchema, + githubConfigSchema, +]) const builderProcessingSchema = z .object({ diff --git a/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts b/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts index 8a735f6b..41dce3ad 100644 --- a/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts +++ b/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts @@ -29,7 +29,8 @@ export class ManagedStorageProvider implements StorageProvider { const scopedConfig = this.applyTenantPrefix(config.upstream, this.effectivePrefix) this.upstreamConfig = scopedConfig - this.needsManualPrefix = scopedConfig.provider === 's3' + this.needsManualPrefix = + scopedConfig.provider === 's3' || scopedConfig.provider === 'oss' || scopedConfig.provider === 'cos' this.upstream = StorageFactory.createProvider(scopedConfig) } @@ -53,10 +54,9 @@ export class ManagedStorageProvider implements StorageProvider { return await this.upstream.generatePublicUrl(targetKey) } - async detectLivePhotos(allObjects?: StorageObject[]): Promise> { - const upstreamObjects = allObjects ? this.toUpstreamObjects(allObjects) : undefined - const sourceObjects = upstreamObjects ?? (await this.upstream.listAllFiles()) - const liveMap = await Promise.resolve(this.upstream.detectLivePhotos(sourceObjects)) + detectLivePhotos(allObjects: StorageObject[]): Map { + const upstreamObjects = this.toUpstreamObjects(allObjects) + const liveMap = this.upstream.detectLivePhotos(upstreamObjects) const result = new Map() for (const [key, value] of liveMap.entries()) { @@ -161,7 +161,7 @@ export class ManagedStorageProvider implements StorageProvider { } private joinSegments(...segments: Array): string { - const filtered = segments.filter(Boolean) + const filtered = segments.filter((segment): segment is string => typeof segment === 'string' && segment.length > 0) if (filtered.length === 0) { return '' } @@ -174,6 +174,8 @@ export class ManagedStorageProvider implements StorageProvider { private extractUpstreamBasePath(config: RemoteStorageConfig): string | null { switch (config.provider) { case 's3': + case 'oss': + case 'cos': case 'b2': { return this.normalizePath(config.prefix) } @@ -193,7 +195,9 @@ export class ManagedStorageProvider implements StorageProvider { } switch (config.provider) { - case 's3': { + case 's3': + case 'oss': + case 'cos': { return { ...config, prefix: normalizedPrefix } } case 'b2': { diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin-settings.controller.ts b/be/apps/core/src/modules/platform/super-admin/super-admin-settings.controller.ts index a4036925..160b0bf0 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin-settings.controller.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin-settings.controller.ts @@ -1,7 +1,9 @@ import { Body, Controller, Get, Patch } from '@afilmory/framework' import { Roles } from 'core/guards/roles.decorator' import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' +import { parseStorageProviders } from 'core/modules/configuration/setting/storage-provider.utils' import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import type { UpdateSystemSettingsInput } from 'core/modules/configuration/system-setting/system-setting.types' import { UpdateSuperAdminSettingsDto } from './super-admin.dto' @@ -19,7 +21,24 @@ export class SuperAdminSettingController { @Patch('/') @BypassResponseTransform() async update(@Body() dto: UpdateSuperAdminSettingsDto) { - await this.systemSettings.updateSettings(dto) + const { managedStorageProviders, ...rest } = dto + const payload: UpdateSystemSettingsInput = { ...rest } + + if (managedStorageProviders !== undefined) { + payload.managedStorageProviders = this.normalizeManagedProviders(managedStorageProviders) + } + + await this.systemSettings.updateSettings(payload) return await this.systemSettings.getOverview() } + + private normalizeManagedProviders( + providers: UpdateSuperAdminSettingsDto['managedStorageProviders'], + ): UpdateSystemSettingsInput['managedStorageProviders'] { + try { + return parseStorageProviders(JSON.stringify(providers ?? [])) + } catch { + return [] + } + } } diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx index e23d9901..050c31f5 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx @@ -20,6 +20,16 @@ const providerTypeConfig: Record< color: 'text-orange-500', bgColor: 'bg-orange-500/10', }, + oss: { + icon: 'cloud-drizzle', + color: 'text-emerald-500', + bgColor: 'bg-emerald-500/10', + }, + cos: { + icon: 'cloud-snow', + color: 'text-cyan-500', + bgColor: 'bg-cyan-500/10', + }, b2: { icon: 'cloud', color: 'text-sky-500', @@ -65,7 +75,9 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit const getPreviewInfo = () => { const cfg = provider.config switch (provider.type) { - case 's3': { + case 's3': + case 'oss': + case 'cos': { return cfg.region || cfg.bucket || t(storageProvidersI18nKeys.card.notConfigured) } case 'github': { diff --git a/be/apps/dashboard/src/modules/storage-providers/constants.ts b/be/apps/dashboard/src/modules/storage-providers/constants.ts index a2785a14..edeb7235 100644 --- a/be/apps/dashboard/src/modules/storage-providers/constants.ts +++ b/be/apps/dashboard/src/modules/storage-providers/constants.ts @@ -75,6 +75,8 @@ export const storageProvidersI18nKeys = { }, types: { s3: 'storage.providers.types.s3', + oss: 'storage.providers.types.oss', + cos: 'storage.providers.types.cos', github: 'storage.providers.types.github', b2: 'storage.providers.types.b2', local: 'storage.providers.types.local', @@ -148,6 +150,8 @@ export const storageProvidersI18nKeys = { } types: { s3: I18nKeys + oss: I18nKeys + cos: I18nKeys github: I18nKeys b2: I18nKeys local: I18nKeys diff --git a/locales/dashboard/en.json b/locales/dashboard/en.json index 1b00d223..ccd67b33 100644 --- a/locales/dashboard/en.json +++ b/locales/dashboard/en.json @@ -679,10 +679,12 @@ "storage.providers.status.saved": "✓ Storage configuration saved", "storage.providers.status.summary": "{{total}} storage provider(s) • {{active}} active", "storage.providers.types.b2": "Backblaze B2 cloud storage", + "storage.providers.types.cos": "Tencent Cloud COS", "storage.providers.types.eagle": "Eagle library", "storage.providers.types.github": "GitHub repository", "storage.providers.types.local": "Local storage", "storage.providers.types.minio": "MinIO", + "storage.providers.types.oss": "Aliyun OSS", "storage.providers.types.s3": "AWS S3 / compatible object storage", "superadmin.brand": "Afilmory · System Settings", "superadmin.builder-debug.actions.cancel": "Cancel Debug", @@ -855,4 +857,4 @@ "welcome.tenant-restricted.register": "Create a new space", "welcome.tenant-restricted.request": "Requested host:", "welcome.tenant-restricted.title": "Space Reserved" -} \ No newline at end of file +} diff --git a/locales/dashboard/zh-CN.json b/locales/dashboard/zh-CN.json index 176a91e2..bdae0f41 100644 --- a/locales/dashboard/zh-CN.json +++ b/locales/dashboard/zh-CN.json @@ -678,10 +678,12 @@ "storage.providers.status.saved": "✓ 存储配置已保存", "storage.providers.status.summary": "{{total}} 个存储提供方 • {{active}} 个启用", "storage.providers.types.b2": "Backblaze B2 云存储", + "storage.providers.types.cos": "腾讯云 COS", "storage.providers.types.eagle": "Eagle 素材库", "storage.providers.types.github": "GitHub 仓库", "storage.providers.types.local": "本地存储", "storage.providers.types.minio": "MinIO 存储", + "storage.providers.types.oss": "阿里云 OSS", "storage.providers.types.s3": "AWS S3 / 兼容对象存储", "superadmin.brand": "Afilmory · 系统管理", "superadmin.builder-debug.actions.cancel": "取消调试", @@ -847,4 +849,4 @@ "welcome.tenant-restricted.register": "创建新空间", "welcome.tenant-restricted.request": "请求的主机:", "welcome.tenant-restricted.title": "空间已被保留" -} \ No newline at end of file +} diff --git a/packages/builder/src/builder/builder.ts b/packages/builder/src/builder/builder.ts index a6b1d03f..6dad2182 100644 --- a/packages/builder/src/builder/builder.ts +++ b/packages/builder/src/builder/builder.ts @@ -522,7 +522,9 @@ export class AfilmoryBuilder { private logBuildStart(): void { const storage = this.getStorageConfig() switch (storage.provider) { - case 's3': { + case 's3': + case 'oss': + case 'cos': { const endpoint = storage.endpoint || '默认 AWS S3' const customDomain = storage.customDomain || '未设置' const { bucket } = storage diff --git a/packages/builder/src/cli.ts b/packages/builder/src/cli.ts index b4382b32..f7811854 100644 --- a/packages/builder/src/cli.ts +++ b/packages/builder/src/cli.ts @@ -76,7 +76,9 @@ async function main() { logger.main.info(` 存储提供商:${storage.provider}`) switch (storage.provider) { - case 's3': { + case 's3': + case 'oss': + case 'cos': { logger.main.info(` 存储桶:${storage.bucket}`) logger.main.info(` 区域:${storage.region || '未设置'}`) logger.main.info(` 端点:${storage.endpoint || '默认'}`) diff --git a/packages/builder/src/photo/execution-context.ts b/packages/builder/src/photo/execution-context.ts index c6daf776..46745326 100644 --- a/packages/builder/src/photo/execution-context.ts +++ b/packages/builder/src/photo/execution-context.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import type { AfilmoryBuilder } from '../builder/builder.js' import type { StorageManager } from '../storage/index.js' -import type { B2Config, GitHubConfig, S3Config, StorageConfig } from '../storage/interfaces.js' +import type { B2Config, GitHubConfig, S3CompatibleConfig, StorageConfig } from '../storage/interfaces.js' import type { PhotoProcessingLoggers } from './logger-adapter.js' export interface PhotoExecutionContext { @@ -44,8 +44,10 @@ export function createStorageKeyNormalizer(storageConfig: StorageConfig): (key: let basePrefix = '' switch (storageConfig.provider) { - case 's3': { - basePrefix = sanitizeStoragePath((storageConfig as S3Config).prefix) + case 's3': + case 'oss': + case 'cos': { + basePrefix = sanitizeStoragePath((storageConfig as S3CompatibleConfig).prefix) break } case 'b2': { diff --git a/packages/builder/src/plugins/storage/s3.ts b/packages/builder/src/plugins/storage/s3.ts index 20552405..60de5c80 100644 --- a/packages/builder/src/plugins/storage/s3.ts +++ b/packages/builder/src/plugins/storage/s3.ts @@ -1,4 +1,4 @@ -import type { S3Config } from '../../storage/interfaces.js' +import type { S3CompatibleConfig } from '../../storage/interfaces.js' import { S3StorageProvider } from '../../storage/providers/s3-provider.js' import type { BuilderPlugin } from '../types.js' @@ -16,7 +16,7 @@ export default function s3StoragePlugin(options: S3StoragePluginOptions = {}): B registerStorageProvider( providerName, (config) => { - return new S3StorageProvider(config as S3Config) + return new S3StorageProvider(config as S3CompatibleConfig) }, { category: 'remote' }, ) diff --git a/packages/builder/src/plugins/thumbnail-storage/index.ts b/packages/builder/src/plugins/thumbnail-storage/index.ts index 721b0a04..fd44dfa8 100644 --- a/packages/builder/src/plugins/thumbnail-storage/index.ts +++ b/packages/builder/src/plugins/thumbnail-storage/index.ts @@ -1,5 +1,5 @@ import { StorageManager } from '../../storage/index.js' -import type { S3Config, StorageConfig } from '../../storage/interfaces.js' +import type { S3CompatibleConfig, StorageConfig } from '../../storage/interfaces.js' import type { BuilderPlugin } from '../types.js' import type { ThumbnailPluginData } from './shared.js' import { @@ -55,8 +55,10 @@ function joinSegments(...segments: Array): string { function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string { switch (config.provider) { - case 's3': { - const base = trimSlashes((config as S3Config).prefix) + case 's3': + case 'oss': + case 'cos': { + const base = trimSlashes((config as S3CompatibleConfig).prefix) return joinSegments(base, directory) } case 'github': { diff --git a/packages/builder/src/s3/client.ts b/packages/builder/src/s3/client.ts index b06a8c9e..10631f3c 100644 --- a/packages/builder/src/s3/client.ts +++ b/packages/builder/src/s3/client.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto' -import type { S3Config } from '../storage/interfaces' +import type { S3CompatibleConfig } from '../storage/interfaces' export interface SimpleS3Client { fetch: (input: string | URL, init?: RequestInit) => Promise @@ -9,9 +9,13 @@ export interface SimpleS3Client { readonly region: string } -export function createS3Client(config: S3Config): SimpleS3Client { - if (config.provider !== 's3') { - throw new Error('Storage provider is not s3') +type S3CompatibleProviderName = S3CompatibleConfig['provider'] + +const S3_COMPATIBLE_PROVIDERS = new Set(['s3', 'oss', 'cos']) + +export function createS3Client(config: S3CompatibleConfig): SimpleS3Client { + if (!S3_COMPATIBLE_PROVIDERS.has(config.provider)) { + throw new Error('Storage provider is not S3-compatible') } const { accessKeyId, secretAccessKey, endpoint, bucket } = config @@ -25,14 +29,15 @@ export function createS3Client(config: S3Config): SimpleS3Client { throw new Error('accessKeyId and secretAccessKey are required') } - const baseUrl = buildBaseUrl({ bucket, region, endpoint }) + const baseUrl = buildBaseUrl({ bucket, region, endpoint, provider: config.provider }) + const sigV4Service = config.sigV4Service ?? inferSigV4Service(config.provider) const signer = new SigV4Signer({ accessKeyId, secretAccessKey, sessionToken: config.sessionToken, region, - service: 's3', + service: sigV4Service, }) return { @@ -56,10 +61,25 @@ export function createS3Client(config: S3Config): SimpleS3Client { } } -function buildBaseUrl(params: { bucket: string; region: string; endpoint?: string }): string { - const { bucket, region, endpoint } = params +function buildBaseUrl(params: { + bucket: string + region: string + endpoint?: string + provider: S3CompatibleProviderName +}): string { + const { bucket, region, endpoint, provider } = params if (!endpoint) { - return `https://${bucket}.s3.${region}.amazonaws.com/` + switch (provider) { + case 'oss': { + return `https://${bucket}.${region}.aliyuncs.com/` + } + case 'cos': { + return `https://${bucket}.cos.${region}.myqcloud.com/` + } + default: { + return `https://${bucket}.s3.${region}.amazonaws.com/` + } + } } const trimmed = endpoint.replace(/\/$/, '') @@ -71,6 +91,14 @@ function buildBaseUrl(params: { bucket: string; region: string; endpoint?: strin return trimmed.endsWith('/') ? trimmed : `${trimmed}/` } + if (provider === 'oss' || /aliyuncs\.com/.test(trimmed)) { + return ensureTrailingSlash(injectBucketAsSubdomain(trimmed, bucket)) + } + + if (provider === 'cos' || /myqcloud\.com/.test(trimmed)) { + return ensureTrailingSlash(injectBucketAsSubdomain(trimmed, bucket)) + } + return `${trimmed}/${bucket}/` } @@ -115,6 +143,37 @@ function encodeRfc3986(value: string): string { const EMPTY_HASH = crypto.createHash('sha256').update('').digest('hex') +function inferSigV4Service(provider: S3CompatibleProviderName): string { + switch (provider) { + case 'oss': { + return 'oss' + } + default: { + return 's3' + } + } +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : `${value}/` +} + +function injectBucketAsSubdomain(endpoint: string, bucket: string): string { + if (!/^https?:\/\//i.test(endpoint)) { + return `${endpoint.replace(/\/$/, '')}/${bucket}` + } + + try { + const url = new URL(endpoint) + if (!url.hostname.startsWith(`${bucket}.`)) { + url.hostname = `${bucket}.${url.hostname}` + } + return url.toString() + } catch { + return `${endpoint.replace(/\/$/, '')}/${bucket}` + } +} + class SigV4Signer { constructor( private readonly options: { diff --git a/packages/builder/src/storage/interfaces.ts b/packages/builder/src/storage/interfaces.ts index 9952f4cc..052cd165 100644 --- a/packages/builder/src/storage/interfaces.ts +++ b/packages/builder/src/storage/interfaces.ts @@ -79,8 +79,7 @@ export interface StorageProvider { moveFile: (sourceKey: string, targetKey: string, options?: StorageUploadOptions) => Promise } -export type S3Config = { - provider: 's3' +type BaseS3LikeConfig = { bucket?: string region?: string endpoint?: string @@ -103,8 +102,29 @@ export type S3Config = { maxAttempts?: number // Download concurrency limiter within a single process/worker downloadConcurrency?: number + /** + * Optional override for the SigV4 service name. Defaults to: + * - `s3` for AWS or generic S3-compatible endpoints + * - `oss` for Aliyun OSS + * - `s3` for Tencent COS + */ + sigV4Service?: string } +export type S3Config = BaseS3LikeConfig & { + provider: 's3' +} + +export type OSSConfig = BaseS3LikeConfig & { + provider: 'oss' +} + +export type COSConfig = BaseS3LikeConfig & { + provider: 'cos' +} + +export type S3CompatibleConfig = S3Config | OSSConfig | COSConfig + export type B2Config = { provider: 'b2' applicationKeyId: string @@ -215,13 +235,13 @@ export interface CustomStorageConfig { [key: string]: unknown } -export type RemoteStorageProviderName = 's3' | 'b2' | 'github' +export type RemoteStorageProviderName = 's3' | 'oss' | 'cos' | 'b2' | 'github' export type LocalStorageProviderName = 'eagle' | 'local' -export const REMOTE_STORAGE_PROVIDERS: readonly RemoteStorageProviderName[] = ['s3', 'b2', 'github'] +export const REMOTE_STORAGE_PROVIDERS: readonly RemoteStorageProviderName[] = ['s3', 'oss', 'cos', 'b2', 'github'] export const LOCAL_STORAGE_PROVIDERS: readonly LocalStorageProviderName[] = ['eagle', 'local'] -export type RemoteStorageConfig = S3Config | B2Config | GitHubConfig +export type RemoteStorageConfig = S3CompatibleConfig | B2Config | GitHubConfig export type LocalStorageConfig = EagleConfig | LocalConfig export type ManagedStorageConfig = { diff --git a/packages/builder/src/storage/providers/README.md b/packages/builder/src/storage/providers/README.md index a0e8b3e3..0ac77457 100644 --- a/packages/builder/src/storage/providers/README.md +++ b/packages/builder/src/storage/providers/README.md @@ -4,7 +4,9 @@ ## S3 存储提供商 -支持 AWS S3 和兼容 S3 API 的存储服务(如 MinIO、阿里云 OSS 等)。 +支持 AWS S3 和大多数 S3 API 兼容的对象存储(如 MinIO、Cloudflare R2、DigitalOcean Spaces 等)。 + +> 如果你使用 **阿里云 OSS** 或 **腾讯云 COS**,现在可以直接使用 `provider: 'oss'` 或 `provider: 'cos'`,它们基于同一套实现,但提供了默认的 Endpoint 和 SigV4 Service 配置,避免重复参数。详见下文。 ### 配置示例 @@ -21,6 +23,54 @@ const s3Config: StorageConfig = { } ``` +## 阿里云 OSS 提供商 + +Aliyun OSS 已提供 SigV4 兼容 API。通过 `provider: 'oss'`,Afilmory 会默认使用 `oss` 作为 SigV4 service 并生成标准的 `bucket.oss-xx.aliyuncs.com` Endpoint。 + +### 配置示例 + +```typescript +const ossConfig: StorageConfig = { + provider: 'oss', + bucket: 'gallery-assets', + region: 'oss-cn-hangzhou', + accessKeyId: process.env.ALIYUN_AK!, + secretAccessKey: process.env.ALIYUN_SK!, + endpoint: 'https://oss-cn-hangzhou.aliyuncs.com', // 可省略,默认会根据 region 生成 + customDomain: 'https://img.example.cn', +} +``` + +### 提示 + +- region 需包含 `oss-` 前缀,例如 `oss-cn-shanghai`。 +- 若使用内网或加速域名,可通过 `endpoint` 覆盖,或设置 `customDomain` 生成公开 URL。 +- 仍可通过 `sigV4Service` 手动覆盖签名服务名(默认 `oss`)。 + +## 腾讯云 COS 提供商 + +COS 与 AWS S3 API 高度兼容。`provider: 'cos'` 会默认生成 `bucket.cos..myqcloud.com` 的 Endpoint 并使用 `s3` SigV4 service。 + +### 配置示例 + +```typescript +const cosConfig: StorageConfig = { + provider: 'cos', + bucket: 'gallery-1250000000', // 记得包含 APPID + region: 'ap-shanghai', + accessKeyId: process.env.COS_SECRET_ID!, + secretAccessKey: process.env.COS_SECRET_KEY!, + endpoint: 'https://cos.ap-shanghai.myqcloud.com', // 可选 + prefix: 'photos/', +} +``` + +### 提示 + +- bucket 需包含 COS 要求的 `-APPID` 后缀。 +- 如果使用自定义加速域名,仅需设置 `customDomain`。 +- 默认 SigV4 service 为 `s3`,如需兼容自建网关可自定义 `sigV4Service`。 + ## GitHub 存储提供商 将照片存储在 GitHub 仓库中,利用 GitHub 的免费存储空间和全球 CDN。 diff --git a/packages/builder/src/storage/providers/register.ts b/packages/builder/src/storage/providers/register.ts index a1ede965..28fa3102 100644 --- a/packages/builder/src/storage/providers/register.ts +++ b/packages/builder/src/storage/providers/register.ts @@ -2,9 +2,11 @@ import type { StorageProviderFactory } from '../factory.js' import { StorageFactory } from '../factory.js' import type { B2Config, + COSConfig, EagleConfig, GitHubConfig, LocalConfig, + OSSConfig, S3Config, StorageProviderCategory, } from '../interfaces.js' @@ -26,6 +28,16 @@ const BUILTIN_PROVIDER_REGISTRATIONS: BuiltinProviderRegistration[] = [ category: 'remote', factory: (config) => new S3StorageProvider(config as S3Config), }, + { + name: 'oss', + category: 'remote', + factory: (config) => new S3StorageProvider(config as OSSConfig), + }, + { + name: 'cos', + category: 'remote', + factory: (config) => new S3StorageProvider(config as COSConfig), + }, { name: 'b2', category: 'remote', diff --git a/packages/builder/src/storage/providers/s3-client.ts b/packages/builder/src/storage/providers/s3-client.ts index b52b0301..fc96e58b 100644 --- a/packages/builder/src/storage/providers/s3-client.ts +++ b/packages/builder/src/storage/providers/s3-client.ts @@ -1,12 +1,12 @@ import type { SimpleS3Client } from '../../s3/client.js' import { createS3Client, encodeS3Key } from '../../s3/client.js' -import type { S3Config } from '../interfaces.js' +import type { S3CompatibleConfig } from '../interfaces.js' import { sanitizeS3Etag } from './s3-utils.js' export class S3ProviderClient { private readonly client: SimpleS3Client - constructor(config: S3Config) { + constructor(config: S3CompatibleConfig) { this.client = createS3Client(config) } diff --git a/packages/builder/src/storage/providers/s3-provider.ts b/packages/builder/src/storage/providers/s3-provider.ts index 2db580b1..eafc7a9c 100644 --- a/packages/builder/src/storage/providers/s3-provider.ts +++ b/packages/builder/src/storage/providers/s3-provider.ts @@ -6,7 +6,13 @@ import { backoffDelay, sleep } from '../../../../utils/src/backoff.js' import { Semaphore } from '../../../../utils/src/semaphore.js' import { SUPPORTED_FORMATS } from '../../constants/index.js' import { logger } from '../../logger/index.js' -import type { ProgressCallback, S3Config, StorageObject, StorageProvider, StorageUploadOptions } from '../interfaces' +import type { + ProgressCallback, + S3CompatibleConfig, + StorageObject, + StorageProvider, + StorageUploadOptions, +} from '../interfaces' import { S3ProviderClient } from './s3-client.js' import { sanitizeS3Etag } from './s3-utils.js' @@ -109,11 +115,11 @@ function formatS3ErrorBody(body?: string | null): string { } export class S3StorageProvider implements StorageProvider { - private config: S3Config + private config: S3CompatibleConfig private client: S3ProviderClient private limiter: Semaphore - constructor(config: S3Config) { + constructor(config: S3CompatibleConfig) { this.config = config this.client = new S3ProviderClient(config) this.limiter = new Semaphore(this.config.downloadConcurrency ?? 16) @@ -245,36 +251,7 @@ export class S3StorageProvider implements StorageProvider { const customDomain = this.config.customDomain.replace(/\/$/, '') // 移除末尾的斜杠 return `${customDomain}/${key}` } - - // 如果使用自定义端点,构建相应的 URL - const { endpoint } = this.config - - const region = this.config.region ?? 'us-east-1' - - if (!endpoint) { - // 默认 AWS S3 端点 - return `https://${this.config.bucket}.s3.${region}.amazonaws.com/${key}` - } - - // 检查是否是标准 AWS S3 端点 - if (endpoint.includes('amazonaws.com')) { - return `https://${this.config.bucket}.s3.${region}.amazonaws.com/${key}` - } - - const baseUrl = endpoint.replace(/\/$/, '') // 移除末尾的斜杠 - - if (endpoint.includes('aliyuncs.com')) { - const protocolEndIndex = baseUrl.indexOf('//') - if (protocolEndIndex === -1) { - throw new Error('Invalid base URL format') - } - // 将 bucket 插入到 'https://` 之后,region 之前 - const prefix = baseUrl.slice(0, protocolEndIndex + 2) // 包括 'https://' - const suffix = baseUrl.slice(protocolEndIndex + 2) // 剩余部分 - return `${prefix}${this.config.bucket}.${suffix}/${key}` - } - // 对于自定义端点(如 MinIO 等) - return `${baseUrl}/${this.config.bucket}/${key}` + return this.client.buildObjectUrl(key) } detectLivePhotos(allObjects: StorageObject[]): Map {