From 3e96106a1658d7b243a0fd7fd9a1a77f3ed18ee0 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 26 Nov 2025 15:23:41 +0800 Subject: [PATCH] chore: sign s3 (#170) Signed-off-by: Innei --- .../components/ui/photo-viewer/ExifPanel.tsx | 5 +- .../ui/photo-viewer/ProgressiveImage.tsx | 11 +- .../src/modules/gallery/MasonryPhotoItem.tsx | 14 +- apps/web/src/modules/gallery/MasonryRoot.tsx | 2 +- be/apps/core/package.json | 5 +- be/apps/core/src/app.constants.ts | 4 + be/apps/core/src/app.factory.ts | 8 +- be/apps/core/src/cli/index.ts | 10 + be/apps/core/src/cli/manifest-migrate.ts | 181 ++ be/apps/core/src/cli/reset-superadmin.ts | 3 +- .../core/src/context/http-context.helper.ts | 53 + .../core/src/database/database.provider.ts | 5 +- be/apps/core/src/helpers/normalize.helper.ts | 47 + be/apps/core/src/index.ts | 3 +- be/apps/core/src/locales/ui-schema/en.ts | 2 + .../configuration/setting/setting.constant.ts | 8 + .../site-setting/site-setting.service.ts | 19 +- .../storage-setting.controller.ts | 8 +- .../storage-setting.service.ts | 2 +- .../system-setting.constants.ts | 6 + .../system-setting/system-setting.service.ts | 18 + .../system-setting/system-setting.types.ts | 1 + .../manifest/manifest-migration.helper.ts | 51 + .../content/manifest/manifest.module.ts | 2 + .../content/manifest/manifest.service.ts | 106 +- .../photo/access/storage-access.controller.ts | 44 + .../photo/access/storage-access.dto.ts | 49 + .../photo/access/storage-access.service.ts | 234 +++ .../photo/access/storage-access.utils.ts | 154 ++ .../photo/assets/photo-asset.service.ts | 192 +- .../photo/assets/photo-upload.parser.ts | 35 +- .../content/photo/assets/photo.controller.ts | 23 +- .../src/modules/content/photo/photo.module.ts | 5 +- .../photo/storage/photo-storage.service.ts | 79 +- .../storage/storage-config-parser.utils.ts | 19 + .../infrastructure/data-sync/data-sync.dto.ts | 2 +- .../data-sync/data-sync.service.ts | 10 +- .../static-web/static-asset.service.ts | 4 +- .../static-web/static-web-dev.template.html | 263 +++ .../static-web/static-web.controller.ts | 35 +- .../managed-storage.provider.ts | 52 +- .../super-admin-builder.controller.ts | 44 +- .../platform/super-admin/super-admin.dto.ts | 1 + .../components/ProviderCard.tsx | 8 +- .../components/StorageProvidersManager.tsx | 63 +- .../modules/storage-providers/constants.ts | 14 + .../src/modules/storage-providers/hooks.ts | 50 +- .../src/modules/storage-providers/types.ts | 1 + .../components/ManagedStorageSettings.tsx | 46 +- .../src/modules/super-admin/types.ts | 2 + .../db/migrations/0009_stormy_sway.sql | 41 + .../db/migrations/meta/0008_snapshot.json | 4 +- .../db/migrations/meta/0009_snapshot.json | 1816 +++++++++++++++++ be/packages/db/migrations/meta/_journal.json | 7 + be/packages/db/scripts/fix-migration-8.sql | 46 + .../db/scripts/fix-migration-conflict.sql | 67 + be/packages/db/src/schema.ts | 74 +- locales/dashboard/en.json | 15 +- locales/dashboard/zh-CN.json | 9 +- packages/builder/src/index.ts | 5 + packages/builder/src/manifest/migrate.ts | 79 +- .../builder/src/manifest/migrations/index.ts | 32 + .../src/manifest/migrations/v1-to-v6.ts | 17 + .../src/manifest/migrations/v6-to-v7.ts | 17 + .../src/manifest/migrations/v7-to-v8.ts | 49 + .../src/manifest/migrations/v8-to-v9.ts | 67 + packages/builder/src/manifest/version.ts | 2 +- packages/builder/src/photo/image-pipeline.ts | 2 + packages/builder/src/s3/client.ts | 7 +- packages/builder/src/types/photo.ts | 1 + packages/ui/src/switch/index.tsx | 2 +- pnpm-lock.yaml | 170 +- 72 files changed, 4089 insertions(+), 443 deletions(-) create mode 100644 be/apps/core/src/app.constants.ts create mode 100644 be/apps/core/src/cli/manifest-migrate.ts create mode 100644 be/apps/core/src/context/http-context.helper.ts create mode 100644 be/apps/core/src/modules/content/manifest/manifest-migration.helper.ts create mode 100644 be/apps/core/src/modules/content/photo/access/storage-access.controller.ts create mode 100644 be/apps/core/src/modules/content/photo/access/storage-access.dto.ts create mode 100644 be/apps/core/src/modules/content/photo/access/storage-access.service.ts create mode 100644 be/apps/core/src/modules/content/photo/access/storage-access.utils.ts create mode 100644 be/apps/core/src/modules/content/photo/storage/storage-config-parser.utils.ts create mode 100644 be/apps/core/src/modules/infrastructure/static-web/static-web-dev.template.html create mode 100644 be/packages/db/migrations/0009_stormy_sway.sql create mode 100644 be/packages/db/migrations/meta/0009_snapshot.json create mode 100644 be/packages/db/scripts/fix-migration-8.sql create mode 100644 be/packages/db/scripts/fix-migration-conflict.sql create mode 100644 packages/builder/src/manifest/migrations/index.ts create mode 100644 packages/builder/src/manifest/migrations/v1-to-v6.ts create mode 100644 packages/builder/src/manifest/migrations/v6-to-v7.ts create mode 100644 packages/builder/src/manifest/migrations/v7-to-v8.ts create mode 100644 packages/builder/src/manifest/migrations/v8-to-v9.ts diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx index bbe51b9f..6ec18a7c 100644 --- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx @@ -19,7 +19,6 @@ import { StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens, TablerAperture, } from '~/icons' -import { getImageFormat } from '~/lib/image-utils' import { convertExifGPSToDecimal } from '~/lib/map-utils' import { formatExifData, Row } from './formatExifData' @@ -45,8 +44,6 @@ export const ExifPanel: FC<{ const decimalLatitude = gpsData?.latitude || null const decimalLongitude = gpsData?.longitude || null - // 使用通用的图片格式提取函数 - const imageFormat = getImageFormat(currentPhoto.originalUrl || currentPhoto.s3Key || '') const megaPixels = (((currentPhoto.height * currentPhoto.width) / 1000000) | 0).toString() return ( @@ -109,7 +106,7 @@ export const ExifPanel: FC<{

{t('exif.basic.info')}

- + {megaPixels && } diff --git a/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx b/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx index 22c303d5..655c237c 100644 --- a/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx +++ b/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx @@ -1,7 +1,7 @@ import { clsxm } from '@afilmory/utils' import { WebGLImageViewer } from '@afilmory/webgl-viewer' import { AnimatePresence, m } from 'motion/react' -import { useCallback, useRef } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch' import { useMediaQuery } from 'usehooks-ts' @@ -70,9 +70,16 @@ export const ProgressiveImage = ({ const domImageViewerRef = useRef(null) const livePhotoRef = useRef(null) + const resolvedSrc = useMemo(() => { + if (src.startsWith('/')) { + return new URL(src, window.location.origin).toString() + } + return src + }, [src]) + // Hooks const imageLoaderManagerRef = useImageLoader( - src, + resolvedSrc, isCurrentImage, highResLoaded, error, diff --git a/apps/web/src/modules/gallery/MasonryPhotoItem.tsx b/apps/web/src/modules/gallery/MasonryPhotoItem.tsx index fdae825e..ef8c2c61 100644 --- a/apps/web/src/modules/gallery/MasonryPhotoItem.tsx +++ b/apps/web/src/modules/gallery/MasonryPhotoItem.tsx @@ -1,7 +1,7 @@ import { Thumbhash } from '@afilmory/ui' import clsx from 'clsx' import { m } from 'motion/react' -import { Fragment, useCallback, useEffect, useRef, useState } from 'react' +import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' @@ -13,10 +13,9 @@ import { } from '~/icons' import { isMobileDevice } from '~/lib/device-viewport' import { ImageLoaderManager } from '~/lib/image-loader-manager' -import { getImageFormat } from '~/lib/image-utils' import type { PhotoManifest } from '~/types/photo' -export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifest; width: number; index: number }) => { +export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; width: number }) => { const photos = useContextPhotos() const photoViewer = usePhotoViewer() const { t } = useTranslation() @@ -96,9 +95,6 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes const exifData = formatExifData() - // 使用通用的图片格式提取函数 - const imageFormat = getImageFormat(data.originalUrl || data.s3Key || '') - // 检查是否有视频内容(Live Photo 或 Motion Photo) const hasVideo = data.video !== undefined @@ -302,7 +298,7 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes {/* 内容层 - 独立的层以支持 backdrop-filter */}
{/* 基本信息和标签 section */} -
+

{data.title}

{data.description && (

@@ -312,7 +308,7 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes {/* 基本信息 */}

- {imageFormat} + {data.format} {data.width} × {data.height} @@ -373,4 +369,4 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes )} ) -} +}) diff --git a/apps/web/src/modules/gallery/MasonryRoot.tsx b/apps/web/src/modules/gallery/MasonryRoot.tsx index e96df53f..43b7bb54 100644 --- a/apps/web/src/modules/gallery/MasonryRoot.tsx +++ b/apps/web/src/modules/gallery/MasonryRoot.tsx @@ -250,7 +250,7 @@ export const MasonryItem = memo( animate="visible" onAnimationComplete={shouldAnimate ? onAnimationComplete : undefined} > - + ) } diff --git a/be/apps/core/package.json b/be/apps/core/package.json index 8bbf7478..ba071764 100644 --- a/be/apps/core/package.json +++ b/be/apps/core/package.json @@ -12,7 +12,8 @@ "db:migrate": "pnpm -C ../../packages/db db:migrate", "db:studio": "pnpm -C ../../packages/db db:studio", "dev": "nodemon", - "dev:reset-superadmin-password": "vite-node src/index.ts --reset-superadmin-password" + "dev:reset-superadmin-password": "vite-node src/index.ts --reset-superadmin-password", + "manifest:migrate": "vite-node src/index.ts manifest:migrate" }, "dependencies": { "@afilmory/be-utils": "workspace:*", @@ -24,7 +25,9 @@ "@afilmory/sdk": "workspace:*", "@afilmory/task-queue": "workspace:*", "@afilmory/utils": "workspace:*", + "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/client-s3": "3.929.0", + "@aws-sdk/s3-request-presigner": "3.939.0", "@creem_io/better-auth": "0.0.8", "@hono/node-server": "^1.19.6", "@resvg/resvg-js": "2.6.2", diff --git a/be/apps/core/src/app.constants.ts b/be/apps/core/src/app.constants.ts new file mode 100644 index 00000000..39462e68 --- /dev/null +++ b/be/apps/core/src/app.constants.ts @@ -0,0 +1,4 @@ +/** + * Application constants + */ +export const APP_GLOBAL_PREFIX = '/api' as const diff --git a/be/apps/core/src/app.factory.ts b/be/apps/core/src/app.factory.ts index 77d4ae17..c6517b0b 100644 --- a/be/apps/core/src/app.factory.ts +++ b/be/apps/core/src/app.factory.ts @@ -15,7 +15,7 @@ import { registerOpenApiRoutes } from './openapi' import { RedisProvider } from './redis/redis.provider' export interface BootstrapOptions { - globalPrefix?: string + globalPrefix: string } const isDevelopment = env.NODE_ENV !== 'production' @@ -31,13 +31,13 @@ const GlobalValidationPipe = createZodValidationPipe({ const honoErrorLogger = createLogger('HonoErrorHandler') -export async function createConfiguredApp(options: BootstrapOptions = {}): Promise { +export async function createConfiguredApp(options: BootstrapOptions): Promise { const hono = new Hono() - registerOpenApiRoutes(hono, { globalPrefix: options.globalPrefix ?? '/api' }) + registerOpenApiRoutes(hono, { globalPrefix: options.globalPrefix }) const app = await createApplication( AppModules, { - globalPrefix: options.globalPrefix ?? '/api', + globalPrefix: options.globalPrefix, }, hono, ) diff --git a/be/apps/core/src/cli/index.ts b/be/apps/core/src/cli/index.ts index e51bd098..2f2c96cf 100644 --- a/be/apps/core/src/cli/index.ts +++ b/be/apps/core/src/cli/index.ts @@ -1,3 +1,5 @@ +import type { ManifestMigrationCliOptions } from './manifest-migrate' +import { handleManifestMigrationCli, parseManifestMigrationCliArgs } from './manifest-migrate' import type { MigrationCliOptions } from './migrate' import { handleMigrationCli, parseMigrationCliArgs } from './migrate' import type { ResetCliOptions } from './reset-superadmin' @@ -19,6 +21,14 @@ const cliCommands: Array> = [ console.error('Database migration failed', error) }, }, + { + name: 'manifest:migrate', + parse: parseManifestMigrationCliArgs, + execute: (options) => handleManifestMigrationCli(options as ManifestMigrationCliOptions), + onError: (error) => { + console.error('Manifest migration failed', error) + }, + }, { name: 'reset-superadmin-password', parse: parseResetCliArgs, diff --git a/be/apps/core/src/cli/manifest-migrate.ts b/be/apps/core/src/cli/manifest-migrate.ts new file mode 100644 index 00000000..a6d26ebe --- /dev/null +++ b/be/apps/core/src/cli/manifest-migrate.ts @@ -0,0 +1,181 @@ +import { photoAssets } from '@afilmory/db' +import { createLogger } from '@afilmory/framework' +import { eq, gt, sql } from 'drizzle-orm' + +import { APP_GLOBAL_PREFIX } from '../app.constants' +import { createConfiguredApp } from '../app.factory' +import { DbAccessor, PgPoolProvider } from '../database/database.provider' +import { ensureCurrentPhotoAssetManifest } from '../modules/content/manifest/manifest-migration.helper' +import { RedisProvider } from '../redis/redis.provider' + +const logger = createLogger('CLI:ManifestMigrate') +const COMMAND_NAME = 'manifest:migrate' +const DRY_RUN_FLAG = '--dry-run' +const BATCH_FLAG = '--batch' +const DEFAULT_BATCH_SIZE = 200 + +export interface ManifestMigrationCliOptions { + readonly command: typeof COMMAND_NAME + readonly dryRun: boolean + readonly batchSize: number +} + +export function parseManifestMigrationCliArgs(argv: readonly string[]): ManifestMigrationCliOptions | null { + if (argv.length === 0 || argv[0] !== COMMAND_NAME) { + return null + } + + let dryRun = false + let batchSize = DEFAULT_BATCH_SIZE + + for (let index = 1; index < argv.length; index++) { + const token = argv[index] + if (!token) continue + + if (token === DRY_RUN_FLAG) { + dryRun = true + continue + } + + if (token.startsWith(`${DRY_RUN_FLAG}=`)) { + const inline = token.slice(DRY_RUN_FLAG.length + 1) + dryRun = inline !== 'false' + continue + } + + if (token === BATCH_FLAG) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('Missing value for --batch') + } + batchSize = parseBatchSize(value) + index++ + continue + } + + if (token.startsWith(`${BATCH_FLAG}=`)) { + const inline = token.slice(BATCH_FLAG.length + 1) + batchSize = parseBatchSize(inline) + continue + } + } + + return { + command: COMMAND_NAME, + dryRun, + batchSize, + } +} + +function parseBatchSize(value: string): number { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error(`Invalid --batch value: "${value}"`) + } + return parsed +} + +export async function handleManifestMigrationCli(options: ManifestMigrationCliOptions): Promise { + const app = await createConfiguredApp({ + globalPrefix: APP_GLOBAL_PREFIX, + }) + + const container = app.getContainer() + const dbAccessor = container.resolve(DbAccessor) + const poolProvider = container.resolve(PgPoolProvider) + const redisProvider = container.resolve(RedisProvider) + + try { + const db = dbAccessor.get() + const total = await countPhotoAssets(db) + + logger.info( + `Scanning ${total} photo assets in batches of ${options.batchSize}${options.dryRun ? ' (dry run)' : ''}`, + ) + const summary = await migratePhotoAssets(db, options) + logger.info( + `${options.dryRun ? 'Dry run' : 'Migration'} completed. Processed ${summary.processed} assets, ${ + options.dryRun ? 'would update' : 'updated' + } ${summary.updated} manifest records.`, + ) + } finally { + await app.close(COMMAND_NAME) + + try { + const pool = poolProvider.getPool() + await pool.end() + } catch (error) { + logger.warn(`Failed to close PostgreSQL pool cleanly: ${String(error)}`) + } + + try { + const redis = redisProvider.getClient() + redis.disconnect() + } catch (error) { + logger.warn(`Failed to disconnect Redis client cleanly: ${String(error)}`) + } + } +} + +type DbClient = ReturnType + +async function countPhotoAssets(db: DbClient): Promise { + const [row] = await db.select({ total: sql`count(*)` }).from(photoAssets) + return row?.total ?? 0 +} + +async function migratePhotoAssets( + db: DbClient, + options: ManifestMigrationCliOptions, +): Promise<{ processed: number; updated: number }> { + let processed = 0 + let updated = 0 + let lastId: string | null = null + + while (true) { + let query: any = db + .select({ + id: photoAssets.id, + manifest: photoAssets.manifest, + manifestVersion: photoAssets.manifestVersion, + }) + .from(photoAssets) + .orderBy(photoAssets.id) + .limit(options.batchSize) + + if (lastId) { + query = query.where(gt(photoAssets.id, lastId)) + } + + const rows = await query + if (rows.length === 0) { + break + } + + lastId = rows.at(-1)?.id ?? null + + for (const row of rows) { + processed++ + const { manifest: resolved, changed } = ensureCurrentPhotoAssetManifest(row.manifest) + if (!resolved || !changed) { + continue + } + + updated++ + if (options.dryRun) { + logger.info(`Would update photo asset ${row.id} -> version ${resolved.version}`) + continue + } + + await db + .update(photoAssets) + .set({ + manifest: resolved, + manifestVersion: resolved.version, + }) + .where(eq(photoAssets.id, row.id)) + } + } + + return { processed, updated } +} diff --git a/be/apps/core/src/cli/reset-superadmin.ts b/be/apps/core/src/cli/reset-superadmin.ts index a34b6e53..07ca8dda 100644 --- a/be/apps/core/src/cli/reset-superadmin.ts +++ b/be/apps/core/src/cli/reset-superadmin.ts @@ -4,6 +4,7 @@ import { authAccounts, authSessions, authUsers, generateId } from '@afilmory/db' import { env } from '@afilmory/env' import { eq } from 'drizzle-orm' +import { APP_GLOBAL_PREFIX } from '../app.constants' import { createConfiguredApp } from '../app.factory' import { DbAccessor, PgPoolProvider } from '../database/database.provider' import { logger } from '../helpers/logger.helper' @@ -93,7 +94,7 @@ function generateRandomPassword(): string { export async function handleResetSuperAdminPassword(options: ResetCliOptions): Promise { const app = await createConfiguredApp({ - globalPrefix: '/api', + globalPrefix: APP_GLOBAL_PREFIX, }) const container = app.getContainer() diff --git a/be/apps/core/src/context/http-context.helper.ts b/be/apps/core/src/context/http-context.helper.ts new file mode 100644 index 00000000..62de8f30 --- /dev/null +++ b/be/apps/core/src/context/http-context.helper.ts @@ -0,0 +1,53 @@ +import { HttpContext } from '@afilmory/framework' +import type { Context } from 'hono' + +/** + * Extract client IP address from Hono context + * Checks headers in order: cf-connecting-ip (Cloudflare), x-forwarded-for, x-real-ip, then socket remote address + */ +export function extractClientIp(context: Context): string | null { + // Cloudflare's real client IP (highest priority) + const cfConnectingIp = context.req.header('cf-connecting-ip')?.trim() + if (cfConnectingIp) { + return cfConnectingIp + } + + // Proxy chain (x-forwarded-for may contain multiple IPs, first one is the original client) + const forwardedFor = context.req.header('x-forwarded-for') + if (forwardedFor) { + const ip = forwardedFor.split(',')[0]?.trim() + if (ip) { + return ip + } + } + + // Nginx and other reverse proxies + const realIp = context.req.header('x-real-ip')?.trim() + if (realIp) { + return realIp + } + + // Fallback to socket remote address + const { raw } = context.req + if (raw && 'socket' in raw && raw.socket && typeof raw.socket === 'object' && 'remoteAddress' in raw.socket) { + return (raw.socket as { remoteAddress?: string | null }).remoteAddress ?? null + } + + return null +} + +/** + * Get client IP from current HTTP context + * Returns null if not in HTTP request context + */ +export function getClientIp(): string | null { + try { + const context = HttpContext.getValue('hono') as Context | undefined + if (!context) { + return null + } + return extractClientIp(context) + } catch { + return null + } +} diff --git a/be/apps/core/src/database/database.provider.ts b/be/apps/core/src/database/database.provider.ts index 3c46ba45..f0bb0045 100644 --- a/be/apps/core/src/database/database.provider.ts +++ b/be/apps/core/src/database/database.provider.ts @@ -121,10 +121,7 @@ export class DrizzleProvider { @injectable() export class DbAccessor { - constructor( - private readonly provider: DrizzleProvider, - private readonly poolProvider: PgPoolProvider, - ) {} + constructor(private readonly provider: DrizzleProvider) {} get(): DrizzleDb { const store = getOptionalDbContext() diff --git a/be/apps/core/src/helpers/normalize.helper.ts b/be/apps/core/src/helpers/normalize.helper.ts index ecab06b3..a77622c2 100644 --- a/be/apps/core/src/helpers/normalize.helper.ts +++ b/be/apps/core/src/helpers/normalize.helper.ts @@ -104,3 +104,50 @@ export function requireStringWithMessage(value: string | undefined | null, messa } return normalized } + +export function normalizedBoolean(value?: boolean | string | null): boolean { + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + return value.trim().toLowerCase() === 'true' + } + return false +} + +/** + * Parses a string value to a number. + * Returns undefined if the value cannot be parsed as a finite number. + * This is stricter than normalizeNumber which returns 0 for invalid values. + */ +export function parseNumber(value?: string | null): number | undefined { + const normalized = normalizeStringToUndefined(value) + if (!normalized) { + return undefined + } + + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : undefined +} + +/** + * Parses a string value to a boolean. + * Supports multiple formats: 'true'/'false', '1'/'0', 'yes'/'no', 'y'/'n', 'on'/'off'. + * Returns undefined if the value cannot be parsed as a boolean. + * This is stricter than normalizedBoolean which returns false for invalid values. + */ +export function parseBoolean(value?: string | null): boolean | undefined { + const normalized = normalizeStringToUndefined(value) + if (!normalized) { + return undefined + } + + const lowered = normalized.toLowerCase() + if (['true', '1', 'yes', 'y', 'on'].includes(lowered)) { + return true + } + if (['false', '0', 'no', 'n', 'off'].includes(lowered)) { + return false + } + return undefined +} diff --git a/be/apps/core/src/index.ts b/be/apps/core/src/index.ts index ace6e7fe..d1382134 100644 --- a/be/apps/core/src/index.ts +++ b/be/apps/core/src/index.ts @@ -5,6 +5,7 @@ import { env } from '@afilmory/env' import { serve } from '@hono/node-server' import { green } from 'picocolors' +import { APP_GLOBAL_PREFIX } from './app.constants' import { createConfiguredApp } from './app.factory' import { runCliPipeline } from './cli' import { logger } from './helpers/logger.helper' @@ -14,7 +15,7 @@ process.title = 'afilmory core' async function bootstrap() { const app = await createConfiguredApp({ - globalPrefix: '/api', + globalPrefix: APP_GLOBAL_PREFIX, }) const hono = app.getInstance() diff --git a/be/apps/core/src/locales/ui-schema/en.ts b/be/apps/core/src/locales/ui-schema/en.ts index 05e412f5..b813ac3e 100644 --- a/be/apps/core/src/locales/ui-schema/en.ts +++ b/be/apps/core/src/locales/ui-schema/en.ts @@ -384,6 +384,8 @@ const enUiSchema = { s3: 'AWS S3 Compatible Object Storage', github: 'GitHub repository', b2: 'Backblaze B2 cloud storage', + cos: 'Tencent Cloud COS', + oss: 'Aliyun OSS', }, fields: { s3: { diff --git a/be/apps/core/src/modules/configuration/setting/setting.constant.ts b/be/apps/core/src/modules/configuration/setting/setting.constant.ts index c12656c5..51d92388 100644 --- a/be/apps/core/src/modules/configuration/setting/setting.constant.ts +++ b/be/apps/core/src/modules/configuration/setting/setting.constant.ts @@ -72,6 +72,14 @@ export const DEFAULT_SETTING_DEFINITIONS = { isSensitive: false, schema: z.string().transform((value) => value.trim()), }, + 'photo.storage.secureAccess': { + isSensitive: false, + schema: z + .string() + .trim() + .transform((value) => (value.length === 0 ? 'false' : value.toLowerCase())) + .pipe(z.enum(['true', 'false'])), + }, [BUILDER_SYSTEM_CONFIG_SETTING_KEY]: { isSensitive: false, schema: z.string().trim(), diff --git a/be/apps/core/src/modules/configuration/site-setting/site-setting.service.ts b/be/apps/core/src/modules/configuration/site-setting/site-setting.service.ts index 8c5f548c..04db1a19 100644 --- a/be/apps/core/src/modules/configuration/site-setting/site-setting.service.ts +++ b/be/apps/core/src/modules/configuration/site-setting/site-setting.service.ts @@ -1,7 +1,7 @@ import { authUsers } from '@afilmory/db' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' -import { normalizeStringToUndefined } from 'core/helpers/normalize.helper' +import { normalizeStringToUndefined, parseBoolean } from 'core/helpers/normalize.helper' import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context' import { asc, eq, sql } from 'drizzle-orm' import { injectable } from 'tsyringe' @@ -397,21 +397,6 @@ function parseJsonStringArray(value: string | null | undefined): string[] | unde } } -function parseBooleanString(value: string | null | undefined): boolean | undefined { - const normalized = normalizeStringToUndefined(value) - if (!normalized) { - return undefined - } - - if (normalized === 'true') { - return true - } - if (normalized === 'false') { - return false - } - return undefined -} - function buildSocialConfig(values: SiteSettingValueMap): SiteConfig['social'] | undefined { const social: NonNullable = {} @@ -423,7 +408,7 @@ function buildSocialConfig(values: SiteSettingValueMap): SiteConfig['social'] | social.github = value }) - const rss = parseBooleanString(values['site.social.rss']) + const rss = parseBoolean(values['site.social.rss']) if (typeof rss === 'boolean') { social.rss = rss } diff --git a/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts b/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts index 5268db96..733e110d 100644 --- a/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts +++ b/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts @@ -8,7 +8,11 @@ import { DeleteSettingDto, GetSettingDto, SetSettingDto } from '../setting/setti import type { SettingEntryInput } from '../setting/setting.service' import { StorageSettingService } from './storage-setting.service' -const STORAGE_SETTING_KEYS = ['builder.storage.providers', 'builder.storage.activeProvider'] as const +const STORAGE_SETTING_KEYS = [ + 'builder.storage.providers', + 'builder.storage.activeProvider', + 'photo.storage.secureAccess', +] as const type StorageSettingKey = (typeof STORAGE_SETTING_KEYS)[number] @Controller('storage/settings') @@ -75,7 +79,7 @@ export class StorageSettingController { } private ensureKeyAllowed(key: string) { - if (!key.startsWith('builder.storage.')) { + if (!STORAGE_SETTING_KEYS.includes(key as StorageSettingKey)) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Only storage settings are available' }) } } diff --git a/be/apps/core/src/modules/configuration/storage-setting/storage-setting.service.ts b/be/apps/core/src/modules/configuration/storage-setting/storage-setting.service.ts index 2386b656..904d85ee 100644 --- a/be/apps/core/src/modules/configuration/storage-setting/storage-setting.service.ts +++ b/be/apps/core/src/modules/configuration/storage-setting/storage-setting.service.ts @@ -5,7 +5,7 @@ import type { SettingEntryInput } from '../setting/setting.service' import { SettingService } from '../setting/setting.service' import { createStorageProviderFormSchema } from './storage-provider.ui-schema' -type StorageSettingKey = 'builder.storage.providers' | 'builder.storage.activeProvider' +type StorageSettingKey = 'builder.storage.providers' | 'builder.storage.activeProvider' | 'photo.storage.secureAccess' @injectable() export class StorageSettingService { diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts index 11f642dc..c4240e96 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts @@ -151,6 +151,12 @@ export const SYSTEM_SETTING_DEFINITIONS = { defaultValue: '[]', isSensitive: true, }, + managedStorageSecureAccess: { + key: 'system.storage.managed.secureAccess', + schema: z.boolean(), + defaultValue: false, + isSensitive: false, + }, } as const const BILLING_PLAN_QUOTA_KEYS = [ diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts index 52b51426..8449be46 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts @@ -171,6 +171,11 @@ export class SystemSettingService { const managedStorageProviders = this.parseManagedStorageProviders( rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProviders.key], ) + const managedStorageSecureAccess = this.parseSetting( + rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageSecureAccess.key], + SYSTEM_SETTING_DEFINITIONS.managedStorageSecureAccess.schema, + SYSTEM_SETTING_DEFINITIONS.managedStorageSecureAccess.defaultValue, + ) return { allowRegistration, maxRegistrableUsers, @@ -192,6 +197,7 @@ export class SystemSettingService { storagePlanPricing, managedStorageProvider, managedStorageProviders, + managedStorageSecureAccess, } } @@ -220,6 +226,11 @@ export class SystemSettingService { return settings.storagePlanProducts ?? {} } + async isManagedStorageSecureAccessEnabled(): Promise { + const settings = await this.getSettings() + return settings.managedStorageSecureAccess ?? false + } + async getStoragePlanPricing(): Promise { const settings = await this.getSettings() return settings.storagePlanPricing ?? {} @@ -330,6 +341,13 @@ export class SystemSettingService { enqueueUpdate('baseDomain', sanitized) } } + + if ( + patch.managedStorageSecureAccess !== undefined && + patch.managedStorageSecureAccess !== current.managedStorageSecureAccess + ) { + enqueueUpdate('managedStorageSecureAccess', patch.managedStorageSecureAccess) + } if (patch.oauthGatewayUrl !== undefined) { const sanitized = this.normalizeGatewayUrl(patch.oauthGatewayUrl) if (sanitized !== current.oauthGatewayUrl) { diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts index 6431ecf8..85eb3e5c 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts @@ -34,6 +34,7 @@ export interface SystemSettings { storagePlanPricing: StoragePlanPricingConfigs managedStorageProvider: string | null managedStorageProviders: BuilderStorageProvider[] + managedStorageSecureAccess: boolean } export type SystemSettingValueMap = { diff --git a/be/apps/core/src/modules/content/manifest/manifest-migration.helper.ts b/be/apps/core/src/modules/content/manifest/manifest-migration.helper.ts new file mode 100644 index 00000000..b130ffc9 --- /dev/null +++ b/be/apps/core/src/modules/content/manifest/manifest-migration.helper.ts @@ -0,0 +1,51 @@ +import type { AfilmoryManifest } from '@afilmory/builder' +import { CURRENT_MANIFEST_VERSION, migrateManifest } from '@afilmory/builder' +import type { ManifestVersion } from '@afilmory/builder/manifest/version.js' +import type { PhotoAssetManifest } from '@afilmory/db' + +export type PhotoAssetManifestPayload = PhotoAssetManifest | null + +export function ensureCurrentPhotoAssetManifest(manifest: PhotoAssetManifestPayload): { + manifest: PhotoAssetManifest | null + changed: boolean +} { + if (!manifest?.data) { + return { manifest: null, changed: false } + } + + const requiresMigration = manifest.version !== CURRENT_MANIFEST_VERSION || !hasValidFormat(manifest.data.format) + if (!requiresMigration) { + return { manifest, changed: false } + } + + const migrated = migrateSingleManifestItem(manifest) + if (!migrated) { + return { manifest, changed: false } + } + + return { manifest: migrated, changed: true } +} + +function migrateSingleManifestItem(input: PhotoAssetManifest): PhotoAssetManifest | null { + const wrapper: AfilmoryManifest = { + version: input.version as ManifestVersion, + data: [structuredClone(input.data)], + cameras: [], + lenses: [], + } + + const migrated = migrateManifest(wrapper, CURRENT_MANIFEST_VERSION) + const migratedItem = migrated.data[0] + if (!migratedItem) { + return null + } + + return { + version: CURRENT_MANIFEST_VERSION, + data: migratedItem, + } +} + +function hasValidFormat(format: string | undefined | null): boolean { + return typeof format === 'string' && format.trim().length > 0 +} diff --git a/be/apps/core/src/modules/content/manifest/manifest.module.ts b/be/apps/core/src/modules/content/manifest/manifest.module.ts index fbf9bb8b..7faa2405 100644 --- a/be/apps/core/src/modules/content/manifest/manifest.module.ts +++ b/be/apps/core/src/modules/content/manifest/manifest.module.ts @@ -1,9 +1,11 @@ import { Module } from '@afilmory/framework' +import { PhotoModule } from '../photo/photo.module' import { ManifestPublicController } from './manifest.public.controller' import { ManifestService } from './manifest.service' @Module({ + imports: [PhotoModule], controllers: [ManifestPublicController], providers: [ManifestService], }) diff --git a/be/apps/core/src/modules/content/manifest/manifest.service.ts b/be/apps/core/src/modules/content/manifest/manifest.service.ts index f1b3dc2b..05c116e1 100644 --- a/be/apps/core/src/modules/content/manifest/manifest.service.ts +++ b/be/apps/core/src/modules/content/manifest/manifest.service.ts @@ -1,13 +1,28 @@ import type { AfilmoryManifest, CameraInfo, LensInfo, PhotoManifestItem } from '@afilmory/builder' +import { CURRENT_MANIFEST_VERSION, migrateManifest } from '@afilmory/builder' +import type { ManifestVersion } from '@afilmory/builder/manifest/version.js' +import type { PhotoAssetManifest } from '@afilmory/db' import { CURRENT_PHOTO_MANIFEST_VERSION, photoAssets } from '@afilmory/db' +import { createLogger } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' +import { StorageAccessService } from 'core/modules/content/photo/access/storage-access.service' +import { createProxyUrl } from 'core/modules/content/photo/access/storage-access.utils' +import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service' import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context' import { and, eq, inArray } from 'drizzle-orm' import { injectable } from 'tsyringe' +import { ensureCurrentPhotoAssetManifest } from './manifest-migration.helper' + @injectable() export class ManifestService { - constructor(private readonly dbAccessor: DbAccessor) {} + private readonly logger = createLogger('ManifestService') + + constructor( + private readonly dbAccessor: DbAccessor, + private readonly photoStorageService: PhotoStorageService, + private readonly storageAccessService: StorageAccessService, + ) {} async getManifest(): Promise { const tenant = requireTenantContext() @@ -15,7 +30,9 @@ export class ManifestService { const records = await db .select({ + id: photoAssets.id, manifest: photoAssets.manifest, + storageProvider: photoAssets.storageProvider, }) .from(photoAssets) .where(and(eq(photoAssets.tenantId, tenant.tenant.id), inArray(photoAssets.syncStatus, ['synced', 'conflict']))) @@ -29,39 +46,104 @@ export class ManifestService { } } + const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) + const secureAccessEnabled = await this.storageAccessService.resolveSecureAccessPreference( + storageConfig, + tenant.tenant.id, + ) const items: PhotoManifestItem[] = [] + const upgrades: Array<{ id: string; manifest: PhotoAssetManifest }> = [] for (const record of records) { - const item = record.manifest?.data - if (item) { - items.push(item) + const { manifest, changed } = ensureCurrentPhotoAssetManifest(record.manifest) + if (!manifest) { + continue } + + if (changed) { + upgrades.push({ id: record.id, manifest }) + } + + const normalized = structuredClone(manifest.data) + if (secureAccessEnabled && (record.storageProvider === 'managed' || record.storageProvider === 's3')) { + if (normalized.s3Key) { + normalized.originalUrl = createProxyUrl(normalized.s3Key) + } + if (normalized.video?.type === 'live-photo' && normalized.video.s3Key) { + normalized.video.videoUrl = createProxyUrl(normalized.video.s3Key, 'live-video') + } + } + items.push(normalized) + } + + if (upgrades.length > 0) { + await this.persistManifestUpgrades(upgrades) } const sorted = this.sortByDateDesc(items) - const cameras = this.buildCameraCollection(sorted) - const lenses = this.buildLensCollection(sorted) + const manifest = this.ensureCurrentManifestVersion({ + version: upgrades.length > 0 ? CURRENT_MANIFEST_VERSION : this.resolveManifestVersion(records), + data: sorted, + cameras: [], + lenses: [], + }) + + const cameras = this.buildCameraCollection(manifest.data) + const lenses = this.buildLensCollection(manifest.data) return { - version: this.resolveManifestVersion(records), - data: sorted, + ...manifest, cameras, lenses, } } private resolveManifestVersion( - records: Array<{ manifest: { version: typeof CURRENT_PHOTO_MANIFEST_VERSION } | null }>, - ): typeof CURRENT_PHOTO_MANIFEST_VERSION { + records: Array<{ manifest: { version: ManifestVersion | string } | null }>, + ): ManifestVersion { for (const record of records) { const version = record.manifest?.version - if (version) { - return version + if (typeof version === 'string' && version.length > 0) { + return version as ManifestVersion } } return CURRENT_PHOTO_MANIFEST_VERSION } + private ensureCurrentManifestVersion(manifest: AfilmoryManifest): AfilmoryManifest { + if (manifest.version === CURRENT_MANIFEST_VERSION) { + return manifest + } + + try { + return migrateManifest(manifest, CURRENT_MANIFEST_VERSION) + } catch (error) { + this.logger.warn('Manifest migration failed; returning original payload', { error }) + return manifest + } + } + + private async persistManifestUpgrades(upgrades: Array<{ id: string; manifest: PhotoAssetManifest }>): Promise { + if (upgrades.length === 0) { + return + } + + const db = this.dbAccessor.get() + for (const entry of upgrades) { + try { + await db + .update(photoAssets) + .set({ + manifest: entry.manifest, + manifestVersion: entry.manifest.version, + }) + .where(eq(photoAssets.id, entry.id)) + } catch (error) { + this.logger.warn('Failed to persist manifest upgrade', { photoAssetId: entry.id, error }) + } + } + } + private sortByDateDesc(items: PhotoManifestItem[]): PhotoManifestItem[] { return [...items].sort((a, b) => this.toTimestamp(b.dateTaken) - this.toTimestamp(a.dateTaken)) } diff --git a/be/apps/core/src/modules/content/photo/access/storage-access.controller.ts b/be/apps/core/src/modules/content/photo/access/storage-access.controller.ts new file mode 100644 index 00000000..9d4b6086 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/access/storage-access.controller.ts @@ -0,0 +1,44 @@ +import { ContextParam, Controller, Get, Query } from '@afilmory/framework' +import { getClientIp } from 'core/context/http-context.helper' +import { BizException, ErrorCode } from 'core/errors' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' +import type { Context } from 'hono' + +import { StorageSignQueryDto } from './storage-access.dto' +import { StorageAccessService } from './storage-access.service' + +@Controller('storage') +export class StorageAccessController { + constructor(private readonly storageAccessService: StorageAccessService) {} + + @Get('sign') + @BypassResponseTransform() + async sign(@ContextParam() context: Context, @Query() query: StorageSignQueryDto) { + if (!(await this.storageAccessService.isSecureAccessEnabled())) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '安全访问尚未启用' }) + } + const storageKey = query.objectKey?.trim() || query.key?.trim() || '' + const { url, expiresAt } = await this.storageAccessService.issueSignedUrl({ + storageKey, + intent: query.intent?.trim() || undefined, + ttlSeconds: query.ttl, + clientIp: getClientIp(), + userAgent: context.req.header('user-agent') ?? null, + referer: context.req.header('referer') ?? context.req.header('referrer') ?? null, + }) + + if (this.shouldReturnJson(context, query.format)) { + return { url, expiresAt } + } + + return context.redirect(url, 302) + } + + private shouldReturnJson(context: Context, format?: string | null): boolean { + if (format === 'json') { + return true + } + const accept = context.req.header('accept')?.toLowerCase() ?? '' + return accept.includes('application/json') + } +} diff --git a/be/apps/core/src/modules/content/photo/access/storage-access.dto.ts b/be/apps/core/src/modules/content/photo/access/storage-access.dto.ts new file mode 100644 index 00000000..5dd026fe --- /dev/null +++ b/be/apps/core/src/modules/content/photo/access/storage-access.dto.ts @@ -0,0 +1,49 @@ +import { createZodSchemaDto } from '@afilmory/framework' +import { z } from 'zod' + +const storageSignQuerySchema = z + .object({ + objectKey: z + .string() + .trim() + .transform((val) => (val.length > 0 ? val : undefined)) + .optional(), + key: z + .string() + .trim() + .transform((val) => (val.length > 0 ? val : undefined)) + .optional(), + ttl: z + .string() + .optional() + .transform((val) => { + if (!val || val.trim().length === 0) { + return undefined + } + const parsed = Number.parseInt(val, 10) + return Number.isFinite(parsed) ? parsed : undefined + }), + intent: z + .string() + .trim() + .transform((val) => (val.length > 0 ? val : undefined)) + .optional(), + format: z + .string() + .trim() + .transform((val) => (val.length > 0 ? val : undefined)) + .optional(), + }) + .refine( + (data) => { + const {objectKey} = data + const {key} = data + return !!(objectKey || key) + }, + { + message: 'objectKey 或 key 参数至少需要一个', + path: ['objectKey'], + }, + ) + +export class StorageSignQueryDto extends createZodSchemaDto(storageSignQuerySchema) {} diff --git a/be/apps/core/src/modules/content/photo/access/storage-access.service.ts b/be/apps/core/src/modules/content/photo/access/storage-access.service.ts new file mode 100644 index 00000000..3bfa4289 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/access/storage-access.service.ts @@ -0,0 +1,234 @@ +import type { ManagedStorageConfig, S3CompatibleConfig, StorageConfig } from '@afilmory/builder' +import { generateId, photoAccessLogs, photoAccessStats, photoAssets } from '@afilmory/db' +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { DbAccessor } from 'core/database/database.provider' +import { BizException, ErrorCode } from 'core/errors' +import { logger } from 'core/helpers/logger.helper' +import { normalizedBoolean } from 'core/helpers/normalize.helper' +import { SettingService } from 'core/modules/configuration/setting/setting.service' +import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service' +import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context' +import { and, eq, sql } from 'drizzle-orm' +import { injectable } from 'tsyringe' + +import { joinSegments, normalizeKeyPath, normalizePath } from './storage-access.utils' + +type RemoteAccessTarget = { + kind: 's3' + config: S3CompatibleConfig + objectKey: string +} + +type IssueSignedUrlOptions = { + storageKey: string + ttlSeconds?: number + intent?: string + clientIp?: string | null + userAgent?: string | null + referer?: string | null +} + +export type IssueSignedUrlResult = { + url: string + expiresAt: string + tokenId: string + headers: Record +} + +const DEFAULT_TTL_SECONDS = 600 +const MIN_TTL_SECONDS = 60 +const MAX_TTL_SECONDS = 600 + +@injectable() +export class StorageAccessService { + private readonly logger = logger.extend('StorageAccessService') + + constructor( + private readonly dbAccessor: DbAccessor, + private readonly photoStorageService: PhotoStorageService, + private readonly settingService: SettingService, + private readonly systemSettingService: SystemSettingService, + ) {} + + async isSecureAccessEnabled(): Promise { + const tenant = requireTenantContext() + const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) + return await this.resolveSecureAccessPreference(storageConfig, tenant.tenant.id) + } + + async resolveSecureAccessPreference(storageConfig: StorageConfig, tenantId: string): Promise { + if (storageConfig.provider === 'managed') { + return await this.systemSettingService.isManagedStorageSecureAccessEnabled() + } + const raw = (await this.settingService.get('photo.storage.secureAccess', { tenantId })) ?? 'false' + return normalizedBoolean(raw) + } + + async issueSignedUrl(options: IssueSignedUrlOptions): Promise { + const tenant = requireTenantContext() + const db = this.dbAccessor.get() + const normalizedKey = normalizeKeyPath(options.storageKey) + if (!normalizedKey) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少有效的 storage key' }) + } + + const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) + const secureAccessEnabled = await this.resolveSecureAccessPreference(storageConfig, tenant.tenant.id) + if (!secureAccessEnabled) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Secure access is not enabled.' }) + } + const target = this.resolveRemoteTarget(storageConfig, normalizedKey, tenant.tenant.id) + const ttl = this.normalizeTtlSeconds(options.ttlSeconds) + const { url, expiresAt, headers } = await this.createS3SignedUrl(target.config, target.objectKey, ttl) + const tokenId = generateId() + + const record = await db + .select({ + id: photoAssets.id, + photoId: photoAssets.photoId, + storageProvider: photoAssets.storageProvider, + }) + .from(photoAssets) + .where(and(eq(photoAssets.tenantId, tenant.tenant.id), eq(photoAssets.storageKey, normalizedKey))) + .limit(1) + .then((rows) => rows[0]) + + if (record) { + const now = new Date().toISOString() + + await db.insert(photoAccessLogs).values({ + id: generateId(), + tenantId: tenant.tenant.id, + photoAssetId: record.id, + photoId: record.photoId, + storageKey: normalizedKey, + provider: target.kind, + intent: options.intent?.trim() || 'original', + tokenId, + signedUrl: url, + status: 'issued', + clientIp: options.clientIp ?? null, + userAgent: options.userAgent ?? null, + referer: options.referer ?? null, + expiresAt, + createdAt: now, + updatedAt: now, + }) + + await db + .insert(photoAccessStats) + .values({ + tenantId: tenant.tenant.id, + photoAssetId: record.id, + photoId: record.photoId, + viewCount: 1, + lastViewedAt: now, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [photoAccessStats.tenantId, photoAccessStats.photoAssetId], + set: { + viewCount: sql`${photoAccessStats.viewCount} + 1`, + lastViewedAt: now, + updatedAt: now, + photoId: record.photoId, + }, + }) + } + return { url, expiresAt, tokenId, headers } + } + + private resolveRemoteTarget(config: StorageConfig, key: string, tenantId: string): RemoteAccessTarget { + switch (config.provider) { + case 'managed': { + const managedConfig = config as ManagedStorageConfig + const managedKey = this.applyManagedPrefix(managedConfig, key) + return this.resolveRemoteTarget(managedConfig.upstream, managedKey, tenantId) + } + case 's3': { + const s3Config = config as S3CompatibleConfig + return { kind: 's3', config: s3Config, objectKey: key } + } + + default: { + this.logger.error( + `Storage provider ${config.provider as string} for tenant ${tenantId} does not support secure download URLs.`, + ) + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前存储提供商不支持安全访问链接' }) + } + } + } + + private applyManagedPrefix(config: ManagedStorageConfig, key: string): string { + const tenantSegment = normalizePath(config.tenantId) + const upstreamBase = normalizePath(this.extractUpstreamBasePath(config.upstream)) + const customBase = normalizePath(config.basePrefix) + const combined = joinSegments(upstreamBase, customBase, tenantSegment) + return joinSegments(combined, key) + } + + private extractUpstreamBasePath(config: StorageConfig): string | null { + switch (config.provider) { + case 's3': + case 'oss': + case 'cos': { + const s3Config = config as S3CompatibleConfig + return normalizePath(s3Config.prefix) || null + } + case 'b2': { + return null + } + case 'github': { + const githubConfig = config as { provider: 'github'; path?: string | null } + return normalizePath(githubConfig.path) || null + } + default: { + return null + } + } + } + + private normalizeTtlSeconds(input?: number): number { + if (!input || !Number.isFinite(input)) { + return DEFAULT_TTL_SECONDS + } + const normalized = Math.trunc(input) + if (normalized < MIN_TTL_SECONDS) { + return MIN_TTL_SECONDS + } + if (normalized > MAX_TTL_SECONDS) { + return MAX_TTL_SECONDS + } + return normalized + } + + private async createS3SignedUrl( + config: S3CompatibleConfig, + key: string, + ttlSeconds: number, + ): Promise<{ url: string; expiresAt: string; headers: Record }> { + if (!config.accessKeyId || !config.secretAccessKey) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'S3 存储配置缺少访问密钥' }) + } + const s3 = new S3Client({ + region: config.region ?? 'us-east-1', + endpoint: config.endpoint, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + forcePathStyle: true, + }) + + const command = new GetObjectCommand({ + Bucket: config.bucket, + Key: key, + }) + + const url = await getSignedUrl(s3 as any, command, { expiresIn: ttlSeconds }) + return { url, expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), headers: {} } + } +} diff --git a/be/apps/core/src/modules/content/photo/access/storage-access.utils.ts b/be/apps/core/src/modules/content/photo/access/storage-access.utils.ts new file mode 100644 index 00000000..ac5b1ee1 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/access/storage-access.utils.ts @@ -0,0 +1,154 @@ +import { APP_GLOBAL_PREFIX } from 'core/app.constants' + +/** + * Utility functions for storage access operations + */ + +const BYTES_PER_MB = 1024 * 1024 + +/** + * Creates a proxy URL for secure storage access + * @param storageKey - The storage key (object key) to access + * @param intent - The intent/purpose of the access (e.g., 'photo', 'live-video') + * @returns The proxy URL for signing the storage access + */ +export function createProxyUrl(storageKey: string, intent = 'photo'): string { + const params = new URLSearchParams() + params.set('objectKey', storageKey) + if (intent) { + params.set('intent', intent) + } + return `${APP_GLOBAL_PREFIX}/storage/sign?${params.toString()}` +} + +/** + * Normalizes a path by: + * - Converting backslashes to forward slashes + * - Collapsing multiple slashes into one + * - Removing leading and trailing slashes + * @param value - The path to normalize + * @returns Normalized path string, or empty string if input is empty/null + */ +export function normalizePath(value?: string | null): string { + if (!value) { + return '' + } + return value + .replaceAll('\\', '/') + .replaceAll(/\/+/g, '/') + .replaceAll(/^\/+|\/+$/g, '') +} + +/** + * Normalizes a key path by: + * - Splitting on both backslashes and forward slashes + * - Removing empty segments, '.', and '..' + * - Joining with forward slashes + * @param raw - The raw key path to normalize + * @returns Normalized key path, or empty string if input is empty/null + */ +export function normalizeKeyPath(raw: string | undefined | null): string { + if (!raw) { + return '' + } + + const segments = raw.split(/[\\/]+/) + const safeSegments: string[] = [] + + for (const segment of segments) { + const trimmed = segment.trim() + if (!trimmed || trimmed === '.' || trimmed === '..') { + continue + } + safeSegments.push(trimmed) + } + + return safeSegments.join('/') +} + +/** + * Formats bytes to a human-readable string with appropriate unit (B, KB, MB, GB, TB) + * @param bytes - The number of bytes to format + * @returns Formatted string (e.g., "1.5 MB", "500 KB") + */ +export function formatBytesForDisplay(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) { + return '0 B' + } + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unitIndex = 0 + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + + const fixed = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1) + return `${fixed} ${units[unitIndex]}` +} + +/** + * Converts bytes to megabytes (MB) with 2 decimal places + * @param bytes - The number of bytes to convert + * @returns The number of megabytes, rounded to 2 decimal places + */ +export function formatBytesToMb(bytes: number): number { + const mb = bytes / BYTES_PER_MB + return Number(mb.toFixed(2)) +} + +/** + * Normalizes a directory value by trimming whitespace + * @param value - The directory value to normalize + * @returns Normalized directory string, or null if empty + */ +export function normalizeDirectoryValue(value: string | null): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +/** + * Normalizes request headers by converting all keys to lowercase + * @param headers - The Headers object to normalize + * @returns Record with lowercase keys + */ +export function normalizeRequestHeaders(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + result[key.toLowerCase()] = value + }) + return result +} + +/** + * Joins path segments into a single path string. + * - Filters out null, undefined, and empty values + * - Converts backslashes to forward slashes + * - Removes leading and trailing slashes from each segment + * - Trims whitespace from each segment + * - Joins segments with forward slashes + * @param segments - Array of path segments to join (can include null/undefined) + * @returns Joined path string, or empty string if no valid segments + */ +export function joinSegments(...segments: Array): string { + const parts: string[] = [] + + for (const raw of segments) { + if (!raw) { + continue + } + const normalized = raw + .replaceAll('\\', '/') + .replaceAll(/^\/+|\/+$/g, '') + .trim() + if (normalized.length > 0) { + parts.push(normalized) + } + } + + return parts.join('/') +} 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 854e2fe6..aa260a3d 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 @@ -31,6 +31,13 @@ import { requireTenantContext } from 'core/modules/platform/tenant/tenant.contex import { and, eq, inArray, sql } from 'drizzle-orm' import { injectable } from 'tsyringe' +import { StorageAccessService } from '../access/storage-access.service' +import { + createProxyUrl, + formatBytesForDisplay, + formatBytesToMb, + normalizeKeyPath, +} from '../access/storage-access.utils' import { PhotoBuilderService } from '../builder/photo-builder.service' import { PhotoStorageService } from '../storage/photo-storage.service' import { TransactionalStorageManager } from '../storage/transactional-storage.manager' @@ -70,6 +77,7 @@ export class PhotoAssetService { private readonly dbAccessor: DbAccessor, private readonly photoBuilderService: PhotoBuilderService, private readonly photoStorageService: PhotoStorageService, + private readonly storageAccessService: StorageAccessService, private readonly billingPlanService: BillingPlanService, private readonly billingUsageService: BillingUsageService, private readonly storagePlanService: StoragePlanService, @@ -96,17 +104,19 @@ export class PhotoAssetService { const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) const storageManager = await this.createStorageManager(builderConfig, storageConfig) + const secureAccessEnabled = await this.storageAccessService.resolveSecureAccessPreference( + storageConfig, + tenant.tenant.id, + ) return await Promise.all( records.map(async (record) => { - let publicUrl: string | null = null - if (record.storageProvider !== DATABASE_ONLY_PROVIDER) { - try { - publicUrl = await Promise.resolve(storageManager.generatePublicUrl(record.storageKey)) - } catch { - publicUrl = null - } - } + const publicUrl = await this.resolvePublicUrlForRecord({ + storageManager, + storageKey: record.storageKey, + storageProvider: record.storageProvider, + secureAccessEnabled, + }) return { id: record.id, @@ -220,7 +230,7 @@ export class PhotoAssetService { try { await storageManager.deleteFile(record.storageKey) if (managedProviderKey) { - managedKeysToDelete.add(this.normalizeKeyPath(record.storageKey)) + managedKeysToDelete.add(normalizeKeyPath(record.storageKey)) } } catch (error) { throw new BizException(ErrorCode.IMAGE_PROCESSING_FAILED, { @@ -236,7 +246,7 @@ export class PhotoAssetService { await storageManager.deleteFile(videoKey) deletedVideoKeys.add(videoKey) if (managedProviderKey) { - managedKeysToDelete.add(this.normalizeKeyPath(videoKey)) + managedKeysToDelete.add(normalizeKeyPath(videoKey)) } } catch { // 忽略缺失的 Live Photo 视频文件 @@ -302,6 +312,10 @@ export class PhotoAssetService { builder.setStorageManager(transactionalStorageManager) await builder.ensurePluginsReady() const storageManager = transactionalStorageManager + const secureAccessEnabled = await this.storageAccessService.resolveSecureAccessPreference( + storageConfig, + tenant.tenant.id, + ) const { photoPlans, videoPlans } = this.prepareUploadPlans(inputs, storageConfig) const unmatchedVideoBaseNames = this.validateLivePhotoPairs(photoPlans, videoPlans) @@ -350,7 +364,14 @@ export class PhotoAssetService { items: existingItemsRaw, keySet: existingPhotoKeySet, baseNameMap: existingBaseNameMap, - } = await this.collectExistingPhotoRecords(photoPlans, videoPlans, tenant.tenant.id, storageManager, db) + } = await this.collectExistingPhotoRecords( + photoPlans, + videoPlans, + tenant.tenant.id, + storageManager, + db, + secureAccessEnabled, + ) throwIfAborted() const existingPhotoIds = await this.collectExistingPhotoIds(photoPlans, tenant.tenant.id, db) @@ -495,6 +516,7 @@ export class PhotoAssetService { videoBufferMap, abortSignal: options?.abortSignal, builderLogEmitter, + secureAccessEnabled, onProcessed: async ({ storageObject, manifestItem }) => { throwIfAborted() processedCount += 1 @@ -687,6 +709,7 @@ export class PhotoAssetService { tenantId: string, storageManager: StorageManager, db: ReturnType, + secureAccessEnabled: boolean, ): Promise<{ items: PhotoAssetListItem[] keySet: Set @@ -735,14 +758,12 @@ export class PhotoAssetService { const records = [...recordMap.values()] const items = await Promise.all( records.map(async (record) => { - let publicUrl: string | null = null - if (record.storageProvider !== DATABASE_ONLY_PROVIDER) { - try { - publicUrl = await Promise.resolve(storageManager.generatePublicUrl(record.storageKey)) - } catch { - publicUrl = null - } - } + const publicUrl = await this.resolvePublicUrlForRecord({ + storageManager, + storageKey: record.storageKey, + storageProvider: record.storageProvider, + secureAccessEnabled, + }) return { id: record.id, @@ -858,7 +879,7 @@ export class PhotoAssetService { if (!object.key) { continue } - const normalizedKey = this.normalizeKeyPath(object.key) + const normalizedKey = normalizeKeyPath(object.key) if (targetKeys.has(normalizedKey)) { map.set(normalizedKey, this.normalizeStorageObjectKey(object, normalizedKey)) } @@ -912,6 +933,7 @@ export class PhotoAssetService { videoBufferMap: Map abortSignal?: AbortSignal builderLogEmitter?: DataSyncProgressEmitter + secureAccessEnabled: boolean onProcessed?: (payload: { plan: PreparedUploadPlan storageObject: StorageObject @@ -931,6 +953,7 @@ export class PhotoAssetService { videoBufferMap, abortSignal, builderLogEmitter, + secureAccessEnabled, onProcessed, } = params @@ -1063,7 +1086,12 @@ export class PhotoAssetService { .limit(1) )[0] - const publicUrl = await Promise.resolve(storageManager.generatePublicUrl(resolvedPhotoKey)) + const publicUrl = await this.resolvePublicUrlForRecord({ + storageManager, + storageKey: resolvedPhotoKey, + storageProvider: storageConfig.provider, + secureAccessEnabled, + }) await this.recordManagedStorageReferences(storageConfig, tenantId, [ { @@ -1109,13 +1137,6 @@ export class PhotoAssetService { return results } - async generatePublicUrl(storageKey: string): Promise { - const tenant = requireTenantContext() - const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) - const storageManager = await this.createStorageManager(builderConfig, storageConfig) - return await Promise.resolve(storageManager.generatePublicUrl(storageKey)) - } - async updateAssetTags(assetId: string, tagsInput: readonly string[]): Promise { const tenant = requireTenantContext() const db = this.dbAccessor.get() @@ -1146,8 +1167,12 @@ export class PhotoAssetService { const normalizedTags = this.normalizeTagList(tagsInput) const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) const storageManager = await this.createStorageManager(builderConfig, storageConfig) + const secureAccessEnabled = await this.storageAccessService.resolveSecureAccessPreference( + storageConfig, + tenant.tenant.id, + ) - const sanitizeKey = this.normalizeKeyPath(record.storageKey) + const sanitizeKey = normalizeKeyPath(record.storageKey) const normalizeStorageKey = createStorageKeyNormalizer(storageConfig) const relativeKey = normalizeStorageKey(sanitizeKey) const fileName = path.basename(relativeKey || sanitizeKey) @@ -1158,7 +1183,7 @@ export class PhotoAssetService { const prefixSegment = this.extractStoragePrefix(sanitizeKey, relativeKey) const tagDirectory = normalizedTags.length > 0 ? this.joinStorageSegments(...normalizedTags) : null const newRelativeKey = tagDirectory ? `${tagDirectory}/${fileName}` : fileName - const normalizedRelativeKey = this.normalizeKeyPath(newRelativeKey) + const normalizedRelativeKey = normalizeKeyPath(newRelativeKey) const newStorageKey = prefixSegment ? this.joinStorageSegments(prefixSegment, normalizedRelativeKey) : normalizedRelativeKey @@ -1224,10 +1249,12 @@ export class PhotoAssetService { throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: '更新标签失败,请稍后再试' }) } - const publicUrl = - saved.storageProvider === DATABASE_ONLY_PROVIDER - ? null - : await Promise.resolve(storageManager.generatePublicUrl(saved.storageKey)) + const publicUrl = await this.resolvePublicUrlForRecord({ + storageManager, + storageKey: saved.storageKey, + storageProvider: saved.storageProvider, + secureAccessEnabled, + }) await this.emitManifestChanged(tenant.tenant.id) @@ -1336,8 +1363,8 @@ export class PhotoAssetService { continue } - const displayLimit = limitMb ?? this.formatBytesToMb(maxBytes) - const actualSize = this.formatBytesToMb(size) + const displayLimit = limitMb ?? formatBytesToMb(maxBytes) + const actualSize = formatBytesToMb(size) throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: `文件 ${input.filename} (${actualSize} MB) 超出允许的单张大小 ${displayLimit} MB`, }) @@ -1351,11 +1378,6 @@ export class PhotoAssetService { return value * 1024 * 1024 } - private formatBytesToMb(value: number): number { - const mb = value / (1024 * 1024) - return Number(mb.toFixed(2)) - } - private async ensurePhotoLibraryCapacity( tenantId: string, db: ReturnType, @@ -1394,7 +1416,7 @@ export class PhotoAssetService { } private normalizeGroupBase(basePath: string): string { - return this.normalizeKeyPath(basePath).toLowerCase() + return normalizeKeyPath(basePath).toLowerCase() } private createPlanGroupKey(basePath: string, sequence: number): string { @@ -1467,7 +1489,7 @@ export class PhotoAssetService { const combinedDirectory = this.joinStorageSegments(storageDirectory, customDirectory) const keySegment = base || timestamp const normalized = combinedDirectory ? `${combinedDirectory}/${keySegment}${ext}` : `${keySegment}${ext}` - return this.normalizeKeyPath(normalized) + return normalizeKeyPath(normalized) } private resolveStorageDirectory(storageConfig: StorageConfig): string | null { @@ -1494,29 +1516,10 @@ export class PhotoAssetService { if (trimmed.length === 0) { return null } - const normalized = this.normalizeKeyPath(trimmed) + const normalized = normalizeKeyPath(trimmed) return normalized.length > 0 ? normalized : null } - private normalizeKeyPath(raw: string): string { - if (!raw) { - return '' - } - - const segments = raw.split(/[\\/]+/) - const safeSegments: string[] = [] - - for (const segment of segments) { - const trimmed = segment.trim() - if (!trimmed || trimmed === '.' || trimmed === '..') { - continue - } - safeSegments.push(trimmed) - } - - return safeSegments.join('/') - } - private resolveThumbnailStorageKey(record: PhotoAssetRecord, remotePrefix: string | null): string | null { const thumbnailUrl = record.manifest?.data?.thumbnailUrl if (!thumbnailUrl) { @@ -1596,7 +1599,7 @@ export class PhotoAssetService { } private normalizeStorageObjectKey(object: StorageObject, fallbackKey: string): StorageObject { - const normalizedKey = this.normalizeKeyPath(object?.key ?? fallbackKey) + const normalizedKey = normalizeKeyPath(object?.key ?? fallbackKey) if (object?.key === normalizedKey) { return object } @@ -1700,7 +1703,7 @@ export class PhotoAssetService { const seenKeys = new Set() const appendPlan = (plan: PreparedUploadPlan) => { - const normalizedKey = this.normalizeKeyPath(plan.storageKey) + const normalizedKey = normalizeKeyPath(plan.storageKey) if (!normalizedKey || plan.isExisting || existingStorageMap.has(normalizedKey) || seenKeys.has(normalizedKey)) { return } @@ -1743,9 +1746,9 @@ export class PhotoAssetService { fileCount: usage.fileCount, }) throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, { - message: `托管存储空间已超出套餐上限:当前已用 ${this.formatBytesForDisplay( + message: `托管存储空间已超出套餐上限:当前已用 ${formatBytesForDisplay( usage.totalBytes, - )},套餐上限 ${this.formatBytesForDisplay(capacity)}。请清理空间或升级存储方案后再试。`, + )},套餐上限 ${formatBytesForDisplay(capacity)}。请清理空间或升级存储方案后再试。`, }) } @@ -1758,32 +1761,15 @@ export class PhotoAssetService { fileCount: usage.fileCount, }) throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, { - message: `托管存储空间不足:当前已用 ${this.formatBytesForDisplay( + message: `托管存储空间不足:当前已用 ${formatBytesForDisplay( usage.totalBytes, - )},上传后预计 ${this.formatBytesForDisplay(projectedBytes)},已超过套餐上限 ${this.formatBytesForDisplay( + )},上传后预计 ${formatBytesForDisplay(projectedBytes)},已超过套餐上限 ${formatBytesForDisplay( capacity, )}。请清理空间或升级存储方案后再试。`, }) } } - private formatBytesForDisplay(bytes: number): string { - if (!Number.isFinite(bytes) || bytes < 0) { - return '0 B' - } - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - let value = bytes - let unitIndex = 0 - - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024 - unitIndex += 1 - } - - const fixed = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1) - return `${fixed} ${units[unitIndex]}` - } - private async recordManagedStorageReferences( storageConfig: StorageConfig, tenantId: string, @@ -1809,7 +1795,7 @@ export class PhotoAssetService { const tasks = references .map((reference) => ({ ...reference, - storageKey: this.normalizeKeyPath(reference.storageKey), + storageKey: normalizeKeyPath(reference.storageKey), })) .filter((reference) => reference.storageKey.length > 0) .map((reference) => @@ -1862,7 +1848,7 @@ export class PhotoAssetService { return null } - const normalizedVideoKey = this.normalizeKeyPath(video.s3Key) + const normalizedVideoKey = normalizeKeyPath(video.s3Key) const { basePath: newPhotoBase } = this.splitStorageKey(newPhotoKey) if (!newPhotoBase) { return null @@ -1888,4 +1874,34 @@ export class PhotoAssetService { videoUrl, } } + + private async resolvePublicUrlForRecord(params: { + storageManager: StorageManager + storageKey: string + storageProvider: string + secureAccessEnabled: boolean + intent?: string + }): Promise { + const { storageManager, storageKey, storageProvider, secureAccessEnabled, intent } = params + if (storageProvider === DATABASE_ONLY_PROVIDER) { + return null + } + + if (secureAccessEnabled) { + return createProxyUrl(storageKey, intent) + } + + try { + return await Promise.resolve(storageManager.generatePublicUrl(storageKey)) + } catch { + return null + } + } + + async generatePublicUrl(storageKey: string): Promise { + const tenant = requireTenantContext() + const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) + const storageManager = await this.createStorageManager(builderConfig, storageConfig) + return await Promise.resolve(storageManager.generatePublicUrl(storageKey)) + } } diff --git a/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts b/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts index 55a17bf3..b8792fed 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts @@ -6,11 +6,10 @@ import { BizException, ErrorCode } from 'core/errors' import type { Context } from 'hono' import { injectable } from 'tsyringe' +import { formatBytesForDisplay, normalizeDirectoryValue, normalizeRequestHeaders } from '../access/storage-access.utils' import type { UploadAssetInput } from './photo-asset.types' import { MAX_TEXT_FIELDS_PER_REQUEST, MAX_UPLOAD_FILES_PER_BATCH } from './photo-upload-limits' -const BYTES_PER_MB = 1024 * 1024 - type MultipartParseOptions = { fileSizeLimitBytes: number totalSizeLimitBytes: number @@ -20,7 +19,7 @@ type MultipartParseOptions = { @injectable() export class PhotoUploadParser { async parse(context: Context, options: MultipartParseOptions): Promise { - const headers = this.normalizeRequestHeaders(context.req.raw.headers) + const headers = normalizeRequestHeaders(context.req.raw.headers) if (!headers['content-type']) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 Content-Type 头' }) } @@ -95,7 +94,7 @@ export class PhotoUploadParser { } if (typeof value === 'string') { - directory = this.normalizeDirectoryValue(value) + directory = normalizeDirectoryValue(value) } }) @@ -120,7 +119,7 @@ export class PhotoUploadParser { process.nextTick(() => { fail( new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: `单次上传大小不能超过 ${this.formatBytesForDisplay(normalizedBatchLimit)}`, + message: `单次上传大小不能超过 ${formatBytesForDisplay(normalizedBatchLimit)}`, }), ) }) @@ -138,7 +137,7 @@ export class PhotoUploadParser { process.nextTick(() => { fail( new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: `文件 ${info.filename} 超出大小限制 ${this.formatBytesForDisplay(normalizedFileSizeLimit)}`, + message: `文件 ${info.filename} 超出大小限制 ${formatBytesForDisplay(normalizedFileSizeLimit)}`, }), ) }) @@ -184,30 +183,6 @@ export class PhotoUploadParser { }) } - private formatBytesForDisplay(bytes: number): string { - return `${this.formatBytesToMb(bytes)} MB` - } - - private formatBytesToMb(bytes: number): number { - return Number((bytes / BYTES_PER_MB).toFixed(2)) - } - - private normalizeDirectoryValue(value: string | null): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null - } - - private normalizeRequestHeaders(headers: Headers): Record { - const result: Record = {} - headers.forEach((value, key) => { - result[key.toLowerCase()] = value - }) - return result - } - private createReadableFromRequest(request: Request): Readable { if (request.bodyUsed) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '请求体已被消费' }) diff --git a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts index 84f91961..c070e884 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts @@ -20,6 +20,7 @@ import { createProgressSseResponse } from 'core/modules/shared/http/sse' import type { Context } from 'hono' import { inject } from 'tsyringe' +import { StorageAccessService } from '../access/storage-access.service' import { UpdatePhotoTagsDto } from './photo-asset.dto' import { PhotoAssetService } from './photo-asset.service' import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.types' @@ -34,7 +35,10 @@ type DeleteAssetsDto = { @Controller('photos') @Roles('admin') export class PhotoController { - constructor(@inject(PhotoAssetService) private readonly photoAssetService: PhotoAssetService) {} + constructor( + @inject(PhotoAssetService) private readonly photoAssetService: PhotoAssetService, + @inject(StorageAccessService) private readonly storageAccessService: StorageAccessService, + ) {} private readonly logger = createLogger(this.constructor.name) @Get('assets') @@ -94,14 +98,25 @@ export class PhotoController { } @Get('storage-url') - async getStorageUrl(@Query() query: { key?: string }) { + async getStorageUrl(@Query() query: { key?: string; ttl?: string; intent?: string }) { const key = query?.key?.trim() if (!key) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 storage key 参数' }) } - const url = await this.photoAssetService.generatePublicUrl(key) - return { url } + const secureAccessEnabled = await this.storageAccessService.isSecureAccessEnabled() + if (!secureAccessEnabled) { + const url = await this.photoAssetService.generatePublicUrl(key) + return { url, expiresAt: null } + } + + const ttlSeconds = Number.parseInt(query?.ttl ?? '', 10) + const { url, expiresAt } = await this.storageAccessService.issueSignedUrl({ + storageKey: key, + intent: query?.intent?.trim() || 'dashboard', + ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : undefined, + }) + return { url, expiresAt } } @Patch('assets/:id/tags') diff --git a/be/apps/core/src/modules/content/photo/photo.module.ts b/be/apps/core/src/modules/content/photo/photo.module.ts index be811da0..a3e3a9f8 100644 --- a/be/apps/core/src/modules/content/photo/photo.module.ts +++ b/be/apps/core/src/modules/content/photo/photo.module.ts @@ -4,6 +4,8 @@ import { SystemSettingModule } from 'core/modules/configuration/system-setting/s import { BillingModule } from 'core/modules/platform/billing/billing.module' import { ManagedStorageModule } from 'core/modules/platform/managed-storage/managed-storage.module' +import { StorageAccessController } from './access/storage-access.controller' +import { StorageAccessService } from './access/storage-access.service' import { PhotoController } from './assets/photo.controller' import { PhotoAssetService } from './assets/photo-asset.service' import { PhotoUploadParser } from './assets/photo-upload.parser' @@ -13,11 +15,12 @@ import { PhotoStorageService } from './storage/photo-storage.service' @Module({ imports: [SystemSettingModule, BillingModule, ManagedStorageModule], - controllers: [PhotoController], + controllers: [PhotoController, StorageAccessController], providers: [ PhotoBuilderService, PhotoStorageService, PhotoAssetService, + StorageAccessService, PhotoUploadLimitInterceptor, PhotoUploadParser, BuilderConfigService, 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 8ac1a046..c9192540 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 @@ -9,7 +9,12 @@ import type { S3CompatibleConfig, } from '@afilmory/builder/storage/interfaces.js' import { BizException, ErrorCode } from 'core/errors' -import { normalizeStringToUndefined, requireStringWithMessage } from 'core/helpers/normalize.helper' +import { + normalizeStringToUndefined, + parseBoolean, + parseNumber, + requireStringWithMessage, +} from 'core/helpers/normalize.helper' import { BuilderConfigService } from 'core/modules/configuration/builder-config/builder-config.service' import { SettingService } from 'core/modules/configuration/setting/setting.service' import type { BuilderStorageProvider } from 'core/modules/configuration/setting/storage-provider.utils' @@ -17,6 +22,8 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/ import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.service' import { injectable } from 'tsyringe' +import { parseRetryMode } from './storage-config-parser.utils' + type ResolveOverrides = { builderConfig?: BuilderConfig storageConfig?: StorageConfig @@ -131,6 +138,7 @@ export class PhotoStorageService { if (accessKeyId) result.accessKeyId = accessKeyId const secretAccessKey = normalizeStringToUndefined(config.secretAccessKey) if (secretAccessKey) result.secretAccessKey = secretAccessKey + const prefix = normalizeStringToUndefined(config.prefix) if (prefix) result.prefix = prefix const customDomain = normalizeStringToUndefined(config.customDomain) @@ -138,27 +146,27 @@ export class PhotoStorageService { const excludeRegex = normalizeStringToUndefined(config.excludeRegex) if (excludeRegex) result.excludeRegex = excludeRegex - const maxFileLimit = this.parseNumber(config.maxFileLimit) + const maxFileLimit = parseNumber(config.maxFileLimit) if (typeof maxFileLimit === 'number') result.maxFileLimit = maxFileLimit - const keepAlive = this.parseBoolean(config.keepAlive) + const keepAlive = parseBoolean(config.keepAlive) if (typeof keepAlive === 'boolean') result.keepAlive = keepAlive - const maxSockets = this.parseNumber(config.maxSockets) + const maxSockets = parseNumber(config.maxSockets) if (typeof maxSockets === 'number') result.maxSockets = maxSockets - const connectionTimeoutMs = this.parseNumber(config.connectionTimeoutMs) + const connectionTimeoutMs = parseNumber(config.connectionTimeoutMs) if (typeof connectionTimeoutMs === 'number') result.connectionTimeoutMs = connectionTimeoutMs - const socketTimeoutMs = this.parseNumber(config.socketTimeoutMs) + const socketTimeoutMs = parseNumber(config.socketTimeoutMs) if (typeof socketTimeoutMs === 'number') result.socketTimeoutMs = socketTimeoutMs - const requestTimeoutMs = this.parseNumber(config.requestTimeoutMs) + const requestTimeoutMs = parseNumber(config.requestTimeoutMs) if (typeof requestTimeoutMs === 'number') result.requestTimeoutMs = requestTimeoutMs - const idleTimeoutMs = this.parseNumber(config.idleTimeoutMs) + const idleTimeoutMs = parseNumber(config.idleTimeoutMs) if (typeof idleTimeoutMs === 'number') result.idleTimeoutMs = idleTimeoutMs - const totalTimeoutMs = this.parseNumber(config.totalTimeoutMs) + const totalTimeoutMs = parseNumber(config.totalTimeoutMs) if (typeof totalTimeoutMs === 'number') result.totalTimeoutMs = totalTimeoutMs - const retryMode = this.parseRetryMode(config.retryMode) + const retryMode = parseRetryMode(config.retryMode) if (retryMode) result.retryMode = retryMode - const maxAttempts = this.parseNumber(config.maxAttempts) + const maxAttempts = parseNumber(config.maxAttempts) if (typeof maxAttempts === 'number') result.maxAttempts = maxAttempts - const downloadConcurrency = this.parseNumber(config.downloadConcurrency) + const downloadConcurrency = parseNumber(config.downloadConcurrency) if (typeof downloadConcurrency === 'number') result.downloadConcurrency = downloadConcurrency const sigV4Service = normalizeStringToUndefined(config.sigV4Service) if (sigV4Service) result.sigV4Service = sigV4Service @@ -181,7 +189,7 @@ export class PhotoStorageService { if (token) result.token = token const pathValue = normalizeStringToUndefined(config.path) if (pathValue) result.path = pathValue - const useRawUrl = this.parseBoolean(config.useRawUrl) + const useRawUrl = parseBoolean(config.useRawUrl) if (typeof useRawUrl === 'boolean') result.useRawUrl = useRawUrl return result @@ -216,11 +224,11 @@ export class PhotoStorageService { const excludeRegex = normalizeStringToUndefined(config.excludeRegex) if (excludeRegex) result.excludeRegex = excludeRegex - const maxFileLimit = this.parseNumber(config.maxFileLimit) + const maxFileLimit = parseNumber(config.maxFileLimit) if (typeof maxFileLimit === 'number') result.maxFileLimit = maxFileLimit - const authorizationTtlMs = this.parseNumber(config.authorizationTtlMs) + const authorizationTtlMs = parseNumber(config.authorizationTtlMs) if (typeof authorizationTtlMs === 'number') result.authorizationTtlMs = authorizationTtlMs - const uploadUrlTtlMs = this.parseNumber(config.uploadUrlTtlMs) + const uploadUrlTtlMs = parseNumber(config.uploadUrlTtlMs) if (typeof uploadUrlTtlMs === 'number') result.uploadUrlTtlMs = uploadUrlTtlMs return result @@ -242,45 +250,6 @@ export class PhotoStorageService { } } - private parseNumber(value?: string | null): number | undefined { - const normalized = normalizeStringToUndefined(value) - if (!normalized) { - return undefined - } - - const parsed = Number(normalized) - return Number.isFinite(parsed) ? parsed : undefined - } - - private parseBoolean(value?: string | null): boolean | undefined { - const normalized = normalizeStringToUndefined(value) - if (!normalized) { - return undefined - } - - const lowered = normalized.toLowerCase() - if (['true', '1', 'yes', 'y', 'on'].includes(lowered)) { - return true - } - if (['false', '0', 'no', 'n', 'off'].includes(lowered)) { - return false - } - return undefined - } - - private parseRetryMode(value?: string | null): S3CompatibleConfig['retryMode'] | undefined { - const normalized = normalizeStringToUndefined(value) - if (!normalized) { - return undefined - } - - if (normalized === 'standard' || normalized === 'adaptive' || normalized === 'legacy') { - return normalized - } - - return undefined - } - private ensureUserSettings(config: BuilderConfig): NonNullable { if (!config.user) { config.user = { diff --git a/be/apps/core/src/modules/content/photo/storage/storage-config-parser.utils.ts b/be/apps/core/src/modules/content/photo/storage/storage-config-parser.utils.ts new file mode 100644 index 00000000..daa67885 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/storage/storage-config-parser.utils.ts @@ -0,0 +1,19 @@ +import type { S3CompatibleConfig } from '@afilmory/builder/storage/interfaces.js' +import { normalizeStringToUndefined } from 'core/helpers/normalize.helper' + +/** + * Parses a string value to S3 retry mode. + * Returns undefined if the value is not a valid retry mode. + */ +export function parseRetryMode(value?: string | null): S3CompatibleConfig['retryMode'] | undefined { + const normalized = normalizeStringToUndefined(value) + if (!normalized) { + return undefined + } + + if (normalized === 'standard' || normalized === 'adaptive' || normalized === 'legacy') { + return 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 c6b71b8f..666e1d93 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 @@ -9,7 +9,7 @@ const s3CompatibleBaseSchema = z.object({ 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(), diff --git a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts index bbaf02be..6cf0a696 100644 --- a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts +++ b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts @@ -6,6 +6,7 @@ import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets, ph import { createLogger, EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' +import { formatBytesToMb } from 'core/modules/content/photo/access/storage-access.utils' import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service' import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service' import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants' @@ -1389,11 +1390,6 @@ export class DataSyncService { return value * 1024 * 1024 } - private formatBytesToMb(size: number): number { - const mb = size / (1024 * 1024) - return Number(mb.toFixed(2)) - } - private ensureLibraryCapacityLimit(payload: { current: number; incoming: number; limit: number | null }): void { if (payload.limit === null || payload.incoming === 0) { return @@ -1420,8 +1416,8 @@ export class DataSyncService { return } - const readableLimit = limits?.maxObjectSizeMb ?? this.formatBytesToMb(maxBytes) - const actualSize = this.formatBytesToMb(size) + const readableLimit = limits?.maxObjectSizeMb ?? formatBytesToMb(maxBytes) + const actualSize = formatBytesToMb(size) throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: `存储对象 ${storageObject.key} (${actualSize} MB) 超出允许的同步大小 ${readableLimit} MB`, diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts index e1a0969c..cd7eb35c 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts @@ -37,7 +37,7 @@ export interface StaticAssetServiceOptions { export interface ResolvedStaticAsset { absolutePath: string relativePath: string - stats: Stats + stats: Pick } export abstract class StaticAssetService { @@ -394,7 +394,7 @@ export abstract class StaticAssetService { return new Response(transformed, { headers, status: 200 }) } - private async transformIndexHtml(html: string, file: ResolvedStaticAsset): Promise { + public async transformIndexHtml(html: string, file: ResolvedStaticAsset): Promise { try { const document = DOM_PARSER.parseFromString(html, 'text/html') as unknown as StaticAssetDocument await this.decorateDocument(document, file) diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web-dev.template.html b/be/apps/core/src/modules/infrastructure/static-web/static-web-dev.template.html new file mode 100644 index 00000000..942f1792 --- /dev/null +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web-dev.template.html @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + Innei's Afilmory + + + + + + + + + +
+ +
+ +
+ +
+ + + + + +
+ + +
+

+ Innei's Afilmory +

+

+ Capturing beautiful moments in life, documenting daily warmth and emotions through my lens. +

+
+ + +
+ +
+
+
+
+
+
+
+ + + +
+
+ + + diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts index c9be4ebc..077c1365 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts @@ -1,12 +1,19 @@ -import { ContextParam, Controller, Get, Param } from '@afilmory/framework' +import { ContextParam, Controller, createZodSchemaDto, Get, Param, Query } from '@afilmory/framework' import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator' import type { Context } from 'hono' +import z from 'zod' import { StaticBaseController } from './static-base.controller' import { StaticControllerUtils } from './static-controller.utils' import { StaticDashboardService } from './static-dashboard.service' import { StaticWebService } from './static-web.service' +import devTemplate from './static-web-dev.template.html?raw' +class StaticWebDto extends createZodSchemaDto( + z.object({ + dev: z.url().optional(), + }), +) {} @Controller({ bypassGlobalPrefix: true }) export class StaticWebController extends StaticBaseController { constructor(staticWebService: StaticWebService, staticDashboardService: StaticDashboardService) { @@ -16,7 +23,11 @@ export class StaticWebController extends StaticBaseController { @Get('/') @Get('/explory') @SkipTenantGuard() - async getStaticWebIndex(@ContextParam() context: Context) { + async getStaticWebIndex(@ContextParam() context: Context, @Query() query: StaticWebDto) { + if (query.dev) { + return await this.serveDev(context, query.dev.toString()) + } + if (StaticControllerUtils.isReservedTenant({ root: true })) { return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService) } @@ -45,4 +56,24 @@ export class StaticWebController extends StaticBaseController { } return await this.staticWebService.decoratePhotoPageResponse(context, photoId, response) } + + private async serveDev(context: Context, devHost: string) { + const template = devTemplate.replaceAll('{{host}}', devHost) + const transformed = await this.staticWebService.transformIndexHtml(template, { + absolutePath: '/', + relativePath: '/', + stats: { + mtime: new Date(), + size: template.length, + }, + }) + return this.staticWebService.decorateHomepageResponse( + context, + new Response(transformed, { + headers: { + 'Content-Type': 'text/html', + }, + }), + ) + } } 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 41dce3ad..a6e0ac91 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 @@ -8,27 +8,29 @@ import type { StorageUploadOptions, } from '@afilmory/builder/storage/interfaces.js' +import { joinSegments, normalizePath } from '../../content/photo/access/storage-access.utils' + type PrefixedStorageObject = StorageObject & { key: string } export class ManagedStorageProvider implements StorageProvider { private readonly upstream: StorageProvider - private readonly upstreamConfig: RemoteStorageConfig + private readonly effectivePrefix: string private readonly needsManualPrefix: boolean constructor(private readonly config: ManagedStorageConfig) { - const tenantSegment = this.normalizePath(config.tenantId) + const tenantSegment = normalizePath(config.tenantId) if (!tenantSegment) { throw new Error('Managed storage provider requires a valid tenantId.') } - const upstreamBase = this.normalizePath(this.extractUpstreamBasePath(config.upstream)) - const customBase = this.normalizePath(config.basePrefix) - const combinedBase = this.joinSegments(upstreamBase, customBase) - this.effectivePrefix = this.joinSegments(combinedBase, tenantSegment) + const upstreamBase = normalizePath(this.extractUpstreamBasePath(config.upstream)) + const customBase = normalizePath(config.basePrefix) + const combinedBase = joinSegments(upstreamBase, customBase) + this.effectivePrefix = joinSegments(combinedBase, tenantSegment) const scopedConfig = this.applyTenantPrefix(config.upstream, this.effectivePrefix) - this.upstreamConfig = scopedConfig + this.needsManualPrefix = scopedConfig.provider === 's3' || scopedConfig.provider === 'oss' || scopedConfig.provider === 'cos' this.upstream = StorageFactory.createProvider(scopedConfig) @@ -92,12 +94,12 @@ export class ManagedStorageProvider implements StorageProvider { } private prepareKeyForUpstream(key: string): string { - const normalizedKey = this.normalizePath(key) ?? '' + const normalizedKey = normalizePath(key) || '' if (!this.needsManualPrefix) { return normalizedKey } - return this.joinSegments(this.effectivePrefix, normalizedKey) + return joinSegments(this.effectivePrefix, normalizedKey) } private toUpstreamObjects(objects: StorageObject[]): PrefixedStorageObject[] { @@ -132,7 +134,7 @@ export class ManagedStorageProvider implements StorageProvider { } private stripEffectivePrefix(rawKey: string): string { - const normalizedKey = this.normalizePath(rawKey) ?? '' + const normalizedKey = normalizePath(rawKey) || '' if (!this.effectivePrefix) { return normalizedKey } @@ -149,38 +151,18 @@ export class ManagedStorageProvider implements StorageProvider { return normalizedKey } - private normalizePath(value?: string | null): string | null { - if (!value) { - return null - } - const normalized = value - .replaceAll('\\', '/') - .replaceAll(/\/+/g, '/') - .replaceAll(/^\/+|\/+$/g, '') - return normalized.length > 0 ? normalized : null - } - - private joinSegments(...segments: Array): string { - const filtered = segments.filter((segment): segment is string => typeof segment === 'string' && segment.length > 0) - if (filtered.length === 0) { - return '' - } - return filtered - .map((segment) => segment.replaceAll(/^\/+|\/+$/g, '')) - .filter(Boolean) - .join('/') - } - private extractUpstreamBasePath(config: RemoteStorageConfig): string | null { switch (config.provider) { case 's3': case 'oss': case 'cos': case 'b2': { - return this.normalizePath(config.prefix) + const normalized = normalizePath(config.prefix) + return normalized || null } case 'github': { - return this.normalizePath(config.path) + const normalized = normalizePath(config.path) + return normalized || null } default: { return null @@ -189,7 +171,7 @@ export class ManagedStorageProvider implements StorageProvider { } private applyTenantPrefix(config: RemoteStorageConfig, prefix: string): RemoteStorageConfig { - const normalizedPrefix = this.normalizePath(prefix) + const normalizedPrefix = normalizePath(prefix) || null if (!normalizedPrefix) { return config } diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts b/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts index 8843eebd..57829aac 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts @@ -18,6 +18,7 @@ import type { DataSyncProgressEmitter } from 'core/modules/infrastructure/data-s import { createProgressSseResponse } from 'core/modules/shared/http/sse' import type { Context } from 'hono' +import { joinSegments, normalizeKeyPath } from '../../content/photo/access/storage-access.utils' import type { BuilderDebugProgressEvent, StorageResolution, UploadedDebugFile } from './InMemoryDebugStorageProvider' import { InMemoryDebugStorageProvider } from './InMemoryDebugStorageProvider' @@ -203,11 +204,11 @@ export class SuperAdminBuilderDebugController { const ext = path.extname(filename) const safeExt = ext || '.jpg' const baseName = `${Date.now()}-${randomUUID()}` - return this.joinSegments(DEBUG_STORAGE_PREFIX, `${baseName}${safeExt}`) + return joinSegments(DEBUG_STORAGE_PREFIX, `${baseName}${safeExt}`) } private normalizeStorageObjectKey(object: StorageObject, fallbackKey: string): StorageObject { - const normalizedKey = this.normalizeKeyPath(object?.key ?? fallbackKey) + const normalizedKey = normalizeKeyPath(object?.key ?? fallbackKey) if (normalizedKey === object?.key) { return object } @@ -217,22 +218,6 @@ export class SuperAdminBuilderDebugController { } } - private normalizeKeyPath(raw: string | undefined | null): string { - if (!raw) { - return '' - } - const segments = raw.split(/[\\/]+/) - const safeSegments: string[] = [] - for (const segment of segments) { - const trimmed = segment.trim() - if (!trimmed || trimmed === '.' || trimmed === '..') { - continue - } - safeSegments.push(trimmed) - } - return safeSegments.join('/') - } - private async cleanupDebugArtifacts(storageManager: StorageManager, keys: Set): Promise { let success = true for (const key of keys) { @@ -256,27 +241,8 @@ export class SuperAdminBuilderDebugController { return null } - const remotePrefix = this.joinSegments(DEFAULT_THUMBNAIL_DIRECTORY) + const remotePrefix = joinSegments(DEFAULT_THUMBNAIL_DIRECTORY) - return this.joinSegments(remotePrefix, data.fileName) - } - - private joinSegments(...segments: Array): string { - const parts: string[] = [] - - for (const raw of segments) { - if (!raw) { - continue - } - const normalized = raw - .replaceAll('\\', '/') - .replaceAll(/^\/+|\/+$/g, '') - .trim() - if (normalized.length > 0) { - parts.push(normalized) - } - } - - return parts.join('/') + return joinSegments(remotePrefix, data.fileName) } } diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts index df05f1c9..6da165fd 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts @@ -68,6 +68,7 @@ const updateSuperAdminSettingsSchema = z storagePlanProducts: z.record(z.string(), z.any()).optional(), managedStorageProvider: z.string().trim().min(1).nullable().optional(), managedStorageProviders: z.array(storageProviderSchema).optional(), + managedStorageSecureAccess: z.boolean().optional(), ...planQuotaFields, ...planPricingFields, ...planProductFields, 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 050c31f5..9dc1eabe 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx @@ -146,13 +146,7 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit {t(storageProvidersI18nKeys.card.makeInactive)} ) : ( - diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx index 7df9b82e..441730ac 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, Prompt } from '@afilmory/ui' +import { Button, Modal, Prompt, Switch } from '@afilmory/ui' import { Spring } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' import { m } from 'motion/react' @@ -13,7 +13,12 @@ import { useBlock } from '~/hooks/useBlock' import { useManagedStoragePlansQuery } from '~/modules/storage-plans' import { MANAGED_STORAGE_ACTIVE_ID, storageProvidersI18nKeys } from '../constants' -import { useStorageProviderSchemaQuery, useStorageProvidersQuery, useUpdateStorageProvidersMutation } from '../hooks' +import { + useStorageProviderSchemaQuery, + useStorageProvidersQuery, + useUpdateStorageProvidersMutation, + useUpdateStorageSecureAccessMutation, +} from '../hooks' import type { StorageProvider } from '../types' import { createEmptyProvider } from '../utils' import { ManagedStorageEntryCard } from './ManagedStorageEntryCard' @@ -35,6 +40,7 @@ export function StorageProvidersManager() { error: schemaError, } = useStorageProviderSchemaQuery() const updateMutation = useUpdateStorageProvidersMutation() + const secureAccessMutation = useUpdateStorageSecureAccessMutation() const managedPlansQuery = useManagedStoragePlansQuery() const { setHeaderActionState } = useMainPageLayout() const navigate = useNavigate() @@ -50,6 +56,7 @@ export function StorageProvidersManager() { const [providers, setProviders] = useState([]) const [activeProviderId, setActiveProviderId] = useState(null) + const [secureAccessEnabled, setSecureAccessEnabled] = useState(false) const [isDirty, setIsDirty] = useState(false) const initialProviderStateRef = useRef(null) const hasShownSyncPromptRef = useRef(false) @@ -73,6 +80,7 @@ export function StorageProvidersManager() { startTransition(() => { setProviders(initialProviders) setActiveProviderId(activeId) + setSecureAccessEnabled(data.secureAccessEnabled ?? false) setIsDirty(false) }) }, [data]) @@ -157,6 +165,7 @@ export function StorageProvidersManager() { { providers, activeProviderId: resolvedActiveId, + secureAccessEnabled, }, { onSuccess: () => { @@ -182,6 +191,19 @@ export function StorageProvidersManager() { ) } + const handleSecureAccessToggle = (nextValue: boolean) => { + if (managedActive) { + return + } + const previous = secureAccessEnabled + setSecureAccessEnabled(nextValue) + secureAccessMutation.mutate(nextValue, { + onError: () => { + setSecureAccessEnabled(previous) + }, + }) + } + const disableSave = isLoading || isError || @@ -316,7 +338,7 @@ export function StorageProvidersManager() { )} - {/* Security Notice */} + {/* Security & Controls */} + + + +
+
+

+ {t(storageProvidersI18nKeys.secureAccess.title)} +

+

+ {t(storageProvidersI18nKeys.secureAccess.description)} +

+

+ {t(storageProvidersI18nKeys.secureAccess.helper)} +

+ {managedActive ? ( +

+ {t(storageProvidersI18nKeys.secureAccess.managedNote)} +

+ ) : null} +
+
+ +
+
+
+
) } diff --git a/be/apps/dashboard/src/modules/storage-providers/constants.ts b/be/apps/dashboard/src/modules/storage-providers/constants.ts index edeb7235..03501923 100644 --- a/be/apps/dashboard/src/modules/storage-providers/constants.ts +++ b/be/apps/dashboard/src/modules/storage-providers/constants.ts @@ -1,9 +1,11 @@ export const STORAGE_SETTING_KEYS = { providers: 'builder.storage.providers', activeProvider: 'builder.storage.activeProvider', + secureAccess: 'photo.storage.secureAccess', } as const satisfies { providers: string activeProvider: string + secureAccess: string } export const MANAGED_STORAGE_ACTIVE_ID = 'managed' @@ -47,6 +49,12 @@ export const storageProvidersI18nKeys = { description: 'storage.providers.security.description', helper: 'storage.providers.security.helper', }, + secureAccess: { + title: 'storage.providers.secure-access.title', + description: 'storage.providers.secure-access.description', + helper: 'storage.providers.secure-access.helper', + managedNote: 'storage.providers.secure-access.managed-note', + }, modal: { createTitle: 'storage.providers.modal.create.title', editTitle: 'storage.providers.modal.edit.title', @@ -122,6 +130,12 @@ export const storageProvidersI18nKeys = { description: I18nKeys helper: I18nKeys } + secureAccess: { + title: I18nKeys + description: I18nKeys + helper: I18nKeys + managedNote: I18nKeys + } modal: { createTitle: I18nKeys editTitle: I18nKeys diff --git a/be/apps/dashboard/src/modules/storage-providers/hooks.ts b/be/apps/dashboard/src/modules/storage-providers/hooks.ts index 94804b45..641d75e6 100644 --- a/be/apps/dashboard/src/modules/storage-providers/hooks.ts +++ b/be/apps/dashboard/src/modules/storage-providers/hooks.ts @@ -17,17 +17,25 @@ export function useStorageProvidersQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: STORAGE_PROVIDERS_QUERY_KEY, queryFn: async () => { - const response = await getStorageSettings([STORAGE_SETTING_KEYS.providers, STORAGE_SETTING_KEYS.activeProvider]) + const response = await getStorageSettings([ + STORAGE_SETTING_KEYS.providers, + STORAGE_SETTING_KEYS.activeProvider, + STORAGE_SETTING_KEYS.secureAccess, + ]) const rawProviders = response.values[STORAGE_SETTING_KEYS.providers] ?? '[]' const providers = parseStorageProviders(rawProviders).map((provider) => normalizeStorageProviderConfig(provider)) const activeProviderRaw = response.values[STORAGE_SETTING_KEYS.activeProvider] ?? '' const activeProviderId = typeof activeProviderRaw === 'string' && activeProviderRaw.trim().length > 0 ? activeProviderRaw.trim() : null + const secureAccessRaw = response.values[STORAGE_SETTING_KEYS.secureAccess] ?? 'false' + const secureAccessEnabled = + typeof secureAccessRaw === 'string' ? secureAccessRaw.trim().toLowerCase() === 'true' : Boolean(secureAccessRaw) return { providers, activeProviderId: ensureActiveProviderId(providers, activeProviderId), + secureAccessEnabled, } }, enabled: options?.enabled ?? true, @@ -51,6 +59,7 @@ export function useUpdateStorageProvidersMutation() { const previousProviders = queryClient.getQueryData<{ providers: StorageProvider[] activeProviderId: string | null + secureAccessEnabled: boolean }>(STORAGE_PROVIDERS_QUERY_KEY)?.providers const resolvedProviders = restoreProviderSecrets(currentProviders, previousProviders ?? []) @@ -70,6 +79,7 @@ export function useUpdateStorageProvidersMutation() { return { providers: resolvedProviders, activeProviderId: resolvedActiveId, + secureAccessEnabled: payload.secureAccessEnabled, } }, onSuccess: (data) => { @@ -81,6 +91,44 @@ export function useUpdateStorageProvidersMutation() { }) } +export function useUpdateStorageSecureAccessMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (enabled: boolean) => { + await updateStorageSettings([ + { + key: STORAGE_SETTING_KEYS.secureAccess, + value: enabled ? 'true' : 'false', + }, + ]) + return enabled + }, + onSuccess: (nextEnabled) => { + queryClient.setQueryData( + STORAGE_PROVIDERS_QUERY_KEY, + ( + previous: + | { + providers: StorageProvider[] + activeProviderId: string | null + secureAccessEnabled: boolean + } + | undefined, + ) => { + if (!previous) { + return previous + } + return { + ...previous, + secureAccessEnabled: nextEnabled, + } + }, + ) + }, + }) +} + function restoreProviderSecrets( nextProviders: StorageProvider[], previousProviders: StorageProvider[], diff --git a/be/apps/dashboard/src/modules/storage-providers/types.ts b/be/apps/dashboard/src/modules/storage-providers/types.ts index 8ee007b9..1eee20b7 100644 --- a/be/apps/dashboard/src/modules/storage-providers/types.ts +++ b/be/apps/dashboard/src/modules/storage-providers/types.ts @@ -12,6 +12,7 @@ export interface StorageProvider { export interface StorageProvidersPayload { providers: StorageProvider[] activeProviderId: string | null + secureAccessEnabled: boolean } export interface StorageSettingEntry { diff --git a/be/apps/dashboard/src/modules/super-admin/components/ManagedStorageSettings.tsx b/be/apps/dashboard/src/modules/super-admin/components/ManagedStorageSettings.tsx index 4954ce45..bda566c9 100644 --- a/be/apps/dashboard/src/modules/super-admin/components/ManagedStorageSettings.tsx +++ b/be/apps/dashboard/src/modules/super-admin/components/ManagedStorageSettings.tsx @@ -1,4 +1,4 @@ -import { Button, Label, Modal } from '@afilmory/ui' +import { Button, Label, Modal, Switch } from '@afilmory/ui' import { nanoid } from 'nanoid' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -8,10 +8,7 @@ import type { StorageProvider } from '~/modules/storage-providers' import { useStorageProviderSchemaQuery } from '~/modules/storage-providers' import { ProviderEditModal } from '~/modules/storage-providers/components/ProviderEditModal' import { storageProvidersI18nKeys } from '~/modules/storage-providers/constants' -import { - createEmptyProvider, - normalizeStorageProviderConfig, -} from '~/modules/storage-providers/utils' +import { createEmptyProvider, normalizeStorageProviderConfig } from '~/modules/storage-providers/utils' import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks' import type { UpdateSuperAdminSettingsPayload } from '../types' @@ -54,6 +51,8 @@ export function ManagedStorageSettings() { const [providers, setProviders] = useState([]) const [baselineProviders, setBaselineProviders] = useState([]) const [managedId, setManagedId] = useState(null) + const [managedSecureAccessEnabled, setManagedSecureAccessEnabled] = useState(false) + const [baselineManagedSecureAccessEnabled, setBaselineManagedSecureAccessEnabled] = useState(false) const settingsSource = useMemo(() => { const payload = settingsQuery.data @@ -76,6 +75,10 @@ export function ManagedStorageSettings() { setBaselineProviders(nextProviders) const fallbackId = baselineManagedId ?? normalizeProviderId(nextProviders[0]?.id) ?? null setManagedId(fallbackId) + const secureAccessFlag = + typeof settingsSource.managedStorageSecureAccess === 'boolean' ? settingsSource.managedStorageSecureAccess : false + setManagedSecureAccessEnabled(secureAccessFlag) + setBaselineManagedSecureAccessEnabled(secureAccessFlag) }, [settingsSource, baselineManagedId]) useEffect(() => { @@ -94,7 +97,8 @@ export function ManagedStorageSettings() { ) const normalizedManagedId = normalizeProviderId(managedId) const managedChanged = normalizedManagedId !== baselineManagedId - const canSave = (providersChanged || managedChanged) && !updateSettings.isPending + const secureAccessChanged = managedSecureAccessEnabled !== baselineManagedSecureAccessEnabled + const canSave = (providersChanged || managedChanged || secureAccessChanged) && !updateSettings.isPending const handleEdit = (provider: StorageProvider | null) => { const providerForm = schemaQuery.data @@ -126,7 +130,7 @@ export function ManagedStorageSettings() { } const handleSave = () => { - if (!providersChanged && !managedChanged) { + if (!providersChanged && !managedChanged && !secureAccessChanged) { return } @@ -137,6 +141,9 @@ export function ManagedStorageSettings() { if (managedChanged) { payload.managedStorageProvider = normalizedManagedId } + if (secureAccessChanged) { + payload.managedStorageSecureAccess = managedSecureAccessEnabled + } updateSettings.mutate(payload) } @@ -182,9 +189,9 @@ export function ManagedStorageSettings() {
{providers.length === 0 ? ( -

{t('superadmin.settings.managed-storage.empty')}

+

{t('superadmin.settings.managed-storage.empty')}

) : ( -
+
{providers.map((provider) => (
)} +
+
+
+

+ {t('superadmin.settings.managed-storage.secure-access.title')} +

+

+ {t('superadmin.settings.managed-storage.secure-access.description')} +

+
+ setManagedSecureAccessEnabled(next)} + disabled={updateSettings.isPending} + /> +
+

+ {t('superadmin.settings.managed-storage.secure-access.helper')} +

+
+