diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e9dd7de..dc619a20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,8 +11,28 @@ }, "tailwindCSS.experimental.classRegex": [ [ - "([\"'`][^\"'`]*.*?[\"'`])", + "cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]" + ], + [ + "clsxm\\(([^)]*)\\)", + "[\"'`]([^\"'`]*).*?[\"'`]" + ], + [ + "tv\\(([^)]*)\\)", + "(?:'|\"|`)([^']*)(?:'|\"|`)" + ], + [ + "tw`([^`]*)`", + "([^`]*)" + ], + [ + "[a-zA-Z]+[cC]lass[nN]ame[\"'`]?:\\s*[\"'`]([^\"'`]*)[\"'`]", + "([^\"'`]*)" + ], + [ + "[a-zA-Z]+[cC]lass[nN]ame\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", + "([^\"'`]*)" ] ], // If you do not want to autofix some rules on save @@ -52,4 +72,4 @@ "exportall.config.folderListener": [ "/apps/ssr/src/schemas" ] -} +} \ No newline at end of file diff --git a/be/apps/core/package.json b/be/apps/core/package.json index a3eee8f1..f456b54e 100644 --- a/be/apps/core/package.json +++ b/be/apps/core/package.json @@ -22,6 +22,7 @@ "@afilmory/framework": "workspace:*", "@afilmory/redis": "workspace:*", "@afilmory/task-queue": "workspace:*", + "@afilmory/utils": "workspace:*", "@aws-sdk/client-s3": "3.916.0", "@hono/node-server": "^1.19.5", "better-auth": "1.3.29", diff --git a/be/apps/core/src/modules/setting/setting.controller.ts b/be/apps/core/src/modules/setting/setting.controller.ts index a39a68cb..7196c00d 100644 --- a/be/apps/core/src/modules/setting/setting.controller.ts +++ b/be/apps/core/src/modules/setting/setting.controller.ts @@ -18,12 +18,14 @@ export class SettingController { } @Get('/:key') + @BypassResponseTransform() async get(@Param() { key }: GetSettingDto) { const value = await this.settingService.get(key, {}) return { key, value } } @Get('/') + @BypassResponseTransform() async getMany(@Query() query: GetSettingsQueryDto) { const keys = query?.keys ?? [] const targetKeys = keys.length > 0 ? keys : Array.from(SettingKeys) diff --git a/be/apps/core/src/modules/setting/setting.service.ts b/be/apps/core/src/modules/setting/setting.service.ts index 588ddac5..5eeb2185 100644 --- a/be/apps/core/src/modules/setting/setting.service.ts +++ b/be/apps/core/src/modules/setting/setting.service.ts @@ -12,6 +12,8 @@ import { TenantService } from '../tenant/tenant.service' import { AES_ALGORITHM, AUTH_TAG_LENGTH, DEFAULT_SETTING_METADATA, IV_LENGTH } from './setting.constant' import type { SettingKeyType, SettingRecord, SettingUiSchemaResponse, SettingValueMap } from './setting.type' import { SETTING_UI_SCHEMA, SETTING_UI_SCHEMA_KEYS } from './setting.ui-schema' +import { STORAGE_PROVIDERS_SETTING_KEY } from './storage-provider.constants' +import { prepareStorageProvidersForPersist, sanitizeStorageProviders } from './storage-provider.utils' export type SettingOption = { tenantId?: string @@ -55,8 +57,13 @@ export class SettingService { if (!record) { return null } - const value = record.isSensitive ? this.decrypt(record.value) : record.value - return value + const rawValue = record.isSensitive ? this.decrypt(record.value) : record.value + + if (key === STORAGE_PROVIDERS_SETTING_KEY) { + return sanitizeStorageProviders(rawValue) + } + + return rawValue } async getMany( @@ -85,7 +92,14 @@ export class SettingService { acc[key] = null return acc } - acc[key] = record.isSensitive ? this.decrypt(record.value) : record.value + const rawValue = record.isSensitive ? this.decrypt(record.value) : record.value + + if (key === STORAGE_PROVIDERS_SETTING_KEY) { + acc[key] = sanitizeStorageProviders(rawValue) + return acc + } + + acc[key] = rawValue return acc }, {}) } @@ -96,8 +110,19 @@ export class SettingService { const tenantId = await this.resolveTenantId(options) const existing = await this.findSettingRecord(key, tenantId) const defaultMetadata = isSettingKey(key) ? DEFAULT_SETTING_METADATA[key] : undefined - const isSensitive = options.isSensitive ?? defaultMetadata?.isSensitive ?? existing?.isSensitive ?? false - const payload = isSensitive ? this.encrypt(value) : value + let isSensitive = options.isSensitive ?? defaultMetadata?.isSensitive ?? existing?.isSensitive ?? false + let persistedValue = value + let maskedValue = value + + if (key === STORAGE_PROVIDERS_SETTING_KEY) { + const existingRaw = existing ? (existing.isSensitive ? this.decrypt(existing.value) : existing.value) : null + const result = prepareStorageProvidersForPersist(value, existingRaw) + persistedValue = result.stored + maskedValue = result.masked + isSensitive = true + } + + const payload = isSensitive ? this.encrypt(persistedValue) : persistedValue const db = this.dbAccessor.get() const insertPayload: typeof settings.$inferInsert = { @@ -121,7 +146,7 @@ export class SettingService { set: updatePayload, }) - await this.eventEmitter.emit('setting.updated', { tenantId, key, value }) + await this.eventEmitter.emit('setting.updated', { tenantId, key, value: maskedValue }) } async setMany(entries: readonly SettingEntryInput[]): Promise { diff --git a/be/apps/core/src/modules/setting/storage-provider.constants.ts b/be/apps/core/src/modules/setting/storage-provider.constants.ts new file mode 100644 index 00000000..535a1262 --- /dev/null +++ b/be/apps/core/src/modules/setting/storage-provider.constants.ts @@ -0,0 +1,6 @@ +export const STORAGE_PROVIDERS_SETTING_KEY = 'builder.storage.providers' + +export const STORAGE_PROVIDER_SENSITIVE_FIELDS: Record = { + s3: ['secretAccessKey'], + github: ['token'], +} diff --git a/be/apps/core/src/modules/setting/storage-provider.utils.ts b/be/apps/core/src/modules/setting/storage-provider.utils.ts new file mode 100644 index 00000000..685a7d4f --- /dev/null +++ b/be/apps/core/src/modules/setting/storage-provider.utils.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto' + +import { STORAGE_PROVIDER_SECRET_PLACEHOLDER } from '@afilmory/utils' + +import { STORAGE_PROVIDER_SENSITIVE_FIELDS } from './storage-provider.constants' + +export interface BuilderStorageProvider { + id: string + name: string + type: string + config: Record + createdAt?: string + updatedAt?: string +} + +function normalizeConfig (config: unknown): Record { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return {} + } + + const result: Record = {} + + for (const [key, value] of Object.entries(config)) { + result[key] = typeof value === 'string' ? value : value == null ? '' : String(value) + } + + return result +} + +function normalizeProvider (input: unknown): BuilderStorageProvider | null { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + const now = new Date().toISOString() + + return { + id: typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : randomUUID(), + name: typeof record.name === 'string' && record.name.trim().length > 0 ? record.name.trim() : '未命名存储', + type: typeof record.type === 'string' ? record.type : 'local', + config: normalizeConfig(record.config), + createdAt: typeof record.createdAt === 'string' ? record.createdAt : now, + updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now, + } +} + +export function parseStorageProviders (raw: string | null): BuilderStorageProvider[] { + if (!raw) { + return [] + } + + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + return [] + } + + return parsed.map((item) => normalizeProvider(item)).filter((item): item is BuilderStorageProvider => item !== null) + } catch { + return [] + } +} + +export function serializeStorageProviders (providers: BuilderStorageProvider[]): string { + return JSON.stringify(providers) +} + +export function maskStorageProviderSecrets (providers: BuilderStorageProvider[]): BuilderStorageProvider[] { + return providers.map((provider) => { + const sensitiveKeys = STORAGE_PROVIDER_SENSITIVE_FIELDS[provider.type] ?? [] + const maskedConfig: Record = { ...provider.config } + + for (const key of sensitiveKeys) { + if (maskedConfig[key] && maskedConfig[key].length > 0) { + maskedConfig[key] = STORAGE_PROVIDER_SECRET_PLACEHOLDER + } + } + + return { + ...provider, + config: maskedConfig, + } + }) +} + +export function mergeStorageProviderSecrets (incoming: BuilderStorageProvider[], + existing: BuilderStorageProvider[]): BuilderStorageProvider[] { + const existingMap = new Map(existing.map((provider) => [provider.id, provider])) + const now = new Date().toISOString() + + return incoming.map((provider) => { + const previous = existingMap.get(provider.id) + const sensitiveKeys = STORAGE_PROVIDER_SENSITIVE_FIELDS[provider.type] ?? [] + const mergedConfig: Record = { ...provider.config } + + for (const key of sensitiveKeys) { + const value = mergedConfig[key] ?? '' + if (value === STORAGE_PROVIDER_SECRET_PLACEHOLDER) { + mergedConfig[key] = previous?.config[key] ?? '' + continue + } + } + + return { + ...provider, + config: mergedConfig, + createdAt: previous?.createdAt ?? provider.createdAt ?? now, + updatedAt: now, + } + }) +} + +export function sanitizeStorageProviders (raw: string | null): string { + const normalized = typeof raw === 'string' ? raw.trim() : '' + if (!normalized) { + return '[]' + } + + const providers = parseStorageProviders(normalized) + const masked = maskStorageProviderSecrets(providers) + return serializeStorageProviders(masked) +} + +export function prepareStorageProvidersForPersist (incomingRaw: string, + existingRaw: string | null): { stored: string; masked: string } { + const incomingProviders = parseStorageProviders(incomingRaw) + const existingProviders = parseStorageProviders(existingRaw) + const merged = mergeStorageProviderSecrets(incomingProviders, existingProviders) + return { + stored: serializeStorageProviders(merged), + masked: serializeStorageProviders(maskStorageProviderSecrets(merged)), + } +} diff --git a/be/apps/core/src/modules/super-admin/super-admin.controller.ts b/be/apps/core/src/modules/super-admin/super-admin.controller.ts index 546dc3b3..bd050884 100644 --- a/be/apps/core/src/modules/super-admin/super-admin.controller.ts +++ b/be/apps/core/src/modules/super-admin/super-admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Patch } from '@afilmory/framework' import { Roles } from 'core/guards/roles.decorator' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service' import { UpdateSuperAdminSettingsDto } from './super-admin.dto' @@ -10,11 +11,13 @@ export class SuperAdminSettingController { constructor(private readonly superAdminSettings: SuperAdminSettingService) {} @Get('/') + @BypassResponseTransform() async getOverview() { return await this.superAdminSettings.getOverview() } @Patch('/') + @BypassResponseTransform() async update(@Body() dto: UpdateSuperAdminSettingsDto) { await this.superAdminSettings.updateSettings(dto) return await this.superAdminSettings.getOverview() diff --git a/be/apps/dashboard/package.json b/be/apps/dashboard/package.json index 49f1e677..c0fe8196 100644 --- a/be/apps/dashboard/package.json +++ b/be/apps/dashboard/package.json @@ -42,6 +42,7 @@ "jotai": "2.15.0", "lucide-react": "0.547.0", "motion": "12.23.24", + "nanoid": "5.1.6", "ofetch": "1.4.1", "radix-ui": "1.4.3", "react": "19.2.0", diff --git a/be/apps/dashboard/src/modules/storage-providers/components/AddProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/AddProviderCard.tsx deleted file mode 100644 index b05c2037..00000000 --- a/be/apps/dashboard/src/modules/storage-providers/components/AddProviderCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { clsxm, Spring } from '@afilmory/utils' -import { DynamicIcon } from 'lucide-react/dynamic' -import { m } from 'motion/react' -import type { FC } from 'react' - -type AddProviderCardProps = { - onClick: () => void -} - -export const AddProviderCard: FC = ({ onClick }) => { - return ( - - {/* Linear gradient borders with accent color on hover */} -
-
-
-
- - {/* Icon */} -
-
- -
-
- - {/* Text */} -
-

