chore: sign

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-25 21:41:29 +08:00
parent e0a78b9391
commit 6e83868d10
42 changed files with 3108 additions and 81 deletions

View File

@@ -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<{
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.basic.info')}</h4>
<div className="space-y-1 text-sm">
<Row label={t('exif.filename')} value={currentPhoto.title} ellipsis={true} />
<Row label={t('exif.format')} value={imageFormat} />
<Row label={t('exif.format')} value={currentPhoto.format} />
<Row label={t('exif.dimensions')} value={`${currentPhoto.width} × ${currentPhoto.height}`} />
<Row label={t('exif.file.size')} value={`${(currentPhoto.size / 1024 / 1024).toFixed(1)}MB`} />
{megaPixels && <Row label={t('exif.pixels')} value={`${megaPixels} MP`} />}

View File

@@ -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<ReactZoomPanPinchRef>(null)
const livePhotoRef = useRef<any>(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,

View File

@@ -24,10 +24,13 @@
"@afilmory/sdk": "workspace:*",
"@afilmory/task-queue": "workspace:*",
"@afilmory/utils": "workspace:*",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/client-s3": "3.929.0",
"@creem_io/better-auth": "0.0.8",
"@hono/node-server": "^1.19.6",
"@resvg/resvg-js": "2.6.2",
"@smithy/protocol-http": "5.3.5",
"@smithy/signature-v4": "5.3.5",
"@types/busboy": "1.5.4",
"better-auth": "1.3.34",
"busboy": "1.6.0",

View File

@@ -0,0 +1,4 @@
/**
* Application constants
*/
export const APP_GLOBAL_PREFIX = '/api' as const

View File

@@ -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<HonoHttpApplication> {
export async function createConfiguredApp(options: BootstrapOptions): Promise<HonoHttpApplication> {
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,
)

View File

@@ -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<void> {
const app = await createConfiguredApp({
globalPrefix: '/api',
globalPrefix: APP_GLOBAL_PREFIX,
})
const container = app.getContainer()

View File

@@ -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
}
}

View File

@@ -104,3 +104,13 @@ 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
}

View File

@@ -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()

View File

@@ -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(),

View File

@@ -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' })
}
}

View File

