mirror of
https://github.com/Afilmory/afilmory
synced 2026-06-01 19:05:42 +00:00
@@ -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`} />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
be/apps/core/src/app.constants.ts
Normal file
4
be/apps/core/src/app.constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Application constants
|
||||
*/
|
||||
export const APP_GLOBAL_PREFIX = '/api' as const
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
53
be/apps/core/src/context/http-context.helper.ts
Normal file
53
be/apps/core/src/context/http-context.helper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SystemSettings {
|
||||
storagePlanPricing: StoragePlanPricingConfigs
|
||||
managedStorageProvider: string | null
|
||||
managedStorageProviders: BuilderStorageProvider[]
|
||||
managedStorageSecureAccess: boolean
|
||||
}
|
||||
|
||||
export type SystemSettingValueMap = {
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface StorageProvider {
|
||||
export interface StorageProvidersPayload {
|
||||
providers: StorageProvider[]
|
||||
activeProviderId: string | null
|
||||
secureAccessEnabled: boolean
|
||||
}
|
||||
|
||||
export interface StorageSettingEntry {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
41
be/packages/db/migrations/0009_stormy_sway.sql
Normal file
41
be/packages/db/migrations/0009_stormy_sway.sql
Normal 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");
|
||||
@@ -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": {
|
||||
|
||||
1816
be/packages/db/migrations/meta/0009_snapshot.json
Normal file
1816
be/packages/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "您有尚未保存的变更",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
13
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user