Add Provider

-

Configure a new storage

-
- - ) -} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx index e147a261..b51ab299 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx @@ -1,6 +1,6 @@ -import { clsxm, Spring } from '@afilmory/utils' +import { Button } from '@afilmory/ui' +import { clsxm } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' -import { m } from 'motion/react' import type { FC } from 'react' import type { StorageProvider } from '../types' @@ -41,13 +41,15 @@ const providerTypeConfig = { type ProviderCardProps = { provider: StorageProvider isActive: boolean - onClick: () => void + onEdit: () => void + onToggleActive: () => void } export const ProviderCard: FC = ({ provider, isActive, - onClick, + onEdit, + onToggleActive, }) => { const config = providerTypeConfig[provider.type as keyof typeof providerTypeConfig] || @@ -76,16 +78,9 @@ export const ProviderCard: FC = ({ } return ( - @@ -131,13 +126,36 @@ export const ProviderCard: FC = ({

- {/* Hover Edit Indicator */} -
- - - Edit - + {/* Actions - bottom right */} +
+ {isActive ? ( + + ) : ( + + )} +
- +
) } diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx index 4aad742a..a6b7e78f 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx @@ -15,6 +15,7 @@ import { import { clsxm, Spring } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' import { m } from 'motion/react' +import { nanoid } from 'nanoid' import { useEffect, useMemo, useState } from 'react' import { @@ -28,15 +29,13 @@ type ProviderEditModalProps = ModalComponentProps & { activeProviderId: string | null onSave: (provider: StorageProvider) => void onSetActive: (id: string) => void - onDelete: (id: string) => void } export const ProviderEditModal = ({ provider, - activeProviderId, + onSave, - onSetActive, - onDelete, + dismiss, }: ProviderEditModalProps) => { const [formData, setFormData] = useState(provider) @@ -50,7 +49,6 @@ export const ProviderEditModal = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [providerKey]) - const isActive = provider?.id === activeProviderId const isNewProvider = !provider?.id const selectedFields = useMemo(() => { @@ -88,23 +86,11 @@ export const ProviderEditModal = ({ const handleSave = () => { if (!formData) return + formData.id = formData.id ?? nanoid() onSave(formData) dismiss() } - const handleDelete = () => { - if (!formData?.id) return - if (window.confirm('确定要删除这个存储提供商吗?此操作无法撤销。')) { - onDelete(formData.id) - dismiss() - } - } - - const handleSetActive = () => { - if (!formData?.id) return - onSetActive(formData.id) - } - if (!formData) return null return ( @@ -261,10 +247,7 @@ export const ProviderEditModal = ({
{/* Footer */} -
- {/* Horizontal divider */} -
- +
{isNewProvider ? ( // Add mode: Simple cancel + create actions
@@ -283,64 +266,23 @@ export const ProviderEditModal = ({ variant="primary" size="sm" > - + Create Provider
) : ( // Edit mode: Delete + cancel + set active + save -
+
- -
- - - {isActive ? ( - - - Active - - ) : ( - - )} - - -
)}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx index 66a5968c..f89489e1 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx @@ -14,7 +14,6 @@ import { } from '../hooks' import type { StorageProvider } from '../types' import { createEmptyProvider, reorderProvidersByActive } from '../utils' -import { AddProviderCard } from './AddProviderCard' import { ProviderCard } from './ProviderCard' import { ProviderEditModal } from './ProviderEditModal' @@ -54,7 +53,6 @@ export const StorageProvidersManager = () => { activeProviderId, onSave: handleSaveProvider, onSetActive: handleSetActive, - onDelete: handleDeleteProvider, }) } @@ -82,20 +80,6 @@ export const StorageProvidersManager = () => { markDirty() } - const handleDeleteProvider = (providerId: string) => { - setProviders((prev) => { - const next = prev.filter((provider) => provider.id !== providerId) - const nextActive = next.some( - (provider) => provider.id === activeProviderId, - ) - ? activeProviderId - : (next[0]?.id ?? null) - setActiveProviderId(nextActive) - return next - }) - markDirty() - } - const handleSetActive = (providerId: string) => { setActiveProviderId(providerId) markDirty() @@ -106,7 +90,7 @@ export const StorageProvidersManager = () => { activeProviderId && providers.some((provider) => provider.id === activeProviderId) ? activeProviderId - : (providers[0]?.id ?? null) + : null updateMutation.mutate( { @@ -146,6 +130,14 @@ export const StorageProvidersManager = () => { const headerActionPortal = ( +