@@ -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 {

View File

@@ -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 = [

View File

@@ -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<boolean> {
const settings = await this.getSettings()
return settings.managedStorageSecureAccess ?? false
}
async getStoragePlanPricing(): Promise<StoragePlanPricingConfigs> {
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) {

View File

@@ -34,6 +34,7 @@ export interface SystemSettings {
storagePlanPricing: StoragePlanPricingConfigs
managedStorageProvider: string | null
managedStorageProviders: BuilderStorageProvider[]
managedStorageSecureAccess: boolean
}
export type SystemSettingValueMap = {

View File

@@ -1,13 +1,21 @@
import type { AfilmoryManifest, CameraInfo, LensInfo, PhotoManifestItem } from '@afilmory/builder'
import { CURRENT_PHOTO_MANIFEST_VERSION, photoAssets } from '@afilmory/db'
import { APP_GLOBAL_PREFIX } from 'core/app.constants'
import { DbAccessor } from 'core/database/database.provider'
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 { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { and, eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@injectable()
export class ManifestService {
constructor(private readonly dbAccessor: DbAccessor) {}
constructor(
private readonly dbAccessor: DbAccessor,
private readonly settingService: SettingService,
private readonly systemSettingService: SystemSettingService,
) {}
async getManifest(): Promise<AfilmoryManifest> {
const tenant = requireTenantContext()
@@ -29,13 +37,24 @@ export class ManifestService {
}
}
const secureAccessEnabled = await this.isSecureAccessEnabled(tenant.tenant.id)
const items: PhotoManifestItem[] = []
for (const record of records) {
const item = record.manifest?.data
if (item) {
items.push(item)
if (!item) {
continue
}
const normalized = structuredClone(item)
if (secureAccessEnabled) {
if (normalized.s3Key) {
normalized.originalUrl = this.createProxyUrl(normalized.s3Key)
}
if (normalized.video?.type === 'live-photo' && normalized.video.s3Key) {
normalized.video.videoUrl = this.createProxyUrl(normalized.video.s3Key, 'live-video')
}
}
items.push(normalized)
}
const sorted = this.sortByDateDesc(items)
@@ -120,4 +139,22 @@ export class ManifestService {
return Array.from(lensMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName))
}
private async isSecureAccessEnabled(tenantId: string): Promise<boolean> {
const activeProvider = await this.settingService.get('builder.storage.activeProvider', { tenantId })
if (activeProvider?.trim() === 'managed') {
return await this.systemSettingService.isManagedStorageSecureAccessEnabled()
}
const value = await this.settingService.get('photo.storage.secureAccess', { tenantId })
return normalizedBoolean(value ?? 'false')
}
private 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()}`
}
}

View File

@@ -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')
}
}

View File

@@ -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) {}

View File

@@ -0,0 +1,613 @@
import type { B2Config, ManagedStorageConfig, S3CompatibleConfig, StorageConfig } from '@afilmory/builder'
import { createS3Client } from '@afilmory/builder/s3/client.js'
import { DATABASE_ONLY_PROVIDER, generateId, photoAccessLogs, photoAccessStats, photoAssets } from '@afilmory/db'
import { Sha256 } from '@aws-crypto/sha256-js'
import { HttpRequest } from '@smithy/protocol-http'
import { SignatureV4 } from '@smithy/signature-v4'
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'
type RemoteAccessTarget =
| {
kind: 's3'
config: S3CompatibleConfig
objectKey: string
}
| {
kind: 'b2'
config: B2Config
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
}
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')
private readonly b2Client = new B2SigningClient()
constructor(
private readonly dbAccessor: DbAccessor,
private readonly photoStorageService: PhotoStorageService,
private readonly settingService: SettingService,
private readonly systemSettingService: SystemSettingService,
) {}
createProxyUrl(storageKey: string, intent = 'photo'): string {
const params = new URLSearchParams()
params.set('objectKey', storageKey)
if (intent) {
params.set('intent', intent)
}
return `/api/storage/sign?${params.toString()}`
}
async isSecureAccessEnabled(): Promise<boolean> {
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<boolean> {
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<IssueSignedUrlResult> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const normalizedKey = this.normalizeKeyPath(options.storageKey)
if (!normalizedKey) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少有效的 storage key' })
}
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) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '未找到对应的图片资源' })
}
if (record.storageProvider === DATABASE_ONLY_PROVIDER) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前资源不支持生成访问链接' })
}
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 } = await this.createProviderSignedUrl(target, ttl)
const tokenId = generateId()
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 }
}
private async createProviderSignedUrl(
target: RemoteAccessTarget,
ttlSeconds: number,
): Promise<{ url: string; expiresAt: string }> {
if (target.kind === 's3') {
return await this.createS3SignedUrl(target.config, target.objectKey, ttlSeconds)
}
return await this.b2Client.createSignedUrl(target.config, target.objectKey, ttlSeconds)
}
private resolveRemoteTarget(config: StorageConfig, key: string, tenantId: string): RemoteAccessTarget {
switch (config.provider) {
case 'managed': {
if (config.provider !== 'managed') {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Invalid managed storage config' })
}
const managedConfig = config as ManagedStorageConfig
const managedKey = this.applyManagedPrefix(managedConfig, key)
return this.resolveRemoteTarget(managedConfig.upstream, managedKey, tenantId)
}
case 's3':
case 'oss':
case 'cos': {
if (config.provider !== 's3' && config.provider !== 'oss' && config.provider !== 'cos') {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Invalid S3-compatible storage config' })
}
const s3Config = config as S3CompatibleConfig
return { kind: 's3', config: s3Config, objectKey: key }
}
case 'b2': {
if (config.provider !== 'b2') {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Invalid B2 storage config' })
}
const b2Config = config as B2Config
const remoteKey = this.applyRemotePrefix(b2Config.prefix, key)
return { kind: 'b2', config: b2Config, objectKey: remoteKey }
}
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 = this.normalizePath(config.tenantId)
const upstreamBase = this.normalizePath(this.extractUpstreamBasePath(config.upstream))
const customBase = this.normalizePath(config.basePrefix)
const combined = this.joinSegments(upstreamBase, customBase, tenantSegment)
return this.joinSegments(combined, key)
}
private extractUpstreamBasePath(config: StorageConfig): string | null {
switch (config.provider) {
case 's3':
case 'oss':
case 'cos': {
const s3Config = config as S3CompatibleConfig
return this.normalizePath(s3Config.prefix)
}
case 'b2': {
const b2Config = config as B2Config
return this.normalizePath(b2Config.prefix)
}
case 'github': {
const githubConfig = config as { provider: 'github'; path?: string | null }
return this.normalizePath(githubConfig.path)
}
default: {
return null
}
}
}
private applyRemotePrefix(prefix: string | null | undefined, key: string): string {
const normalizedPrefix = this.normalizePath(prefix)
const normalizedKey = this.normalizePath(key)
if (!normalizedPrefix) {
return normalizedKey
}
if (!normalizedKey) {
return normalizedPrefix
}
return `${normalizedPrefix}/${normalizedKey}`
}
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 normalizeKeyPath(value: string): string {
if (!value) {
return ''
}
const segments = value.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 normalizePath(value?: string | null): string {
if (!value) {
return ''
}
return value
.replaceAll('\\', '/')
.replaceAll(/\/+/g, '/')
.replaceAll(/^\/+|\/+$/g, '')
}
private joinSegments(...segments: Array<string | null>): 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 inferS3ServiceName(provider: S3CompatibleConfig['provider']): string {
switch (provider) {
case 'oss': {
return 'oss'
}
case 'cos': {
return 's3' // COS 仍兼容 s3 签名
}
default: {
return 's3'
}
}
}
private formatHttpRequestUrl(request: HttpRequest): string {
const protocol = request.protocol ?? 'https:'
const hostname = request.hostname ?? 'localhost'
const port = request.port ? `:${request.port}` : ''
const path = request.path?.startsWith('/') ? request.path : `/${request.path ?? ''}`
const queryString = this.stringifyQuery(request.query ?? {})
return `${protocol}//${hostname}${port}${path}${queryString}`
}
private stringifyQuery(query: HttpRequest['query']): string {
const entries: string[] = []
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined) {
continue
}
if (Array.isArray(value)) {
value.forEach((entry) => {
if (entry !== undefined) {
entries.push(`${encodeURIComponent(key)}=${encodeURIComponent(entry)}`)
}
})
} else {
entries.push(`${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`)
}
}
if (entries.length === 0) {
return ''
}
return `?${entries.join('&')}`
}
private createQueryRecord(params: URLSearchParams): HttpRequest['query'] {
const record: Record<string, string | string[]> = {}
for (const [key, value] of params.entries()) {
if (record[key] === undefined) {
record[key] = value
} else if (Array.isArray(record[key])) {
;(record[key] as string[]).push(value)
} else {
record[key] = [record[key] as string, value]
}
}
return record
}
private async createS3SignedUrl(
config: S3CompatibleConfig,
key: string,
ttlSeconds: number,
): Promise<{ url: string; expiresAt: string }> {
if (!config.accessKeyId || !config.secretAccessKey) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'S3 存储配置缺少访问密钥' })
}
const client = createS3Client(config)
const objectUrl = client.buildObjectUrl(key)
const url = new URL(objectUrl)
const signer = new SignatureV4({
service: config.sigV4Service ?? this.inferS3ServiceName(config.provider),
region: config.region ?? 'us-east-1',
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
sessionToken: config.sessionToken,
},
sha256: Sha256,
})
const headers: Record<string, string> = {
host: url.host,
}
const query = this.createQueryRecord(url.searchParams)
const hasLowerContentSha = Object.keys(query).some((key) => key.toLowerCase() === 'x-amz-content-sha256')
if (!hasLowerContentSha) {
query['X-Amz-Content-Sha256'] = 'UNSIGNED-PAYLOAD'
}
const hasLowerSecurityToken = Object.keys(query).some((key) => key.toLowerCase() === 'x-amz-security-token')
if (config.sessionToken && !hasLowerSecurityToken) {
query['X-Amz-Security-Token'] = config.sessionToken
}
if (!('x-amz-checksum-mode' in query)) {
query['x-amz-checksum-mode'] = 'ENABLED'
}
if (!('x-id' in query)) {
query['x-id'] = 'GetObject'
}
const request = new HttpRequest({
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
method: 'GET',
path: url.pathname,
query,
headers,
})
const signed = await signer.presign(request, { expiresIn: ttlSeconds })
return {
url: this.formatHttpRequestUrl(signed as HttpRequest),
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
}
}
}
class B2SigningClient {
private readonly authorizationCache = new Map<string, AuthorizationState>()
private readonly bucketNameCache = new Map<string, string>()
async createSignedUrl(
config: B2Config,
remoteKey: string,
ttlSeconds: number,
): Promise<{ url: string; expiresAt: string }> {
const normalizedKey = this.encodeFileName(remoteKey)
const authorization = await this.authorize(config)
const bucketName = await this.resolveBucketName(config, authorization)
const validDuration = Math.min(Math.max(ttlSeconds, MIN_TTL_SECONDS), MAX_TTL_SECONDS)
const token = await this.getDownloadToken(config, authorization, remoteKey, validDuration)
const baseUrl = authorization.downloadUrl.replace(/\/+$/, '')
const url = `${baseUrl}/file/${bucketName}/${normalizedKey}?Authorization=${token}`
return {
url,
expiresAt: new Date(Date.now() + validDuration * 1000).toISOString(),
}
}
private async authorize(config: B2Config, force = false): Promise<AuthorizationState> {
const cacheKey = `${config.applicationKeyId}:${config.bucketId}`
const cached = this.authorizationCache.get(cacheKey)
if (!force && cached && cached.expiresAt > Date.now()) {
return cached
}
const basicToken = Buffer.from(`${config.applicationKeyId}:${config.applicationKey}`).toString('base64')
const response = await fetch('https://api.backblazeb2.com/b2api/v3/b2_authorize_account', {
headers: {
Authorization: `Basic ${basicToken}`,
},
})
const text = await response.text()
if (!response.ok) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: this.formatB2Error(response.status, text) })
}
const payload = text ? (JSON.parse(text) as B2AuthorizeAccountResponse) : null
const storageApi = payload?.apiInfo?.storageApi
const apiUrl = payload?.apiUrl ?? storageApi?.apiUrl
const downloadUrl = payload?.downloadUrl ?? storageApi?.downloadUrl
if (!apiUrl || !downloadUrl) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '无法从 B2 获取有效的 API 地址,请检查凭证或网络',
})
}
const next: AuthorizationState = {
token: payload?.authorizationToken ?? '',
apiUrl,
downloadUrl,
allowedBucketId: payload?.allowed?.bucketId ?? storageApi?.bucketId ?? null,
allowedBucketName: payload?.allowed?.bucketName ?? storageApi?.bucketName ?? null,
expiresAt: Date.now() + 1000 * 60 * 60 * 22,
}
this.authorizationCache.set(cacheKey, next)
if (config.bucketName) {
this.bucketNameCache.set(config.bucketId, config.bucketName)
} else if (next.allowedBucketName && next.allowedBucketId) {
this.bucketNameCache.set(next.allowedBucketId, next.allowedBucketName)
}
return next
}
private async getDownloadToken(
config: B2Config,
authorization: AuthorizationState,
remoteKey: string,
ttlSeconds: number,
): Promise<string> {
const payload = {
bucketId: config.bucketId,
fileNamePrefix: remoteKey,
validDurationInSeconds: ttlSeconds,
}
const response = await fetch(`${authorization.apiUrl.replace(/\/+$/, '')}/b2api/v3/b2_get_download_authorization`, {
method: 'POST',
headers: {
Authorization: authorization.token,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const text = await response.text()
if (response.status === 401) {
const refreshed = await this.authorize(config, true)
return await this.getDownloadToken(config, refreshed, remoteKey, ttlSeconds)
}
if (!response.ok) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: this.formatB2Error(response.status, text) })
}
const data = text ? (JSON.parse(text) as { authorizationToken: string }) : null
if (!data?.authorizationToken) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'B2 下载授权响应格式异常' })
}
return data.authorizationToken
}
private async resolveBucketName(config: B2Config, authorization: AuthorizationState): Promise<string> {
if (config.bucketName) {
return config.bucketName
}
const cached = this.bucketNameCache.get(config.bucketId)
if (cached) {
return cached
}
if (authorization.allowedBucketName && authorization.allowedBucketId === config.bucketId) {
this.bucketNameCache.set(config.bucketId, authorization.allowedBucketName)
return authorization.allowedBucketName
}
const response = await fetch(`${authorization.apiUrl.replace(/\/+$/, '')}/b2api/v3/b2_get_bucket`, {
method: 'POST',
headers: {
Authorization: authorization.token,
'Content-Type': 'application/json',
},
body: JSON.stringify({
bucketId: config.bucketId,
}),
})
const text = await response.text()
if (!response.ok) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: this.formatB2Error(response.status, text) })
}
const data = text ? (JSON.parse(text) as B2BucketResponse) : null
if (!data?.bucketName) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '无法解析 B2 bucket 信息' })
}
this.bucketNameCache.set(config.bucketId, data.bucketName)
return data.bucketName
}
private encodeFileName(value: string): string {
return value
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
}
private formatB2Error(status: number, payload: string | null): string {
if (!payload) {
return `B2 API 请求失败 (status ${status})`
}
try {
const parsed = JSON.parse(payload) as { code?: string; message?: string }
if (parsed?.code || parsed?.message) {
const parts: string[] = []
if (parsed.code) parts.push(`[${parsed.code}]`)
if (parsed.message) parts.push(parsed.message)
return `B2 API 请求失败 (status ${status}) ${parts.join(' ')}`
}
} catch {
// ignore
}
return `B2 API 请求失败 (status ${status}) ${payload}`
}
}
interface B2AuthorizeAccountResponse {
authorizationToken?: string
apiUrl?: string
downloadUrl?: string
apiInfo?: {
storageApi?: {
apiUrl?: string
downloadUrl?: string
bucketId?: string | null
bucketName?: string | null
}
}
allowed?: {
bucketId?: string | null
bucketName?: string | null
}
}
interface AuthorizationState {
token: string
apiUrl: string
downloadUrl: string
allowedBucketId: string | null
allowedBucketName: string | null
expiresAt: number
}
interface B2BucketResponse {
bucketId: string
bucketName: string
}

View File

@@ -31,6 +31,7 @@ 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 { PhotoBuilderService } from '../builder/photo-builder.service'
import { PhotoStorageService } from '../storage/photo-storage.service'
import { TransactionalStorageManager } from '../storage/transactional-storage.manager'
@@ -70,6 +71,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 +98,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,
@@ -302,6 +306,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 +358,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 +510,7 @@ export class PhotoAssetService {
videoBufferMap,
abortSignal: options?.abortSignal,
builderLogEmitter,
secureAccessEnabled,
onProcessed: async ({ storageObject, manifestItem }) => {
throwIfAborted()
processedCount += 1
@@ -687,6 +703,7 @@ export class PhotoAssetService {
tenantId: string,
storageManager: StorageManager,
db: ReturnType<DbAccessor['get']>,
secureAccessEnabled: boolean,
): Promise<{
items: PhotoAssetListItem[]
keySet: Set<string>
@@ -735,14 +752,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,
@@ -912,6 +927,7 @@ export class PhotoAssetService {
videoBufferMap: Map<string, Buffer>
abortSignal?: AbortSignal
builderLogEmitter?: DataSyncProgressEmitter
secureAccessEnabled: boolean
onProcessed?: (payload: {
plan: PreparedUploadPlan
storageObject: StorageObject
@@ -931,6 +947,7 @@ export class PhotoAssetService {
videoBufferMap,
abortSignal,
builderLogEmitter,
secureAccessEnabled,
onProcessed,
} = params
@@ -1063,7 +1080,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 +1131,6 @@ export class PhotoAssetService {
return results
}
async generatePublicUrl(storageKey: string): Promise<string> {
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<PhotoAssetListItem> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
@@ -1146,6 +1161,10 @@ 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 normalizeStorageKey = createStorageKeyNormalizer(storageConfig)
@@ -1224,10 +1243,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)
@@ -1888,4 +1909,34 @@ export class PhotoAssetService {
videoUrl,
}
}
private async resolvePublicUrlForRecord(params: {
storageManager: StorageManager
storageKey: string
storageProvider: string
secureAccessEnabled: boolean
intent?: string
}): Promise<string | null> {
const { storageManager, storageKey, storageProvider, secureAccessEnabled, intent } = params
if (storageProvider === DATABASE_ONLY_PROVIDER) {
return null
}
if (secureAccessEnabled) {
return this.storageAccessService.createProxyUrl(storageKey, intent)
}
try {
return await Promise.resolve(storageManager.generatePublicUrl(storageKey))
} catch {
return null
}
}
async generatePublicUrl(storageKey: string): Promise<string> {
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))
}
}

View File

@@ -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')

View File

@@ -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,

View File

@@ -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,

View File

@@ -146,13 +146,7 @@ export const ProviderCard: FC<ProviderCardProps> = ({ provider, isActive, onEdit
<span>{t(storageProvidersI18nKeys.card.makeInactive)}</span>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="border-accent/30 bg-accent/10 text-accent hover:bg-accent/20 border"
onClick={onToggleActive}
>
<Button type="button" variant="ghost" size="sm" onClick={onToggleActive}>
<DynamicIcon name="check" className="h-3.5 w-3.5 mr-1" />
<span>{t(storageProvidersI18nKeys.card.makeActive)}</span>
</Button>

View File

@@ -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<StorageProvider[]>([])
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const [secureAccessEnabled, setSecureAccessEnabled] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const initialProviderStateRef = useRef<boolean | null>(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() {
)}
</m.div>
{/* Security Notice */}
{/* Security & Controls */}
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
@@ -346,6 +368,41 @@ export function StorageProvidersManager() {
</div>
</LinearBorderPanel>
</m.div>
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="mb-6"
>
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<p className="text-text text-sm font-semibold sm:text-base">
{t(storageProvidersI18nKeys.secureAccess.title)}
</p>
<p className="text-text-secondary text-xs leading-relaxed sm:text-sm">
{t(storageProvidersI18nKeys.secureAccess.description)}
</p>
<p className="text-text-tertiary text-[11px] sm:text-xs">
{t(storageProvidersI18nKeys.secureAccess.helper)}
</p>
{managedActive ? (
<p className="text-warning text-[11px] sm:text-xs">
{t(storageProvidersI18nKeys.secureAccess.managedNote)}
</p>
) : null}
</div>
<div className="flex items-center gap-3">
<Switch
checked={secureAccessEnabled}
onCheckedChange={handleSecureAccessToggle}
disabled={managedActive || updateMutation.isPending || secureAccessMutation.isPending}
/>
</div>
</div>
</LinearBorderPanel>
</m.div>
</>
)
}

View File

@@ -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

View File

@@ -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[],

View File

@@ -12,6 +12,7 @@ export interface StorageProvider {
export interface StorageProvidersPayload {
providers: StorageProvider[]
activeProviderId: string | null
secureAccessEnabled: boolean
}
export interface StorageSettingEntry {

View File

@@ -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<StorageProvider[]>([])
const [baselineProviders, setBaselineProviders] = useState<StorageProvider[]>([])
const [managedId, setManagedId] = useState<string | null>(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() {
</div>
{providers.length === 0 ? (
<p className="text-text-secondary text-sm">{t('superadmin.settings.managed-storage.empty')}</p>
<p className="text-text-secondary text-sm mt-4">{t('superadmin.settings.managed-storage.empty')}</p>
) : (
<div className="space-y-3">
<div className="space-y-3 mt-4">
{providers.map((provider) => (
<div
key={provider.id}
@@ -225,6 +232,27 @@ export function ManagedStorageSettings() {
</div>
)}
<div className="border-fill/60 bg-fill/5 mt-6 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-text text-sm font-semibold">
{t('superadmin.settings.managed-storage.secure-access.title')}
</p>
<p className="text-text-secondary text-xs">
{t('superadmin.settings.managed-storage.secure-access.description')}
</p>
</div>
<Switch
checked={managedSecureAccessEnabled}
onCheckedChange={(next) => setManagedSecureAccessEnabled(next)}
disabled={updateSettings.isPending}
/>
</div>
<p className="text-text-tertiary text-[11px] mt-2">
{t('superadmin.settings.managed-storage.secure-access.helper')}
</p>
</div>
<div className="flex items-center justify-end gap-3 my-4">
<Button variant="secondary" disabled={!canSave} isLoading={updateSettings.isPending} onClick={handleSave}>
{updateSettings.isPending

View File

@@ -13,6 +13,7 @@ export type SuperAdminSettingsWithStorage = SuperAdminSettings & {
storagePlanProducts?: Record<string, unknown>
managedStorageProvider?: string | null
managedStorageProviders?: StorageProvider[]
managedStorageSecureAccess?: boolean
}
export interface SuperAdminStats {
@@ -41,6 +42,7 @@ export type UpdateSuperAdminSettingsPayload = Partial<{
storagePlanCatalog: Record<string, unknown>
storagePlanPricing: Record<string, unknown>
storagePlanProducts: Record<string, unknown>
managedStorageSecureAccess: boolean
}>
export type BuilderDebugProgressEvent =

View File

@@ -0,0 +1,41 @@
CREATE TABLE "photo_access_log" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"photo_asset_id" text NOT NULL,
"photo_id" text NOT NULL,
"storage_key" text NOT NULL,
"provider" text NOT NULL,
"intent" text DEFAULT 'original' NOT NULL,
"token_id" text NOT NULL,
"signed_url" text NOT NULL,
"status" text DEFAULT 'issued' NOT NULL,
"client_ip" text,
"user_agent" text,
"referer" text,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "photo_access_stat" (
"tenant_id" text NOT NULL,
"photo_asset_id" text NOT NULL,
"photo_id" text NOT NULL,
"view_count" bigint DEFAULT 0 NOT NULL,
"last_viewed_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "pk_photo_access_stat" PRIMARY KEY("tenant_id","photo_asset_id")
);
--> statement-breakpoint
DROP TABLE "tenant_auth_account" CASCADE;--> statement-breakpoint
DROP TABLE "tenant_auth_session" CASCADE;--> statement-breakpoint
DROP TABLE "tenant_auth_user" CASCADE;--> statement-breakpoint
ALTER TABLE "photo_access_log" ADD CONSTRAINT "photo_access_log_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photo_access_log" ADD CONSTRAINT "photo_access_log_photo_asset_id_photo_asset_id_fk" FOREIGN KEY ("photo_asset_id") REFERENCES "public"."photo_asset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photo_access_stat" ADD CONSTRAINT "photo_access_stat_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photo_access_stat" ADD CONSTRAINT "photo_access_stat_photo_asset_id_photo_asset_id_fk" FOREIGN KEY ("photo_asset_id") REFERENCES "public"."photo_asset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_photo_access_log_tenant" ON "photo_access_log" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "idx_photo_access_log_asset" ON "photo_access_log" USING btree ("photo_asset_id");--> statement-breakpoint
CREATE INDEX "idx_photo_access_log_token" ON "photo_access_log" USING btree ("token_id");--> statement-breakpoint
CREATE INDEX "idx_photo_access_stat_photo" ON "photo_access_stat" USING btree ("tenant_id","photo_id");

View File

@@ -1,6 +1,6 @@
{
"id": "87d6b9a6-bfb6-4588-8121-96ca11573b06",
"prevId": "53661480-95e7-4242-9ef7-d39885e82a20",
"id": "9ad71deb-f947-4c7d-89f7-37e4aee872a3",
"prevId": "87d6b9a6-bfb6-4588-8121-96ca11573b06",
"version": "7",
"dialect": "postgresql",
"tables": {

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1763656043992,
"tag": "0008_managed_storage_usage_operation",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1764070049538,
"tag": "0009_stormy_sway",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,17 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { bigint, boolean, index, integer, jsonb, pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
import {
bigint,
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
unique,
} from 'drizzle-orm/pg-core'
import { generateId } from './snowflake'
@@ -292,6 +304,58 @@ export const photoAssets = pgTable(
],
)
export const photoAccessLogs = pgTable(
'photo_access_log',
{
id: snowflakeId,
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
photoAssetId: text('photo_asset_id')
.notNull()
.references(() => photoAssets.id, { onDelete: 'cascade' }),
photoId: text('photo_id').notNull(),
storageKey: text('storage_key').notNull(),
provider: text('provider').notNull(),
intent: text('intent').notNull().default('original'),
tokenId: text('token_id').notNull(),
signedUrl: text('signed_url').notNull(),
status: text('status').notNull().default('issued'),
clientIp: text('client_ip'),
userAgent: text('user_agent'),
referer: text('referer'),
expiresAt: timestamp('expires_at', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [
index('idx_photo_access_log_tenant').on(t.tenantId),
index('idx_photo_access_log_asset').on(t.photoAssetId),
index('idx_photo_access_log_token').on(t.tokenId),
],
)
export const photoAccessStats = pgTable(
'photo_access_stat',
{
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
photoAssetId: text('photo_asset_id')
.notNull()
.references(() => photoAssets.id, { onDelete: 'cascade' }),
photoId: text('photo_id').notNull(),
viewCount: bigint('view_count', { mode: 'number' }).notNull().default(0),
lastViewedAt: timestamp('last_viewed_at', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [
primaryKey({ name: 'pk_photo_access_stat', columns: [t.tenantId, t.photoAssetId] }),
index('idx_photo_access_stat_photo').on(t.tenantId, t.photoId),
],
)
export const photoSyncRuns = pgTable(
'photo_sync_run',
{
@@ -348,6 +412,8 @@ export const dbSchema = {
managedStorageUsages,
managedStorageFileReferences,
photoAssets,
photoAccessLogs,
photoAccessStats,
photoSyncRuns,
billingUsageEvents,
}

View File

@@ -295,7 +295,7 @@
"photos.storage.managed.price.free": "Included in your current plan",
"photos.storage.managed.price.label": "{{price}} / month",
"photos.storage.managed.provider": "Backed by provider {{provider}}",
"photos.storage.managed.title": "Managed storage",
"photos.storage.managed.title": "Managed Storage",
"photos.storage.managed.toast.checkout-failure": "Unable to start checkout, please try again.",
"photos.storage.managed.toast.checkout-unavailable": "Managed storage checkout is unavailable right now.",
"photos.storage.managed.toast.error": "Failed to update managed storage: {{reason}}",
@@ -671,9 +671,13 @@
"storage.providers.prompt.sync.confirm": "Start syncing",
"storage.providers.prompt.sync.description": "Storage provider configuration is saved. Go to Data Sync now to scan storage and update the database?",
"storage.providers.prompt.sync.title": "Sync photos now?",
"storage.providers.secure-access.description": "Serve downloads via short-lived, presigned URLs so you can revoke access and trace requests. Disable to fall back to direct bucket URLs (less secure).",
"storage.providers.secure-access.helper": "Recommended for managed storage or when you need detailed access logs. Requires S3/B2 credentials that allow presigned downloads.",
"storage.providers.secure-access.managed-note": "Managed tenants follow the platform-wide policy controlled by the super admin.",
"storage.providers.secure-access.title": "Secure Access Proxy",
"storage.providers.security.description": "Sensitive credentials (keys, tokens, etc.) are encrypted with {{algorithm}} to protect your data.",
"storage.providers.security.helper": "{{algorithm}} provides authenticated encryption to keep data confidential and tamper-proof.",
"storage.providers.security.title": "Storage security",
"storage.providers.security.title": "Storage Security",
"storage.providers.status.dirty": "{{total}} provider(s) pending save",
"storage.providers.status.error": "Save failed: {{reason}}",
"storage.providers.status.saved": "✓ Storage configuration saved",
@@ -794,6 +798,9 @@
"superadmin.settings.managed-storage.description": "Configure the storage provider used for built-in managed storage (e.g., B2).",
"superadmin.settings.managed-storage.empty": "No storage providers yet. Add one to use as managed storage.",
"superadmin.settings.managed-storage.error": "Failed to load storage providers: {{reason}}",
"superadmin.settings.managed-storage.secure-access.description": "Force managed tenants to serve downloads through presigned URLs for access control and tracing.",
"superadmin.settings.managed-storage.secure-access.helper": "Disabling this exposes raw object-storage URLs to every managed tenant.",
"superadmin.settings.managed-storage.secure-access.title": "Secure access proxy",
"superadmin.settings.managed-storage.title": "Managed storage provider",
"superadmin.settings.managed-storage.type": "Type: {{type}}",
"superadmin.settings.message.dirty": "You have unsaved changes",
@@ -806,7 +813,7 @@
"superadmin.settings.stats.total-users": "Total users",
"superadmin.settings.stats.unlimited": "Unlimited",
"superadmin.settings.tabs.general": "General",
"superadmin.settings.tabs.managed-storage": "Managed storage",
"superadmin.settings.tabs.managed-storage": "Managed Storage",
"superadmin.settings.title": "System Settings",
"superadmin.tenants.button.ban": "Ban",
"superadmin.tenants.button.processing": "Working…",

View File

@@ -670,6 +670,10 @@
"storage.providers.prompt.sync.confirm": "开始同步",
"storage.providers.prompt.sync.description": "存储提供方配置已保存。是否立即前往数据同步,扫描存储并更新数据库?",
"storage.providers.prompt.sync.title": "立即同步照片?",
"storage.providers.secure-access.description": "启用后将通过服务端生成短期签名链接,便于追踪与及时吊销;关闭则使用公开地址(安全性更低)。",
"storage.providers.secure-access.helper": "建议在托管存储或需要访问日志时开启,需要具备 S3/B2 的签名下载权限。",
"storage.providers.secure-access.managed-note": "托管存储由平台统一控制此选项,如需调整请联系管理员。",
"storage.providers.secure-access.title": "安全访问代理",
"storage.providers.security.description": "密钥、令牌等敏感凭据会使用 {{algorithm}} 加密以保护数据。",
"storage.providers.security.helper": "{{algorithm}} 提供认证加密,确保数据保密且不可篡改。",
"storage.providers.security.title": "存储安全",
@@ -793,6 +797,9 @@
"superadmin.settings.managed-storage.description": "配置用于内置托管存储的 Provider例如 B2。",
"superadmin.settings.managed-storage.empty": "还没有存储提供商,请先新增。",
"superadmin.settings.managed-storage.error": "加载存储提供商失败:{{reason}}",
"superadmin.settings.managed-storage.secure-access.description": "是否强制托管租户通过短期签名链接访问,以便控制与追踪。",
"superadmin.settings.managed-storage.secure-access.helper": "关闭后租户将直接返回对象存储地址,安全性会降低。",
"superadmin.settings.managed-storage.secure-access.title": "安全访问代理",
"superadmin.settings.managed-storage.title": "托管存储 Provider",
"superadmin.settings.managed-storage.type": "类型:{{type}}",
"superadmin.settings.message.dirty": "您有尚未保存的变更",

View File

@@ -53,6 +53,7 @@ export type {
export type { StorageProviderFactory, StorageProviderRegistrationOptions } from './storage/index.js'
export { LOCAL_STORAGE_PROVIDERS, REMOTE_STORAGE_PROVIDERS } from './storage/index.js'
export { StorageFactory, StorageManager } from './storage/index.js'
export type { B2Config, ManagedStorageConfig, S3CompatibleConfig } from './storage/interfaces.js'
export type { BuilderConfig, BuilderConfigInput } from './types/config.js'
export type { AfilmoryManifest, CameraInfo, LensInfo } from './types/manifest.js'
export type { FujiRecipe, PhotoManifestItem, PickedExif, ToneAnalysis } from './types/photo.js'

View File

@@ -209,8 +209,10 @@ export async function executePhotoProcessingPipeline(
// 10. 构建照片清单项
const aspectRatio = metadata.width / metadata.height
const extension = path.extname(photoKey).slice(1).toUpperCase()
const photoItem: PhotoManifestItem = {
id: photoId,
format: extension || 'UNKNOWN',
title: photoInfo.title,
description: photoInfo.description,
dateTaken: photoInfo.dateTaken,

View File

@@ -58,6 +58,7 @@ export interface ImageMetadata {
export interface PhotoManifestItem extends PhotoInfo {
id: string
originalUrl: string
format: string
thumbnailUrl: string
thumbHash: string | null
width: number

View File

@@ -21,7 +21,7 @@ export function Switch({ className, ...props }: SwitchProps) {
{...props}
>
<SwitchThumb
className="data-[state=checked]:bg-accent data-[state=unchecked]:bg-disabled-control aspect-square h-full rounded-full transition-colors duration-200"
className="data-[state=checked]:bg-accent data-[state=unchecked]:bg-background-quaternary aspect-square h-full rounded-full transition-colors duration-200"
pressedAnimation={{ width: 22 }}
/>
</SwitchAnimate>

13
pnpm-lock.yaml generated
View File

@@ -103,7 +103,7 @@ importers:
version: 9.39.1(jiti@2.6.1)
eslint-config-hyoban:
specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
fast-glob:
specifier: 3.3.3
version: 3.3.3
@@ -462,7 +462,7 @@ importers:
version: 9.39.1(jiti@2.6.1)
eslint-config-hyoban:
specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
husky:
specifier: 9.1.7
version: 9.1.7
@@ -949,6 +949,9 @@ importers:
'@afilmory/utils':
specifier: workspace:*
version: link:../../../packages/utils
'@aws-crypto/sha256-js':
specifier: 5.2.0
version: 5.2.0
'@aws-sdk/client-s3':
specifier: 3.929.0
version: 3.929.0
@@ -961,6 +964,12 @@ importers:
'@resvg/resvg-js':
specifier: 2.6.2
version: 2.6.2
'@smithy/protocol-http':
specifier: 5.3.5
version: 5.3.5
'@smithy/signature-v4':
specifier: 5.3.5
version: 5.3.5
'@types/busboy':
specifier: 1.5.4
version: 1.5.4